Compare commits
225 Commits
cli-v0.1.3
...
dbea96960f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbea96960f | ||
|
|
a022da1998 | ||
|
|
5df2664bae | ||
|
|
816c42feae | ||
|
|
4c0a417b7c | ||
|
|
e6962f1454 | ||
|
|
1d506f3ea5 | ||
|
|
64266a75f7 | ||
|
|
2710f354a9 | ||
|
|
6b55859d38 | ||
|
|
7d31cc6283 | ||
|
|
0403cfeb76 | ||
|
|
d8e6900072 | ||
|
|
ed8dab8bd3 | ||
|
|
dad51870d9 | ||
|
|
a6af0f2154 | ||
|
|
0661e6223a | ||
|
|
05e3c43e29 | ||
|
|
e3fa6e6a5e | ||
|
|
17066b4f6c | ||
|
|
8d1685e64d | ||
|
|
bb28e16c7d | ||
|
|
ac59d2acfe | ||
|
|
0a1af84712 | ||
|
|
18dc29aba1 | ||
|
|
795217093f | ||
|
|
61b0813924 | ||
|
|
c10337ab9f | ||
|
|
126bbfeb2c | ||
|
|
c914f2b7db | ||
|
|
a8b9348b36 | ||
|
|
c3dd4efe82 | ||
|
|
a7d9ecab15 | ||
|
|
d263fe0f26 | ||
|
|
3226493e6d | ||
|
|
4cb5a97512 | ||
|
|
c080bc517f | ||
|
|
471e88b3e6 | ||
|
|
c66e3adf67 | ||
|
|
3f46a6657a | ||
|
|
83ba1aa373 | ||
|
|
7430e4ffe0 | ||
|
|
d72e49b8fd | ||
|
|
3f57944921 | ||
|
|
b31aab8aeb | ||
|
|
5db9842261 | ||
|
|
81e520fdbb | ||
|
|
26c4502277 | ||
|
|
bfc62b9a72 | ||
|
|
f8c6f9ae74 | ||
|
|
3497700fad | ||
|
|
2c156f832e | ||
|
|
4ee810242d | ||
|
|
b6224c4186 | ||
|
|
4c385a16cc | ||
|
|
4ae6a86bf6 | ||
|
|
c327c282e3 | ||
|
|
e645455b22 | ||
|
|
45505a1635 | ||
|
|
17e6361d64 | ||
|
|
528e7e21b1 | ||
|
|
7b875de301 | ||
|
|
8a3c96dc7c | ||
|
|
b0634b829c | ||
|
|
2bd388a5e2 | ||
|
|
71c0767a1b | ||
|
|
6a3f087209 | ||
|
|
873f588057 | ||
|
|
070a3b7422 | ||
|
|
75ca892ea7 | ||
|
|
a90046a8e3 | ||
|
|
02a165dd76 | ||
|
|
52393429f9 | ||
|
|
9474d985ae | ||
|
|
643c808685 | ||
|
|
2c24f667f9 | ||
|
|
b0113913f2 | ||
|
|
e1cafa54b3 | ||
|
|
a4f2e0aa81 | ||
|
|
cbcde4d910 | ||
|
|
495c234159 | ||
|
|
42c1d02f5e | ||
|
|
a33c925216 | ||
|
|
6ab3fbbea3 | ||
|
|
26adbafde2 | ||
|
|
13e8ce07ac | ||
|
|
5398ca6833 | ||
|
|
56b1cc0756 | ||
|
|
fc8a7edc23 | ||
|
|
e09671cdcb | ||
|
|
32fc4a0c98 | ||
|
|
b315b31cc9 | ||
|
|
21cb6efced | ||
|
|
125b576e2c | ||
|
|
3641618391 | ||
|
|
a92cf6b629 | ||
|
|
2c9c8c7b6c | ||
|
|
98fda20ab6 | ||
|
|
025a53a70c | ||
|
|
b55cf269a4 | ||
|
|
504111c50c | ||
|
|
05d9b56f28 | ||
|
|
c8cb1e3ea5 | ||
|
|
86a258301f | ||
|
|
7e102a235b | ||
|
|
5563f90733 | ||
|
|
b3b9972e60 | ||
|
|
fe9285351b | ||
|
|
08e289a5e3 | ||
|
|
7d432b3aaa | ||
|
|
b0dc538119 | ||
|
|
27c9d2a02c | ||
|
|
87e0d0004d | ||
|
|
dba0fb7b33 | ||
|
|
72be651ca8 | ||
|
|
db2bf3ea06 | ||
|
|
e87380775f | ||
|
|
58ba01f20f | ||
|
|
59332dc47d | ||
|
|
f34b8fbc6b | ||
|
|
79525af42e | ||
|
|
69e93d4b8c | ||
|
|
810f372d1c | ||
|
|
453705a4e1 | ||
|
|
5cb4cc4fe7 | ||
|
|
eeac47c360 | ||
|
|
0bb9d71a26 | ||
|
|
3ff7a61e3f | ||
|
|
e76ade64d2 | ||
|
|
59848f0d3e | ||
|
|
d0fa1c028f | ||
|
|
8f925d9a9e | ||
|
|
4ce1034dcd | ||
|
|
e26a36e543 | ||
|
|
60c74d9463 | ||
|
|
6fba9bd4eb | ||
|
|
5bcc1fe323 | ||
|
|
e70f0ed1ff | ||
|
|
5f696f47ea | ||
|
|
ccb9fb2a68 | ||
|
|
898c061089 | ||
|
|
f7a6559429 | ||
|
|
579d0c3d3e | ||
|
|
190f5a958e | ||
|
|
03661e1b68 | ||
|
|
d451fc296e | ||
|
|
3da5d71275 | ||
|
|
cdf335f609 | ||
|
|
0cd16ff358 | ||
|
|
3e9707276d | ||
|
|
82cfee315c | ||
|
|
ab08be04a5 | ||
|
|
ee585a8370 | ||
|
|
1f078bf0c8 | ||
|
|
2372032a68 | ||
|
|
a70c5fd124 | ||
|
|
5c62d287cf | ||
|
|
9ae378c2e3 | ||
|
|
7381738f0b | ||
|
|
8c6b0c0e07 | ||
|
|
ec9626503c | ||
|
|
820ec085b2 | ||
|
|
9e6f6d7bc9 | ||
|
|
7194e7d28e | ||
|
|
0b4e389f2b | ||
|
|
7a5f786e0c | ||
|
|
10e5fdcfd1 | ||
|
|
cc6e56aef9 | ||
|
|
1aaa483d60 | ||
|
|
99d9d19079 | ||
|
|
888078876a | ||
|
|
02b1e5695f | ||
|
|
663f800b4b | ||
|
|
2557235c68 | ||
|
|
a987e9e27b | ||
|
|
ff86db615f | ||
|
|
4aa61b40e2 | ||
|
|
4afe365c00 | ||
|
|
92bb276a3e | ||
|
|
af8f8ed1f9 | ||
|
|
c8682dd700 | ||
|
|
004602a83c | ||
|
|
2a2aac3622 | ||
|
|
e0659b0b6f | ||
|
|
4c057be069 | ||
|
|
aaab7feea6 | ||
|
|
af13125424 | ||
|
|
4c52ee236c | ||
|
|
7d51f101d7 | ||
|
|
d8bafe3144 | ||
|
|
2be08ab85f | ||
|
|
d3e60d4d82 | ||
|
|
9cefe863e3 | ||
|
|
78c80cc43c | ||
|
|
59ce33f943 | ||
|
|
2cdcdccbc9 | ||
|
|
9653171b78 | ||
|
|
d14bdf6b5a | ||
|
|
f1af8c0a79 | ||
|
|
96cae38196 | ||
|
|
a14b6c28dd | ||
|
|
479d6a454a | ||
|
|
c5bf1c303f | ||
|
|
c0cb19c53a | ||
|
|
b758fe07ff | ||
|
|
8de952d91b | ||
|
|
03ca9f10d3 | ||
|
|
8bd8d1ff76 | ||
|
|
57a6af5013 | ||
|
|
067ef10b70 | ||
|
|
6b062ab239 | ||
|
|
5c4cb2cf84 | ||
|
|
8fa2bb5cd2 | ||
|
|
253e0ac43c | ||
|
|
8fca7fb21a | ||
|
|
8c7a6a05c3 | ||
|
|
8e906daf6f | ||
|
|
de684c44bb | ||
|
|
66b9696b2d | ||
|
|
09c5d759fa | ||
|
|
a1c6c6dc6a | ||
|
|
00b5ba8190 | ||
|
|
ccff802163 | ||
|
|
231618c595 | ||
|
|
f698aaeac7 |
@@ -16,3 +16,6 @@ URL="http://localhost:3000"
|
||||
|
||||
# Default locale of the apps, can be overridden separately in each app.
|
||||
DEFAULT_LOCALE="en"
|
||||
|
||||
# Shared secret for CLI sync JWT signing (HS256) — must match between broker and web app
|
||||
CLI_SYNC_SECRET="<your-cli-sync-secret>"
|
||||
|
||||
8
.gitignore
vendored
@@ -45,6 +45,9 @@ yarn-error.log*
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# secrets
|
||||
.cli_sync_secret
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -67,3 +70,8 @@ dist/
|
||||
|
||||
# Auto Claude data directory
|
||||
.auto-claude/
|
||||
|
||||
# Payload CMS
|
||||
apps/web/payload.db
|
||||
apps/web/public/media/*
|
||||
!apps/web/public/media/.gitkeep
|
||||
|
||||
30
CLAUDE.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# claudemesh
|
||||
|
||||
Peer mesh for Claude Code sessions. Broker + CLI + MCP server.
|
||||
|
||||
## Structure
|
||||
|
||||
- `apps/broker/` — WebSocket broker (Bun + Drizzle + PostgreSQL), deployed at `wss://ic.claudemesh.com/ws`
|
||||
- `apps/cli/` — `claudemesh-cli` npm package (CLI + MCP server)
|
||||
- `apps/web/` — Marketing site + dashboard at claudemesh.com
|
||||
- `docs/` — Protocol spec, quickstart, FAQ, roadmap
|
||||
|
||||
## Key docs
|
||||
|
||||
- `SPEC.md` — What claudemesh is, protocol, crypto, wire format
|
||||
- `docs/protocol.md` — Wire protocol reference
|
||||
- `docs/roadmap.md` — Public roadmap (shipped + planned)
|
||||
- `docs/vision-20260407.md` — Internal feature brainstorm with 19 ideas across 3 tiers, effort estimates, and build order
|
||||
|
||||
## Deploy
|
||||
|
||||
- **Broker:** `git push gitea-vps main` triggers Coolify auto-deploy. Manual: `curl -s -X GET "http://100.122.34.28:8000/api/v1/deploy?uuid=mcn8m74tbxfxbplmyb40b2ia" -H "Authorization: Bearer 3|K2vkSJzdUA69rj22CKZc5z0YB6pkY43GLEonti3UzcnqVJj6WhrqqYTAng6DzMUi"`
|
||||
- **CLI:** `cd apps/cli && pnpm publish --access public --no-git-checks`
|
||||
- **Web:** Vercel auto-deploy on push to GitHub
|
||||
|
||||
## Dev
|
||||
|
||||
- Monorepo: pnpm workspaces + Turborepo
|
||||
- Broker dev: `cd apps/broker && bun --hot src/index.ts`
|
||||
- CLI build: `cd apps/cli && pnpm build` (Bun bundler)
|
||||
- CLI link for local testing: `cd apps/cli && npm link`
|
||||
@@ -37,7 +37,7 @@ COPY --from=deps --chown=bun:bun /deploy /app
|
||||
|
||||
EXPOSE 7900
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
|
||||
CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||
|
||||
# Non-root user (oven/bun image ships with 'bun' uid 1000)
|
||||
|
||||
@@ -15,10 +15,14 @@
|
||||
},
|
||||
"prettier": "@turbostarter/prettier-config",
|
||||
"dependencies": {
|
||||
"@qdrant/js-client-rest": "1.17.0",
|
||||
"@turbostarter/db": "workspace:*",
|
||||
"@turbostarter/shared": "workspace:*",
|
||||
"drizzle-orm": "0.44.7",
|
||||
"grammy": "^1.35.0",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"minio": "8.0.7",
|
||||
"neo4j-driver": "6.0.1",
|
||||
"ws": "8.20.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
215
apps/broker/src/audit.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Signed audit log with hash-chain integrity.
|
||||
*
|
||||
* Every significant mesh event is recorded as an append-only entry.
|
||||
* Each entry's SHA-256 hash includes the previous entry's hash,
|
||||
* forming a tamper-evident chain per mesh. If any row is modified
|
||||
* or deleted, all subsequent hashes will fail verification.
|
||||
*
|
||||
* NEVER logs message content (ciphertext or plaintext) — only metadata.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { asc, desc, eq, sql, and } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { auditLog } from "@turbostarter/db/schema/mesh";
|
||||
import { log } from "./logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory last-hash cache (one entry per mesh, loaded from DB on startup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lastHash = new Map<string, string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core audit logging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computeHash(
|
||||
prevHash: string,
|
||||
meshId: string,
|
||||
eventType: string,
|
||||
actorMemberId: string | null,
|
||||
payload: Record<string, unknown>,
|
||||
createdAt: Date,
|
||||
): string {
|
||||
const input = `${prevHash}|${meshId}|${eventType}|${actorMemberId}|${JSON.stringify(payload)}|${createdAt.toISOString()}`;
|
||||
return createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an audit entry for a mesh event.
|
||||
*
|
||||
* Fire-and-forget safe — callers should `void audit(...)` or
|
||||
* `.catch(log.warn)` to avoid blocking the hot path.
|
||||
*/
|
||||
export async function audit(
|
||||
meshId: string,
|
||||
eventType: string,
|
||||
actorMemberId: string | null,
|
||||
actorDisplayName: string | null,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const prevHash = lastHash.get(meshId) ?? "genesis";
|
||||
const createdAt = new Date();
|
||||
const hash = computeHash(prevHash, meshId, eventType, actorMemberId, payload, createdAt);
|
||||
|
||||
try {
|
||||
await db.insert(auditLog).values({
|
||||
meshId,
|
||||
eventType,
|
||||
actorMemberId,
|
||||
actorDisplayName,
|
||||
payload,
|
||||
prevHash,
|
||||
hash,
|
||||
createdAt,
|
||||
});
|
||||
lastHash.set(meshId, hash);
|
||||
} catch (e) {
|
||||
log.warn("audit log insert failed", {
|
||||
mesh_id: meshId,
|
||||
event_type: eventType,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Startup: load last hash per mesh from DB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function loadLastHashes(): Promise<void> {
|
||||
try {
|
||||
// For each mesh, find the most recent audit entry by id (serial).
|
||||
// DISTINCT ON (mesh_id) ORDER BY id DESC gives us one row per mesh.
|
||||
const rows = await db.execute<{ mesh_id: string; hash: string }>(sql`
|
||||
SELECT DISTINCT ON (mesh_id) mesh_id, hash
|
||||
FROM mesh.audit_log
|
||||
ORDER BY mesh_id, id DESC
|
||||
`);
|
||||
|
||||
for (const row of rows) {
|
||||
lastHash.set(row.mesh_id, row.hash);
|
||||
}
|
||||
log.info("audit: loaded last hashes", { meshes: lastHash.size });
|
||||
} catch (e) {
|
||||
// Table may not exist yet on first boot — that's fine.
|
||||
log.warn("audit: loadLastHashes failed (table may not exist yet)", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function verifyChain(
|
||||
meshId: string,
|
||||
): Promise<{ valid: boolean; entries: number; brokenAt?: number }> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(eq(auditLog.meshId, meshId))
|
||||
.orderBy(asc(auditLog.id));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { valid: true, entries: 0 };
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]!;
|
||||
const expectedPrevHash = i === 0 ? "genesis" : rows[i - 1]!.hash;
|
||||
|
||||
// Verify prevHash linkage
|
||||
if (row.prevHash !== expectedPrevHash) {
|
||||
return { valid: false, entries: rows.length, brokenAt: row.id };
|
||||
}
|
||||
|
||||
// Recompute hash and verify
|
||||
const recomputed = computeHash(
|
||||
row.prevHash,
|
||||
row.meshId,
|
||||
row.eventType,
|
||||
row.actorMemberId,
|
||||
row.payload as Record<string, unknown>,
|
||||
row.createdAt,
|
||||
);
|
||||
if (recomputed !== row.hash) {
|
||||
return { valid: false, entries: rows.length, brokenAt: row.id };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, entries: rows.length };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query: paginated audit entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function queryAuditLog(
|
||||
meshId: string,
|
||||
options?: { limit?: number; offset?: number; eventType?: string },
|
||||
): Promise<{ entries: Array<{ id: number; eventType: string; actor: string; payload: Record<string, unknown>; hash: string; createdAt: string }>; total: number }> {
|
||||
const limit = options?.limit ?? 50;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
const conditions = [eq(auditLog.meshId, meshId)];
|
||||
if (options?.eventType) {
|
||||
conditions.push(eq(auditLog.eventType, options.eventType));
|
||||
}
|
||||
const where = conditions.length === 1 ? conditions[0]! : and(...conditions);
|
||||
|
||||
const [rows, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(auditLog)
|
||||
.where(where)
|
||||
.orderBy(desc(auditLog.id))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(auditLog)
|
||||
.where(where),
|
||||
]);
|
||||
|
||||
return {
|
||||
entries: rows.map((r) => ({
|
||||
id: r.id,
|
||||
eventType: r.eventType,
|
||||
actor: r.actorDisplayName ?? r.actorMemberId ?? "system",
|
||||
payload: r.payload as Record<string, unknown>,
|
||||
hash: r.hash,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
total: Number(countResult[0]?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ensure table exists (raw DDL for first-boot before migrations run)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function ensureAuditLogTable(): Promise<void> {
|
||||
try {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS mesh.audit_log (
|
||||
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
mesh_id TEXT NOT NULL REFERENCES mesh.mesh(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
actor_member_id TEXT,
|
||||
actor_display_name TEXT,
|
||||
payload JSONB NOT NULL DEFAULT '{}',
|
||||
prev_hash TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
} catch (e) {
|
||||
log.warn("audit: ensureAuditLogTable failed", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
68
apps/broker/src/broker-crypto.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Broker-side symmetric encryption for persisting resolved env vars.
|
||||
*
|
||||
* Uses Node's built-in crypto (AES-256-GCM). The key comes from
|
||||
* BROKER_ENCRYPTION_KEY env var (64 hex chars = 32 bytes). If not set,
|
||||
* a random key is generated and logged on first use — operator should
|
||||
* persist it to survive broker restarts.
|
||||
*
|
||||
* This is NOT the same as peer-side E2E crypto (libsodium). This is
|
||||
* platform-level encryption-at-rest, same model as Heroku/Coolify/AWS.
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import { env } from "./env";
|
||||
import { log } from "./logger";
|
||||
|
||||
const ALGO = "aes-256-gcm";
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
|
||||
let _key: Buffer | null = null;
|
||||
|
||||
function getKey(): Buffer {
|
||||
if (_key) return _key;
|
||||
|
||||
if (env.BROKER_ENCRYPTION_KEY && env.BROKER_ENCRYPTION_KEY.length === 64) {
|
||||
_key = Buffer.from(env.BROKER_ENCRYPTION_KEY, "hex");
|
||||
} else {
|
||||
_key = randomBytes(32);
|
||||
log.warn("BROKER_ENCRYPTION_KEY not set — generated ephemeral key. " +
|
||||
"Set BROKER_ENCRYPTION_KEY=" + _key.toString("hex") + " to persist across restarts.");
|
||||
}
|
||||
return _key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a JSON-serializable value. Returns a base64 string containing
|
||||
* IV + ciphertext + auth tag.
|
||||
*/
|
||||
export function encryptForStorage(plaintext: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LEN);
|
||||
const cipher = createCipheriv(ALGO, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// Pack: IV (12) + tag (16) + ciphertext
|
||||
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value produced by encryptForStorage. Returns the plaintext
|
||||
* string, or null if decryption fails (wrong key, tampered).
|
||||
*/
|
||||
export function decryptFromStorage(packed: string): string | null {
|
||||
try {
|
||||
const key = getKey();
|
||||
const buf = Buffer.from(packed, "base64");
|
||||
const iv = buf.subarray(0, IV_LEN);
|
||||
const tag = buf.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||
const ciphertext = buf.subarray(IV_LEN + TAG_LEN);
|
||||
const decipher = createDecipheriv(ALGO, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return decrypted.toString("utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
133
apps/broker/src/cli-sync.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* POST /cli-sync handler.
|
||||
*
|
||||
* Accepts a sync JWT from the dashboard, creates or finds member rows
|
||||
* for each mesh in the token, and returns mesh details + member IDs.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { verifySyncToken, type SyncTokenPayload } from "./jwt";
|
||||
|
||||
// Import schema tables
|
||||
import {
|
||||
mesh as meshTable,
|
||||
meshMember as memberTable,
|
||||
} from "@turbostarter/db/schema/mesh";
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
export interface CliSyncRequest {
|
||||
sync_token: string;
|
||||
peer_pubkey: string; // ed25519 hex (64 chars)
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface CliSyncResponse {
|
||||
ok: true;
|
||||
account_id: string;
|
||||
meshes: Array<{
|
||||
mesh_id: string;
|
||||
slug: string;
|
||||
broker_url: string;
|
||||
member_id: string;
|
||||
role: "admin" | "member";
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CliSyncError {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export async function handleCliSync(
|
||||
body: CliSyncRequest,
|
||||
): Promise<CliSyncResponse | CliSyncError> {
|
||||
// 1. Validate inputs
|
||||
if (!body.sync_token || !body.peer_pubkey || !body.display_name) {
|
||||
return { ok: false, error: "sync_token, peer_pubkey, display_name required" };
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/i.test(body.peer_pubkey)) {
|
||||
return { ok: false, error: "peer_pubkey must be 64 hex chars (32 bytes)" };
|
||||
}
|
||||
|
||||
// 2. Verify JWT
|
||||
const tokenResult = await verifySyncToken(body.sync_token);
|
||||
if (!tokenResult.ok) {
|
||||
return { ok: false, error: `sync token invalid: ${tokenResult.error}` };
|
||||
}
|
||||
const payload = tokenResult.payload;
|
||||
|
||||
// 3. For each mesh in the token, create or find a member row
|
||||
const resultMeshes: CliSyncResponse["meshes"] = [];
|
||||
|
||||
for (const tokenMesh of payload.meshes) {
|
||||
// Verify mesh exists and is not archived
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id, slug: meshTable.slug })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, tokenMesh.id), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) {
|
||||
// Skip meshes that don't exist (could have been deleted)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this pubkey is already a member of this mesh
|
||||
const [existing] = await db
|
||||
.select({ id: memberTable.id, role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.meshId, tokenMesh.id),
|
||||
eq(memberTable.peerPubkey, body.peer_pubkey),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
let memberId: string;
|
||||
let role: "admin" | "member";
|
||||
|
||||
if (existing) {
|
||||
// Already a member — update dashboard link + display name
|
||||
memberId = existing.id;
|
||||
role = existing.role;
|
||||
await db
|
||||
.update(memberTable)
|
||||
.set({
|
||||
dashboardUserId: payload.sub,
|
||||
displayName: body.display_name,
|
||||
})
|
||||
.where(eq(memberTable.id, existing.id));
|
||||
} else {
|
||||
// Create new member row
|
||||
memberId = generateId();
|
||||
role = tokenMesh.role;
|
||||
await db.insert(memberTable).values({
|
||||
id: memberId,
|
||||
meshId: tokenMesh.id,
|
||||
peerPubkey: body.peer_pubkey,
|
||||
displayName: body.display_name,
|
||||
role: tokenMesh.role,
|
||||
dashboardUserId: payload.sub,
|
||||
});
|
||||
}
|
||||
|
||||
resultMeshes.push({
|
||||
mesh_id: tokenMesh.id,
|
||||
slug: m.slug,
|
||||
broker_url: process.env.BROKER_PUBLIC_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
member_id: memberId,
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
if (resultMeshes.length === 0) {
|
||||
return { ok: false, error: "no valid meshes found in sync token" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
account_id: payload.sub,
|
||||
meshes: resultMeshes,
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,20 @@ const envSchema = z.object({
|
||||
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
|
||||
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
|
||||
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30),
|
||||
MINIO_ENDPOINT: z.string().default("minio:9000"),
|
||||
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
|
||||
MINIO_SECRET_KEY: z.string().default("changeme"),
|
||||
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
|
||||
QDRANT_URL: z.string().default("http://qdrant:6333"),
|
||||
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
|
||||
NEO4J_USER: z.string().default("neo4j"),
|
||||
NEO4J_PASSWORD: z.string().default("changeme"),
|
||||
RUNNER_URL: z.string().default("http://runner:7901"),
|
||||
CLAUDEMESH_SERVICES_DIR: z.string().default("/var/claudemesh/services"),
|
||||
BROKER_ENCRYPTION_KEY: z.string().default(""), // 64 hex chars (32 bytes). Auto-generated if empty.
|
||||
CLI_SYNC_SECRET: z.string().default(""), // HS256 shared secret for dashboard→broker sync JWTs. Required for /cli-sync.
|
||||
MAX_SERVICES_PER_MESH: z.coerce.number().int().positive().default(20),
|
||||
MAX_SERVICE_ZIP_BYTES: z.coerce.number().int().positive().default(50 * 1024 * 1024),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
|
||||
146
apps/broker/src/jwt.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* JWT verification for CLI sync tokens.
|
||||
*
|
||||
* Sync tokens are HS256 JWTs issued by the dashboard after OAuth,
|
||||
* shared secret between dashboard and broker via env var.
|
||||
*
|
||||
* JTI dedup: tracks used token IDs in a TTL-evicted Set to prevent replay.
|
||||
*/
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface SyncTokenPayload {
|
||||
sub: string; // dashboard user ID
|
||||
email: string;
|
||||
meshes: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
role: "admin" | "member";
|
||||
}>;
|
||||
action: "sync" | "create";
|
||||
newMesh?: {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
jti: string; // unique token ID for replay prevention
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// --- JTI dedup ---
|
||||
|
||||
const usedJtis = new Map<string, number>(); // jti → expiry timestamp (ms)
|
||||
|
||||
// Sweep expired JTIs every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [jti, exp] of usedJtis) {
|
||||
if (exp < now) usedJtis.delete(jti);
|
||||
}
|
||||
}, 5 * 60_000);
|
||||
|
||||
// --- Verification ---
|
||||
|
||||
/**
|
||||
* Verify and decode a sync token JWT.
|
||||
* Returns the decoded payload on success, or an error string on failure.
|
||||
*/
|
||||
export async function verifySyncToken(
|
||||
token: string,
|
||||
): Promise<{ ok: true; payload: SyncTokenPayload } | { ok: false; error: string }> {
|
||||
// Get shared secret from env
|
||||
const secret = env.CLI_SYNC_SECRET;
|
||||
if (!secret) {
|
||||
return { ok: false, error: "CLI_SYNC_SECRET not configured on broker" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode JWT manually (HS256)
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
return { ok: false, error: "malformed JWT" };
|
||||
}
|
||||
|
||||
const headerB64 = parts[0]!;
|
||||
const payloadB64 = parts[1]!;
|
||||
const signatureB64 = parts[2]!;
|
||||
|
||||
// Verify signature (HS256)
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
|
||||
const signatureInput = encoder.encode(`${headerB64}.${payloadB64}`);
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
|
||||
const valid = await crypto.subtle.verify("HMAC", key, signature, signatureInput);
|
||||
if (!valid) {
|
||||
return { ok: false, error: "invalid signature" };
|
||||
}
|
||||
|
||||
// Decode header — must be HS256
|
||||
const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64)));
|
||||
if (header.alg !== "HS256") {
|
||||
return { ok: false, error: `unsupported algorithm: ${header.alg}` };
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(payloadB64)),
|
||||
) as SyncTokenPayload;
|
||||
|
||||
// Check expiry
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp && payload.exp < now) {
|
||||
return { ok: false, error: "token expired" };
|
||||
}
|
||||
|
||||
// Check iat not in the future (30s tolerance)
|
||||
if (payload.iat && payload.iat > now + 30) {
|
||||
return { ok: false, error: "token issued in the future" };
|
||||
}
|
||||
|
||||
// JTI dedup
|
||||
if (!payload.jti) {
|
||||
return { ok: false, error: "missing jti" };
|
||||
}
|
||||
if (usedJtis.has(payload.jti)) {
|
||||
return { ok: false, error: "token already used" };
|
||||
}
|
||||
// Mark as used with expiry time
|
||||
usedJtis.set(payload.jti, (payload.exp ?? now + 900) * 1000);
|
||||
|
||||
// Basic validation
|
||||
if (!payload.sub || !payload.email) {
|
||||
return { ok: false, error: "missing sub or email" };
|
||||
}
|
||||
if (!Array.isArray(payload.meshes)) {
|
||||
return { ok: false, error: "missing meshes array" };
|
||||
}
|
||||
|
||||
return { ok: true, payload };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function base64UrlDecode(input: string): Uint8Array {
|
||||
// Add padding
|
||||
let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (base64.length % 4) base64 += "=";
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
284
apps/broker/src/member-api.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Member profile REST API handlers.
|
||||
*
|
||||
* PATCH /mesh/:meshId/member/:memberId — update member profile
|
||||
* GET /mesh/:meshId/members — list all members with online status
|
||||
* PATCH /mesh/:meshId/settings — update mesh settings (selfEditable)
|
||||
*
|
||||
* These are standalone handler functions. Route wiring happens in index.ts.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import {
|
||||
mesh as meshTable,
|
||||
meshMember as memberTable,
|
||||
presence as presenceTable,
|
||||
} from "@turbostarter/db/schema/mesh";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface MemberProfileUpdate {
|
||||
displayName?: string;
|
||||
roleTag?: string;
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
messageMode?: "push" | "inbox" | "off";
|
||||
}
|
||||
|
||||
export interface MemberPermissionUpdate {
|
||||
permission?: "admin" | "member"; // only admins can change this
|
||||
}
|
||||
|
||||
export type MemberUpdateRequest = MemberProfileUpdate & MemberPermissionUpdate;
|
||||
|
||||
interface SelfEditablePolicy {
|
||||
displayName: boolean;
|
||||
roleTag: boolean;
|
||||
groups: boolean;
|
||||
messageMode: boolean;
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
/**
|
||||
* Update a member's profile fields.
|
||||
*
|
||||
* Authorization:
|
||||
* - If caller is the target member: check mesh.selfEditable for each field
|
||||
* - If caller is a mesh admin: allow all fields
|
||||
* - permission field: admin-only always
|
||||
*
|
||||
* Returns: { ok: true, member: {...} } or { ok: false, error: string }
|
||||
*/
|
||||
export async function updateMemberProfile(
|
||||
meshId: string,
|
||||
memberId: string,
|
||||
callerMemberId: string, // from auth header or WS connection
|
||||
updates: MemberUpdateRequest,
|
||||
): Promise<
|
||||
| { ok: true; member: Record<string, unknown>; changes: MemberProfileUpdate }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
// 1. Load mesh for selfEditable policy
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id, selfEditable: meshTable.selfEditable })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) return { ok: false, error: "mesh not found" };
|
||||
|
||||
// 2. Load caller's member row to check permission
|
||||
const [caller] = await db
|
||||
.select({ id: memberTable.id, role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.id, callerMemberId),
|
||||
eq(memberTable.meshId, meshId),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!caller) return { ok: false, error: "caller not a member of this mesh" };
|
||||
|
||||
const isAdmin = caller.role === "admin";
|
||||
const isSelf = callerMemberId === memberId;
|
||||
|
||||
if (!isAdmin && !isSelf) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "not authorized — only admins or self can edit",
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Check self-edit permissions for non-admin self-edits
|
||||
const policy: SelfEditablePolicy =
|
||||
(m.selfEditable as SelfEditablePolicy) ?? {
|
||||
displayName: true,
|
||||
roleTag: true,
|
||||
groups: true,
|
||||
messageMode: true,
|
||||
};
|
||||
|
||||
const rejected: string[] = [];
|
||||
if (!isAdmin && isSelf) {
|
||||
if (updates.displayName !== undefined && !policy.displayName)
|
||||
rejected.push("displayName");
|
||||
if (updates.roleTag !== undefined && !policy.roleTag)
|
||||
rejected.push("roleTag");
|
||||
if (updates.groups !== undefined && !policy.groups)
|
||||
rejected.push("groups");
|
||||
if (updates.messageMode !== undefined && !policy.messageMode)
|
||||
rejected.push("messageMode");
|
||||
if (updates.permission !== undefined) rejected.push("permission");
|
||||
}
|
||||
|
||||
if (rejected.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `admin-managed fields: ${rejected.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Build update set
|
||||
const set: Record<string, unknown> = {};
|
||||
const changes: MemberProfileUpdate = {};
|
||||
|
||||
if (updates.displayName !== undefined) {
|
||||
set.displayName = updates.displayName;
|
||||
changes.displayName = updates.displayName;
|
||||
}
|
||||
if (updates.roleTag !== undefined) {
|
||||
set.roleTag = updates.roleTag;
|
||||
changes.roleTag = updates.roleTag;
|
||||
}
|
||||
if (updates.groups !== undefined) {
|
||||
set.defaultGroups = updates.groups;
|
||||
changes.groups = updates.groups;
|
||||
}
|
||||
if (updates.messageMode !== undefined) {
|
||||
set.messageMode = updates.messageMode;
|
||||
changes.messageMode = updates.messageMode;
|
||||
}
|
||||
if (updates.permission !== undefined && isAdmin) {
|
||||
set.role = updates.permission;
|
||||
}
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
return { ok: false, error: "no fields to update" };
|
||||
}
|
||||
|
||||
// 5. Update member row
|
||||
await db.update(memberTable).set(set).where(eq(memberTable.id, memberId));
|
||||
|
||||
// 6. Read back the updated member
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(eq(memberTable.id, memberId));
|
||||
|
||||
if (!updated) return { ok: false, error: "member not found after update" };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
member: {
|
||||
id: updated.id,
|
||||
displayName: updated.displayName,
|
||||
roleTag: updated.roleTag,
|
||||
groups: updated.defaultGroups,
|
||||
messageMode: updated.messageMode,
|
||||
permission: updated.role,
|
||||
dashboardUserId: updated.dashboardUserId,
|
||||
joinedAt: updated.joinedAt,
|
||||
lastSeenAt: updated.lastSeenAt,
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all members of a mesh with online status.
|
||||
*/
|
||||
export async function listMeshMembers(
|
||||
meshId: string,
|
||||
): Promise<
|
||||
| { ok: true; members: Array<Record<string, unknown>> }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
// Verify mesh exists
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) return { ok: false, error: "mesh not found" };
|
||||
|
||||
// Get all non-revoked members
|
||||
const members = await db
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(eq(memberTable.meshId, meshId), isNull(memberTable.revokedAt)),
|
||||
);
|
||||
|
||||
// Early return for empty member list (avoids invalid SQL IN clause)
|
||||
if (members.length === 0) {
|
||||
return { ok: true, members: [] };
|
||||
}
|
||||
|
||||
// Get active presences for online status
|
||||
const activePresences = await db
|
||||
.select({
|
||||
memberId: presenceTable.memberId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(presenceTable)
|
||||
.where(
|
||||
and(
|
||||
isNull(presenceTable.disconnectedAt),
|
||||
sql`${presenceTable.memberId} IN (${sql.join(
|
||||
members.map((m) => sql`${m.id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
)
|
||||
.groupBy(presenceTable.memberId);
|
||||
|
||||
const onlineMap = new Map(
|
||||
activePresences.map((p) => [p.memberId, p.count]),
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
members: members.map((member) => ({
|
||||
id: member.id,
|
||||
displayName: member.displayName,
|
||||
roleTag: member.roleTag,
|
||||
groups: member.defaultGroups,
|
||||
messageMode: member.messageMode,
|
||||
permission: member.role,
|
||||
dashboardUserId: member.dashboardUserId,
|
||||
joinedAt: member.joinedAt?.toISOString(),
|
||||
lastSeenAt: member.lastSeenAt?.toISOString(),
|
||||
online: onlineMap.has(member.id),
|
||||
sessionCount: onlineMap.get(member.id) ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mesh settings (currently: selfEditable policy).
|
||||
* Admin-only.
|
||||
*/
|
||||
export async function updateMeshSettings(
|
||||
meshId: string,
|
||||
callerMemberId: string,
|
||||
settings: { selfEditable?: SelfEditablePolicy },
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
// Check caller is admin
|
||||
const [caller] = await db
|
||||
.select({ role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.id, callerMemberId),
|
||||
eq(memberTable.meshId, meshId),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!caller || caller.role !== "admin") {
|
||||
return { ok: false, error: "admin access required" };
|
||||
}
|
||||
|
||||
const set: Record<string, unknown> = {};
|
||||
if (settings.selfEditable) set.selfEditable = settings.selfEditable;
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
return { ok: false, error: "no settings to update" };
|
||||
}
|
||||
|
||||
await db.update(meshTable).set(set).where(eq(meshTable.id, meshId));
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
28
apps/broker/src/minio.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* MinIO client for file storage.
|
||||
*
|
||||
* Each mesh gets its own bucket (mesh-{meshId}). Files are stored under
|
||||
* a key path that encodes persistence and origin:
|
||||
* - persistent: shared/{fileId}/{originalName}
|
||||
* - ephemeral: ephemeral/{YYYY-MM-DD}/{fileId}/{originalName}
|
||||
*/
|
||||
|
||||
import { Client } from "minio";
|
||||
import { env } from "./env";
|
||||
|
||||
export const minioClient = new Client({
|
||||
endPoint: env.MINIO_ENDPOINT.split(":")[0]!,
|
||||
port: parseInt(env.MINIO_ENDPOINT.split(":")[1] || "9000"),
|
||||
useSSL: env.MINIO_USE_SSL,
|
||||
accessKey: env.MINIO_ACCESS_KEY,
|
||||
secretKey: env.MINIO_SECRET_KEY,
|
||||
});
|
||||
|
||||
export async function ensureBucket(name: string): Promise<void> {
|
||||
const exists = await minioClient.bucketExists(name);
|
||||
if (!exists) await minioClient.makeBucket(name);
|
||||
}
|
||||
|
||||
export function meshBucketName(meshId: string): string {
|
||||
return `mesh-${meshId.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
|
||||
}
|
||||
22
apps/broker/src/neo4j-client.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import neo4j from "neo4j-driver";
|
||||
import { env } from "./env";
|
||||
|
||||
export const neo4jDriver = neo4j.driver(
|
||||
env.NEO4J_URL,
|
||||
neo4j.auth.basic(env.NEO4J_USER, env.NEO4J_PASSWORD),
|
||||
);
|
||||
|
||||
export function meshDbName(meshId: string): string {
|
||||
return `mesh_${meshId.toLowerCase().replace(/[^a-z0-9]/g, "")}`;
|
||||
}
|
||||
|
||||
export async function ensureDatabase(name: string): Promise<void> {
|
||||
const session = neo4jDriver.session({ database: "system" });
|
||||
try {
|
||||
await session.run(`CREATE DATABASE $name IF NOT EXISTS`, { name });
|
||||
} catch {
|
||||
/* may not support multi-db in community edition — fall back to default */
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
24
apps/broker/src/qdrant.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
import { env } from "./env";
|
||||
|
||||
export const qdrant = new QdrantClient({ url: env.QDRANT_URL });
|
||||
|
||||
export function meshCollectionName(
|
||||
meshId: string,
|
||||
collection: string,
|
||||
): string {
|
||||
return `mesh_${meshId}_${collection}`.toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
||||
}
|
||||
|
||||
export async function ensureCollection(
|
||||
name: string,
|
||||
vectorSize = 1536,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await qdrant.getCollection(name);
|
||||
} catch {
|
||||
await qdrant.createCollection(name, {
|
||||
vectors: { size: vectorSize, distance: "Cosine" },
|
||||
});
|
||||
}
|
||||
}
|
||||
788
apps/broker/src/service-manager.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
/**
|
||||
* Service Manager — lifecycle management for mesh-deployed MCP servers.
|
||||
*
|
||||
* Each deployed MCP server runs as a child process with its own stdio pipe.
|
||||
* The manager spawns, monitors, restarts, and routes tool calls to them.
|
||||
*
|
||||
* In production: child processes run inside a Docker container (one per mesh).
|
||||
* In dev: child processes run directly on the broker host.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { log } from "./logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** MCP tool definition returned by tools/list. */
|
||||
export interface ToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Per-service deploy-time configuration. */
|
||||
export interface ServiceConfig {
|
||||
env?: Record<string, string>;
|
||||
memory_mb?: number;
|
||||
cpus?: number;
|
||||
network_allow?: string[];
|
||||
runtime?: "node" | "python" | "bun";
|
||||
}
|
||||
|
||||
/** Observable lifecycle states. */
|
||||
export type ServiceStatus =
|
||||
| "building"
|
||||
| "installing"
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "failed"
|
||||
| "crashed"
|
||||
| "restarting";
|
||||
|
||||
/** Internal bookkeeping for a spawned service. */
|
||||
interface ManagedService {
|
||||
name: string;
|
||||
meshId: string;
|
||||
process: ChildProcess | null;
|
||||
tools: ToolDef[];
|
||||
status: ServiceStatus;
|
||||
config: ServiceConfig;
|
||||
sourcePath: string;
|
||||
runtime: "node" | "python" | "bun";
|
||||
restartCount: number;
|
||||
maxRestarts: number;
|
||||
healthFailures: number;
|
||||
logBuffer: string[]; // ring buffer, max LOG_BUFFER_SIZE
|
||||
pendingCalls: Map<
|
||||
string,
|
||||
{
|
||||
resolve: (result: { result?: unknown; error?: string }) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
>;
|
||||
pid?: number;
|
||||
startedAt?: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LOG_BUFFER_SIZE = 1000;
|
||||
const HEALTH_INTERVAL_MS = 30_000;
|
||||
const HEALTH_TIMEOUT_MS = 5_000;
|
||||
const MAX_HEALTH_FAILURES = 3;
|
||||
const DEFAULT_MAX_RESTARTS = 5;
|
||||
const CALL_TIMEOUT_MS = 25_000;
|
||||
const SERVICES_BASE_DIR =
|
||||
process.env.CLAUDEMESH_SERVICES_DIR ?? "/var/claudemesh/services";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const services = new Map<string, ManagedService>(); // keyed by "meshId:serviceName"
|
||||
let healthTimer: NodeJS.Timer | null = null;
|
||||
|
||||
function serviceKey(meshId: string, name: string): string {
|
||||
return `${meshId}:${name}`;
|
||||
}
|
||||
|
||||
/** Validate service name: alphanumeric, hyphens, underscores only. No path traversal. */
|
||||
const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
||||
|
||||
export function validateServiceName(name: string): string | null {
|
||||
if (!SAFE_NAME_RE.test(name)) {
|
||||
return "service name must be 1-64 chars, alphanumeric/hyphens/underscores, starting with alphanumeric";
|
||||
}
|
||||
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
|
||||
return "service name must not contain path separators";
|
||||
}
|
||||
return null; // valid
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect the runtime for a service based on its source directory contents.
|
||||
*
|
||||
* Priority: bun (lockfile/config) > node (package.json) > python
|
||||
* (pyproject.toml / requirements.txt). Falls back to node.
|
||||
*/
|
||||
export function detectRuntime(sourcePath: string): "node" | "python" | "bun" {
|
||||
if (
|
||||
existsSync(join(sourcePath, "bun.lockb")) ||
|
||||
existsSync(join(sourcePath, "bunfig.toml"))
|
||||
) {
|
||||
return "bun";
|
||||
}
|
||||
if (existsSync(join(sourcePath, "package.json"))) {
|
||||
return "node";
|
||||
}
|
||||
if (
|
||||
existsSync(join(sourcePath, "pyproject.toml")) ||
|
||||
existsSync(join(sourcePath, "requirements.txt"))
|
||||
) {
|
||||
return "python";
|
||||
}
|
||||
return "node"; // default
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function detectEntry(
|
||||
sourcePath: string,
|
||||
runtime: "node" | "python" | "bun",
|
||||
): { command: string; args: string[] } {
|
||||
if (runtime === "python") {
|
||||
if (existsSync(join(sourcePath, "requirements.txt"))) {
|
||||
for (const entry of [
|
||||
"server.py",
|
||||
"src/server.py",
|
||||
"main.py",
|
||||
"src/main.py",
|
||||
]) {
|
||||
if (existsSync(join(sourcePath, entry))) {
|
||||
return { command: "python", args: [entry] };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (existsSync(join(sourcePath, "pyproject.toml"))) {
|
||||
return { command: "python", args: ["-m", "server"] };
|
||||
}
|
||||
return { command: "python", args: ["server.py"] };
|
||||
}
|
||||
|
||||
// Node / Bun
|
||||
const cmd = runtime === "bun" ? "bun" : "node";
|
||||
if (existsSync(join(sourcePath, "package.json"))) {
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(sourcePath, "package.json"), "utf-8"),
|
||||
);
|
||||
if (pkg.main) return { command: cmd, args: [pkg.main] };
|
||||
if (pkg.bin) {
|
||||
const bin =
|
||||
typeof pkg.bin === "string"
|
||||
? pkg.bin
|
||||
: (Object.values(pkg.bin)[0] as string);
|
||||
if (bin) return { command: cmd, args: [bin] };
|
||||
}
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
}
|
||||
|
||||
// Common entry points
|
||||
for (const entry of [
|
||||
"dist/index.js",
|
||||
"src/index.js",
|
||||
"src/index.ts",
|
||||
"index.js",
|
||||
]) {
|
||||
if (existsSync(join(sourcePath, entry))) {
|
||||
return { command: cmd, args: [entry] };
|
||||
}
|
||||
}
|
||||
|
||||
return { command: cmd, args: ["src/index.js"] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Install dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Install dependencies for a service. Resolves on success, rejects with
|
||||
* the tail of stderr on failure.
|
||||
*/
|
||||
export async function installDeps(
|
||||
sourcePath: string,
|
||||
runtime: "node" | "python" | "bun",
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cmd: string;
|
||||
let args: string[];
|
||||
|
||||
if (runtime === "python") {
|
||||
if (existsSync(join(sourcePath, "requirements.txt"))) {
|
||||
cmd = "pip";
|
||||
args = ["install", "--no-cache-dir", "-r", "requirements.txt"];
|
||||
} else {
|
||||
cmd = "pip";
|
||||
args = ["install", "--no-cache-dir", "."];
|
||||
}
|
||||
} else if (runtime === "bun") {
|
||||
cmd = "bun";
|
||||
args = ["install"];
|
||||
} else {
|
||||
cmd = "npm";
|
||||
args = ["install", "--production", "--legacy-peer-deps"];
|
||||
}
|
||||
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: sourcePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (d: Buffer) => {
|
||||
stderr += d.toString();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else
|
||||
reject(
|
||||
new Error(
|
||||
`${cmd} install failed (exit ${code}): ${stderr.slice(-500)}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log ring buffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function appendLog(svc: ManagedService, line: string): void {
|
||||
svc.logBuffer.push(`${new Date().toISOString()} ${line}`);
|
||||
if (svc.logBuffer.length > LOG_BUFFER_SIZE) {
|
||||
svc.logBuffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP JSON-RPC helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let callIdCounter = 0;
|
||||
|
||||
function sendMcpRequest(
|
||||
svc: ManagedService,
|
||||
method: string,
|
||||
params?: unknown,
|
||||
): Promise<{ result?: unknown; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
if (!svc.process || !svc.process.stdin?.writable) {
|
||||
resolve({ error: "service not running" });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `call_${++callIdCounter}`;
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
...(params ? { params } : {}),
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
svc.pendingCalls.delete(id);
|
||||
resolve({ error: `tool call timed out after ${CALL_TIMEOUT_MS}ms` });
|
||||
}, CALL_TIMEOUT_MS);
|
||||
|
||||
svc.pendingCalls.set(id, { resolve, timer });
|
||||
|
||||
try {
|
||||
svc.process.stdin!.write(JSON.stringify(request) + "\n");
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
svc.pendingCalls.delete(id);
|
||||
resolve({
|
||||
error: `write failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize MCP server (handshake + tool discovery)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function initializeMcp(svc: ManagedService): Promise<ToolDef[]> {
|
||||
// MCP initialize handshake
|
||||
const initResult = await sendMcpRequest(svc, "initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "claudemesh-runner", version: "0.1.0" },
|
||||
});
|
||||
|
||||
if (initResult.error) {
|
||||
throw new Error(`MCP initialize failed: ${initResult.error}`);
|
||||
}
|
||||
|
||||
// Send initialized notification (no response expected)
|
||||
if (svc.process?.stdin?.writable) {
|
||||
svc.process.stdin.write(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/initialized",
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch tool list
|
||||
const toolsResult = await sendMcpRequest(svc, "tools/list", {});
|
||||
if (toolsResult.error) {
|
||||
throw new Error(`tools/list failed: ${toolsResult.error}`);
|
||||
}
|
||||
|
||||
const result = toolsResult.result as { tools?: ToolDef[] } | undefined;
|
||||
return result?.tools ?? [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn an MCP server child process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function spawnService(svc: ManagedService): void {
|
||||
const { command, args } = detectEntry(svc.sourcePath, svc.runtime);
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
...(svc.config.env ?? {}),
|
||||
NODE_ENV: "production",
|
||||
};
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: svc.sourcePath,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env,
|
||||
});
|
||||
|
||||
svc.process = child;
|
||||
svc.pid = child.pid;
|
||||
svc.startedAt = new Date();
|
||||
svc.status = "running";
|
||||
svc.healthFailures = 0;
|
||||
|
||||
// Read MCP JSON-RPC responses from stdout
|
||||
const rl = createInterface({ input: child.stdout! });
|
||||
rl.on("line", (line) => {
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id && svc.pendingCalls.has(String(msg.id))) {
|
||||
const pending = svc.pendingCalls.get(String(msg.id))!;
|
||||
clearTimeout(pending.timer);
|
||||
svc.pendingCalls.delete(String(msg.id));
|
||||
if (msg.error) {
|
||||
pending.resolve({
|
||||
error: msg.error.message ?? JSON.stringify(msg.error),
|
||||
});
|
||||
} else {
|
||||
pending.resolve({ result: msg.result });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — treat as log output
|
||||
appendLog(svc, `[stdout] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Capture stderr as logs
|
||||
const stderrRl = createInterface({ input: child.stderr! });
|
||||
stderrRl.on("line", (line) => {
|
||||
appendLog(svc, `[stderr] ${line}`);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
log.warn("service exited", {
|
||||
service: svc.name,
|
||||
mesh_id: svc.meshId,
|
||||
code,
|
||||
signal,
|
||||
restarts: svc.restartCount,
|
||||
});
|
||||
|
||||
// Reject all pending calls
|
||||
for (const [, pending] of svc.pendingCalls) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve({ error: "service crashed" });
|
||||
}
|
||||
svc.pendingCalls.clear();
|
||||
svc.process = null;
|
||||
svc.pid = undefined;
|
||||
|
||||
// Auto-restart if under limit
|
||||
if (svc.status === "running" && svc.restartCount < svc.maxRestarts) {
|
||||
svc.restartCount++;
|
||||
svc.status = "restarting";
|
||||
log.info("auto-restarting service", {
|
||||
service: svc.name,
|
||||
attempt: svc.restartCount,
|
||||
});
|
||||
setTimeout(() => spawnService(svc), 1000 * svc.restartCount); // backoff
|
||||
} else if (svc.status === "running") {
|
||||
svc.status = "crashed";
|
||||
log.error("service max restarts exceeded", {
|
||||
service: svc.name,
|
||||
restarts: svc.restartCount,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
log.error("service spawn error", {
|
||||
service: svc.name,
|
||||
error: err.message,
|
||||
});
|
||||
svc.status = "failed";
|
||||
});
|
||||
|
||||
log.info("service spawned", {
|
||||
service: svc.name,
|
||||
mesh_id: svc.meshId,
|
||||
pid: child.pid,
|
||||
command,
|
||||
args,
|
||||
runtime: svc.runtime,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deploy (or redeploy) an MCP server.
|
||||
*
|
||||
* Installs dependencies, spawns the child process, runs the MCP
|
||||
* initialize handshake, and returns the discovered tool list.
|
||||
*/
|
||||
export async function deploy(opts: {
|
||||
meshId: string;
|
||||
name: string;
|
||||
sourcePath: string;
|
||||
config: ServiceConfig;
|
||||
resolvedEnv?: Record<string, string>;
|
||||
}): Promise<{ tools: ToolDef[]; status: ServiceStatus }> {
|
||||
const key = serviceKey(opts.meshId, opts.name);
|
||||
|
||||
// Kill existing if redeploying
|
||||
const existing = services.get(key);
|
||||
if (existing?.process) {
|
||||
existing.process.kill("SIGTERM");
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
const runtime = opts.config.runtime ?? detectRuntime(opts.sourcePath);
|
||||
|
||||
const svc: ManagedService = {
|
||||
name: opts.name,
|
||||
meshId: opts.meshId,
|
||||
process: null,
|
||||
tools: [],
|
||||
status: "installing",
|
||||
config: {
|
||||
...opts.config,
|
||||
env: { ...(opts.config.env ?? {}), ...(opts.resolvedEnv ?? {}) },
|
||||
},
|
||||
sourcePath: opts.sourcePath,
|
||||
runtime,
|
||||
restartCount: 0,
|
||||
maxRestarts: DEFAULT_MAX_RESTARTS,
|
||||
healthFailures: 0,
|
||||
logBuffer: [],
|
||||
pendingCalls: new Map(),
|
||||
};
|
||||
|
||||
services.set(key, svc);
|
||||
|
||||
// Install dependencies
|
||||
try {
|
||||
await installDeps(opts.sourcePath, runtime);
|
||||
} catch (e) {
|
||||
svc.status = "failed";
|
||||
appendLog(
|
||||
svc,
|
||||
`Install failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Spawn and initialize
|
||||
spawnService(svc);
|
||||
|
||||
// Wait a moment for the process to start
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// Get tool list via MCP initialize handshake
|
||||
try {
|
||||
svc.tools = await initializeMcp(svc);
|
||||
log.info("service deployed", {
|
||||
service: opts.name,
|
||||
mesh_id: opts.meshId,
|
||||
tools: svc.tools.length,
|
||||
runtime,
|
||||
});
|
||||
} catch (e) {
|
||||
svc.status = "failed";
|
||||
appendLog(
|
||||
svc,
|
||||
`MCP init failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
|
||||
return { tools: svc.tools, status: svc.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Undeploy a running service. Sends SIGTERM, waits for graceful exit
|
||||
* (up to 10 s), then SIGKILL. All pending tool calls are rejected.
|
||||
*/
|
||||
export async function undeploy(meshId: string, name: string): Promise<void> {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return;
|
||||
|
||||
svc.status = "stopped";
|
||||
if (svc.process) {
|
||||
svc.process.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
svc.process?.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 10_000);
|
||||
svc.process?.on("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Reject pending calls
|
||||
for (const [, pending] of svc.pendingCalls) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.resolve({ error: "service undeployed" });
|
||||
}
|
||||
|
||||
services.delete(key);
|
||||
log.info("service undeployed", { service: name, mesh_id: meshId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a tool call to the named service. Returns the MCP response
|
||||
* payload or an error string.
|
||||
*/
|
||||
export async function callTool(
|
||||
meshId: string,
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<{ result?: unknown; error?: string }> {
|
||||
const key = serviceKey(meshId, serverName);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return { error: `service "${serverName}" not found` };
|
||||
if (svc.status !== "running")
|
||||
return { error: `service "${serverName}" is ${svc.status}` };
|
||||
if (!svc.process)
|
||||
return { error: `service "${serverName}" has no running process` };
|
||||
|
||||
return sendMcpRequest(svc, "tools/call", { name: toolName, arguments: args });
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the last N log lines for a service (from its ring buffer).
|
||||
*/
|
||||
export function getLogs(meshId: string, name: string, lines = 50): string[] {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return [];
|
||||
return svc.logBuffer.slice(-Math.min(lines, LOG_BUFFER_SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return current status, PID, restart count, tool list, and uptime
|
||||
* for a single service. Returns null if the service doesn't exist.
|
||||
*/
|
||||
export function getStatus(
|
||||
meshId: string,
|
||||
name: string,
|
||||
): {
|
||||
status: ServiceStatus;
|
||||
pid?: number;
|
||||
restartCount: number;
|
||||
tools: ToolDef[];
|
||||
startedAt?: string;
|
||||
} | null {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
if (!svc) return null;
|
||||
return {
|
||||
status: svc.status,
|
||||
pid: svc.pid,
|
||||
restartCount: svc.restartCount,
|
||||
tools: svc.tools,
|
||||
startedAt: svc.startedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the tool definitions for a service, or an empty array if the
|
||||
* service doesn't exist.
|
||||
*/
|
||||
export function getTools(meshId: string, name: string): ToolDef[] {
|
||||
const key = serviceKey(meshId, name);
|
||||
const svc = services.get(key);
|
||||
return svc?.tools ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* List all services belonging to a mesh with summary info.
|
||||
*/
|
||||
export function listServices(
|
||||
meshId: string,
|
||||
): Array<{
|
||||
name: string;
|
||||
status: ServiceStatus;
|
||||
toolCount: number;
|
||||
runtime: string;
|
||||
restartCount: number;
|
||||
pid?: number;
|
||||
}> {
|
||||
const result: Array<{
|
||||
name: string;
|
||||
status: ServiceStatus;
|
||||
toolCount: number;
|
||||
runtime: string;
|
||||
restartCount: number;
|
||||
pid?: number;
|
||||
}> = [];
|
||||
for (const [key, svc] of services) {
|
||||
if (!key.startsWith(`${meshId}:`)) continue;
|
||||
result.push({
|
||||
name: svc.name,
|
||||
status: svc.status,
|
||||
toolCount: svc.tools.length,
|
||||
runtime: svc.runtime,
|
||||
restartCount: svc.restartCount,
|
||||
pid: svc.pid,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function healthCheckAll(): Promise<void> {
|
||||
for (const [, svc] of services) {
|
||||
if (svc.status !== "running" || !svc.process) continue;
|
||||
|
||||
const result = await sendMcpRequest(svc, "ping", {});
|
||||
if (result.error) {
|
||||
svc.healthFailures++;
|
||||
log.warn("health check failed", {
|
||||
service: svc.name,
|
||||
failures: svc.healthFailures,
|
||||
error: result.error,
|
||||
});
|
||||
if (svc.healthFailures >= MAX_HEALTH_FAILURES) {
|
||||
log.error("health check threshold exceeded, restarting", {
|
||||
service: svc.name,
|
||||
});
|
||||
svc.process.kill("SIGTERM");
|
||||
// exit handler will trigger auto-restart
|
||||
}
|
||||
} else {
|
||||
svc.healthFailures = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the periodic health check loop (30 s interval). No-op if already running. */
|
||||
export function startHealthChecks(): void {
|
||||
if (healthTimer) return;
|
||||
healthTimer = setInterval(healthCheckAll, HEALTH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/** Stop the periodic health check loop. */
|
||||
export function stopHealthChecks(): void {
|
||||
if (healthTimer) {
|
||||
clearInterval(healthTimer);
|
||||
healthTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restore all services on broker boot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Re-deploy every persisted service record. Called once at broker startup
|
||||
* to bring services back after a restart. Failures are logged but don't
|
||||
* prevent other services from restoring.
|
||||
*/
|
||||
export async function restoreAll(
|
||||
getServiceRecords: () => Promise<
|
||||
Array<{
|
||||
meshId: string;
|
||||
name: string;
|
||||
sourcePath: string;
|
||||
config: ServiceConfig;
|
||||
resolvedEnv?: Record<string, string>;
|
||||
}>
|
||||
>,
|
||||
): Promise<void> {
|
||||
const records = await getServiceRecords();
|
||||
log.info("restoring services", { count: records.length });
|
||||
|
||||
for (const record of records) {
|
||||
try {
|
||||
await deploy({
|
||||
meshId: record.meshId,
|
||||
name: record.name,
|
||||
sourcePath: record.sourcePath,
|
||||
config: record.config,
|
||||
resolvedEnv: record.resolvedEnv,
|
||||
});
|
||||
log.info("service restored", {
|
||||
service: record.name,
|
||||
mesh_id: record.meshId,
|
||||
});
|
||||
} catch (e) {
|
||||
log.error("service restore failed", {
|
||||
service: record.name,
|
||||
mesh_id: record.meshId,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
startHealthChecks();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shutdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Gracefully shut down all running services. Stops health checks, sends
|
||||
* SIGTERM to every child, waits for exit, then clears the registry.
|
||||
*/
|
||||
export async function shutdownAll(): Promise<void> {
|
||||
stopHealthChecks();
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const [, svc] of services) {
|
||||
if (svc.process) {
|
||||
svc.status = "stopped";
|
||||
promises.push(undeploy(svc.meshId, svc.name));
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(promises);
|
||||
services.clear();
|
||||
}
|
||||
1711
apps/broker/src/telegram-bridge.ts
Normal file
148
apps/broker/src/telegram-token.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* JWT utilities for Telegram bridge connections.
|
||||
*
|
||||
* When a user connects their Telegram chat to a mesh, the broker generates
|
||||
* a short-lived JWT containing mesh credentials. The Telegram bot decodes
|
||||
* this token to establish the connection.
|
||||
*
|
||||
* Pure-crypto implementation — no external JWT library.
|
||||
* Tokens are URL-safe (base64url) for use as Telegram deep link parameters.
|
||||
*
|
||||
* IMPORTANT: The JWT payload contains the member's secretKey.
|
||||
* Never log the token or its decoded payload.
|
||||
*/
|
||||
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface TelegramConnectPayload {
|
||||
meshId: string;
|
||||
meshSlug: string;
|
||||
memberId: string;
|
||||
pubkey: string;
|
||||
secretKey: string; // ed25519 secret key — sensitive
|
||||
createdBy: string; // Dashboard userId or CLI memberId
|
||||
}
|
||||
|
||||
interface JwtClaims extends TelegramConnectPayload {
|
||||
iss: string;
|
||||
sub: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function base64url(data: string): string {
|
||||
return Buffer.from(data).toString("base64url");
|
||||
}
|
||||
|
||||
function base64urlDecode(str: string): string {
|
||||
return Buffer.from(str, "base64url").toString("utf-8");
|
||||
}
|
||||
|
||||
function sign(input: string, secret: string): string {
|
||||
return createHmac("sha256", secret).update(input).digest("base64url");
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
const JWT_HEADER = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const TOKEN_TTL_SECONDS = 15 * 60; // 15 minutes
|
||||
|
||||
/**
|
||||
* Create a signed JWT containing Telegram connect credentials.
|
||||
* Expires in 15 minutes.
|
||||
*/
|
||||
export function generateTelegramConnectToken(
|
||||
payload: TelegramConnectPayload,
|
||||
secret: string,
|
||||
): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const claims: JwtClaims = {
|
||||
...payload,
|
||||
iss: "claudemesh-broker",
|
||||
sub: "telegram-connect",
|
||||
iat: now,
|
||||
exp: now + TOKEN_TTL_SECONDS,
|
||||
};
|
||||
|
||||
const encodedPayload = base64url(JSON.stringify(claims));
|
||||
const signingInput = `${JWT_HEADER}.${encodedPayload}`;
|
||||
const signature = sign(signingInput, secret);
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and decode a Telegram connect JWT.
|
||||
* Returns the payload on success, or null on any failure
|
||||
* (bad signature, expired, wrong subject).
|
||||
*/
|
||||
export function validateTelegramConnectToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
): TelegramConnectPayload | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts as [string, string, string];
|
||||
|
||||
// Verify signature
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
const expectedSignature = sign(signingInput, secret);
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
const a = Buffer.from(signatureB64);
|
||||
const b = Buffer.from(expectedSignature);
|
||||
if (a.length !== b.length) return null;
|
||||
const { timingSafeEqual } = require("node:crypto");
|
||||
if (!timingSafeEqual(a, b)) return null;
|
||||
|
||||
// Verify header algorithm
|
||||
const header = JSON.parse(base64urlDecode(headerB64));
|
||||
if (header.alg !== "HS256") return null;
|
||||
|
||||
// Decode and validate claims
|
||||
const claims: JwtClaims = JSON.parse(base64urlDecode(payloadB64));
|
||||
|
||||
// Check subject
|
||||
if (claims.sub !== "telegram-connect") return null;
|
||||
|
||||
// Check expiry
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (claims.exp < now) return null;
|
||||
|
||||
// Check iat not in the future (30s tolerance)
|
||||
if (claims.iat > now + 30) return null;
|
||||
|
||||
// Extract payload fields (strip JWT claims)
|
||||
const {
|
||||
meshId,
|
||||
meshSlug,
|
||||
memberId,
|
||||
pubkey,
|
||||
secretKey,
|
||||
createdBy,
|
||||
} = claims;
|
||||
|
||||
// Basic presence check
|
||||
if (!meshId || !meshSlug || !memberId || !pubkey || !secretKey || !createdBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { meshId, meshSlug, memberId, pubkey, secretKey, createdBy };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Telegram deep link that passes the JWT as start parameter.
|
||||
* Format: https://t.me/{botUsername}?start={token}
|
||||
*/
|
||||
export function generateDeepLink(token: string, botUsername: string): string {
|
||||
return `https://t.me/${botUsername}?start=${token}`;
|
||||
}
|
||||
97
apps/broker/src/webhooks.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Inbound webhook handler.
|
||||
*
|
||||
* External services POST JSON to `/hook/:meshId/:secret`. The broker
|
||||
* verifies the secret against the mesh.webhook table, then pushes the
|
||||
* payload to all connected peers in that mesh as a "webhook" push.
|
||||
*/
|
||||
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { meshWebhook } from "@turbostarter/db/schema/mesh";
|
||||
import type { WSPushMessage } from "./types";
|
||||
import { log } from "./logger";
|
||||
|
||||
export interface WebhookResult {
|
||||
status: number;
|
||||
body: { ok: boolean; delivered?: number; error?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a webhook by meshId + secret, verify it's active, then return
|
||||
* the webhook name for push routing. Returns null if not found/inactive.
|
||||
*/
|
||||
async function findActiveWebhook(
|
||||
meshId: string,
|
||||
secret: string,
|
||||
): Promise<{ id: string; name: string; meshId: string } | null> {
|
||||
const rows = await db
|
||||
.select({ id: meshWebhook.id, name: meshWebhook.name, meshId: meshWebhook.meshId })
|
||||
.from(meshWebhook)
|
||||
.where(
|
||||
and(
|
||||
eq(meshWebhook.meshId, meshId),
|
||||
eq(meshWebhook.secret, secret),
|
||||
eq(meshWebhook.active, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an inbound webhook HTTP request.
|
||||
*
|
||||
* @param meshId - mesh ID from the URL path
|
||||
* @param secret - webhook secret from the URL path
|
||||
* @param body - parsed JSON body from the request
|
||||
* @param broadcastToMesh - callback to push a message to all connected peers in a mesh.
|
||||
* Returns the number of peers the message was delivered to.
|
||||
*/
|
||||
export async function handleWebhook(
|
||||
meshId: string,
|
||||
secret: string,
|
||||
body: unknown,
|
||||
broadcastToMesh: (meshId: string, msg: WSPushMessage) => number,
|
||||
): Promise<WebhookResult> {
|
||||
try {
|
||||
const webhook = await findActiveWebhook(meshId, secret);
|
||||
if (!webhook) {
|
||||
log.warn("webhook auth failed", { mesh_id: meshId });
|
||||
return { status: 401, body: { ok: false, error: "unauthorized" } };
|
||||
}
|
||||
|
||||
if (body === null || body === undefined || typeof body !== "object") {
|
||||
return { status: 400, body: { ok: false, error: "invalid JSON body" } };
|
||||
}
|
||||
|
||||
const pushMsg: WSPushMessage = {
|
||||
type: "push",
|
||||
subtype: "webhook" as any,
|
||||
event: webhook.name,
|
||||
eventData: body as Record<string, unknown>,
|
||||
messageId: crypto.randomUUID(),
|
||||
meshId: webhook.meshId,
|
||||
senderPubkey: `webhook:${webhook.name}`,
|
||||
priority: "next",
|
||||
nonce: "",
|
||||
ciphertext: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const delivered = broadcastToMesh(webhook.meshId, pushMsg);
|
||||
|
||||
log.info("webhook delivered", {
|
||||
webhook_name: webhook.name,
|
||||
mesh_id: webhook.meshId,
|
||||
delivered,
|
||||
});
|
||||
|
||||
return { status: 200, body: { ok: true, delivered } };
|
||||
} catch (e) {
|
||||
log.error("webhook handler error", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return { status: 500, body: { ok: false, error: "internal error" } };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudemesh-cli",
|
||||
"version": "0.1.3",
|
||||
"version": "0.9.2",
|
||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"citty": "0.2.2",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"ws": "8.20.0",
|
||||
"zod": "4.1.13"
|
||||
|
||||
42
apps/cli/src/__tests__/crypto-roundtrip.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { encryptDirect, decryptDirect } from "../crypto/envelope";
|
||||
import { generateKeypair } from "../crypto/keypair";
|
||||
|
||||
describe("crypto roundtrip", () => {
|
||||
it("Alice encrypts for Bob, Bob decrypts successfully", async () => {
|
||||
const alice = await generateKeypair();
|
||||
const bob = await generateKeypair();
|
||||
|
||||
const plaintext = "hello world";
|
||||
const envelope = await encryptDirect(plaintext, bob.publicKey, alice.secretKey);
|
||||
|
||||
const decrypted = await decryptDirect(envelope, alice.publicKey, bob.secretKey);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("Carol cannot decrypt a message encrypted for Bob", async () => {
|
||||
const alice = await generateKeypair();
|
||||
const bob = await generateKeypair();
|
||||
const carol = await generateKeypair();
|
||||
|
||||
const envelope = await encryptDirect("hello world", bob.publicKey, alice.secretKey);
|
||||
|
||||
const decrypted = await decryptDirect(envelope, alice.publicKey, carol.secretKey);
|
||||
expect(decrypted).toBeNull();
|
||||
});
|
||||
|
||||
it("tampered ciphertext returns null on decrypt", async () => {
|
||||
const alice = await generateKeypair();
|
||||
const bob = await generateKeypair();
|
||||
|
||||
const envelope = await encryptDirect("hello world", bob.publicKey, alice.secretKey);
|
||||
|
||||
// Flip a byte in the ciphertext
|
||||
const raw = Buffer.from(envelope.ciphertext, "base64");
|
||||
raw[0] = raw[0]! ^ 0xff;
|
||||
const tampered = { nonce: envelope.nonce, ciphertext: raw.toString("base64") };
|
||||
|
||||
const decrypted = await decryptDirect(tampered, alice.publicKey, bob.secretKey);
|
||||
expect(decrypted).toBeNull();
|
||||
});
|
||||
});
|
||||
67
apps/cli/src/__tests__/invite-parse.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseInviteLink,
|
||||
buildSignedInvite,
|
||||
extractInviteToken,
|
||||
} from "../invite/parse";
|
||||
import { generateKeypair } from "../crypto/keypair";
|
||||
|
||||
describe("invite parse", () => {
|
||||
it("round-trips a signed invite through encode and parse", async () => {
|
||||
const owner = await generateKeypair();
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
|
||||
|
||||
const { link, payload } = await buildSignedInvite({
|
||||
v: 1,
|
||||
mesh_id: "mesh-abc-123",
|
||||
mesh_slug: "test-mesh",
|
||||
broker_url: "wss://broker.example.com",
|
||||
expires_at: expiresAt,
|
||||
mesh_root_key: "deadbeefcafebabe",
|
||||
role: "member",
|
||||
owner_pubkey: owner.publicKey,
|
||||
owner_secret_key: owner.secretKey,
|
||||
});
|
||||
|
||||
const parsed = await parseInviteLink(link);
|
||||
expect(parsed.payload.mesh_id).toBe("mesh-abc-123");
|
||||
expect(parsed.payload.mesh_slug).toBe("test-mesh");
|
||||
expect(parsed.payload.broker_url).toBe("wss://broker.example.com");
|
||||
expect(parsed.payload.expires_at).toBe(expiresAt);
|
||||
expect(parsed.payload.role).toBe("member");
|
||||
expect(parsed.payload.owner_pubkey).toBe(owner.publicKey);
|
||||
expect(parsed.payload.signature).toBe(payload.signature);
|
||||
});
|
||||
|
||||
it("rejects an expired invite", async () => {
|
||||
const owner = await generateKeypair();
|
||||
const expiredAt = Math.floor(Date.now() / 1000) - 60; // 1 minute ago
|
||||
|
||||
const { link } = await buildSignedInvite({
|
||||
v: 1,
|
||||
mesh_id: "mesh-expired",
|
||||
mesh_slug: "expired-mesh",
|
||||
broker_url: "wss://broker.example.com",
|
||||
expires_at: expiredAt,
|
||||
mesh_root_key: "deadbeef",
|
||||
role: "member",
|
||||
owner_pubkey: owner.publicKey,
|
||||
owner_secret_key: owner.secretKey,
|
||||
});
|
||||
|
||||
await expect(parseInviteLink(link)).rejects.toThrow("invite expired");
|
||||
});
|
||||
|
||||
it("rejects malformed base64 in invite URL", async () => {
|
||||
// Empty payload after ic://join/ should throw.
|
||||
expect(() => extractInviteToken("ic://join/")).toThrow("invite link has no payload");
|
||||
|
||||
// Short garbage that doesn't match any format should throw.
|
||||
expect(() => extractInviteToken("!!!not-valid!!!")).toThrow("invalid invite format");
|
||||
|
||||
// A sufficiently long but garbage base64url token that decodes to
|
||||
// invalid JSON should throw at the JSON parse stage.
|
||||
const garbage = "A".repeat(30); // valid base64url chars, decodes to binary
|
||||
await expect(parseInviteLink(`ic://join/${garbage}`)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
90
apps/cli/src/auth/callback-listener.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Localhost HTTP callback listener for CLI-to-browser sync flow.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /ping → reachability check (web page preflight)
|
||||
* GET /callback → receives sync token via ?token= query param
|
||||
* OPTIONS * → CORS preflight for claudemesh.com
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from "node:http";
|
||||
|
||||
export interface CallbackListener {
|
||||
/** Port the server is listening on. */
|
||||
port: number;
|
||||
/** Resolves when the /callback endpoint receives a token. */
|
||||
token: Promise<string>;
|
||||
/** Shut down the server. */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a localhost HTTP server on a random OS-assigned port.
|
||||
* Returns the port and a promise that resolves with the sync token.
|
||||
*/
|
||||
export function startCallbackListener(): Promise<CallbackListener> {
|
||||
return new Promise((resolveStart) => {
|
||||
let resolveToken: (token: string) => void;
|
||||
const tokenPromise = new Promise<string>((r) => {
|
||||
resolveToken = r;
|
||||
});
|
||||
|
||||
const server: Server = createServer((req, res) => {
|
||||
const url = new URL(req.url!, "http://localhost");
|
||||
|
||||
// CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204, {
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
"Access-Control-Allow-Methods": "GET",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reachability check — web page calls this before redirecting
|
||||
if (url.pathname === "/ping") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/plain",
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
});
|
||||
res.end("ok");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync token callback
|
||||
if (url.pathname === "/callback") {
|
||||
const token = url.searchParams.get("token");
|
||||
if (token) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html",
|
||||
"Access-Control-Allow-Origin": "https://claudemesh.com",
|
||||
});
|
||||
res.end(
|
||||
"<html><body><h2>Done! You can close this tab.</h2><p>Launching claudemesh...</p></body></html>",
|
||||
);
|
||||
resolveToken(token);
|
||||
// Close server after a short delay to ensure response is sent
|
||||
setTimeout(() => server.close(), 500);
|
||||
} else {
|
||||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||||
res.end("Missing token");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as { port: number };
|
||||
resolveStart({
|
||||
port: addr.port,
|
||||
token: tokenPromise,
|
||||
close: () => server.close(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
4
apps/cli/src/auth/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { startCallbackListener, type CallbackListener } from "./callback-listener";
|
||||
export { openBrowser } from "./open-browser";
|
||||
export { generatePairingCode } from "./pairing-code";
|
||||
export { syncWithBroker, type SyncResult } from "./sync-with-broker";
|
||||
33
apps/cli/src/auth/open-browser.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Cross-platform browser opener.
|
||||
* Respects BROWSER env var. Falls back to platform-specific launcher.
|
||||
*/
|
||||
|
||||
import { exec } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Open a URL in the user's default browser.
|
||||
* Returns true if the command succeeded, false otherwise.
|
||||
* Non-fatal — callers should show the URL as fallback.
|
||||
*/
|
||||
export function openBrowser(url: string): Promise<boolean> {
|
||||
// Validate URL
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const quoted = JSON.stringify(url);
|
||||
const browserCmd = process.env.BROWSER;
|
||||
|
||||
const cmd = browserCmd
|
||||
? `${browserCmd} ${quoted}`
|
||||
: process.platform === "darwin"
|
||||
? `open ${quoted}`
|
||||
: process.platform === "win32"
|
||||
? `rundll32 url.dll,FileProtocolHandler ${quoted}`
|
||||
: `xdg-open ${quoted}`;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
exec(cmd, (err) => resolve(!err));
|
||||
});
|
||||
}
|
||||
17
apps/cli/src/auth/pairing-code.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Generate a short pairing code for CLI-to-browser visual confirmation.
|
||||
* Excludes ambiguous characters (0/O, 1/l/I) for readability.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||
|
||||
/**
|
||||
* Generate a 4-character alphanumeric pairing code.
|
||||
* Example output: "A3Kx", "Hn7v", "pQ4m"
|
||||
*/
|
||||
export function generatePairingCode(): string {
|
||||
const bytes = randomBytes(4);
|
||||
return Array.from(bytes, (b) => CHARS[b % CHARS.length]).join("");
|
||||
}
|
||||
83
apps/cli/src/auth/sync-with-broker.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Call the broker's POST /cli-sync endpoint to sync dashboard meshes.
|
||||
*
|
||||
* Takes a sync JWT (from the browser callback) and a freshly generated
|
||||
* ed25519 keypair. The broker creates member rows and returns mesh details.
|
||||
*/
|
||||
|
||||
export interface SyncResult {
|
||||
account_id: string;
|
||||
meshes: Array<{
|
||||
mesh_id: string;
|
||||
slug: string;
|
||||
broker_url: string;
|
||||
member_id: string;
|
||||
role: "admin" | "member";
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync meshes from dashboard via broker.
|
||||
*
|
||||
* @param syncToken - JWT from the browser sync flow
|
||||
* @param peerPubkey - ed25519 public key hex (64 chars)
|
||||
* @param displayName - display name for the new member
|
||||
* @param brokerBaseUrl - HTTPS base URL of the broker (derived from WSS URL)
|
||||
*/
|
||||
export async function syncWithBroker(
|
||||
syncToken: string,
|
||||
peerPubkey: string,
|
||||
displayName: string,
|
||||
brokerBaseUrl?: string,
|
||||
): Promise<SyncResult> {
|
||||
// Default broker URL — derive HTTPS from WSS
|
||||
const base = brokerBaseUrl ?? deriveHttpUrl(
|
||||
process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
);
|
||||
|
||||
const res = await fetch(`${base}/cli-sync`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sync_token: syncToken,
|
||||
peer_pubkey: peerPubkey,
|
||||
display_name: displayName,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
let msg: string;
|
||||
try {
|
||||
msg = JSON.parse(body).error ?? body;
|
||||
} catch {
|
||||
msg = body;
|
||||
}
|
||||
throw new Error(`Broker sync failed (${res.status}): ${msg}`);
|
||||
}
|
||||
|
||||
const body = (await res.json()) as { ok: boolean; account_id?: string; meshes?: SyncResult["meshes"]; error?: string };
|
||||
|
||||
if (!body.ok) {
|
||||
throw new Error(`Broker sync failed: ${body.error ?? "unknown error"}`);
|
||||
}
|
||||
|
||||
return {
|
||||
account_id: body.account_id!,
|
||||
meshes: body.meshes!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a WSS broker URL to an HTTPS base URL.
|
||||
* wss://ic.claudemesh.com/ws → https://ic.claudemesh.com
|
||||
* ws://localhost:3001/ws → http://localhost:3001
|
||||
*/
|
||||
function deriveHttpUrl(wssUrl: string): string {
|
||||
const url = new URL(wssUrl);
|
||||
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
||||
// Remove /ws path suffix
|
||||
url.pathname = url.pathname.replace(/\/ws\/?$/, "");
|
||||
// Remove trailing slash
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
65
apps/cli/src/commands/connect-telegram.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { loadConfig } from "../state/config";
|
||||
|
||||
export async function connectTelegram(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run 'claudemesh join' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mesh = config.meshes[0]!;
|
||||
const linkOnly = args.includes("--link");
|
||||
|
||||
// Convert WS broker URL to HTTP
|
||||
const brokerHttp = mesh.brokerUrl
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.replace("/ws", "");
|
||||
|
||||
console.log("Requesting Telegram connect token...");
|
||||
|
||||
const res = await fetch(`${brokerHttp}/tg/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
meshId: mesh.meshId,
|
||||
memberId: mesh.memberId,
|
||||
pubkey: mesh.pubkey,
|
||||
secretKey: mesh.secretKey,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
console.error(`Failed: ${(err as any).error ?? res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { token, deepLink } = (await res.json()) as {
|
||||
token: string;
|
||||
deepLink: string;
|
||||
};
|
||||
|
||||
if (linkOnly) {
|
||||
console.log(deepLink);
|
||||
return;
|
||||
}
|
||||
|
||||
// Print QR code using simple block characters
|
||||
console.log("\n Connect Telegram to your mesh:\n");
|
||||
console.log(` ${deepLink}\n`);
|
||||
console.log(" Open this link on your phone, or scan the QR code");
|
||||
console.log(" with your Telegram camera.\n");
|
||||
|
||||
// Try to generate QR with qrcode-terminal if available
|
||||
try {
|
||||
const QRCode = require("qrcode-terminal");
|
||||
QRCode.generate(deepLink, { small: true }, (code: string) => {
|
||||
console.log(code);
|
||||
});
|
||||
} catch {
|
||||
// qrcode-terminal not available, link is enough
|
||||
console.log(" (Install qrcode-terminal for QR code display)");
|
||||
}
|
||||
}
|
||||
59
apps/cli/src/commands/connect.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Short-lived WS connection helper for CLI commands (peers, send, inbox, state).
|
||||
*
|
||||
* Opens a connection to one mesh, runs a callback, then closes cleanly.
|
||||
* The caller never deals with connect/close lifecycle.
|
||||
*/
|
||||
|
||||
import { hostname } from "node:os";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
import { loadConfig } from "../state/config";
|
||||
import type { JoinedMesh } from "../state/config";
|
||||
|
||||
export interface ConnectOpts {
|
||||
/** Mesh slug to connect to. Auto-selects if only one mesh joined. */
|
||||
meshSlug?: string | null;
|
||||
/** Display name for this session. Defaults to hostname-pid. */
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export async function withMesh<T>(
|
||||
opts: ConnectOpts,
|
||||
fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let mesh: JoinedMesh;
|
||||
if (opts.meshSlug) {
|
||||
const found = config.meshes.find((m) => m.slug === opts.meshSlug);
|
||||
if (!found) {
|
||||
console.error(
|
||||
`Mesh "${opts.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
mesh = found;
|
||||
} else if (config.meshes.length === 1) {
|
||||
mesh = config.meshes[0]!;
|
||||
} else {
|
||||
console.error(
|
||||
`Multiple meshes joined. Specify one with --mesh <slug>.\nJoined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const displayName = opts.displayName ?? config.displayName ?? `${hostname()}-${process.pid}`;
|
||||
const client = new BrokerClient(mesh, { displayName });
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await fn(client, mesh);
|
||||
return result;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
39
apps/cli/src/commands/create.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* `claudemesh create` — Create a new mesh with an optional template.
|
||||
* Lists available templates if --list-templates is passed.
|
||||
*/
|
||||
import { listTemplates, getTemplate } from "../templates/index.js";
|
||||
|
||||
export function runCreate(args: Record<string, unknown>): void {
|
||||
if (args["list-templates"]) {
|
||||
console.log("Available mesh templates:\n");
|
||||
for (const t of listTemplates()) {
|
||||
console.log(` ${t.name}`);
|
||||
console.log(` ${t.description}`);
|
||||
console.log(` Groups: ${t.groups.map((g) => g.name).join(", ") || "(none)"}`);
|
||||
console.log(` State keys: ${Object.keys(t.stateKeys).join(", ") || "(none)"}`);
|
||||
console.log();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const templateName = args.template as string | undefined;
|
||||
if (templateName) {
|
||||
const template = getTemplate(templateName);
|
||||
if (!template) {
|
||||
console.error(`Unknown template "${templateName}". Use --list-templates to see available options.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Template "${template.name}" loaded:`);
|
||||
console.log(` Groups: ${template.groups.map((g) => `@${g.name}`).join(", ")}`);
|
||||
console.log(` State keys: ${Object.keys(template.stateKeys).join(", ")}`);
|
||||
console.log(` Hint: ${template.systemPromptHint.slice(0, 80)}...`);
|
||||
console.log();
|
||||
console.log("Template applied. Use `claudemesh launch` with --groups to join the predefined groups.");
|
||||
// Future: wire into actual mesh creation API
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Usage: claudemesh create --template <name>");
|
||||
console.log(" claudemesh create --list-templates");
|
||||
}
|
||||
3
apps/cli/src/commands/disconnect-telegram.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function disconnectTelegram(): Promise<void> {
|
||||
console.log("To disconnect Telegram, send /disconnect in the bot chat.");
|
||||
}
|
||||
60
apps/cli/src/commands/inbox.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* `claudemesh inbox` — read pending peer messages.
|
||||
*
|
||||
* Connects, waits briefly for push delivery, drains the buffer, prints.
|
||||
* Works best when message-mode is "inbox" or "off" (messages held at broker).
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import type { InboundPush } from "../ws/client";
|
||||
|
||||
export interface InboxFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
wait?: number;
|
||||
}
|
||||
|
||||
function formatMessage(msg: InboundPush, useColor: boolean): string {
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const text = msg.plaintext ?? `[encrypted: ${msg.ciphertext.slice(0, 32)}…]`;
|
||||
const from = msg.senderPubkey.slice(0, 8);
|
||||
const time = new Date(msg.createdAt).toLocaleTimeString();
|
||||
const kindTag = msg.kind === "direct" ? "→ direct" : msg.kind;
|
||||
|
||||
return ` ${bold(from)} ${dim(`[${kindTag}] ${time}`)}\n ${text}`;
|
||||
}
|
||||
|
||||
export async function runInbox(flags: InboxFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const waitMs = (flags.wait ?? 1) * 1000;
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
// Wait briefly for broker to push any held messages.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
|
||||
|
||||
const messages = client.drainPushBuffer();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(messages, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
console.log(dim(`No messages on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(`Inbox — ${mesh.slug}`) + dim(` (${messages.length} message${messages.length === 1 ? "" : "s"})`));
|
||||
console.log("");
|
||||
for (const msg of messages) {
|
||||
console.log(formatMessage(msg, useColor));
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
}
|
||||
58
apps/cli/src/commands/info.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* `claudemesh info` — show mesh overview: slug, broker URL, peer count, state count.
|
||||
*
|
||||
* Useful for AI agents to orient themselves in a mesh via bash.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import { loadConfig } from "../state/config";
|
||||
|
||||
export interface InfoFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runInfo(flags: InfoFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const [brokerInfo, peers, state] = await Promise.all([
|
||||
client.meshInfo(),
|
||||
client.listPeers(),
|
||||
client.listState(),
|
||||
]);
|
||||
|
||||
const output = {
|
||||
slug: mesh.slug,
|
||||
meshId: mesh.meshId,
|
||||
memberId: mesh.memberId,
|
||||
brokerUrl: mesh.brokerUrl,
|
||||
displayName: config.displayName ?? null,
|
||||
peerCount: peers.length,
|
||||
stateCount: state.length,
|
||||
...(brokerInfo ?? {}),
|
||||
};
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(mesh.slug) + dim(` · ${mesh.brokerUrl}`));
|
||||
console.log(dim(` mesh: ${mesh.meshId}`));
|
||||
console.log(dim(` member: ${mesh.memberId}`));
|
||||
console.log(` peers: ${peers.length} connected`);
|
||||
console.log(` state: ${state.length} keys`);
|
||||
if (brokerInfo && typeof brokerInfo === "object") {
|
||||
for (const [k, v] of Object.entries(brokerInfo)) {
|
||||
if (["slug", "meshId", "brokerUrl"].includes(k)) continue;
|
||||
console.log(dim(` ${k}: ${JSON.stringify(v)}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
@@ -28,6 +29,7 @@ import { homedir, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { loadConfig } from "../state/config";
|
||||
|
||||
const MCP_NAME = "claudemesh";
|
||||
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
|
||||
@@ -65,7 +67,65 @@ function readClaudeConfig(): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function writeClaudeConfig(obj: Record<string, unknown>): void {
|
||||
/**
|
||||
* Create a timestamped backup of ~/.claude.json before any write.
|
||||
*/
|
||||
function backupClaudeConfig(): void {
|
||||
if (!existsSync(CLAUDE_CONFIG)) return;
|
||||
const backupDir = join(dirname(CLAUDE_CONFIG), ".claude", "backups");
|
||||
mkdirSync(backupDir, { recursive: true });
|
||||
const ts = Date.now();
|
||||
const dest = join(backupDir, `.claude.json.pre-claudemesh.${ts}`);
|
||||
copyFileSync(CLAUDE_CONFIG, dest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
|
||||
* patches ONLY the `claudemesh` MCP entry. Never touches other keys.
|
||||
* Returns the action taken ("added" | "updated" | "unchanged").
|
||||
*/
|
||||
function patchMcpServer(entry: McpEntry): "added" | "updated" | "unchanged" {
|
||||
backupClaudeConfig();
|
||||
const cfg = readClaudeConfig();
|
||||
const servers =
|
||||
((cfg.mcpServers as Record<string, McpEntry>) ?? {});
|
||||
if (!cfg.mcpServers) cfg.mcpServers = servers;
|
||||
|
||||
const existing = servers[MCP_NAME];
|
||||
let action: "added" | "updated" | "unchanged";
|
||||
if (!existing) {
|
||||
servers[MCP_NAME] = entry;
|
||||
action = "added";
|
||||
} else if (entriesEqual(existing, entry)) {
|
||||
return "unchanged";
|
||||
} else {
|
||||
servers[MCP_NAME] = entry;
|
||||
action = "updated";
|
||||
}
|
||||
|
||||
flushClaudeConfig(cfg);
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
|
||||
* removes ONLY the `claudemesh` MCP entry. Never touches other keys.
|
||||
* Returns true if an entry was removed.
|
||||
*/
|
||||
function removeMcpServer(): boolean {
|
||||
if (!existsSync(CLAUDE_CONFIG)) return false;
|
||||
backupClaudeConfig();
|
||||
const cfg = readClaudeConfig();
|
||||
const servers = cfg.mcpServers as Record<string, McpEntry> | undefined;
|
||||
if (!servers || !(MCP_NAME in servers)) return false;
|
||||
delete servers[MCP_NAME];
|
||||
cfg.mcpServers = servers;
|
||||
flushClaudeConfig(cfg);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Low-level write — callers must backup + merge first. */
|
||||
function flushClaudeConfig(obj: Record<string, unknown>): void {
|
||||
mkdirSync(dirname(CLAUDE_CONFIG), { recursive: true });
|
||||
writeFileSync(
|
||||
CLAUDE_CONFIG,
|
||||
@@ -79,6 +139,7 @@ function writeClaudeConfig(obj: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Check `bun` is on PATH — OS-agnostic, node:child_process. */
|
||||
function bunAvailable(): boolean {
|
||||
const res =
|
||||
@@ -152,6 +213,92 @@ function writeClaudeSettings(obj: Record<string, unknown>): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All claudemesh MCP tool names, prefixed for allowedTools.
|
||||
* These let Claude Code use claudemesh tools without --dangerously-skip-permissions.
|
||||
*/
|
||||
const CLAUDEMESH_TOOLS = [
|
||||
"mcp__claudemesh__cancel_scheduled",
|
||||
"mcp__claudemesh__check_messages",
|
||||
"mcp__claudemesh__claim_task",
|
||||
"mcp__claudemesh__complete_task",
|
||||
"mcp__claudemesh__create_stream",
|
||||
"mcp__claudemesh__create_task",
|
||||
"mcp__claudemesh__delete_file",
|
||||
"mcp__claudemesh__file_status",
|
||||
"mcp__claudemesh__forget",
|
||||
"mcp__claudemesh__get_context",
|
||||
"mcp__claudemesh__get_file",
|
||||
"mcp__claudemesh__get_state",
|
||||
"mcp__claudemesh__grant_file_access",
|
||||
"mcp__claudemesh__graph_execute",
|
||||
"mcp__claudemesh__graph_query",
|
||||
"mcp__claudemesh__join_group",
|
||||
"mcp__claudemesh__leave_group",
|
||||
"mcp__claudemesh__list_collections",
|
||||
"mcp__claudemesh__list_contexts",
|
||||
"mcp__claudemesh__list_files",
|
||||
"mcp__claudemesh__list_peers",
|
||||
"mcp__claudemesh__list_scheduled",
|
||||
"mcp__claudemesh__list_state",
|
||||
"mcp__claudemesh__list_streams",
|
||||
"mcp__claudemesh__list_tasks",
|
||||
"mcp__claudemesh__mesh_execute",
|
||||
"mcp__claudemesh__mesh_info",
|
||||
"mcp__claudemesh__mesh_query",
|
||||
"mcp__claudemesh__mesh_schema",
|
||||
"mcp__claudemesh__message_status",
|
||||
"mcp__claudemesh__ping_mesh",
|
||||
"mcp__claudemesh__publish",
|
||||
"mcp__claudemesh__recall",
|
||||
"mcp__claudemesh__remember",
|
||||
"mcp__claudemesh__schedule_reminder",
|
||||
"mcp__claudemesh__send_message",
|
||||
"mcp__claudemesh__set_state",
|
||||
"mcp__claudemesh__set_status",
|
||||
"mcp__claudemesh__set_summary",
|
||||
"mcp__claudemesh__share_context",
|
||||
"mcp__claudemesh__share_file",
|
||||
"mcp__claudemesh__subscribe",
|
||||
"mcp__claudemesh__vector_delete",
|
||||
"mcp__claudemesh__vector_search",
|
||||
"mcp__claudemesh__vector_store",
|
||||
];
|
||||
|
||||
/**
|
||||
* Pre-approve all claudemesh MCP tools in allowedTools.
|
||||
* Merges into any existing list — never overwrites other entries.
|
||||
* Returns which tools were added vs already present.
|
||||
*/
|
||||
function installAllowedTools(): { added: string[]; unchanged: number } {
|
||||
const settings = readClaudeSettings();
|
||||
const existing = new Set<string>((settings.allowedTools as string[] | undefined) ?? []);
|
||||
const toAdd = CLAUDEMESH_TOOLS.filter((t) => !existing.has(t));
|
||||
if (toAdd.length > 0) {
|
||||
settings.allowedTools = [...Array.from(existing), ...toAdd];
|
||||
writeClaudeSettings(settings);
|
||||
}
|
||||
return { added: toAdd, unchanged: CLAUDEMESH_TOOLS.length - toAdd.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove claudemesh tools from allowedTools.
|
||||
* Leaves all other entries intact. Returns count removed.
|
||||
*/
|
||||
function uninstallAllowedTools(): number {
|
||||
if (!existsSync(CLAUDE_SETTINGS)) return 0;
|
||||
const settings = readClaudeSettings();
|
||||
const existing = (settings.allowedTools as string[] | undefined) ?? [];
|
||||
const toolSet = new Set(CLAUDEMESH_TOOLS);
|
||||
const kept = existing.filter((t) => !toolSet.has(t));
|
||||
const removed = existing.length - kept.length;
|
||||
if (removed > 0) {
|
||||
settings.allowedTools = kept;
|
||||
writeClaudeSettings(settings);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
|
||||
* idempotent on the command string. Returns counts for reporting.
|
||||
@@ -231,24 +378,8 @@ export function runInstall(args: string[] = []): void {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cfg = readClaudeConfig();
|
||||
const servers =
|
||||
((cfg.mcpServers ??= {}) as Record<string, McpEntry>) ?? {};
|
||||
const desired = buildMcpEntry(entry);
|
||||
const existing = servers[MCP_NAME];
|
||||
let action: "added" | "updated" | "unchanged";
|
||||
if (!existing) {
|
||||
servers[MCP_NAME] = desired;
|
||||
action = "added";
|
||||
} else if (entriesEqual(existing, desired)) {
|
||||
action = "unchanged";
|
||||
} else {
|
||||
servers[MCP_NAME] = desired;
|
||||
action = "updated";
|
||||
}
|
||||
cfg.mcpServers = servers;
|
||||
|
||||
writeClaudeConfig(cfg);
|
||||
const action = patchMcpServer(desired);
|
||||
|
||||
// Read-back verification.
|
||||
const verify = readClaudeConfig();
|
||||
@@ -277,6 +408,26 @@ export function runInstall(args: string[] = []): void {
|
||||
),
|
||||
);
|
||||
|
||||
// allowedTools — pre-approve claudemesh MCP tools so peers don't need
|
||||
// --dangerously-skip-permissions just to call mesh tools.
|
||||
try {
|
||||
const { added, unchanged } = installAllowedTools();
|
||||
if (added.length > 0) {
|
||||
console.log(
|
||||
`✓ allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`,
|
||||
);
|
||||
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
|
||||
console.log(dim(` Your existing allowedTools entries were preserved.`));
|
||||
} else {
|
||||
console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
|
||||
}
|
||||
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
|
||||
if (!skipHooks) {
|
||||
try {
|
||||
@@ -301,12 +452,35 @@ export function runInstall(args: string[] = []): void {
|
||||
console.log(dim("· Hooks skipped (--no-hooks)"));
|
||||
}
|
||||
|
||||
// Check if user has any meshes joined — nudge them if not.
|
||||
let hasMeshes = false;
|
||||
try {
|
||||
const meshConfig = loadConfig();
|
||||
hasMeshes = meshConfig.meshes.length > 0;
|
||||
} catch {
|
||||
// Config missing or corrupt — treat as no meshes.
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
|
||||
console.log("");
|
||||
console.log(
|
||||
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
||||
);
|
||||
|
||||
if (!hasMeshes) {
|
||||
console.log("");
|
||||
console.log(yellow("No meshes joined.") + " To connect with peers:");
|
||||
console.log(
|
||||
` ${bold("claudemesh join <invite-url>")}` +
|
||||
dim(" — join an existing mesh"),
|
||||
);
|
||||
console.log(
|
||||
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
|
||||
);
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(
|
||||
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(
|
||||
yellow("⚠ For real-time push messages from peers, launch with:"),
|
||||
@@ -324,22 +498,25 @@ export function runUninstall(): void {
|
||||
console.log("claudemesh uninstall");
|
||||
console.log("--------------------");
|
||||
|
||||
// MCP entry
|
||||
if (existsSync(CLAUDE_CONFIG)) {
|
||||
const cfg = readClaudeConfig();
|
||||
const servers = cfg.mcpServers as
|
||||
| Record<string, McpEntry>
|
||||
| undefined;
|
||||
if (servers && MCP_NAME in servers) {
|
||||
delete servers[MCP_NAME];
|
||||
cfg.mcpServers = servers;
|
||||
writeClaudeConfig(cfg);
|
||||
console.log(`✓ MCP server "${MCP_NAME}" removed`);
|
||||
} else {
|
||||
console.log(`· MCP server "${MCP_NAME}" not present`);
|
||||
}
|
||||
// MCP entry — only removes claudemesh, never touches other servers.
|
||||
if (removeMcpServer()) {
|
||||
console.log(`✓ MCP server "${MCP_NAME}" removed`);
|
||||
} else {
|
||||
console.log(`· no ${CLAUDE_CONFIG} — MCP entry skipped`);
|
||||
console.log(`· MCP server "${MCP_NAME}" not present`);
|
||||
}
|
||||
|
||||
// allowedTools
|
||||
try {
|
||||
const removed = uninstallAllowedTools();
|
||||
if (removed > 0) {
|
||||
console.log(`✓ allowedTools: ${removed} claudemesh tools removed`);
|
||||
} else {
|
||||
console.log("· No claudemesh allowedTools to remove");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks
|
||||
|
||||
@@ -14,7 +14,10 @@ import { parseInviteLink } from "../invite/parse";
|
||||
import { enrollWithBroker } from "../invite/enroll";
|
||||
import { generateKeypair } from "../crypto/keypair";
|
||||
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
||||
import { hostname } from "node:os";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir, hostname } from "node:os";
|
||||
import { env } from "../env";
|
||||
|
||||
export async function runJoin(args: string[]): Promise<void> {
|
||||
const link = args[0];
|
||||
@@ -78,6 +81,16 @@ export async function runJoin(args: string[]): Promise<void> {
|
||||
});
|
||||
saveConfig(config);
|
||||
|
||||
// 4b. Store invite token for per-session re-enrollment (launch --name).
|
||||
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||
const inviteFile = join(configDir, `invite-${payload.mesh_slug}.txt`);
|
||||
try {
|
||||
mkdirSync(dirname(inviteFile), { recursive: true });
|
||||
writeFileSync(inviteFile, link, "utf-8");
|
||||
} catch {
|
||||
// Non-fatal — launch will fall back to shared identity.
|
||||
}
|
||||
|
||||
// 5. Report.
|
||||
console.log("");
|
||||
console.log(
|
||||
|
||||
@@ -1,82 +1,566 @@
|
||||
/**
|
||||
* `claudemesh launch` — spawn `claude` with the dev-channel flag so the
|
||||
* claudemesh MCP server's `notifications/claude/channel` pushes get
|
||||
* injected as system reminders mid-turn.
|
||||
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
||||
*
|
||||
* Equivalent to:
|
||||
* claude --dangerously-load-development-channels server:claudemesh [extra args]
|
||||
* Flags are defined in index.ts (citty command) — that is the source of
|
||||
* truth. This file receives already-parsed flags and rawArgs.
|
||||
*
|
||||
* Any additional args (e.g. --model opus, --resume, -c) are passed
|
||||
* through verbatim. Use --quiet to skip the informational banner.
|
||||
* Flow:
|
||||
* 1. Receive parsed flags from citty + rawArgs for -- passthrough
|
||||
* 2. If --join: run join flow first
|
||||
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
|
||||
* 4. Write per-session config to tmpdir (isolates mesh selection)
|
||||
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
|
||||
* 6. On exit: cleanup tmpdir
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir, hostname, homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { loadConfig, getConfigPath } from "../state/config";
|
||||
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
|
||||
import { startCallbackListener, openBrowser, generatePairingCode } from "../auth";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
|
||||
function printBanner(): void {
|
||||
// Flags as parsed by citty (index.ts is the source of truth for definitions).
|
||||
export interface LaunchFlags {
|
||||
name?: string;
|
||||
role?: string;
|
||||
groups?: string;
|
||||
join?: string;
|
||||
mesh?: string;
|
||||
"message-mode"?: string;
|
||||
"system-prompt"?: string;
|
||||
resume?: string;
|
||||
continue?: boolean;
|
||||
yes?: boolean;
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
// --- Interactive mesh picker ---
|
||||
|
||||
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
|
||||
if (meshes.length === 1) return meshes[0]!;
|
||||
|
||||
console.log("\n Select mesh:");
|
||||
meshes.forEach((m, i) => {
|
||||
console.log(` ${i + 1}) ${m.slug}`);
|
||||
});
|
||||
console.log("");
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(" Choice [1]: ", (answer) => {
|
||||
rl.close();
|
||||
const idx = parseInt(answer || "1", 10) - 1;
|
||||
if (idx >= 0 && idx < meshes.length) {
|
||||
resolve(meshes[idx]!);
|
||||
} else {
|
||||
console.error(" Invalid choice, using first mesh.");
|
||||
resolve(meshes[0]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Group string parser ---
|
||||
|
||||
/** Parse "frontend:lead,reviewers:member,all" → GroupEntry[] */
|
||||
function parseGroupsString(raw: string): GroupEntry[] {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((token) => {
|
||||
const idx = token.indexOf(":");
|
||||
if (idx === -1) return { name: token };
|
||||
return { name: token.slice(0, idx), role: token.slice(idx + 1) };
|
||||
});
|
||||
}
|
||||
|
||||
// --- Interactive role/groups prompts ---
|
||||
|
||||
function askLine(prompt: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Permission confirmation ---
|
||||
|
||||
async function confirmPermissions(): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||
|
||||
console.log(yellow(bold(" Autonomous mode")));
|
||||
console.log("");
|
||||
console.log(" Claude will run with --dangerously-skip-permissions, bypassing");
|
||||
console.log(" ALL permission prompts — not just claudemesh tools.");
|
||||
console.log(" Peers exchange text only — no file access, no tool calls.");
|
||||
console.log("");
|
||||
console.log(dim(" Without -y: only claudemesh tools are pre-approved (via allowedTools)."));
|
||||
console.log(dim(" Use -y for autonomous agents. Omit it for shared/multi-person meshes."));
|
||||
console.log("");
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve, reject) => {
|
||||
rl.question(` ${bold("Continue?")} [Y/n] `, (answer) => {
|
||||
rl.close();
|
||||
const a = answer.trim().toLowerCase();
|
||||
if (a === "" || a === "y" || a === "yes") {
|
||||
resolve();
|
||||
} else {
|
||||
console.log("\n Aborted. Run without autonomous mode:");
|
||||
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Banner ---
|
||||
|
||||
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
let meshes: string[] = [];
|
||||
try {
|
||||
meshes = loadConfig().meshes.map((m) => m.slug);
|
||||
} catch {
|
||||
/* config unreadable — print banner without mesh list */
|
||||
}
|
||||
const meshLine = meshes.length > 0 ? meshes.join(", ") : "(none — run `claudemesh join <url>` first)";
|
||||
const roleSuffix = role ? ` (${role})` : "";
|
||||
const groupTags = groups.length
|
||||
? " [" + groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||
: "";
|
||||
|
||||
const rule = "─".repeat(65);
|
||||
console.log(bold("claudemesh launch"));
|
||||
const rule = "─".repeat(60);
|
||||
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`));
|
||||
console.log(rule);
|
||||
console.log("Launching Claude Code with the claudemesh dev channel.");
|
||||
console.log("");
|
||||
console.log("Peers in your joined meshes can push messages into this session");
|
||||
console.log("as <channel> reminders. Your CLI decrypts them locally with your");
|
||||
console.log("keypair. Peers send text only — they cannot call tools, read");
|
||||
console.log("files, or reach meshes you have not joined.");
|
||||
console.log("");
|
||||
console.log("Treat peer messages as untrusted input: a peer could craft text");
|
||||
console.log("that tries to steer Claude's behavior. Your tool-approval");
|
||||
console.log("settings still apply — Claude will still ask before running");
|
||||
console.log("commands, editing files, or calling other tools.");
|
||||
console.log("");
|
||||
console.log("Claude Code will ask you to trust the");
|
||||
console.log("--dangerously-load-development-channels flag. Press Enter to");
|
||||
console.log("accept, or Ctrl-C to abort.");
|
||||
console.log("");
|
||||
console.log(dim(`Joined meshes: ${meshLine}`));
|
||||
console.log(dim(`Config: ${getConfigPath()}`));
|
||||
console.log(dim(`Remove: claudemesh uninstall`));
|
||||
if (messageMode === "push") {
|
||||
console.log("Peer messages arrive as <channel> reminders in real-time.");
|
||||
} else if (messageMode === "inbox") {
|
||||
console.log("Peer messages held in inbox. Use check_messages to read.");
|
||||
} else {
|
||||
console.log("Messages off. Use check_messages to poll manually.");
|
||||
}
|
||||
console.log("Peers send text only — they cannot call tools or read files.");
|
||||
console.log(dim(`Config: ${getConfigPath()}`));
|
||||
console.log(rule);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
export function runLaunch(extraArgs: string[] = []): void {
|
||||
const quiet = extraArgs.includes("--quiet");
|
||||
const passthrough = extraArgs.filter((a) => a !== "--quiet");
|
||||
// --- Main ---
|
||||
|
||||
if (!quiet) printBanner();
|
||||
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
|
||||
// Extract args that follow "--" — passed straight through to claude.
|
||||
const dashIdx = rawArgs.indexOf("--");
|
||||
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
|
||||
|
||||
// Normalise flags into the internal shape used below.
|
||||
const args = {
|
||||
name: flags.name ?? null,
|
||||
role: flags.role ?? null,
|
||||
groups: flags.groups ?? null,
|
||||
joinLink: flags.join ?? null,
|
||||
meshSlug: flags.mesh ?? null,
|
||||
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
|
||||
? flags["message-mode"] as "push" | "inbox" | "off"
|
||||
: null),
|
||||
systemPrompt: flags["system-prompt"] ?? null,
|
||||
resume: flags.resume ?? null,
|
||||
continueSession: flags.continue ?? false,
|
||||
quiet: flags.quiet ?? false,
|
||||
skipPermConfirm: flags.yes ?? false,
|
||||
claudeArgs: claudePassthrough,
|
||||
};
|
||||
|
||||
// 1. If --join, run join flow first.
|
||||
if (args.joinLink) {
|
||||
console.log("Joining mesh...");
|
||||
const invite = await parseInviteLink(args.joinLink);
|
||||
const keypair = await generateKeypair();
|
||||
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
||||
const enroll = await enrollWithBroker({
|
||||
brokerWsUrl: invite.payload.broker_url,
|
||||
inviteToken: invite.token,
|
||||
invitePayload: invite.payload,
|
||||
peerPubkey: keypair.publicKey,
|
||||
displayName,
|
||||
});
|
||||
const config = loadConfig();
|
||||
config.meshes = config.meshes.filter(
|
||||
(m) => m.slug !== invite.payload.mesh_slug,
|
||||
);
|
||||
config.meshes.push({
|
||||
meshId: invite.payload.mesh_id,
|
||||
memberId: enroll.memberId,
|
||||
slug: invite.payload.mesh_slug,
|
||||
name: invite.payload.mesh_slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: invite.payload.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
const { saveConfig } = await import("../state/config");
|
||||
saveConfig(config);
|
||||
console.log(
|
||||
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Load config, pick mesh.
|
||||
const config = loadConfig();
|
||||
let justSynced = false;
|
||||
|
||||
if (config.meshes.length === 0 && !args.joinLink) {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
|
||||
const code = generatePairingCode();
|
||||
const listener = await startCallbackListener();
|
||||
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
|
||||
|
||||
console.log(`\n ${bold("Welcome to claudemesh!")} No meshes found.`);
|
||||
console.log(` Opening browser to sign in...\n`);
|
||||
|
||||
const opened = await openBrowser(url);
|
||||
if (!opened) {
|
||||
console.log(` Couldn't open browser automatically.`);
|
||||
}
|
||||
console.log(` ${dim(`Visit: ${url}`)}`);
|
||||
console.log(` ${dim(`Or join with invite: claudemesh launch --join <url>`)}\n`);
|
||||
|
||||
// Race: localhost callback vs manual paste vs timeout
|
||||
const manualPromise = new Promise<string>((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question(" Paste sync token (or wait for browser): ", (answer) => {
|
||||
rl.close();
|
||||
if (answer.trim()) resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), 15 * 60_000);
|
||||
});
|
||||
|
||||
const syncToken = await Promise.race([
|
||||
listener.token,
|
||||
manualPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
listener.close();
|
||||
|
||||
if (!syncToken) {
|
||||
console.error("\n Timed out waiting for sign-in.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate keypair and sync with broker
|
||||
const { generateKeypair } = await import("../crypto/keypair");
|
||||
const keypair = await generateKeypair();
|
||||
const displayNameForSync = args.name ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
const { syncWithBroker } = await import("../auth/sync-with-broker");
|
||||
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
|
||||
|
||||
// Write all meshes to config
|
||||
const { saveConfig } = await import("../state/config");
|
||||
for (const m of result.meshes) {
|
||||
config.meshes.push({
|
||||
meshId: m.mesh_id,
|
||||
memberId: m.member_id,
|
||||
slug: m.slug,
|
||||
name: m.slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: m.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
justSynced = true;
|
||||
|
||||
console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`);
|
||||
}
|
||||
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` or use --join <url>.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let mesh: JoinedMesh;
|
||||
if (args.meshSlug) {
|
||||
const found = config.meshes.find((m) => m.slug === args.meshSlug);
|
||||
if (!found) {
|
||||
console.error(
|
||||
`Mesh "${args.meshSlug}" not found. Joined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
mesh = found;
|
||||
} else {
|
||||
mesh = await pickMesh(config.meshes);
|
||||
}
|
||||
|
||||
// 3. Session identity + role/groups.
|
||||
// The WS client auto-generates a per-session ephemeral keypair on
|
||||
// connect (sent in hello as sessionPubkey). We set display name via env var.
|
||||
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
// Interactive wizard for role & groups (when not provided via flags and not --quiet).
|
||||
let role: string | null = args.role;
|
||||
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
|
||||
|
||||
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
|
||||
|
||||
if (!args.quiet && !justSynced) {
|
||||
if (role === null) {
|
||||
const answer = await askLine(" Role (optional): ");
|
||||
if (answer) role = answer;
|
||||
}
|
||||
if (parsedGroups.length === 0 && args.groups === null) {
|
||||
const answer = await askLine(" Groups (comma-separated, optional): ");
|
||||
if (answer) parsedGroups = parseGroupsString(answer);
|
||||
}
|
||||
if (args.messageMode === null) {
|
||||
console.log("\n Message mode:");
|
||||
console.log(" 1) Push (real-time, peers can interrupt your work)");
|
||||
console.log(" 2) Inbox (held until you check, notification only)");
|
||||
console.log(" 3) Off (tools only, no messages)");
|
||||
console.log("");
|
||||
const answer = await askLine(" Choice [1]: ");
|
||||
const choice = parseInt(answer || "1", 10);
|
||||
if (choice === 2) messageMode = "inbox";
|
||||
else if (choice === 3) messageMode = "off";
|
||||
else messageMode = "push";
|
||||
}
|
||||
if (role || parsedGroups.length) console.log("");
|
||||
}
|
||||
|
||||
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)
|
||||
const tmpBase = tmpdir();
|
||||
try {
|
||||
for (const entry of readdirSync(tmpBase)) {
|
||||
if (!entry.startsWith("claudemesh-")) continue;
|
||||
const full = join(tmpBase, entry);
|
||||
const age = Date.now() - statSync(full).mtimeMs;
|
||||
if (age > 3600_000) rmSync(full, { recursive: true, force: true });
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// Clean up stale mesh MCP entries from crashed sessions
|
||||
try {
|
||||
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||
if (existsSync(claudeConfigPath)) {
|
||||
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||
const mcpServers = claudeConfig.mcpServers ?? {};
|
||||
let cleaned = 0;
|
||||
for (const key of Object.keys(mcpServers)) {
|
||||
if (!key.startsWith("mesh:")) continue;
|
||||
const meta = mcpServers[key]?._meshSession;
|
||||
if (!meta?.pid) continue;
|
||||
// Check if the PID is still alive
|
||||
try {
|
||||
process.kill(meta.pid, 0); // signal 0 = check existence
|
||||
} catch {
|
||||
// PID is dead — remove stale entry
|
||||
delete mcpServers[key];
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
if (cleaned > 0) {
|
||||
claudeConfig.mcpServers = mcpServers;
|
||||
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// --- Fetch deployed services for native MCP entries ---
|
||||
let serviceCatalog: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
tools: Array<{ name: string; description: string; inputSchema: object }>;
|
||||
deployed_by: string;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const tmpClient = new BrokerClient(mesh, { displayName });
|
||||
await tmpClient.connect();
|
||||
// Wait briefly for hello_ack with service catalog
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
serviceCatalog = tmpClient.serviceCatalog;
|
||||
tmpClient.close();
|
||||
} catch {
|
||||
// Non-fatal — launch without native service entries
|
||||
if (!args.quiet) {
|
||||
console.log(" (Could not fetch service catalog — mesh services won't be natively available)");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Write session config to tmpdir (isolates mesh selection).
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
|
||||
const sessionConfig: Config = {
|
||||
version: 1,
|
||||
meshes: [mesh],
|
||||
displayName,
|
||||
...(role ? { role } : {}),
|
||||
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
|
||||
messageMode,
|
||||
};
|
||||
writeFileSync(
|
||||
join(tmpDir, "config.json"),
|
||||
JSON.stringify(sessionConfig, null, 2) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// 5. Banner + permission confirmation.
|
||||
if (!args.quiet) {
|
||||
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
|
||||
// Auto-permissions confirmation — needed for autonomous peer messaging.
|
||||
if (!args.skipPermConfirm) {
|
||||
await confirmPermissions();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Install native MCP entries for deployed mesh services ---
|
||||
const meshMcpEntries: Array<{ key: string; entry: unknown }> = [];
|
||||
|
||||
if (serviceCatalog.length > 0) {
|
||||
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||
|
||||
// Read-modify-write: only touch mesh:* entries in mcpServers
|
||||
let claudeConfig: Record<string, unknown> = {};
|
||||
try {
|
||||
claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||
} catch {
|
||||
claudeConfig = {};
|
||||
}
|
||||
|
||||
const mcpServers = (claudeConfig.mcpServers ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Session-scoped key: mesh:<service>:<sessionId>
|
||||
const sessionTag = `${process.pid}`;
|
||||
|
||||
for (const svc of serviceCatalog) {
|
||||
if (svc.status !== "running") continue;
|
||||
const entryKey = `mesh:${svc.name}:${sessionTag}`;
|
||||
const entry = {
|
||||
command: "claudemesh",
|
||||
args: ["mcp", "--service", svc.name],
|
||||
env: {
|
||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||
},
|
||||
_meshSession: {
|
||||
pid: process.pid,
|
||||
meshSlug: mesh.slug,
|
||||
serviceName: svc.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
mcpServers[entryKey] = entry;
|
||||
meshMcpEntries.push({ key: entryKey, entry });
|
||||
}
|
||||
|
||||
claudeConfig.mcpServers = mcpServers;
|
||||
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||
|
||||
if (!args.quiet && meshMcpEntries.length > 0) {
|
||||
console.log(` ${meshMcpEntries.length} mesh service(s) registered as native MCPs:`);
|
||||
for (const { key } of meshMcpEntries) {
|
||||
const svcName = key.split(":")[1];
|
||||
const svc = serviceCatalog.find(s => s.name === svcName);
|
||||
console.log(` ${svcName} (${svc?.tools.length ?? 0} tools)`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Spawn claude with ephemeral config + dev channel + auto-permissions.
|
||||
// Strip any user-supplied --dangerously flags to avoid duplicates.
|
||||
const filtered: string[] = [];
|
||||
for (let i = 0; i < args.claudeArgs.length; i++) {
|
||||
if (args.claudeArgs[i] === "--dangerously-load-development-channels"
|
||||
|| args.claudeArgs[i] === "--dangerously-skip-permissions") {
|
||||
if (args.claudeArgs[i] === "--dangerously-load-development-channels") i++;
|
||||
continue;
|
||||
}
|
||||
filtered.push(args.claudeArgs[i]!);
|
||||
}
|
||||
// --dangerously-skip-permissions is only added when the user explicitly
|
||||
// passes -y / --yes. Without it, claudemesh tools still work because
|
||||
// `claudemesh install` pre-approves them via allowedTools in settings.json.
|
||||
// This keeps permissions tight for multi-person meshes.
|
||||
// Session identity: --resume reuses existing session, otherwise generate new.
|
||||
// When resuming, Claude Code reuses the session ID so the mesh peer identity persists.
|
||||
const isResume = args.resume !== null || args.continueSession;
|
||||
const claudeSessionId = isResume ? undefined : randomUUID();
|
||||
|
||||
const claudeArgs = [
|
||||
"--dangerously-load-development-channels",
|
||||
"server:claudemesh",
|
||||
...passthrough,
|
||||
...(claudeSessionId ? ["--session-id", claudeSessionId] : []),
|
||||
...(args.resume ? ["--resume", args.resume] : []),
|
||||
...(args.continueSession ? ["--continue"] : []),
|
||||
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
|
||||
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
|
||||
...filtered,
|
||||
];
|
||||
// Windows: npm global binaries are .cmd shims. Node's spawn without
|
||||
// shell:true does not resolve PATHEXT, so we need shell:true on win32
|
||||
// to find claude.cmd. POSIX stays shell-less to avoid quoting surprises.
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const child = spawn("claude", claudeArgs, {
|
||||
stdio: "inherit",
|
||||
shell: isWindows,
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||
CLAUDEMESH_DISPLAY_NAME: displayName,
|
||||
...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}),
|
||||
MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000",
|
||||
MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000",
|
||||
...(role ? { CLAUDEMESH_ROLE: role } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// 7. Cleanup on exit.
|
||||
const cleanup = (): void => {
|
||||
// Remove mesh MCP entries from ~/.claude.json
|
||||
if (meshMcpEntries.length > 0) {
|
||||
try {
|
||||
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||
const mcpServers = claudeConfig.mcpServers ?? {};
|
||||
for (const { key } of meshMcpEntries) {
|
||||
delete mcpServers[key];
|
||||
}
|
||||
claudeConfig.mcpServers = mcpServers;
|
||||
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
// Existing tmpdir cleanup
|
||||
try {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
cleanup();
|
||||
if (err.code === "ENOENT") {
|
||||
console.error(
|
||||
"✗ `claude` not found on PATH. Install Claude Code first: https://claude.com/claude-code",
|
||||
"✗ `claude` not found on PATH. Install Claude Code first.",
|
||||
);
|
||||
} else {
|
||||
console.error(`✗ failed to launch claude: ${err.message}`);
|
||||
@@ -85,10 +569,15 @@ export function runLaunch(extraArgs: string[] = []): void {
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
cleanup();
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
|
||||
// Cleanup on parent signals too.
|
||||
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
||||
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
||||
}
|
||||
|
||||
63
apps/cli/src/commands/memory.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* `claudemesh remember <text> [--tags tag1,tag2]` — store a memory in the mesh.
|
||||
* `claudemesh recall <query>` — search mesh memory.
|
||||
*
|
||||
* Useful for AI agents using bash when the MCP server isn't active.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface MemoryFlags {
|
||||
mesh?: string;
|
||||
tags?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runRemember(flags: MemoryFlags, content: string): Promise<void> {
|
||||
const tags = flags.tags
|
||||
? flags.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const id = await client.remember(content, tags);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ id, content, tags }));
|
||||
return;
|
||||
}
|
||||
if (id) {
|
||||
console.log(`✓ Remembered (${id.slice(0, 8)})`);
|
||||
} else {
|
||||
console.error("✗ Failed to store memory");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function runRecall(flags: MemoryFlags, query: string): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const memories = await client.recall(query);
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(memories, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log(dim("No memories found."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const m of memories) {
|
||||
const tags = m.tags.length ? dim(` [${m.tags.join(", ")}]`) : "";
|
||||
console.log(`${bold(m.id.slice(0, 8))}${tags}`);
|
||||
console.log(` ${m.content}`);
|
||||
console.log(dim(` ${m.rememberedBy} · ${new Date(m.rememberedAt).toLocaleString()}`));
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
}
|
||||
55
apps/cli/src/commands/peers.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* `claudemesh peers` — list connected peers in the mesh.
|
||||
*
|
||||
* Connects, fetches the peer list, prints it, disconnects.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface PeersFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runPeers(flags: PeersFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const peers = await client.listPeers();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(peers, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (peers.length === 0) {
|
||||
console.log(dim(`No peers connected on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(`Peers on ${mesh.slug}`) + dim(` (${peers.length})`));
|
||||
console.log("");
|
||||
for (const p of peers) {
|
||||
const groups = p.groups.length
|
||||
? " [" + p.groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||
: "";
|
||||
const statusIcon = p.status === "working" ? yellow("●") : green("●");
|
||||
const name = bold(p.displayName);
|
||||
const meta: string[] = [];
|
||||
if (p.peerType) meta.push(p.peerType);
|
||||
if (p.channel) meta.push(p.channel);
|
||||
if (p.model) meta.push(p.model);
|
||||
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
|
||||
const cwdStr = p.cwd ? dim(` cwd: ${p.cwd}`) : "";
|
||||
const summary = p.summary ? dim(` ${p.summary}`) : "";
|
||||
console.log(` ${statusIcon} ${name}${groups}${metaStr}${summary}`);
|
||||
if (cwdStr) console.log(` ${cwdStr}`);
|
||||
}
|
||||
console.log("");
|
||||
});
|
||||
}
|
||||
114
apps/cli/src/commands/profile.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* `claudemesh profile` — view or edit your member profile.
|
||||
*
|
||||
* Profile fields (roleTag, groups, messageMode, displayName) are persistent
|
||||
* on the server. Changes are pushed to active sessions in real-time.
|
||||
*/
|
||||
|
||||
import { loadConfig } from "../state/config";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
|
||||
export interface ProfileFlags {
|
||||
mesh?: string;
|
||||
"role-tag"?: string;
|
||||
groups?: string;
|
||||
"message-mode"?: string;
|
||||
name?: string;
|
||||
member?: string; // admin only: edit another member
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runProfile(flags: ProfileFlags): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
|
||||
const config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pick mesh
|
||||
const mesh = flags.mesh
|
||||
? config.meshes.find(m => m.slug === flags.mesh)
|
||||
: config.meshes[0]!;
|
||||
|
||||
if (!mesh) {
|
||||
console.error(`Mesh "${flags.mesh}" not found. Joined: ${config.meshes.map(m => m.slug).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Derive broker HTTP URL from WSS URL
|
||||
const brokerUrl = mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace(/\/ws\/?$/, "");
|
||||
|
||||
const hasEdits = flags["role-tag"] !== undefined || flags.groups !== undefined || flags["message-mode"] !== undefined || flags.name !== undefined;
|
||||
|
||||
if (hasEdits) {
|
||||
// PATCH member profile
|
||||
const targetMemberId = flags.member ?? mesh.memberId; // TODO: resolve --member by name
|
||||
const body: Record<string, unknown> = {};
|
||||
if (flags.name !== undefined) body.displayName = flags.name;
|
||||
if (flags["role-tag"] !== undefined) body.roleTag = flags["role-tag"];
|
||||
if (flags.groups !== undefined) {
|
||||
body.groups = flags.groups.split(",").map(s => {
|
||||
const [name, role] = s.trim().split(":");
|
||||
return role ? { name: name!, role } : { name: name! };
|
||||
});
|
||||
}
|
||||
if (flags["message-mode"] !== undefined) body.messageMode = flags["message-mode"];
|
||||
|
||||
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/member/${targetMemberId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Member-Id": mesh.memberId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await res.json() as Record<string, unknown>;
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (result.ok) {
|
||||
console.log(green("✓ Profile updated"));
|
||||
const member = result.member as Record<string, unknown>;
|
||||
printProfile(member, dim);
|
||||
} else {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// GET members list, show current user's profile
|
||||
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/members`);
|
||||
const result = await res.json() as { ok: boolean; members?: Array<Record<string, unknown>>; error?: string };
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const me = result.members?.find(m => m.id === mesh.memberId);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(me ?? {}, null, 2));
|
||||
} else if (me) {
|
||||
printProfile(me, dim);
|
||||
} else {
|
||||
console.log("Member not found in mesh.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printProfile(member: Record<string, unknown>, dim: (s: string) => string): void {
|
||||
const groups = member.groups as Array<{ name: string; role?: string }> | undefined;
|
||||
const groupStr = groups?.length
|
||||
? groups.map(g => g.role ? `${g.name} (${g.role})` : g.name).join(", ")
|
||||
: dim("(none)");
|
||||
|
||||
console.log(` Name: ${member.displayName ?? dim("(not set)")}`);
|
||||
console.log(` Role: ${member.roleTag ?? dim("(not set)")}`);
|
||||
console.log(` Groups: ${groupStr}`);
|
||||
console.log(` Messages: ${member.messageMode ?? "push"}`);
|
||||
console.log(` Access: ${member.permission ?? "member"}`);
|
||||
console.log(` Mesh: ${dim(String(member.id ?? ""))}`);
|
||||
}
|
||||
142
apps/cli/src/commands/remind.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* `claudemesh remind <message> --in <duration> | --at <time>`
|
||||
* `claudemesh remind list`
|
||||
* `claudemesh remind cancel <id>`
|
||||
*
|
||||
* Human-facing interface to the broker's scheduled message delivery.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface RemindFlags {
|
||||
mesh?: string;
|
||||
in?: string; // e.g. "2h", "30m", "90s"
|
||||
at?: string; // ISO or HH:MM
|
||||
cron?: string; // 5-field cron expression for recurring
|
||||
to?: string; // default: self
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function parseDuration(raw: string): number | null {
|
||||
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)?$/i);
|
||||
if (!m) return null;
|
||||
const n = parseFloat(m[1]!);
|
||||
const unit = (m[2] ?? "s").toLowerCase();
|
||||
if (unit.startsWith("d")) return n * 86_400_000;
|
||||
if (unit.startsWith("h")) return n * 3_600_000;
|
||||
if (unit.startsWith("m")) return n * 60_000;
|
||||
return n * 1_000;
|
||||
}
|
||||
|
||||
function parseDeliverAt(flags: RemindFlags): number | null {
|
||||
if (flags.in) {
|
||||
const ms = parseDuration(flags.in);
|
||||
if (ms === null) return null;
|
||||
return Date.now() + ms;
|
||||
}
|
||||
if (flags.at) {
|
||||
// Try HH:MM first
|
||||
const hm = flags.at.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (hm) {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
target.setHours(parseInt(hm[1]!, 10), parseInt(hm[2]!, 10), 0, 0);
|
||||
if (target <= now) target.setDate(target.getDate() + 1); // next occurrence
|
||||
return target.getTime();
|
||||
}
|
||||
const ts = Date.parse(flags.at);
|
||||
return isNaN(ts) ? null : ts;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function runRemind(
|
||||
flags: RemindFlags,
|
||||
positional: string[],
|
||||
): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const action = positional[0];
|
||||
|
||||
// claudemesh remind list
|
||||
if (action === "list") {
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const scheduled = await client.listScheduled();
|
||||
if (flags.json) { console.log(JSON.stringify(scheduled, null, 2)); return; }
|
||||
if (scheduled.length === 0) { console.log(dim("No pending reminders.")); return; }
|
||||
for (const m of scheduled) {
|
||||
const when = new Date(m.deliverAt).toLocaleString();
|
||||
const to = m.to === client.getSessionPubkey() ? dim("(self)") : m.to;
|
||||
console.log(` ${bold(m.id.slice(0, 8))} → ${to} at ${when}`);
|
||||
console.log(` ${dim(m.message.slice(0, 80))}`);
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind cancel <id>
|
||||
if (action === "cancel") {
|
||||
const id = positional[1];
|
||||
if (!id) { console.error("Usage: claudemesh remind cancel <id>"); process.exit(1); }
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const ok = await client.cancelScheduled(id);
|
||||
if (ok) console.log(`✓ Cancelled ${id}`);
|
||||
else { console.error(`✗ Not found or already fired: ${id}`); process.exit(1); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind <message> --in <duration> | --at <time> | --cron <expr>
|
||||
const message = action ?? positional.join(" ");
|
||||
if (!message) {
|
||||
console.error("Usage: claudemesh remind <message> --in <duration>");
|
||||
console.error(" claudemesh remind <message> --at <time>");
|
||||
console.error(' claudemesh remind <message> --cron "0 */2 * * *"');
|
||||
console.error(" claudemesh remind list");
|
||||
console.error(" claudemesh remind cancel <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isCron = !!flags.cron;
|
||||
const deliverAt = isCron ? 0 : parseDeliverAt(flags);
|
||||
if (!isCron && deliverAt === null) {
|
||||
console.error('Specify when: --in <duration> (e.g. "2h", "30m"), --at <time> (e.g. "15:00"), or --cron <expression>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
// Determine target: --to flag or self
|
||||
let targetSpec: string;
|
||||
if (flags.to && flags.to !== "self") {
|
||||
if (flags.to.startsWith("@") || flags.to === "*" || /^[0-9a-f]{64}$/i.test(flags.to)) {
|
||||
targetSpec = flags.to;
|
||||
} else {
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find((p) => p.displayName.toLowerCase() === flags.to!.toLowerCase());
|
||||
if (!match) {
|
||||
console.error(`Peer "${flags.to}" not found. Online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
} else {
|
||||
targetSpec = client.getSessionPubkey() ?? "*";
|
||||
}
|
||||
|
||||
const result = await client.scheduleMessage(targetSpec, message, deliverAt ?? 0, false, flags.cron);
|
||||
if (!result) { console.error("✗ Broker did not acknowledge — check connection"); process.exit(1); }
|
||||
|
||||
if (flags.json) { console.log(JSON.stringify(result)); return; }
|
||||
const toLabel = !flags.to || flags.to === "self" ? "yourself" : flags.to;
|
||||
if (isCron) {
|
||||
const nextFire = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Recurring reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} — cron: ${flags.cron}, next fire: ${nextFire}`);
|
||||
} else {
|
||||
const when = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
51
apps/cli/src/commands/send.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* `claudemesh send <to> <message>` — send a message to a peer or group.
|
||||
*
|
||||
* <to> can be:
|
||||
* - a display name ("Mou")
|
||||
* - a pubkey hex ("abc123...")
|
||||
* - @group ("@flexicar")
|
||||
* - * (broadcast to all)
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import type { Priority } from "../ws/client";
|
||||
|
||||
export interface SendFlags {
|
||||
mesh?: string;
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
|
||||
const priority: Priority =
|
||||
flags.priority === "now" ? "now"
|
||||
: flags.priority === "low" ? "low"
|
||||
: "next";
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
// Resolve display name → pubkey for direct messages.
|
||||
// If `to` starts with @, *, or looks like a hex pubkey, use as-is.
|
||||
let targetSpec = to;
|
||||
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
|
||||
// Treat as display name — look up pubkey via list_peers.
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find(
|
||||
(p) => p.displayName.toLowerCase() === to.toLowerCase(),
|
||||
);
|
||||
if (!match) {
|
||||
const names = peers.map((p) => p.displayName).join(", ");
|
||||
console.error(`Peer "${to}" not found. Online: ${names || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
|
||||
const result = await client.send(targetSpec, message, priority);
|
||||
if (result.ok) {
|
||||
console.log(`✓ Sent to ${to}${result.messageId ? ` (${result.messageId.slice(0, 8)})` : ""}`);
|
||||
} else {
|
||||
console.error(`✗ Send failed: ${result.error ?? "unknown error"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
75
apps/cli/src/commands/state.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* `claudemesh state get <key>` — read a shared state value
|
||||
* `claudemesh state set <key> <value>` — write a shared state value
|
||||
* `claudemesh state list` — list all state entries
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface StateFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runStateGet(flags: StateFlags, key: string): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const entry = await client.getState(key);
|
||||
if (!entry) {
|
||||
console.log(dim(`(not set)`));
|
||||
return;
|
||||
}
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(entry, null, 2));
|
||||
return;
|
||||
}
|
||||
const val = typeof entry.value === "string" ? entry.value : JSON.stringify(entry.value);
|
||||
console.log(val);
|
||||
console.log(dim(` set by ${entry.updatedBy} at ${new Date(entry.updatedAt).toLocaleString()}`));
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStateSet(flags: StateFlags, key: string, value: string): Promise<void> {
|
||||
// Try to parse as JSON so numbers/booleans/objects work; fall back to string.
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
parsed = value;
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
await client.setState(key, parsed);
|
||||
console.log(`✓ ${key} = ${JSON.stringify(parsed)}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStateList(flags: StateFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const entries = await client.listState();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(dim(`No state on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const val = typeof e.value === "string" ? e.value : JSON.stringify(e.value);
|
||||
console.log(`${bold(e.key)}: ${val}`);
|
||||
console.log(dim(` ${e.updatedBy} · ${new Date(e.updatedAt).toLocaleString()}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
88
apps/cli/src/commands/sync.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* `claudemesh sync` — re-sync meshes from dashboard account.
|
||||
*
|
||||
* Opens browser for OAuth, receives sync token, calls broker /cli-sync,
|
||||
* merges new meshes into local config.
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { hostname } from "node:os";
|
||||
import { loadConfig, saveConfig } from "../state/config";
|
||||
import { startCallbackListener, openBrowser, generatePairingCode, syncWithBroker } from "../auth";
|
||||
import { generateKeypair } from "../crypto/keypair";
|
||||
|
||||
export async function runSync(args: { force?: boolean }): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const code = generatePairingCode();
|
||||
const listener = await startCallbackListener();
|
||||
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
|
||||
|
||||
console.log(`Opening browser to sync meshes...`);
|
||||
console.log(dim(`Visit: ${url}`));
|
||||
await openBrowser(url);
|
||||
|
||||
// Race: localhost callback vs manual paste vs timeout
|
||||
const manualPromise = new Promise<string>((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question("Paste sync token (or wait for browser): ", (answer) => {
|
||||
rl.close();
|
||||
if (answer.trim()) resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), 15 * 60_000);
|
||||
});
|
||||
|
||||
const syncToken = await Promise.race([
|
||||
listener.token,
|
||||
manualPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
listener.close();
|
||||
|
||||
if (!syncToken) {
|
||||
console.error("Timed out waiting for sign-in.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Use existing keypair from first mesh, or generate new
|
||||
const keypair = config.meshes.length > 0
|
||||
? { publicKey: config.meshes[0]!.pubkey, secretKey: config.meshes[0]!.secretKey }
|
||||
: await generateKeypair();
|
||||
|
||||
const displayName = config.displayName ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
const result = await syncWithBroker(syncToken, keypair.publicKey, displayName);
|
||||
|
||||
// Merge: add new meshes, skip duplicates
|
||||
let added = 0;
|
||||
for (const m of result.meshes) {
|
||||
if (config.meshes.some(existing => existing.meshId === m.mesh_id)) continue;
|
||||
config.meshes.push({
|
||||
meshId: m.mesh_id,
|
||||
memberId: m.member_id,
|
||||
slug: m.slug,
|
||||
name: m.slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: m.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
added++;
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
|
||||
if (added > 0) {
|
||||
console.log(green(`✓ Added ${added} new mesh(es)`));
|
||||
} else {
|
||||
console.log(`Already up to date (${config.meshes.length} meshes)`);
|
||||
}
|
||||
}
|
||||
111
apps/cli/src/commands/welcome.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Stateful welcome screen — shown when the user runs `claudemesh`
|
||||
* with no arguments. Detects install state + joined meshes + prints
|
||||
* the next action they should take.
|
||||
*
|
||||
* States, in priority order:
|
||||
* 1. MCP not registered in ~/.claude.json → run install
|
||||
* 2. Config dir exists but no meshes joined → run join
|
||||
* 3. Meshes joined, all reachable → run launch
|
||||
* 4. Meshes joined, broker unreachable → run status / doctor
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { loadConfig } from "../state/config";
|
||||
import { VERSION } from "../version";
|
||||
|
||||
type State = "no-install" | "no-meshes" | "ready" | "broken-config";
|
||||
|
||||
function detectState(): State {
|
||||
// 1. MCP registered?
|
||||
const claudeConfig = join(homedir(), ".claude.json");
|
||||
let mcpRegistered = false;
|
||||
if (existsSync(claudeConfig)) {
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(claudeConfig, "utf-8")) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
mcpRegistered = Boolean(cfg.mcpServers?.["claudemesh"]);
|
||||
} catch {
|
||||
/* treat parse errors as not-registered */
|
||||
}
|
||||
}
|
||||
if (!mcpRegistered) return "no-install";
|
||||
|
||||
// 2. Config parseable + has meshes?
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
return cfg.meshes.length === 0 ? "no-meshes" : "ready";
|
||||
} catch {
|
||||
return "broken-config";
|
||||
}
|
||||
}
|
||||
|
||||
export function runWelcome(): void {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||
|
||||
console.log(bold(`claudemesh v${VERSION}`) + dim(" — peer mesh for Claude Code"));
|
||||
console.log("─".repeat(60));
|
||||
|
||||
const state = detectState();
|
||||
|
||||
switch (state) {
|
||||
case "no-install":
|
||||
console.log("Welcome. Let's get you set up.");
|
||||
console.log("");
|
||||
console.log(bold("Step 1:") + " register the MCP server + status hooks");
|
||||
console.log(` ${green("$")} claudemesh install`);
|
||||
console.log("");
|
||||
console.log(dim("Step 2 (after restart): claudemesh join <invite-url>"));
|
||||
console.log(dim("Step 3: claudemesh launch"));
|
||||
break;
|
||||
|
||||
case "no-meshes":
|
||||
console.log(green("✓") + " MCP registered. Now join a mesh.");
|
||||
console.log("");
|
||||
console.log(bold("Step 2:") + " join a mesh");
|
||||
console.log(` ${green("$")} claudemesh join https://claudemesh.com/join/<token>`);
|
||||
console.log("");
|
||||
console.log(
|
||||
dim(" Don't have an invite? Create one at ") +
|
||||
bold("https://claudemesh.com") +
|
||||
dim(" or ask a mesh owner."),
|
||||
);
|
||||
console.log("");
|
||||
console.log(dim("Step 3 (after joining): claudemesh launch"));
|
||||
break;
|
||||
|
||||
case "ready": {
|
||||
const cfg = loadConfig();
|
||||
const meshNames = cfg.meshes.map((m) => m.slug).join(", ");
|
||||
console.log(green("✓") + " MCP registered.");
|
||||
console.log(green("✓") + ` ${cfg.meshes.length} mesh(es) joined: ${meshNames}`);
|
||||
console.log("");
|
||||
console.log(bold("You're ready.") + " Launch Claude Code with real-time peer messages:");
|
||||
console.log(` ${green("$")} claudemesh launch`);
|
||||
console.log("");
|
||||
console.log(dim(" (Plain `claude` works too — messages pull-only via check_messages.)"));
|
||||
console.log("");
|
||||
console.log(dim("Health check: claudemesh status"));
|
||||
console.log(dim("Diagnostics: claudemesh doctor"));
|
||||
console.log(dim("All commands: claudemesh --help"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "broken-config":
|
||||
console.log(yellow("⚠") + " Your ~/.claudemesh/config.json is unreadable.");
|
||||
console.log("");
|
||||
console.log("Run diagnostics to see what's wrong:");
|
||||
console.log(` ${green("$")} claudemesh doctor`);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
}
|
||||
90
apps/cli/src/crypto/file-crypto.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* File encryption for claudemesh E2E file sharing.
|
||||
*
|
||||
* Symmetric: crypto_secretbox_easy with random Kf (32-byte key).
|
||||
* Key wrapping: crypto_box_seal to recipient's X25519 pub (converted from ed25519).
|
||||
* Key opening: crypto_box_seal_open with own X25519 keypair.
|
||||
*/
|
||||
|
||||
import { ensureSodium } from "./keypair";
|
||||
|
||||
export interface EncryptedFile {
|
||||
ciphertext: Uint8Array; // secretbox ciphertext (includes MAC)
|
||||
nonce: string; // base64 24-byte nonce
|
||||
key: Uint8Array; // 32-byte symmetric Kf (keep in memory only)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt file bytes with a fresh random symmetric key.
|
||||
* Returns ciphertext, nonce (base64), and the plaintext Kf.
|
||||
*/
|
||||
export async function encryptFile(plaintext: Uint8Array): Promise<EncryptedFile> {
|
||||
const sodium = await ensureSodium();
|
||||
const key = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES);
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
|
||||
return {
|
||||
ciphertext,
|
||||
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
||||
key,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt file bytes with the symmetric key Kf.
|
||||
* Returns null if decryption fails.
|
||||
*/
|
||||
export async function decryptFile(
|
||||
ciphertext: Uint8Array,
|
||||
nonceB64: string,
|
||||
key: Uint8Array,
|
||||
): Promise<Uint8Array | null> {
|
||||
const sodium = await ensureSodium();
|
||||
try {
|
||||
const nonce = sodium.from_base64(nonceB64, sodium.base64_variants.ORIGINAL);
|
||||
return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal Kf for a recipient using crypto_box_seal (ephemeral sender key).
|
||||
* recipientPubkeyHex: ed25519 pubkey of recipient (64 hex chars).
|
||||
* Returns base64 sealed box.
|
||||
*/
|
||||
export async function sealKeyForPeer(
|
||||
kf: Uint8Array,
|
||||
recipientPubkeyHex: string,
|
||||
): Promise<string> {
|
||||
const sodium = await ensureSodium();
|
||||
const recipientCurve = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||
sodium.from_hex(recipientPubkeyHex),
|
||||
);
|
||||
const sealed = sodium.crypto_box_seal(kf, recipientCurve);
|
||||
return sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a sealed key blob using own ed25519 keypair (converted to X25519).
|
||||
* Returns the 32-byte Kf or null if decryption fails.
|
||||
*/
|
||||
export async function openSealedKey(
|
||||
sealedB64: string,
|
||||
myPubkeyHex: string,
|
||||
mySecretKeyHex: string,
|
||||
): Promise<Uint8Array | null> {
|
||||
const sodium = await ensureSodium();
|
||||
try {
|
||||
const myCurvePub = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||
sodium.from_hex(myPubkeyHex),
|
||||
);
|
||||
const myCurveSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
|
||||
sodium.from_hex(mySecretKeyHex),
|
||||
);
|
||||
const sealed = sodium.from_base64(sealedB64, sodium.base64_variants.ORIGINAL);
|
||||
return sodium.crypto_box_seal_open(sealed, myCurvePub, myCurveSec);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,23 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* CLI environment config.
|
||||
*
|
||||
* Read once at startup. Overridable via env vars so users can point
|
||||
* at a self-hosted broker or a staging instance without rebuilding.
|
||||
*/
|
||||
const envSchema = z.object({
|
||||
CLAUDEMESH_BROKER_URL: z.string().default("wss://ic.claudemesh.com/ws"),
|
||||
CLAUDEMESH_CONFIG_DIR: z.string().optional(),
|
||||
CLAUDEMESH_DEBUG: z.coerce.boolean().default(false),
|
||||
});
|
||||
|
||||
export type CliEnv = z.infer<typeof envSchema>;
|
||||
export interface CliEnv {
|
||||
CLAUDEMESH_BROKER_URL: string;
|
||||
CLAUDEMESH_CONFIG_DIR: string | undefined;
|
||||
CLAUDEMESH_DEBUG: boolean;
|
||||
}
|
||||
|
||||
export function loadEnv(): CliEnv {
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error("[claudemesh] invalid environment:");
|
||||
console.error(z.treeifyError(parsed.error));
|
||||
process.exit(1);
|
||||
}
|
||||
return parsed.data;
|
||||
return {
|
||||
CLAUDEMESH_BROKER_URL:
|
||||
process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
CLAUDEMESH_CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || undefined,
|
||||
CLAUDEMESH_DEBUG: process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true",
|
||||
};
|
||||
}
|
||||
|
||||
export const env = loadEnv();
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* claudemesh-cli entry point.
|
||||
*
|
||||
* Uses citty to define commands and flags. --help is generated from
|
||||
* the command definitions — the flag list here IS the documentation.
|
||||
*
|
||||
* Dispatches between two modes:
|
||||
* - `claudemesh mcp` → MCP server (stdio transport)
|
||||
* - `claudemesh <subcommand>` → CLI subcommand
|
||||
*
|
||||
* Claude Code invokes the `mcp` mode via stdio. Humans use all others.
|
||||
*/
|
||||
|
||||
import { defineCommand, runMain } from "citty";
|
||||
import { startMcpServer } from "./mcp/server";
|
||||
import { runInstall, runUninstall } from "./commands/install";
|
||||
import { runJoin } from "./commands/join";
|
||||
@@ -18,93 +20,320 @@ import { runHook } from "./commands/hook";
|
||||
import { runLaunch } from "./commands/launch";
|
||||
import { runStatus } from "./commands/status";
|
||||
import { runDoctor } from "./commands/doctor";
|
||||
import { runWelcome } from "./commands/welcome";
|
||||
import { runPeers } from "./commands/peers";
|
||||
import { runSend } from "./commands/send";
|
||||
import { runInbox } from "./commands/inbox";
|
||||
import { runStateGet, runStateSet, runStateList } from "./commands/state";
|
||||
import { runRemember, runRecall } from "./commands/memory";
|
||||
import { runInfo } from "./commands/info";
|
||||
import { runRemind } from "./commands/remind";
|
||||
import { runCreate } from "./commands/create";
|
||||
import { runSync } from "./commands/sync";
|
||||
import { runProfile, type ProfileFlags } from "./commands/profile";
|
||||
import { connectTelegram } from "./commands/connect-telegram";
|
||||
import { disconnectTelegram } from "./commands/disconnect-telegram";
|
||||
import { VERSION } from "./version";
|
||||
|
||||
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
|
||||
|
||||
Usage:
|
||||
claudemesh <command> [args]
|
||||
|
||||
Commands:
|
||||
install Register MCP + Stop/UserPromptSubmit status hooks
|
||||
(add --no-hooks for bare MCP registration)
|
||||
uninstall Remove MCP server + hooks
|
||||
launch [args] Launch Claude Code with real-time push messages enabled
|
||||
(add --quiet to skip the info banner; passes through
|
||||
extra flags, e.g. --model, --resume)
|
||||
join <url> Join a mesh via https://claudemesh.com/join/... URL
|
||||
list Show all joined meshes
|
||||
leave <slug> Leave a joined mesh
|
||||
status Health report: broker reachability per joined mesh
|
||||
doctor Diagnostic checks (install, config, keypairs, PATH)
|
||||
seed-test-mesh Dev-only: inject a mesh into config (skips invite flow)
|
||||
mcp Start MCP server (stdio) — invoked by Claude Code
|
||||
--help, -h Show this help
|
||||
--version, -v Show the CLI version
|
||||
|
||||
Environment:
|
||||
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws)
|
||||
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/)
|
||||
CLAUDEMESH_DEBUG=1 Verbose logging
|
||||
`;
|
||||
|
||||
const cmd = process.argv[2];
|
||||
const args = process.argv.slice(3);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
switch (cmd) {
|
||||
case "mcp":
|
||||
await startMcpServer();
|
||||
return;
|
||||
case "install":
|
||||
runInstall(args);
|
||||
return;
|
||||
case "uninstall":
|
||||
runUninstall();
|
||||
return;
|
||||
case "hook":
|
||||
await runHook(args);
|
||||
return;
|
||||
case "launch":
|
||||
runLaunch(args);
|
||||
return;
|
||||
case "join":
|
||||
await runJoin(args);
|
||||
return;
|
||||
case "list":
|
||||
runList();
|
||||
return;
|
||||
case "leave":
|
||||
runLeave(args);
|
||||
return;
|
||||
case "status":
|
||||
await runStatus();
|
||||
return;
|
||||
case "doctor":
|
||||
await runDoctor();
|
||||
return;
|
||||
case "seed-test-mesh":
|
||||
runSeedTestMesh(args);
|
||||
return;
|
||||
case "--version":
|
||||
case "-v":
|
||||
case "version":
|
||||
console.log(VERSION);
|
||||
return;
|
||||
case "--help":
|
||||
case "-h":
|
||||
case "help":
|
||||
case undefined:
|
||||
console.log(HELP);
|
||||
return;
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd}`);
|
||||
console.error("Run `claudemesh --help` for usage.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(`claudemesh: ${e instanceof Error ? e.message : String(e)}`);
|
||||
process.exit(1);
|
||||
const launch = defineCommand({
|
||||
meta: {
|
||||
name: "launch",
|
||||
description: "Spawn a Claude Code session with mesh connectivity and MCP tools",
|
||||
},
|
||||
args: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Display name visible to other peers",
|
||||
},
|
||||
role: {
|
||||
type: "string",
|
||||
description: "Free-form role tag: `dev`, `lead`, `analyst`, etc",
|
||||
},
|
||||
groups: {
|
||||
type: "string",
|
||||
description: 'Groups to join as `group:role,...` — e.g. `"eng/frontend:lead,qa:member"`',
|
||||
},
|
||||
mesh: {
|
||||
type: "string",
|
||||
description: "Mesh slug (interactive picker if omitted and >1 joined)",
|
||||
},
|
||||
join: {
|
||||
type: "string",
|
||||
description: "Join a mesh via invite URL before launching",
|
||||
},
|
||||
"message-mode": {
|
||||
type: "string",
|
||||
description: '`"push"` (default) | `"inbox"` | `"off"` — how peer messages arrive',
|
||||
},
|
||||
"system-prompt": {
|
||||
type: "string",
|
||||
description: "Custom system prompt for this Claude session",
|
||||
},
|
||||
yes: {
|
||||
type: "boolean",
|
||||
alias: "y",
|
||||
description: "Skip the --dangerously-skip-permissions confirmation",
|
||||
default: false,
|
||||
},
|
||||
resume: {
|
||||
type: "string",
|
||||
alias: "r",
|
||||
description: "Resume a previous Claude Code session by ID, or pass `true` for interactive picker",
|
||||
},
|
||||
continue: {
|
||||
type: "boolean",
|
||||
alias: "c",
|
||||
description: "Continue the most recent conversation in this directory",
|
||||
default: false,
|
||||
},
|
||||
quiet: {
|
||||
type: "boolean",
|
||||
description: "Suppress banner and interactive prompts",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
run({ args, rawArgs }) {
|
||||
// Forward to the existing launch runner, preserving -- passthrough to claude.
|
||||
return runLaunch(args, rawArgs);
|
||||
},
|
||||
});
|
||||
|
||||
const install = defineCommand({
|
||||
meta: {
|
||||
name: "install",
|
||||
description: "Register MCP server and status hooks with Claude Code",
|
||||
},
|
||||
args: {
|
||||
"no-hooks": {
|
||||
type: "boolean",
|
||||
description: "Register MCP server only, skip hooks",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
run({ rawArgs }) {
|
||||
runInstall(rawArgs);
|
||||
},
|
||||
});
|
||||
|
||||
const join = defineCommand({
|
||||
meta: {
|
||||
name: "join",
|
||||
description: "Join a mesh via invite URL or token",
|
||||
},
|
||||
args: {
|
||||
url: {
|
||||
type: "positional",
|
||||
description: "Invite URL (`https://claudemesh.com/join/...`) or token",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
run({ args }) {
|
||||
return runJoin([args.url]);
|
||||
},
|
||||
});
|
||||
|
||||
const leave = defineCommand({
|
||||
meta: {
|
||||
name: "leave",
|
||||
description: "Leave a joined mesh and remove its local keypair",
|
||||
},
|
||||
args: {
|
||||
slug: {
|
||||
type: "positional",
|
||||
description: "Mesh slug to leave (see `claudemesh list`)",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
run({ args }) {
|
||||
runLeave([args.slug]);
|
||||
},
|
||||
});
|
||||
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
name: "claudemesh",
|
||||
version: VERSION,
|
||||
description: "Peer mesh for Claude Code sessions",
|
||||
},
|
||||
subCommands: {
|
||||
launch,
|
||||
create: defineCommand({
|
||||
meta: { name: "create", description: "Create a new mesh from a template" },
|
||||
args: {
|
||||
template: { type: "string", description: "Template name: `dev-team`, `research`, `ops-incident`, `simulation`, `personal`" },
|
||||
"list-templates": { type: "boolean", description: "List available templates and exit", default: false },
|
||||
},
|
||||
run({ args }) { runCreate(args); },
|
||||
}),
|
||||
install,
|
||||
uninstall: defineCommand({
|
||||
meta: { name: "uninstall", description: "Remove MCP server and hooks from Claude Code config" },
|
||||
run() { runUninstall(); },
|
||||
}),
|
||||
join,
|
||||
list: defineCommand({
|
||||
meta: { name: "list", description: "Show joined meshes, slugs, and local identities" },
|
||||
run() { runList(); },
|
||||
}),
|
||||
leave,
|
||||
peers: defineCommand({
|
||||
meta: { name: "peers", description: "List online peers with status, summary, and groups" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runPeers(args); },
|
||||
}),
|
||||
send: defineCommand({
|
||||
meta: { name: "send", description: "Send a message to a peer, group, or all peers" },
|
||||
args: {
|
||||
to: { type: "positional", description: "Recipient: display name, `@group`, `*` (broadcast), or pubkey hex", required: true },
|
||||
message: { type: "positional", description: "Message text", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
priority: { type: "string", description: '`"now"` | `"next"` (default) | `"low"`' },
|
||||
},
|
||||
async run({ args }) { await runSend(args, args.to, args.message); },
|
||||
}),
|
||||
inbox: defineCommand({
|
||||
meta: { name: "inbox", description: "Drain pending inbound messages" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
wait: { type: "string", description: "Seconds to wait for broker delivery (default: `1`)" },
|
||||
},
|
||||
async run({ args }) {
|
||||
await runInbox({ ...args, wait: args.wait ? parseInt(args.wait, 10) : undefined });
|
||||
},
|
||||
}),
|
||||
state: defineCommand({
|
||||
meta: { name: "state", description: "Get, set, or list shared key-value state in the mesh" },
|
||||
args: {
|
||||
action: { type: "positional", description: "`get <key>` | `set <key> <value>` | `list`", required: true },
|
||||
key: { type: "positional", description: "State key (required for `get` and `set`)" },
|
||||
value: { type: "positional", description: "Value to store (required for `set`)" },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
if (args.action === "list") {
|
||||
await runStateList(args);
|
||||
} else if (args.action === "get") {
|
||||
if (!args.key) { console.error("Usage: claudemesh state get <key>"); process.exit(1); }
|
||||
await runStateGet(args, args.key);
|
||||
} else if (args.action === "set") {
|
||||
if (!args.key || !args.value) { console.error("Usage: claudemesh state set <key> <value>"); process.exit(1); }
|
||||
await runStateSet(args, args.key, args.value);
|
||||
} else {
|
||||
console.error(`Unknown action "${args.action}". Use: get, set, list`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
}),
|
||||
info: defineCommand({
|
||||
meta: { name: "info", description: "Show mesh overview: slug, broker, peer count, state keys" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runInfo(args); },
|
||||
}),
|
||||
remember: defineCommand({
|
||||
meta: { name: "remember", description: "Store a persistent memory visible to all peers" },
|
||||
args: {
|
||||
content: { type: "positional", description: "Text to store", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
tags: { type: "string", description: "Comma-separated tags, e.g. `task,context`" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runRemember(args, args.content); },
|
||||
}),
|
||||
recall: defineCommand({
|
||||
meta: { name: "recall", description: "Search mesh memories by keyword or phrase" },
|
||||
args: {
|
||||
query: { type: "positional", description: "Full-text search query", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runRecall(args, args.query); },
|
||||
}),
|
||||
remind: defineCommand({
|
||||
meta: { name: "remind", description: "Schedule a delayed message. Also: `remind list`, `remind cancel <id>`" },
|
||||
args: {
|
||||
message: { type: "positional", description: "Message text — or `list` / `cancel <id>` to manage reminders", required: false },
|
||||
extra: { type: "positional", description: "Reminder ID for `cancel`", required: false },
|
||||
in: { type: "string", description: 'Deliver after duration: `"2h"`, `"30m"`, `"90s"`' },
|
||||
at: { type: "string", description: 'Deliver at time: `"15:00"` or ISO timestamp' },
|
||||
cron: { type: "string", description: 'Recurring cron expression: `"0 */2 * * *"` (every 2h), `"30 9 * * 1-5"` (9:30 weekdays)' },
|
||||
to: { type: "string", description: "Recipient (default: self). Name, `@group`, `*`, or pubkey" },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args, rawArgs }) {
|
||||
// Collect positional args from rawArgs (before any flags)
|
||||
const positionals = rawArgs.filter((a) => !a.startsWith("-"));
|
||||
await runRemind(args, positionals);
|
||||
},
|
||||
}),
|
||||
sync: defineCommand({
|
||||
meta: { name: "sync", description: "Sync meshes from your dashboard account" },
|
||||
args: {
|
||||
force: { type: "boolean", description: "Re-link account even if already linked", default: false },
|
||||
},
|
||||
async run({ args }) { await runSync(args); },
|
||||
}),
|
||||
profile: defineCommand({
|
||||
meta: { name: "profile", description: "View or edit your member profile" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
"role-tag": { type: "string", description: "Set role tag (e.g. 'backend-dev', 'lead')" },
|
||||
groups: { type: "string", description: "Set groups as 'group:role,...' (e.g. 'eng:lead,review')" },
|
||||
"message-mode": { type: "string", description: "'push' | 'inbox' | 'off'" },
|
||||
name: { type: "string", description: "Set display name" },
|
||||
member: { type: "string", description: "Edit another member (admin only)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runProfile(args as ProfileFlags); },
|
||||
}),
|
||||
status: defineCommand({
|
||||
meta: { name: "status", description: "Check broker connectivity for each joined mesh" },
|
||||
async run() { await runStatus(); },
|
||||
}),
|
||||
doctor: defineCommand({
|
||||
meta: { name: "doctor", description: "Diagnose install, config, keypairs, and PATH issues" },
|
||||
async run() { await runDoctor(); },
|
||||
}),
|
||||
mcp: defineCommand({
|
||||
meta: { name: "mcp", description: "Start MCP server on stdio (called by Claude Code, not users)" },
|
||||
async run() { await startMcpServer(); },
|
||||
}),
|
||||
"seed-test-mesh": defineCommand({
|
||||
meta: { name: "seed-test-mesh", description: "Dev: inject a mesh into local config, skip invite flow" },
|
||||
run({ rawArgs }) { runSeedTestMesh(rawArgs); },
|
||||
}),
|
||||
hook: defineCommand({
|
||||
meta: { name: "hook", description: "Internal: handle Claude Code hook events" },
|
||||
async run({ rawArgs }) { await runHook(rawArgs); },
|
||||
}),
|
||||
connect: defineCommand({
|
||||
meta: { name: "connect", description: "Connect an integration (e.g. telegram)" },
|
||||
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
|
||||
async run({ args }) {
|
||||
if (args.target === "telegram") await connectTelegram(process.argv.slice(process.argv.indexOf("telegram") + 1));
|
||||
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
|
||||
},
|
||||
}),
|
||||
disconnect: defineCommand({
|
||||
meta: { name: "disconnect", description: "Disconnect an integration (e.g. telegram)" },
|
||||
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
|
||||
async run({ args }) {
|
||||
if (args.target === "telegram") await disconnectTelegram();
|
||||
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
|
||||
},
|
||||
}),
|
||||
},
|
||||
run() {
|
||||
runWelcome();
|
||||
},
|
||||
});
|
||||
|
||||
runMain(main);
|
||||
|
||||
@@ -5,22 +5,19 @@
|
||||
* verification and one-time-use invite-token tracking land in Step 18.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { ensureSodium } from "../crypto/keypair";
|
||||
|
||||
const invitePayloadSchema = z.object({
|
||||
v: z.literal(1),
|
||||
mesh_id: z.string().min(1),
|
||||
mesh_slug: z.string().min(1),
|
||||
broker_url: z.string().min(1),
|
||||
expires_at: z.number().int().positive(),
|
||||
mesh_root_key: z.string().min(1),
|
||||
role: z.enum(["admin", "member"]),
|
||||
owner_pubkey: z.string().regex(/^[0-9a-f]{64}$/i),
|
||||
signature: z.string().regex(/^[0-9a-f]{128}$/i),
|
||||
});
|
||||
|
||||
export type InvitePayload = z.infer<typeof invitePayloadSchema>;
|
||||
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;
|
||||
@@ -28,6 +25,21 @@ export interface ParsedInvite {
|
||||
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;
|
||||
@@ -96,41 +108,34 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = invitePayloadSchema.safeParse(obj);
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`invite link shape invalid: ${parsed.error.issues.map((i) => i.path.join(".") + ": " + i.message).join("; ")}`,
|
||||
);
|
||||
}
|
||||
const payload = validatePayload(obj);
|
||||
|
||||
// Expiry check (unix seconds).
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
if (parsed.data.expires_at < nowSeconds) {
|
||||
if (payload.expires_at < nowSeconds) {
|
||||
throw new Error(
|
||||
`invite expired: expires_at=${parsed.data.expires_at}, now=${nowSeconds}`,
|
||||
`invite expired: expires_at=${payload.expires_at}, now=${nowSeconds}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the ed25519 signature against the embedded owner_pubkey.
|
||||
// Client-side verification gives immediate feedback on tampered
|
||||
// links; broker re-verifies authoritatively on /join.
|
||||
const s = await ensureSodium();
|
||||
const canonical = canonicalInvite({
|
||||
v: parsed.data.v,
|
||||
mesh_id: parsed.data.mesh_id,
|
||||
mesh_slug: parsed.data.mesh_slug,
|
||||
broker_url: parsed.data.broker_url,
|
||||
expires_at: parsed.data.expires_at,
|
||||
mesh_root_key: parsed.data.mesh_root_key,
|
||||
role: parsed.data.role,
|
||||
owner_pubkey: parsed.data.owner_pubkey,
|
||||
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(parsed.data.signature),
|
||||
s.from_hex(payload.signature),
|
||||
s.from_string(canonical),
|
||||
s.from_hex(parsed.data.owner_pubkey),
|
||||
s.from_hex(payload.owner_pubkey),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
@@ -140,7 +145,7 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
|
||||
throw new Error("invite signature invalid (link tampered?)");
|
||||
}
|
||||
|
||||
return { payload: parsed.data, raw: link, token: encoded };
|
||||
return { payload, raw: link, token: encoded };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,8 +160,6 @@ export function encodeInviteLink(payload: InvitePayload): string {
|
||||
|
||||
/**
|
||||
* Sign and assemble an invite payload → ic://join/... link.
|
||||
* The canonical bytes (everything except signature) are signed with
|
||||
* the mesh owner's ed25519 secret key.
|
||||
*/
|
||||
export async function buildSignedInvite(args: {
|
||||
v: 1;
|
||||
|
||||
@@ -12,13 +12,16 @@ export const TOOLS: Tool[] = [
|
||||
{
|
||||
name: "send_message",
|
||||
description:
|
||||
"Send a message to a peer in one of your joined meshes. `to` is a peer display name, hex pubkey, or `#channel`. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
|
||||
"Send a message to a peer in one of your joined meshes. `to` can be a peer display name (resolved via list_peers), hex pubkey, @group, `#channel`, or `*` for broadcast. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
to: {
|
||||
type: "string",
|
||||
description: "Peer name, pubkey, or #channel",
|
||||
oneOf: [
|
||||
{ type: "string", description: "Peer name, pubkey, @group" },
|
||||
{ type: "array", items: { type: "string" }, description: "Multiple targets" },
|
||||
],
|
||||
description: "Single target or array of targets",
|
||||
},
|
||||
message: { type: "string", description: "Message text" },
|
||||
priority: {
|
||||
@@ -44,6 +47,21 @@ export const TOOLS: Tool[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "message_status",
|
||||
description:
|
||||
"Check the delivery status of a sent message. Shows whether each recipient received it.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
description: "Message ID (returned by send_message)",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "check_messages",
|
||||
description:
|
||||
@@ -78,4 +96,925 @@ export const TOOLS: Tool[] = [
|
||||
required: ["status"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_visible",
|
||||
description:
|
||||
"Control your visibility in the mesh. When hidden, you won't appear in list_peers and won't receive broadcasts — but direct messages still reach you.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
visible: {
|
||||
type: "boolean",
|
||||
description: "true to be visible (default), false to hide",
|
||||
},
|
||||
},
|
||||
required: ["visible"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_profile",
|
||||
description:
|
||||
"Set your public profile — what other peers see about you. Avatar (emoji), title, bio, and capabilities list.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
avatar: {
|
||||
type: "string",
|
||||
description: "Emoji or URL for your avatar",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Short role label (e.g. 'Frontend Lead', 'DevOps')",
|
||||
},
|
||||
bio: {
|
||||
type: "string",
|
||||
description: "One-liner about yourself",
|
||||
},
|
||||
capabilities: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "What you can help with",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "join_group",
|
||||
description:
|
||||
"Join a group with an optional role. Other peers see your group membership in list_peers.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Group name (without @)" },
|
||||
role: {
|
||||
type: "string",
|
||||
description: "Your role in the group (e.g. lead, member, observer)",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "leave_group",
|
||||
description: "Leave a group.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Group name (without @)" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- State tools ---
|
||||
{
|
||||
name: "set_state",
|
||||
description:
|
||||
"Set a shared state value visible to all peers in the mesh. Pushes a change notification.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string" },
|
||||
value: { description: "Any JSON value" },
|
||||
},
|
||||
required: ["key", "value"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_state",
|
||||
description: "Read a shared state value.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string" },
|
||||
},
|
||||
required: ["key"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_state",
|
||||
description: "List all shared state keys and values in the mesh.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Memory tools ---
|
||||
{
|
||||
name: "remember",
|
||||
description:
|
||||
"Store persistent knowledge in the mesh's shared memory. Survives across sessions.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
content: {
|
||||
type: "string",
|
||||
description: "The knowledge to remember",
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Optional categorization tags",
|
||||
},
|
||||
},
|
||||
required: ["content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recall",
|
||||
description: "Search the mesh's shared memory by relevance.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query" },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "forget",
|
||||
description: "Remove a memory from the mesh's shared knowledge.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Memory ID to forget" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- File tools ---
|
||||
{
|
||||
name: "share_file",
|
||||
description:
|
||||
"Share a persistent file with the mesh. All current and future peers can access it. If `to` is specified, the file is E2E encrypted and only accessible to that peer (and you).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Local file path to share" },
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Display name (defaults to filename)",
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags for categorization",
|
||||
},
|
||||
to: {
|
||||
type: "string",
|
||||
description: "Peer display name or pubkey hex — if set, file is E2E encrypted for this peer only",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_file",
|
||||
description: "Download a shared file to a local path.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "File ID" },
|
||||
save_to: {
|
||||
type: "string",
|
||||
description: "Local path to save the file",
|
||||
},
|
||||
},
|
||||
required: ["id", "save_to"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_files",
|
||||
description: "List files shared in the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search by name or tags" },
|
||||
from: { type: "string", description: "Filter by uploader name" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "file_status",
|
||||
description: "Check who has accessed a shared file.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "File ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete_file",
|
||||
description: "Remove a shared file from the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "File ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "grant_file_access",
|
||||
description: "Grant a peer access to an E2E encrypted file you shared. You must be the owner.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileId: { type: "string", description: "File ID" },
|
||||
to: { type: "string", description: "Peer display name or pubkey hex to grant access to" },
|
||||
},
|
||||
required: ["fileId", "to"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Vector tools ---
|
||||
{
|
||||
name: "vector_store",
|
||||
description:
|
||||
"Store an embedding in a per-mesh Qdrant collection. Auto-creates the collection on first use.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
collection: { type: "string", description: "Collection name" },
|
||||
text: { type: "string", description: "Text to embed and store" },
|
||||
metadata: {
|
||||
type: "object",
|
||||
description: "Optional metadata to attach",
|
||||
},
|
||||
},
|
||||
required: ["collection", "text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "vector_search",
|
||||
description: "Semantic search over stored embeddings in a collection.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
collection: { type: "string", description: "Collection name" },
|
||||
query: { type: "string", description: "Search query text" },
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "Max results (default: 10)",
|
||||
},
|
||||
},
|
||||
required: ["collection", "query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "vector_delete",
|
||||
description: "Remove an embedding from a collection.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
collection: { type: "string", description: "Collection name" },
|
||||
id: { type: "string", description: "Embedding ID to delete" },
|
||||
},
|
||||
required: ["collection", "id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_collections",
|
||||
description: "List vector collections in this mesh.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Graph tools ---
|
||||
{
|
||||
name: "graph_query",
|
||||
description:
|
||||
"Run a read-only Cypher query on the per-mesh Neo4j database.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
cypher: { type: "string", description: "Cypher MATCH query" },
|
||||
},
|
||||
required: ["cypher"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "graph_execute",
|
||||
description:
|
||||
"Run a write Cypher query (CREATE, MERGE, DELETE) on the per-mesh Neo4j database.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
cypher: { type: "string", description: "Cypher write query" },
|
||||
},
|
||||
required: ["cypher"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Mesh Database tools ---
|
||||
{
|
||||
name: "mesh_query",
|
||||
description:
|
||||
"Run a SELECT query on the per-mesh shared database.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sql: { type: "string", description: "SQL SELECT query" },
|
||||
},
|
||||
required: ["sql"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_execute",
|
||||
description:
|
||||
"Run DDL/DML on the per-mesh database (CREATE TABLE, INSERT, UPDATE, DELETE).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sql: { type: "string", description: "SQL statement" },
|
||||
},
|
||||
required: ["sql"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_schema",
|
||||
description:
|
||||
"List tables and columns in the per-mesh shared database.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Stream tools ---
|
||||
{
|
||||
name: "create_stream",
|
||||
description:
|
||||
"Create a real-time data stream in the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Stream name" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "publish",
|
||||
description:
|
||||
"Push data to a stream. Subscribers receive it in real-time.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stream: { type: "string", description: "Stream name" },
|
||||
data: { description: "Any JSON data to publish" },
|
||||
},
|
||||
required: ["stream", "data"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "subscribe",
|
||||
description:
|
||||
"Subscribe to a stream. Data pushes arrive as channel notifications.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
stream: { type: "string", description: "Stream name" },
|
||||
},
|
||||
required: ["stream"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_streams",
|
||||
description:
|
||||
"List active streams in the mesh.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Context tools ---
|
||||
{
|
||||
name: "share_context",
|
||||
description:
|
||||
"Share your session understanding with the mesh. Call after exploring a codebase area.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
summary: {
|
||||
type: "string",
|
||||
description: "Summary of what you explored/learned",
|
||||
},
|
||||
files_read: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "File paths you read",
|
||||
},
|
||||
key_findings: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Key findings or insights",
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags for categorization",
|
||||
},
|
||||
},
|
||||
required: ["summary"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_context",
|
||||
description:
|
||||
"Find context from peers who explored an area. Check before re-reading files another peer already analyzed.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "Search query (file path, topic, etc.)",
|
||||
},
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_contexts",
|
||||
description: "See what all peers currently know about the codebase.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Task tools ---
|
||||
{
|
||||
name: "create_task",
|
||||
description: "Create a work item for the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Task title" },
|
||||
assignee: {
|
||||
type: "string",
|
||||
description: "Peer name to assign (optional)",
|
||||
},
|
||||
priority: {
|
||||
type: "string",
|
||||
enum: ["low", "normal", "high", "urgent"],
|
||||
description: "Priority level (default: normal)",
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags for categorization",
|
||||
},
|
||||
},
|
||||
required: ["title"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "claim_task",
|
||||
description: "Claim an unclaimed task to take ownership.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Task ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complete_task",
|
||||
description: "Mark a task as done with an optional result summary.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Task ID" },
|
||||
result: {
|
||||
type: "string",
|
||||
description: "Summary of what was done",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_tasks",
|
||||
description: "List tasks filtered by status and/or assignee.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: {
|
||||
type: "string",
|
||||
enum: ["open", "claimed", "completed"],
|
||||
description: "Filter by status",
|
||||
},
|
||||
assignee: {
|
||||
type: "string",
|
||||
description: "Filter by assignee name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// --- Scheduled messages ---
|
||||
{
|
||||
name: "schedule_reminder",
|
||||
description:
|
||||
"Schedule a one-shot or recurring message. Without `to`, it fires back to yourself (a self-reminder). With `to`, it delivers to a peer, @group, or * broadcast. For one-shot, provide `deliver_at` or `in_seconds`. For recurring, provide `cron` (standard 5-field expression). The broker persists schedules to the database — they survive restarts. Receivers see `subtype: reminder` in the push envelope.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string", description: "Message or reminder text" },
|
||||
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver (one-shot)" },
|
||||
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds (one-shot)" },
|
||||
cron: { type: "string", description: "Cron expression for recurring reminders (e.g. '0 */2 * * *' for every 2 hours, '30 9 * * 1-5' for 9:30 weekdays)" },
|
||||
to: {
|
||||
type: "string",
|
||||
description: "Recipient: display name, pubkey hex, @group, or * (omit for self-reminder)",
|
||||
},
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_scheduled",
|
||||
description: "List all your pending scheduled messages: id, recipient, preview, and delivery time.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "cancel_scheduled",
|
||||
description: "Cancel a pending scheduled message before it fires.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Scheduled message ID" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Mesh info ---
|
||||
{
|
||||
name: "mesh_info",
|
||||
description:
|
||||
"Get a complete overview of the mesh: peers, groups, state, memory, files, tasks, streams, tables. Call on session start for full situational awareness.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Stats ---
|
||||
{
|
||||
name: "mesh_stats",
|
||||
description:
|
||||
"View resource usage stats for all peers: messages sent/received, tool calls, uptime, errors.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- MCP Proxy ---
|
||||
{
|
||||
name: "mesh_mcp_register",
|
||||
description:
|
||||
"Register an MCP server with the mesh. Other peers can invoke its tools through the mesh without restarting their sessions. Provide the server name, description, and full tool definitions.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
server_name: { type: "string", description: "Unique name for the MCP server (e.g. 'github', 'jira')" },
|
||||
description: { type: "string", description: "What this MCP server does" },
|
||||
tools: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
description: { type: "string" },
|
||||
inputSchema: { type: "object", description: "JSON Schema for tool arguments" },
|
||||
},
|
||||
required: ["name", "description", "inputSchema"],
|
||||
},
|
||||
description: "Tool definitions to expose",
|
||||
},
|
||||
persistent: {
|
||||
type: "boolean",
|
||||
description: "If true, registration survives peer disconnect. Other peers see it as 'offline' until you reconnect. Default: false",
|
||||
},
|
||||
},
|
||||
required: ["server_name", "description", "tools"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_list",
|
||||
description:
|
||||
"List MCP servers available in the mesh with their tools. Shows which peer hosts each server.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "mesh_tool_call",
|
||||
description:
|
||||
"Call a tool on a mesh-registered MCP server. Route: you -> broker -> hosting peer -> execute -> result back. Timeout: 30s.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
server_name: { type: "string", description: "Name of the MCP server" },
|
||||
tool_name: { type: "string", description: "Name of the tool to call" },
|
||||
args: { type: "object", description: "Tool arguments (JSON object)" },
|
||||
},
|
||||
required: ["server_name", "tool_name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_remove",
|
||||
description:
|
||||
"Unregister an MCP server you previously registered with the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
server_name: { type: "string", description: "Name of the MCP server to remove" },
|
||||
},
|
||||
required: ["server_name"],
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
// --- Simulation clock tools ---
|
||||
{
|
||||
name: "mesh_set_clock",
|
||||
description:
|
||||
"Set the simulation clock speed. x1 = real-time, x10 = 10x faster, x100 = 100x. Peers receive heartbeat ticks at the simulated rate.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
speed: {
|
||||
type: "number",
|
||||
description: "Speed multiplier (1-100). x1 = tick every 60s, x10 = tick every 6s, x100 = tick every 600ms.",
|
||||
},
|
||||
},
|
||||
required: ["speed"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_pause_clock",
|
||||
description:
|
||||
"Pause the simulation clock. Ticks stop until resumed.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "mesh_resume_clock",
|
||||
description:
|
||||
"Resume a paused simulation clock.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "mesh_clock",
|
||||
description:
|
||||
"Get current simulation clock status: speed, tick count, simulated time.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Skills ---
|
||||
{
|
||||
name: "share_skill",
|
||||
description:
|
||||
"Publish a reusable skill to the mesh. Other peers can discover and load it as a slash command. If a skill with the same name exists, it is updated. Skills are automatically exposed as MCP prompts and skill:// resources for native Claude Code integration.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Unique skill name (e.g. 'code-review', 'deploy-checklist'). Becomes the slash command name." },
|
||||
description: { type: "string", description: "Short description of what the skill does" },
|
||||
instructions: { type: "string", description: "Full instructions/prompt markdown. Can include frontmatter (---) block." },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags for discoverability",
|
||||
},
|
||||
when_to_use: { type: "string", description: "Detailed description of when Claude should auto-invoke this skill" },
|
||||
allowed_tools: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tool names this skill is allowed to use (e.g. ['Bash', 'Read', 'Edit'])",
|
||||
},
|
||||
model: { type: "string", description: "Model override (e.g. 'sonnet', 'opus', 'haiku')" },
|
||||
context: { type: "string", enum: ["inline", "fork"], description: "Execution context: 'inline' (default) or 'fork' (sub-agent)" },
|
||||
agent: { type: "string", description: "Agent type when forked (e.g. 'general-purpose')" },
|
||||
user_invocable: { type: "boolean", description: "Whether users can invoke via /skill-name (default: true)" },
|
||||
argument_hint: { type: "string", description: "Hint text for arguments (e.g. '<file-path>')" },
|
||||
},
|
||||
required: ["name", "description", "instructions"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_skill",
|
||||
description:
|
||||
"Load a skill's full instructions by name. Use to acquire capabilities shared by other peers.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Skill name to load" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_skills",
|
||||
description:
|
||||
"Browse available skills in the mesh. Optionally filter by keyword across name, description, and tags.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search keyword (optional)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove_skill",
|
||||
description:
|
||||
"Remove a skill you published from the mesh.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Skill name to remove" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Diagnostics ---
|
||||
{
|
||||
name: "ping_mesh",
|
||||
description:
|
||||
"Send test messages through the full pipeline and measure round-trip timing per priority. Diagnoses push delivery issues.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
priorities: {
|
||||
type: "array",
|
||||
items: { type: "string", enum: ["now", "next", "low"] },
|
||||
description: "Priorities to test (default: [\"now\", \"next\"])",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// --- Peer file sharing ---
|
||||
{
|
||||
name: "read_peer_file",
|
||||
description:
|
||||
"Read a file from another peer's project. Specify the peer (by name) and the file path relative to their working directory. The peer must be online and sharing files. Max file size: 1MB.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
peer: { type: "string", description: "Peer display name or pubkey" },
|
||||
path: { type: "string", description: "File path relative to peer's working directory" },
|
||||
},
|
||||
required: ["peer", "path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_peer_files",
|
||||
description:
|
||||
"List files in a peer's shared directory. Returns a tree of file names (not contents). The peer must be online and sharing files.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
peer: { type: "string", description: "Peer display name or pubkey" },
|
||||
path: { type: "string", description: "Directory path relative to peer's cwd (default: root)" },
|
||||
pattern: { type: "string", description: "Glob-like filter pattern (e.g. '*.ts', 'src/*')" },
|
||||
},
|
||||
required: ["peer"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Webhooks ---
|
||||
{
|
||||
name: "create_webhook",
|
||||
description:
|
||||
"Create an inbound webhook. Returns a URL that external services (GitHub, CI/CD, monitoring) can POST to — the payload becomes a mesh message to all peers.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Webhook name (e.g. 'github-ci', 'datadog-alerts')",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_webhooks",
|
||||
description: "List active webhooks for this mesh.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "delete_webhook",
|
||||
description: "Deactivate a webhook.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Webhook name to deactivate" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Service deployment tools ---
|
||||
|
||||
{
|
||||
name: "mesh_mcp_deploy",
|
||||
description: "Deploy an MCP server to the mesh from a zip file or git repo. Runs on the broker VPS, persists across peer sessions. Default scope: private (only you).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
server_name: { type: "string", description: "Unique name for the server in this mesh" },
|
||||
file_id: { type: "string", description: "File ID of uploaded zip (from share_file)" },
|
||||
git_url: { type: "string", description: "Git repo URL" },
|
||||
git_branch: { type: "string", description: "Branch to clone (default: main)" },
|
||||
npx_package: { type: "string", description: "npm package name to run via npx (e.g. @upstash/context7-mcp)" },
|
||||
env: { type: "object", description: "Environment variables. Use $vault:<key> for vault secrets." },
|
||||
runtime: { type: "string", enum: ["node", "python", "bun"], description: "Runtime (auto-detected if omitted)" },
|
||||
memory_mb: { type: "number", description: "Memory limit in MB (default: 256)" },
|
||||
network_allow: { type: "array", items: { type: "string" }, description: "Allowed outbound hosts (default: none)" },
|
||||
scope: { description: "Visibility: 'peer' (default), 'mesh', or {group/groups/role/peers}" },
|
||||
},
|
||||
required: ["server_name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_undeploy",
|
||||
description: "Stop and remove a managed MCP server from the mesh.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_update",
|
||||
description: "Pull latest code and restart a git-sourced MCP server.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_logs",
|
||||
description: "View recent logs from a managed MCP server.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" }, lines: { type: "number", description: "Lines (default: 50, max: 1000)" } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_scope",
|
||||
description: "Get or set the visibility scope of a deployed MCP server.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" }, scope: { description: "New scope to set. Omit to read current." } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_schema",
|
||||
description: "Inspect tool schemas for a deployed MCP server.",
|
||||
inputSchema: { type: "object", properties: { server_name: { type: "string" }, tool_name: { type: "string", description: "Specific tool (omit for all)" } }, required: ["server_name"] },
|
||||
},
|
||||
{
|
||||
name: "mesh_mcp_catalog",
|
||||
description: "List all deployed services in the mesh with status, scope, and tool count.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
|
||||
// --- Skill deployment tools ---
|
||||
|
||||
{
|
||||
name: "mesh_skill_deploy",
|
||||
description: "Deploy a multi-file skill bundle from a zip or git repo.",
|
||||
inputSchema: { type: "object", properties: { file_id: { type: "string" }, git_url: { type: "string" }, git_branch: { type: "string" } } },
|
||||
},
|
||||
|
||||
// --- Vault tools ---
|
||||
|
||||
{
|
||||
name: "vault_set",
|
||||
description: "Store an encrypted credential in your vault. Reference in mesh_mcp_deploy with $vault:<key>.",
|
||||
inputSchema: { type: "object", properties: { key: { type: "string" }, value: { type: "string", description: "Secret value or local file path (for type=file)" }, type: { type: "string", enum: ["env", "file"] }, mount_path: { type: "string" }, description: { type: "string" } }, required: ["key", "value"] },
|
||||
},
|
||||
{
|
||||
name: "vault_list",
|
||||
description: "List your vault entries (keys and metadata only, no secret values).",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "vault_delete",
|
||||
description: "Remove a credential from your vault.",
|
||||
inputSchema: { type: "object", properties: { key: { type: "string" } }, required: ["key"] },
|
||||
},
|
||||
|
||||
// --- URL Watch tools ---
|
||||
|
||||
{
|
||||
name: "mesh_watch",
|
||||
description: "Watch a URL for changes. The broker polls it at the given interval and notifies you when the response changes. Works with any URL — websites (hash mode), JSON APIs (json mode), or status codes (status mode).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string", description: "URL to watch" },
|
||||
mode: { type: "string", enum: ["hash", "json", "status"], description: "Detection mode: hash (SHA-256 of body), json (extract jsonpath value), status (HTTP status code). Default: hash" },
|
||||
extract: { type: "string", description: "For json mode: dot path to extract (e.g. 'status' or 'data.deployments[0].status')" },
|
||||
interval: { type: "number", description: "Poll interval in seconds (min: 5, default: 30)" },
|
||||
notify_on: { type: "string", description: "When to notify: 'change' (default), 'match:<value>', 'not_match:<value>'" },
|
||||
headers: { type: "object", description: "Optional HTTP headers (e.g. for auth)" },
|
||||
label: { type: "string", description: "Human-readable label for this watch" },
|
||||
},
|
||||
required: ["url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_unwatch",
|
||||
description: "Stop watching a URL.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { watch_id: { type: "string" } },
|
||||
required: ["watch_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mesh_watches",
|
||||
description: "List your active URL watches.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -6,7 +6,7 @@ export type Priority = "now" | "next" | "low";
|
||||
export type PeerStatus = "idle" | "working" | "dnd";
|
||||
|
||||
export interface SendMessageArgs {
|
||||
to: string; // peer name, pubkey, or #channel
|
||||
to: string | string[]; // peer name, pubkey, @group, or array of targets
|
||||
message: string;
|
||||
priority?: Priority;
|
||||
}
|
||||
@@ -22,3 +22,60 @@ export interface SetSummaryArgs {
|
||||
export interface SetStatusArgs {
|
||||
status: PeerStatus;
|
||||
}
|
||||
|
||||
// --- Service deployment types ---
|
||||
|
||||
export type ServiceScope =
|
||||
| "peer"
|
||||
| "mesh"
|
||||
| { peers: string[] }
|
||||
| { group: string }
|
||||
| { groups: string[] }
|
||||
| { role: string };
|
||||
|
||||
export interface ServiceInfo {
|
||||
name: string;
|
||||
type: "mcp" | "skill";
|
||||
description: string;
|
||||
status: string;
|
||||
tool_count: number;
|
||||
deployed_by: string;
|
||||
scope: ServiceScope;
|
||||
source_type: string;
|
||||
runtime?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ServiceToolSchema {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface VaultEntry {
|
||||
key: string;
|
||||
entry_type: "env" | "file";
|
||||
mount_path?: string;
|
||||
description?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MeshMcpDeployArgs {
|
||||
server_name: string;
|
||||
file_id?: string;
|
||||
git_url?: string;
|
||||
git_branch?: string;
|
||||
env?: Record<string, string>;
|
||||
runtime?: "node" | "python" | "bun";
|
||||
memory_mb?: number;
|
||||
network_allow?: string[];
|
||||
scope?: ServiceScope;
|
||||
}
|
||||
|
||||
export interface VaultSetArgs {
|
||||
key: string;
|
||||
value: string;
|
||||
type?: "env" | "file";
|
||||
mount_path?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -15,38 +15,48 @@ import {
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { env } from "../env";
|
||||
|
||||
const joinedMeshSchema = z.object({
|
||||
meshId: z.string(),
|
||||
memberId: z.string(),
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
pubkey: z.string(), // ed25519 hex (32 bytes = 64 chars)
|
||||
secretKey: z.string(), // ed25519 hex (64 bytes = 128 chars)
|
||||
brokerUrl: z.string(),
|
||||
joinedAt: z.string(),
|
||||
});
|
||||
export interface JoinedMesh {
|
||||
meshId: string;
|
||||
memberId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
pubkey: string; // ed25519 hex (32 bytes = 64 chars)
|
||||
secretKey: string; // ed25519 hex (64 bytes = 128 chars)
|
||||
brokerUrl: string;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
const configSchema = z.object({
|
||||
version: z.literal(1).default(1),
|
||||
meshes: z.array(joinedMeshSchema).default([]),
|
||||
});
|
||||
export interface GroupEntry {
|
||||
name: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export type JoinedMesh = z.infer<typeof joinedMeshSchema>;
|
||||
export type Config = z.infer<typeof configSchema>;
|
||||
export interface Config {
|
||||
version: 1;
|
||||
meshes: JoinedMesh[];
|
||||
displayName?: string; // per-session override, written by `claudemesh launch --name`
|
||||
role?: string; // per-session role tag (display + hello)
|
||||
groups?: GroupEntry[];
|
||||
messageMode?: "push" | "inbox" | "off";
|
||||
accountId?: string; // linked dashboard user ID (from CLI sync flow)
|
||||
}
|
||||
|
||||
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
||||
|
||||
export function loadConfig(): Config {
|
||||
if (!existsSync(CONFIG_PATH)) {
|
||||
return configSchema.parse({ version: 1, meshes: [] });
|
||||
return { version: 1, meshes: [] };
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
||||
return configSchema.parse(JSON.parse(raw));
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !Array.isArray(parsed.meshes)) {
|
||||
return { version: 1, meshes: [] };
|
||||
}
|
||||
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode, accountId: parsed.accountId };
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
|
||||
17
apps/cli/src/templates/dev-team.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "dev-team",
|
||||
"description": "Software development team with frontend, backend, and devops groups",
|
||||
"groups": [
|
||||
{ "name": "frontend", "roles": ["lead", "member"] },
|
||||
{ "name": "backend", "roles": ["lead", "member"] },
|
||||
{ "name": "devops", "roles": ["lead", "member"] },
|
||||
{ "name": "qa", "roles": ["lead", "member"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"sprint": "current",
|
||||
"deploy-frozen": "false",
|
||||
"pr-queue": "[]"
|
||||
},
|
||||
"suggestedRoles": ["lead", "member", "reviewer"],
|
||||
"systemPromptHint": "You are part of a dev team. Coordinate with @frontend, @backend, @devops groups. Check state keys for sprint status and deploy freezes before making changes."
|
||||
}
|
||||
30
apps/cli/src/templates/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import devTeam from "./dev-team.json" with { type: "json" };
|
||||
import research from "./research.json" with { type: "json" };
|
||||
import opsIncident from "./ops-incident.json" with { type: "json" };
|
||||
import simulation from "./simulation.json" with { type: "json" };
|
||||
import personal from "./personal.json" with { type: "json" };
|
||||
|
||||
export interface MeshTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
groups: Array<{ name: string; roles: string[] }>;
|
||||
stateKeys: Record<string, string>;
|
||||
suggestedRoles: string[];
|
||||
systemPromptHint: string;
|
||||
}
|
||||
|
||||
export const TEMPLATES: Record<string, MeshTemplate> = {
|
||||
"dev-team": devTeam,
|
||||
research,
|
||||
"ops-incident": opsIncident,
|
||||
simulation,
|
||||
personal,
|
||||
};
|
||||
|
||||
export function listTemplates(): MeshTemplate[] {
|
||||
return Object.values(TEMPLATES);
|
||||
}
|
||||
|
||||
export function getTemplate(name: string): MeshTemplate | undefined {
|
||||
return TEMPLATES[name];
|
||||
}
|
||||
17
apps/cli/src/templates/ops-incident.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "ops-incident",
|
||||
"description": "Incident response team with oncall, comms, and engineering groups",
|
||||
"groups": [
|
||||
{ "name": "oncall", "roles": ["primary", "secondary"] },
|
||||
{ "name": "comms", "roles": ["lead", "scribe"] },
|
||||
{ "name": "engineering", "roles": ["lead", "responder"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"incident-status": "investigating",
|
||||
"severity": "unknown",
|
||||
"commander": "",
|
||||
"timeline": "[]"
|
||||
},
|
||||
"suggestedRoles": ["commander", "primary-oncall", "scribe", "responder"],
|
||||
"systemPromptHint": "INCIDENT MODE. Priority: now for all messages. Update incident-status state. Commander coordinates. Scribe maintains timeline. Engineering fixes."
|
||||
}
|
||||
11
apps/cli/src/templates/personal.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "personal",
|
||||
"description": "Private mesh for a single user — all sessions auto-join",
|
||||
"groups": [],
|
||||
"stateKeys": {
|
||||
"focus": "",
|
||||
"todos": "[]"
|
||||
},
|
||||
"suggestedRoles": [],
|
||||
"systemPromptHint": "Personal workspace. All your Claude Code sessions share this mesh. Use state keys to track focus and todos across sessions."
|
||||
}
|
||||
16
apps/cli/src/templates/research.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "research",
|
||||
"description": "Research and analysis team focused on deep investigation and knowledge sharing",
|
||||
"groups": [
|
||||
{ "name": "analysis", "roles": ["lead", "analyst"] },
|
||||
{ "name": "writing", "roles": ["lead", "writer", "reviewer"] },
|
||||
{ "name": "data", "roles": ["engineer", "analyst"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"research-topic": "",
|
||||
"phase": "exploration",
|
||||
"findings-count": "0"
|
||||
},
|
||||
"suggestedRoles": ["lead", "analyst", "writer", "reviewer"],
|
||||
"systemPromptHint": "You are part of a research team. Share findings via remember(), use recall() before starting new analysis. Coordinate phases through state keys."
|
||||
}
|
||||
17
apps/cli/src/templates/simulation.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "simulation",
|
||||
"description": "Load testing simulation with configurable time multiplier and user personas",
|
||||
"groups": [
|
||||
{ "name": "personas", "roles": ["admin", "user", "customer"] },
|
||||
{ "name": "observers", "roles": ["monitor", "analyst"] },
|
||||
{ "name": "control", "roles": ["orchestrator"] }
|
||||
],
|
||||
"stateKeys": {
|
||||
"clock-speed": "x1",
|
||||
"sim-status": "paused",
|
||||
"tick-count": "0",
|
||||
"scenario": ""
|
||||
},
|
||||
"suggestedRoles": ["orchestrator", "persona", "monitor"],
|
||||
"systemPromptHint": "SIMULATION MODE. Follow the clock-speed state for time multiplier. Act according to your persona role and the simulated time. Report actions to @observers."
|
||||
}
|
||||
@@ -11,15 +11,21 @@ import type { Config, JoinedMesh } from "../state/config";
|
||||
import { env } from "../env";
|
||||
|
||||
const clients = new Map<string, BrokerClient>();
|
||||
let configDisplayName: string | undefined;
|
||||
let configGroups: Config["groups"] = [];
|
||||
|
||||
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
||||
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
const existing = clients.get(mesh.meshId);
|
||||
if (existing) return existing;
|
||||
const client = new BrokerClient(mesh, { debug: env.CLAUDEMESH_DEBUG });
|
||||
const client = new BrokerClient(mesh, { debug: env.CLAUDEMESH_DEBUG, displayName: configDisplayName });
|
||||
clients.set(mesh.meshId, client);
|
||||
try {
|
||||
await client.connect();
|
||||
// Auto-join groups declared at launch time (--groups flag or config).
|
||||
for (const g of configGroups ?? []) {
|
||||
try { await client.joinGroup(g.name, g.role); } catch { /* best effort */ }
|
||||
}
|
||||
} catch {
|
||||
// Connect failed → client is in "reconnecting" state, leave it
|
||||
// wired so tool calls can surface the status.
|
||||
@@ -29,6 +35,8 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
|
||||
/** Start clients for every joined mesh. Called once on MCP server start. */
|
||||
export async function startClients(config: Config): Promise<void> {
|
||||
configDisplayName = config.displayName;
|
||||
configGroups = config.groups ?? [];
|
||||
await Promise.allSettled(config.meshes.map(ensureClient));
|
||||
}
|
||||
|
||||
|
||||
7
apps/cli/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/__tests__/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
38
apps/runner/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# claudemesh runner — executes deployed MCP servers as child processes.
|
||||
# Multi-runtime: Node 22 + Python 3.12 + uv + Bun
|
||||
#
|
||||
# The runner supervisor (Node) listens on HTTP :7901 for commands from
|
||||
# the broker (load, call, unload, health, list_tools). Each deployed
|
||||
# MCP server runs as a child process with its own stdio pipe.
|
||||
|
||||
FROM node:22-slim AS base
|
||||
|
||||
# Install Python 3.12 + uv (fast pip replacement) + git
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-venv \
|
||||
curl ca-certificates git unzip \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& ln -sf /root/.local/bin/uv /usr/local/bin/uv \
|
||||
&& ln -sf /root/.local/bin/uvx /usr/local/bin/uvx \
|
||||
&& curl -fsSL https://bun.sh/install | bash \
|
||||
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
&& ln -sf /root/.bun/bin/bunx /usr/local/bin/bunx \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the runner supervisor
|
||||
COPY supervisor.mjs /app/supervisor.mjs
|
||||
|
||||
# Services directory (shared volume with broker)
|
||||
RUN mkdir -p /var/claudemesh/services
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV RUNNER_PORT=7901
|
||||
|
||||
EXPOSE 7901
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD node -e "fetch('http://localhost:7901/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "supervisor.mjs"]
|
||||
365
apps/runner/supervisor.mjs
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* claudemesh runner supervisor — manages MCP server child processes.
|
||||
*
|
||||
* HTTP API (called by broker):
|
||||
* POST /load { name, sourcePath, env, runtime } → spawn MCP, return tools
|
||||
* POST /call { name, tool, args } → route tool call
|
||||
* POST /unload { name } → kill process
|
||||
* GET /health → { ok, services }
|
||||
* GET /list { name? } → tools for a service
|
||||
*
|
||||
* Each MCP server is a child process with its own stdio pipe.
|
||||
* The supervisor talks MCP JSON-RPC over stdin/stdout to each child.
|
||||
*/
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PORT = parseInt(process.env.RUNNER_PORT || "7901", 10);
|
||||
const CALL_TIMEOUT_MS = 25_000;
|
||||
const LOG_BUFFER_SIZE = 500;
|
||||
|
||||
// --- Service registry ---
|
||||
|
||||
const services = new Map();
|
||||
let callIdCounter = 0;
|
||||
|
||||
// --- Runtime detection ---
|
||||
|
||||
function detectRuntime(sourcePath) {
|
||||
if (existsSync(join(sourcePath, "bun.lockb")) || existsSync(join(sourcePath, "bunfig.toml"))) return "bun";
|
||||
if (existsSync(join(sourcePath, "package.json"))) return "node";
|
||||
if (existsSync(join(sourcePath, "pyproject.toml")) || existsSync(join(sourcePath, "requirements.txt"))) return "python";
|
||||
return "node";
|
||||
}
|
||||
|
||||
function detectEntry(sourcePath, runtime) {
|
||||
if (runtime === "python") {
|
||||
for (const e of ["server.py", "src/server.py", "main.py", "src/main.py"]) {
|
||||
if (existsSync(join(sourcePath, e))) return { cmd: "python3", args: [e] };
|
||||
}
|
||||
if (existsSync(join(sourcePath, "pyproject.toml"))) return { cmd: "python3", args: ["-m", "server"] };
|
||||
return { cmd: "python3", args: ["server.py"] };
|
||||
}
|
||||
const cmd = runtime === "bun" ? "bun" : "node";
|
||||
if (existsSync(join(sourcePath, "package.json"))) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(sourcePath, "package.json"), "utf-8"));
|
||||
if (pkg.main) return { cmd, args: [pkg.main] };
|
||||
if (pkg.bin) {
|
||||
const bin = typeof pkg.bin === "string" ? pkg.bin : Object.values(pkg.bin)[0];
|
||||
if (bin) return { cmd, args: [bin] };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
for (const e of ["dist/index.js", "src/index.js", "src/index.ts", "index.js"]) {
|
||||
if (existsSync(join(sourcePath, e))) return { cmd, args: [e] };
|
||||
}
|
||||
return { cmd, args: ["src/index.js"] };
|
||||
}
|
||||
|
||||
// --- Install deps ---
|
||||
|
||||
function installDeps(sourcePath, runtime) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cmd, args;
|
||||
if (runtime === "python") {
|
||||
if (existsSync(join(sourcePath, "requirements.txt"))) {
|
||||
cmd = "pip3"; args = ["install", "--no-cache-dir", "-r", "requirements.txt"];
|
||||
} else { cmd = "pip3"; args = ["install", "--no-cache-dir", "."]; }
|
||||
} else if (runtime === "bun") {
|
||||
cmd = "bun"; args = ["install"];
|
||||
} else {
|
||||
cmd = "npm"; args = ["install", "--production", "--legacy-peer-deps"];
|
||||
}
|
||||
const child = spawn(cmd, args, { cwd: sourcePath, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", d => { stderr += d.toString(); });
|
||||
child.on("exit", code => code === 0 ? resolve() : reject(new Error(`${cmd} install exit ${code}: ${stderr.slice(-300)}`)));
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// --- MCP JSON-RPC ---
|
||||
|
||||
function sendMcpRequest(svc, method, params) {
|
||||
return new Promise(resolve => {
|
||||
if (!svc.process?.stdin?.writable) { resolve({ error: "not running" }); return; }
|
||||
const id = `c_${++callIdCounter}`;
|
||||
const timer = setTimeout(() => { svc.pending.delete(id); resolve({ error: "timeout" }); }, CALL_TIMEOUT_MS);
|
||||
svc.pending.set(id, { resolve, timer });
|
||||
try {
|
||||
svc.process.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, ...(params ? { params } : {}) }) + "\n");
|
||||
} catch (e) {
|
||||
clearTimeout(timer); svc.pending.delete(id);
|
||||
resolve({ error: e.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initMcp(svc) {
|
||||
const init = await sendMcpRequest(svc, "initialize", {
|
||||
protocolVersion: "2024-11-05", capabilities: {},
|
||||
clientInfo: { name: "claudemesh-runner", version: "0.1.0" },
|
||||
});
|
||||
if (init.error) throw new Error(`init failed: ${init.error}`);
|
||||
if (svc.process?.stdin?.writable) {
|
||||
svc.process.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
|
||||
}
|
||||
const tools = await sendMcpRequest(svc, "tools/list", {});
|
||||
if (tools.error) throw new Error(`tools/list failed: ${tools.error}`);
|
||||
return tools.result?.tools ?? [];
|
||||
}
|
||||
|
||||
// --- Spawn ---
|
||||
|
||||
function spawnService(svc) {
|
||||
// npx/uvx packages have pre-resolved entry points
|
||||
let cmd, args;
|
||||
if (svc._pythonModule) {
|
||||
// Python MCPs: run via venv python -m <module>
|
||||
cmd = svc._venvPython;
|
||||
args = ["-m", svc._pythonModule];
|
||||
} else if (svc._npxBin) {
|
||||
cmd = "node";
|
||||
args = [svc._npxBin];
|
||||
} else {
|
||||
({ cmd, args } = detectEntry(svc.sourcePath, svc.runtime));
|
||||
}
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: svc.sourcePath,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, ...svc.env, NODE_ENV: "production" },
|
||||
});
|
||||
svc.process = child;
|
||||
svc.pid = child.pid;
|
||||
svc.status = "running";
|
||||
svc.healthFailures = 0;
|
||||
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
rl.on("line", line => {
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id && svc.pending.has(String(msg.id))) {
|
||||
const p = svc.pending.get(String(msg.id));
|
||||
clearTimeout(p.timer); svc.pending.delete(String(msg.id));
|
||||
p.resolve(msg.error ? { error: msg.error.message ?? JSON.stringify(msg.error) } : { result: msg.result });
|
||||
}
|
||||
} catch { svc.logs.push(`[stdout] ${line}`); if (svc.logs.length > LOG_BUFFER_SIZE) svc.logs.shift(); }
|
||||
});
|
||||
|
||||
const errRl = createInterface({ input: child.stderr });
|
||||
errRl.on("line", line => { svc.logs.push(`[stderr] ${line}`); if (svc.logs.length > LOG_BUFFER_SIZE) svc.logs.shift(); });
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
console.log(`[runner] ${svc.name} exited code=${code} signal=${signal} restarts=${svc.restarts}`);
|
||||
for (const [, p] of svc.pending) { clearTimeout(p.timer); p.resolve({ error: "crashed" }); }
|
||||
svc.pending.clear(); svc.process = null; svc.pid = null;
|
||||
if (svc.status === "running" && svc.restarts < 5) {
|
||||
svc.restarts++;
|
||||
svc.status = "restarting";
|
||||
setTimeout(() => spawnService(svc), 1000 * svc.restarts);
|
||||
} else if (svc.status === "running") { svc.status = "crashed"; }
|
||||
});
|
||||
|
||||
child.on("error", err => { console.error(`[runner] ${svc.name} spawn error: ${err.message}`); svc.status = "failed"; });
|
||||
console.log(`[runner] spawned ${svc.name} pid=${child.pid} cmd=${cmd} ${args.join(" ")}`);
|
||||
}
|
||||
|
||||
// --- HTTP API ---
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on("data", c => chunks.push(c));
|
||||
req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch (e) { reject(e); } });
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
const svcs = [];
|
||||
for (const [name, svc] of services) {
|
||||
svcs.push({ name, status: svc.status, pid: svc.pid, tools: svc.tools.length, restarts: svc.restarts });
|
||||
}
|
||||
return json(res, 200, { ok: true, services: svcs });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url?.startsWith("/list")) {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const name = url.searchParams.get("name");
|
||||
if (name) {
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: `service "${name}" not found` });
|
||||
return json(res, 200, { tools: svc.tools });
|
||||
}
|
||||
const all = {};
|
||||
for (const [n, s] of services) all[n] = s.tools;
|
||||
return json(res, 200, all);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url?.startsWith("/logs")) {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const name = url.searchParams.get("name");
|
||||
const lines = parseInt(url.searchParams.get("lines") || "50", 10);
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: "not found" });
|
||||
return json(res, 200, { lines: svc.logs.slice(-lines) });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/load") {
|
||||
const body = await readBody(req);
|
||||
const { name, sourcePath, gitUrl, gitBranch, npxPackage, env: svcEnv, runtime: rt } = body;
|
||||
if (!name) return json(res, 400, { error: "name required" });
|
||||
|
||||
// Kill existing
|
||||
const existing = services.get(name);
|
||||
if (existing?.process) { existing.status = "stopped"; existing.process.kill("SIGTERM"); await new Promise(r => setTimeout(r, 1000)); }
|
||||
|
||||
// Determine source path — git clone, npx, or pre-existing path
|
||||
let svcSourcePath = sourcePath;
|
||||
let svcRuntime = rt;
|
||||
|
||||
if (gitUrl) {
|
||||
// Git clone into runner's local storage
|
||||
svcSourcePath = join("/var/claudemesh/services", name);
|
||||
const { execSync } = await import("node:child_process");
|
||||
mkdirSync(svcSourcePath, { recursive: true });
|
||||
try {
|
||||
// Clean existing clone
|
||||
execSync(`rm -rf ${svcSourcePath}/*`, { timeout: 10_000 });
|
||||
execSync(`git clone --depth 1 ${gitBranch ? `--branch ${gitBranch}` : ""} ${gitUrl} .`, { cwd: svcSourcePath, timeout: 120_000, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
console.log(`[runner] git clone complete: ${gitUrl} -> ${svcSourcePath}`);
|
||||
} catch (e) {
|
||||
return json(res, 500, { error: `git clone failed: ${e.message}` });
|
||||
}
|
||||
} else if (npxPackage) {
|
||||
// npx-based: create a minimal package.json that depends on the package
|
||||
svcSourcePath = join("/var/claudemesh/services", name);
|
||||
mkdirSync(svcSourcePath, { recursive: true });
|
||||
const pkg = { name: `mcp-${name}`, private: true, dependencies: { [npxPackage]: "*" } };
|
||||
writeFileSync(join(svcSourcePath, "package.json"), JSON.stringify(pkg, null, 2));
|
||||
svcRuntime = svcRuntime || "node";
|
||||
} else if (body.uvxPackage) {
|
||||
// uvx-based Python MCP: install via uv and find the entry point
|
||||
svcSourcePath = join("/var/claudemesh/services", name);
|
||||
mkdirSync(svcSourcePath, { recursive: true });
|
||||
const { execSync } = await import("node:child_process");
|
||||
try {
|
||||
execSync(`uv venv --clear ${join(svcSourcePath, ".venv")}`, { timeout: 30_000, stdio: "pipe" });
|
||||
execSync(`uv pip install --python ${join(svcSourcePath, ".venv/bin/python")} "${body.uvxPackage}" "mcp[cli]"`, { timeout: 120_000, stdio: "pipe" });
|
||||
console.log(`[runner] uvx package installed: ${body.uvxPackage}`);
|
||||
} catch (e) {
|
||||
return json(res, 500, { error: `uvx install failed: ${e.message}` });
|
||||
}
|
||||
// For Python MCPs: run via `python -m <module>` using the venv python.
|
||||
// The module name is derived from the package name: mcp-server-time → mcp_server_time
|
||||
const venvPython = join(svcSourcePath, ".venv/bin/python");
|
||||
const moduleName = body.uvxPackage.replace(/-/g, "_");
|
||||
svcRuntime = "python";
|
||||
// _pythonModule signals spawnService to use `python -m <module>` instead of binary
|
||||
const svc2 = { name, sourcePath: svcSourcePath, runtime: svcRuntime, env: svcEnv || {}, process: null, pid: null, tools: [], status: "running", pending: new Map(), logs: [], restarts: 0, healthFailures: 0, _venvPython: venvPython, _pythonModule: moduleName };
|
||||
services.set(name, svc2);
|
||||
spawnService(svc2);
|
||||
// Python MCPs take longer to start — retry init with backoff
|
||||
let initErr = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 1500 + attempt * 1000));
|
||||
try {
|
||||
svc2.tools = await initMcp(svc2);
|
||||
console.log(`[runner] ${name} ready (uvx), ${svc2.tools.length} tools`);
|
||||
return json(res, 200, { status: "running", tools: svc2.tools });
|
||||
} catch (e) { initErr = e; }
|
||||
}
|
||||
svc2.status = "failed"; svc2.logs.push(`MCP init failed after 3 attempts: ${initErr?.message}`);
|
||||
return json(res, 500, { error: initErr?.message, logs: svc2.logs.slice(-10) });
|
||||
} else if (!svcSourcePath) {
|
||||
return json(res, 400, { error: "one of sourcePath, gitUrl, or npxPackage required" });
|
||||
}
|
||||
|
||||
const runtime = svcRuntime || detectRuntime(svcSourcePath);
|
||||
const svc = { name, sourcePath: svcSourcePath, runtime, env: svcEnv || {}, process: null, pid: null, tools: [], status: "installing", pending: new Map(), logs: [], restarts: 0, healthFailures: 0 };
|
||||
services.set(name, svc);
|
||||
|
||||
// Install deps
|
||||
try { await installDeps(svcSourcePath, runtime); } catch (e) {
|
||||
svc.status = "failed"; svc.logs.push(`install failed: ${e.message}`);
|
||||
return json(res, 500, { error: e.message });
|
||||
}
|
||||
|
||||
// For npx packages: find the binary in node_modules/.bin
|
||||
if (npxPackage) {
|
||||
const binDir = join(svcSourcePath, "node_modules", ".bin");
|
||||
if (existsSync(binDir)) {
|
||||
const bins = readdirSync(binDir).filter(b => !["node-which", "which", "semver", "resolve"].includes(b));
|
||||
// Prefer binary matching the package name
|
||||
const pkgShort = npxPackage.split("/").pop().replace(/^@/, "");
|
||||
const match = bins.find(b => b === pkgShort || b.includes(pkgShort)) || bins[0];
|
||||
if (match) {
|
||||
svc._npxBin = join(binDir, match);
|
||||
console.log(`[runner] npx binary resolved: ${match}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn + MCP handshake
|
||||
spawnService(svc);
|
||||
await new Promise(r => setTimeout(r, 1000)); // npx packages may need more startup time
|
||||
try {
|
||||
svc.tools = await initMcp(svc);
|
||||
console.log(`[runner] ${name} ready, ${svc.tools.length} tools`);
|
||||
return json(res, 200, { status: "running", tools: svc.tools });
|
||||
} catch (e) {
|
||||
svc.status = "failed"; svc.logs.push(`MCP init failed: ${e.message}`);
|
||||
return json(res, 500, { error: e.message, logs: svc.logs.slice(-10) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/call") {
|
||||
const body = await readBody(req);
|
||||
const { name, tool, args } = body;
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: `service "${name}" not found` });
|
||||
if (svc.status !== "running") return json(res, 503, { error: `service is ${svc.status}` });
|
||||
const result = await sendMcpRequest(svc, "tools/call", { name: tool, arguments: args || {} });
|
||||
return json(res, 200, result);
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/unload") {
|
||||
const body = await readBody(req);
|
||||
const { name } = body;
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: "not found" });
|
||||
svc.status = "stopped";
|
||||
if (svc.process) { svc.process.kill("SIGTERM"); await new Promise(r => setTimeout(r, 2000)); if (svc.process) svc.process.kill("SIGKILL"); }
|
||||
for (const [, p] of svc.pending) { clearTimeout(p.timer); p.resolve({ error: "unloaded" }); }
|
||||
services.delete(name);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
json(res, 404, { error: "not found" });
|
||||
} catch (e) {
|
||||
console.error("[runner] request error:", e);
|
||||
json(res, 500, { error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`[runner] supervisor listening on :${PORT}`);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("[runner] shutting down...");
|
||||
for (const [, svc] of services) { svc.status = "stopped"; svc.process?.kill("SIGTERM"); }
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
15
apps/telegram/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# Telegram bridge for claudemesh
|
||||
# Node 22 runtime with tsx for TypeScript execution
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src/ ./src/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["npx", "tsx", "src/index.ts"]
|
||||
20
apps/telegram/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@claudemesh/telegram",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "bun src/index.ts",
|
||||
"dev": "bun --hot src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"grammy": "^1.35.0",
|
||||
"ws": "^8.18.0",
|
||||
"libsodium": "^0.7.15",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"tsx": "^4.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.13",
|
||||
"@types/libsodium-wrappers": "^0.7.14"
|
||||
}
|
||||
}
|
||||
759
apps/telegram/src/index.ts
Normal file
@@ -0,0 +1,759 @@
|
||||
/**
|
||||
* Claudemesh ↔ Telegram Bridge
|
||||
*
|
||||
* Joins the mesh as a peer named "telegram-bridge", relays messages
|
||||
* between a Telegram chat and mesh peers.
|
||||
*
|
||||
* Telegram → Mesh:
|
||||
* "@Mou fix the bug" → send_message(to: "Mou", message: "fix the bug")
|
||||
* "/peers" → list_peers → reply with online list
|
||||
* "/broadcast hello" → send_message(to: "*", message: "hello")
|
||||
* "any text" → send_message(to: "*", message: text) (broadcast)
|
||||
*
|
||||
* Mesh → Telegram:
|
||||
* Any push message addressed to this peer → forward to Telegram chat
|
||||
*/
|
||||
|
||||
import { Bot, InputFile } from "grammy";
|
||||
import WebSocket from "ws";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
// --- Config ---
|
||||
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
|
||||
const ALLOWED_CHAT_IDS = (process.env.TELEGRAM_CHAT_IDS ?? "").split(",").filter(Boolean).map(Number);
|
||||
const CONFIG_DIR = process.env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||
const DISPLAY_NAME = process.env.BRIDGE_NAME ?? "telegram-bridge";
|
||||
|
||||
if (!BOT_TOKEN) {
|
||||
console.error("TELEGRAM_BOT_TOKEN required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Load mesh config ---
|
||||
interface JoinedMesh {
|
||||
meshId: string;
|
||||
memberId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
pubkey: string;
|
||||
secretKey: string;
|
||||
brokerUrl: string;
|
||||
}
|
||||
|
||||
function loadMeshConfig(): JoinedMesh[] {
|
||||
// Support env-based config for Docker/VPS deployment
|
||||
if (process.env.MESH_ID && process.env.MESH_MEMBER_ID && process.env.MESH_PUBKEY && process.env.MESH_SECRET_KEY) {
|
||||
return [{
|
||||
meshId: process.env.MESH_ID,
|
||||
memberId: process.env.MESH_MEMBER_ID,
|
||||
slug: process.env.MESH_SLUG ?? "mesh",
|
||||
name: process.env.MESH_NAME ?? "mesh",
|
||||
pubkey: process.env.MESH_PUBKEY,
|
||||
secretKey: process.env.MESH_SECRET_KEY,
|
||||
brokerUrl: process.env.MESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
}];
|
||||
}
|
||||
// Fall back to config file
|
||||
const path = join(CONFIG_DIR, "config.json");
|
||||
if (!existsSync(path)) {
|
||||
console.error(`No config at ${path} — set MESH_ID/MESH_MEMBER_ID/MESH_PUBKEY/MESH_SECRET_KEY env vars or run 'claudemesh join' first`);
|
||||
process.exit(1);
|
||||
}
|
||||
const config = JSON.parse(readFileSync(path, "utf-8"));
|
||||
return config.meshes ?? [];
|
||||
}
|
||||
|
||||
// --- Crypto ---
|
||||
let sodiumReady = false;
|
||||
|
||||
async function ensureSodium() {
|
||||
if (!sodiumReady) {
|
||||
await sodium.ready;
|
||||
sodiumReady = true;
|
||||
}
|
||||
return sodium;
|
||||
}
|
||||
|
||||
async function generateSessionKeypair() {
|
||||
const s = await ensureSodium();
|
||||
const kp = s.crypto_sign_keypair();
|
||||
return {
|
||||
publicKey: s.to_hex(kp.publicKey),
|
||||
secretKey: s.to_hex(kp.privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
async function signHello(meshId: string, memberId: string, pubkey: string, secretKeyHex: 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) };
|
||||
}
|
||||
|
||||
/** Decrypt a direct message envelope using crypto_box (X25519). */
|
||||
async function decryptDirect(
|
||||
nonce: string,
|
||||
ciphertext: string,
|
||||
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 nonceBytes = s.from_base64(nonce, s.base64_variants.ORIGINAL);
|
||||
const ciphertextBytes = s.from_base64(ciphertext, s.base64_variants.ORIGINAL);
|
||||
const plain = s.crypto_box_open_easy(ciphertextBytes, nonceBytes, senderPub, recipientSec);
|
||||
return s.to_string(plain);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mesh WS Client (simplified) ---
|
||||
interface PeerInfo {
|
||||
displayName: string;
|
||||
pubkey: string;
|
||||
status: string;
|
||||
summary?: string;
|
||||
cwd?: string;
|
||||
groups?: string[];
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
class MeshBridge {
|
||||
private ws: WebSocket | null = null;
|
||||
private mesh: JoinedMesh;
|
||||
private sessionPubkey: string | null = null;
|
||||
private sessionSecretKey: string | null = null;
|
||||
private connected = false;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private reconnectAttempt = 0;
|
||||
private onMessage: (from: string, text: string, priority: string) => void;
|
||||
private resolvers = new Map<string, { resolve: (v: any) => void; timer: NodeJS.Timeout }>();
|
||||
/** Map pubkey → {name, avatar}, populated from list_peers */
|
||||
private peerInfo = new Map<string, { name: string; avatar?: string }>();
|
||||
|
||||
constructor(mesh: JoinedMesh, onMessage: (from: string, text: string, priority: string) => void) {
|
||||
this.mesh = mesh;
|
||||
this.onMessage = onMessage;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const sessionKP = await generateSessionKeypair();
|
||||
this.sessionPubkey = sessionKP.publicKey;
|
||||
this.sessionSecretKey = sessionKP.secretKey;
|
||||
return this._connect();
|
||||
}
|
||||
|
||||
private _connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.mesh.brokerUrl);
|
||||
this.ws = ws;
|
||||
|
||||
ws.on("open", async () => {
|
||||
try {
|
||||
const { timestamp, signature } = await signHello(
|
||||
this.mesh.meshId, this.mesh.memberId,
|
||||
this.mesh.pubkey, this.mesh.secretKey,
|
||||
);
|
||||
ws.send(JSON.stringify({
|
||||
type: "hello",
|
||||
meshId: this.mesh.meshId,
|
||||
memberId: this.mesh.memberId,
|
||||
pubkey: this.mesh.pubkey,
|
||||
sessionPubkey: this.sessionPubkey,
|
||||
displayName: DISPLAY_NAME,
|
||||
sessionId: `telegram-${process.pid}-${Date.now()}`,
|
||||
pid: process.pid,
|
||||
cwd: process.cwd(),
|
||||
hostname: require("os").hostname(),
|
||||
peerType: "bridge",
|
||||
channel: "telegram",
|
||||
timestamp,
|
||||
signature,
|
||||
}));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
const helloTimeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("hello_ack timeout"));
|
||||
}, 10_000);
|
||||
|
||||
ws.on("message", async (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type !== "hello_ack" && msg.type !== "ack") {
|
||||
console.log(`[mesh] recv: ${msg.type}${msg.subtype ? '/' + msg.subtype : ''}${msg.event ? '/' + msg.event : ''}`);
|
||||
}
|
||||
|
||||
if (msg.type === "hello_ack") {
|
||||
clearTimeout(helloTimeout);
|
||||
this.connected = true;
|
||||
this.reconnectAttempt = 0;
|
||||
console.log(`[mesh] connected to ${this.mesh.slug} as ${DISPLAY_NAME}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Push messages from peers
|
||||
if (msg.type === "push") {
|
||||
let text: string | null = null;
|
||||
const senderPubkey = msg.senderPubkey ?? msg.senderSessionPubkey;
|
||||
|
||||
// System messages (no encryption)
|
||||
if (msg.subtype === "system") {
|
||||
const event = msg.event ?? "";
|
||||
const data = msg.eventData ?? {};
|
||||
if (event === "peer_joined") text = `[joined] ${data.displayName ?? "peer"}`;
|
||||
else if (event === "peer_left") text = `[left] ${data.displayName ?? "peer"}`;
|
||||
else if (event === "peer_returned") text = `[returned] ${data.name ?? "peer"}`;
|
||||
else text = msg.plaintext ?? `[${event}]`;
|
||||
}
|
||||
// Encrypted direct message
|
||||
else if (senderPubkey && msg.nonce && msg.ciphertext) {
|
||||
// Try session key first, then mesh member key
|
||||
text = await decryptDirect(msg.nonce, msg.ciphertext, senderPubkey, this.sessionSecretKey!)
|
||||
?? await decryptDirect(msg.nonce, msg.ciphertext, senderPubkey, this.mesh.secretKey);
|
||||
if (!text) text = "[could not decrypt]";
|
||||
}
|
||||
// Plaintext fallback (broadcasts, legacy)
|
||||
else if (msg.plaintext) {
|
||||
text = msg.plaintext;
|
||||
}
|
||||
// Base64 ciphertext without nonce (legacy broadcast)
|
||||
else if (msg.ciphertext && !msg.nonce) {
|
||||
try { text = Buffer.from(msg.ciphertext, "base64").toString("utf-8"); } catch { text = "[decode error]"; }
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const info = senderPubkey ? this.peerInfo.get(senderPubkey) : null;
|
||||
const fromName = info?.name ?? (senderPubkey?.slice(0, 12) ?? "system");
|
||||
const avatar = info?.avatar ?? "🤖";
|
||||
console.log(`[mesh] push from ${avatar} ${fromName}: ${text.slice(0, 80)}`);
|
||||
this.onMessage(`${avatar} ${fromName}`, text, msg.priority ?? "next");
|
||||
} else {
|
||||
console.log(`[mesh] push with no text. subtype=${msg.subtype}, hasSender=${!!senderPubkey}, hasNonce=${!!msg.nonce}, hasCipher=${!!msg.ciphertext}, hasPlain=${!!msg.plaintext}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve pending requests
|
||||
const reqId = msg._reqId;
|
||||
if (reqId && this.resolvers.has(reqId)) {
|
||||
const r = this.resolvers.get(reqId)!;
|
||||
clearTimeout(r.timer);
|
||||
this.resolvers.delete(reqId);
|
||||
r.resolve(msg);
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
this.connected = false;
|
||||
this.ws = null;
|
||||
if (this.reconnectTimer) return;
|
||||
const delays = [1000, 2000, 4000, 8000, 16000, 30000];
|
||||
const delay = delays[Math.min(this.reconnectAttempt, delays.length - 1)]!;
|
||||
this.reconnectAttempt++;
|
||||
console.log(`[mesh] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this._connect().catch(e => console.error("[mesh] reconnect failed:", e));
|
||||
}, delay);
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
console.error("[mesh] ws error:", err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private makeReqId(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
private request(msg: Record<string, unknown>, timeout = 10_000): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
const timer = setTimeout(() => {
|
||||
this.resolvers.delete(reqId);
|
||||
resolve(null);
|
||||
}, timeout);
|
||||
this.resolvers.set(reqId, { resolve, timer });
|
||||
this.ws?.send(JSON.stringify({ ...msg, _reqId: reqId }));
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(to: string, message: string, priority: string = "next"): Promise<boolean> {
|
||||
if (!this.ws || !this.connected) return false;
|
||||
|
||||
// For direct targets (pubkeys), use crypto_box encryption.
|
||||
// For broadcasts/groups, use base64-encoded plaintext (legacy format).
|
||||
let nonce = "";
|
||||
let ciphertext = "";
|
||||
const isDirect = /^[0-9a-f]{64}$/.test(to);
|
||||
if (isDirect) {
|
||||
const s = await ensureSodium();
|
||||
const recipientPub = s.crypto_sign_ed25519_pk_to_curve25519(s.from_hex(to));
|
||||
const senderSec = s.crypto_sign_ed25519_sk_to_curve25519(s.from_hex(this.sessionSecretKey!));
|
||||
const nonceBytes = s.randombytes_buf(s.crypto_box_NONCEBYTES);
|
||||
const ciphertextBytes = s.crypto_box_easy(s.from_string(message), nonceBytes, recipientPub, senderSec);
|
||||
nonce = s.to_base64(nonceBytes, s.base64_variants.ORIGINAL);
|
||||
ciphertext = s.to_base64(ciphertextBytes, s.base64_variants.ORIGINAL);
|
||||
} else {
|
||||
// Broadcast/group: base64 plaintext (CLI decodes this when no nonce present)
|
||||
ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||
}
|
||||
|
||||
const id = this.makeReqId();
|
||||
console.log(`[mesh] sending to ${to.slice(0, 16)}…, encrypted=${isDirect}`);
|
||||
this.ws.send(JSON.stringify({
|
||||
type: "send",
|
||||
id,
|
||||
targetSpec: to,
|
||||
priority,
|
||||
nonce,
|
||||
ciphertext,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Find all peers matching a display name. */
|
||||
async findPeersByName(name: string): Promise<PeerInfo[]> {
|
||||
const peers = await this.listPeers();
|
||||
return peers.filter(p => p.displayName.toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
/** Upload a file to the mesh via broker HTTP. Returns file ID. */
|
||||
async uploadFile(data: Buffer, fileName: string, tags?: string[]): Promise<string | null> {
|
||||
const brokerHttp = this.mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
try {
|
||||
const res = await fetch(`${brokerHttp}/upload`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-Mesh-Id": this.mesh.meshId,
|
||||
"X-Member-Id": this.mesh.memberId,
|
||||
"X-File-Name": fileName,
|
||||
"X-Tags": JSON.stringify(tags ?? ["telegram"]),
|
||||
"X-Persistent": "true",
|
||||
},
|
||||
body: data,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const body = await res.json() as { ok?: boolean; fileId?: string; error?: string };
|
||||
if (!res.ok || !body.fileId) return null;
|
||||
return body.fileId;
|
||||
} catch (e) {
|
||||
console.error("[mesh] upload failed:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a download URL for a mesh file. */
|
||||
async getFileUrl(fileId: string): Promise<{ url: string; name: string } | null> {
|
||||
const resp = await this.request({ type: "get_file", fileId });
|
||||
if (!resp?.url) return null;
|
||||
return { url: resp.url, name: resp.name ?? "file" };
|
||||
}
|
||||
|
||||
async listPeers(): Promise<PeerInfo[]> {
|
||||
const resp = await this.request({ type: "list_peers" });
|
||||
if (!resp?.peers) return [];
|
||||
return resp.peers.map((p: any) => {
|
||||
const name = p.displayName ?? p.pubkey?.slice(0, 12) ?? "?";
|
||||
const avatar = p.profile?.avatar;
|
||||
// Cache pubkey → info for push message attribution
|
||||
const info = { name, avatar };
|
||||
if (p.pubkey) this.peerInfo.set(p.pubkey, info);
|
||||
if (p.sessionPubkey) this.peerInfo.set(p.sessionPubkey, info);
|
||||
return {
|
||||
displayName: name,
|
||||
pubkey: p.pubkey ?? "",
|
||||
status: p.status ?? "unknown",
|
||||
summary: p.summary,
|
||||
cwd: p.cwd,
|
||||
groups: p.groups?.map((g: any) => g.name) ?? [],
|
||||
avatar: avatar,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Refresh peer name cache. Called periodically. */
|
||||
async refreshPeerNames(): Promise<void> {
|
||||
await this.listPeers();
|
||||
}
|
||||
|
||||
async setSummary(summary: string): Promise<void> {
|
||||
this.ws?.send(JSON.stringify({ type: "set_summary", summary }));
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
this.ws?.close();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resolve display name from peers ---
|
||||
async function resolveTarget(bridge: MeshBridge, name: string): Promise<string> {
|
||||
// If it starts with @, it's a group
|
||||
if (name.startsWith("@")) return name;
|
||||
// If *, broadcast
|
||||
if (name === "*") return "*";
|
||||
// Otherwise resolve as display name — the broker handles this via targetSpec
|
||||
return name;
|
||||
}
|
||||
|
||||
// --- Telegram Bot ---
|
||||
async function main() {
|
||||
const meshes = loadMeshConfig();
|
||||
if (meshes.length === 0) {
|
||||
console.error("No meshes joined — run 'claudemesh join' first");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const bot = new Bot(BOT_TOKEN);
|
||||
const bridges: MeshBridge[] = [];
|
||||
|
||||
// One bridge per mesh
|
||||
for (const mesh of meshes) {
|
||||
const bridge = new MeshBridge(mesh, (from, text, priority) => {
|
||||
// Forward mesh messages to all allowed Telegram chats
|
||||
const prefix = `[${mesh.slug}] ${from}`;
|
||||
const formatted = `💬 *${prefix}*\n${text}`;
|
||||
for (const chatId of ALLOWED_CHAT_IDS) {
|
||||
bot.api.sendMessage(chatId, formatted, { parse_mode: "Markdown" }).catch(e => {
|
||||
console.error(`[tg] failed to send to ${chatId}:`, e.message);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await bridge.connect();
|
||||
await bridge.setSummary("Telegram bridge — relays messages between Telegram and mesh peers");
|
||||
await bridge.refreshPeerNames();
|
||||
bridges.push(bridge);
|
||||
// Refresh peer names every 30s for display name resolution on pushes
|
||||
setInterval(() => bridge.refreshPeerNames().catch(() => {}), 30_000);
|
||||
} catch (e) {
|
||||
console.error(`[mesh] failed to connect to ${mesh.slug}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (bridges.length === 0) {
|
||||
console.error("Failed to connect to any mesh");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const defaultBridge = bridges[0]!;
|
||||
|
||||
// --- Bot commands ---
|
||||
|
||||
bot.command("peers", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const peers = await defaultBridge.listPeers();
|
||||
if (peers.length === 0) {
|
||||
await ctx.reply("No peers online.");
|
||||
return;
|
||||
}
|
||||
const lines = peers.map(p => {
|
||||
const status = p.status === "idle" ? "🟢" : p.status === "working" ? "🟡" : "🔴";
|
||||
const summary = p.summary ? ` — _${p.summary}_` : "";
|
||||
return `${status} *${p.displayName}*${summary}`;
|
||||
});
|
||||
await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
|
||||
});
|
||||
|
||||
// Pending messages waiting for peer selection (chatId → {message, matches})
|
||||
const pendingDMs = new Map<number, { message: string; matches: PeerInfo[]; selected: Set<number> }>();
|
||||
|
||||
bot.command("dm", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const text = ctx.match;
|
||||
if (!text) {
|
||||
await ctx.reply("Usage: /dm <peer-name> <message>");
|
||||
return;
|
||||
}
|
||||
const spaceIdx = text.indexOf(" ");
|
||||
if (spaceIdx === -1) {
|
||||
await ctx.reply("Usage: /dm <peer-name> <message>");
|
||||
return;
|
||||
}
|
||||
const target = text.slice(0, spaceIdx);
|
||||
const message = text.slice(spaceIdx + 1);
|
||||
|
||||
// Find matching peers
|
||||
const matches = await defaultBridge.findPeersByName(target);
|
||||
if (matches.length === 0) {
|
||||
await ctx.reply(`❌ No peer named "${target}" found.`);
|
||||
return;
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
// Single match — send directly
|
||||
const ok = await defaultBridge.sendMessage(matches[0]!.pubkey, `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}` : "❌ Not connected");
|
||||
return;
|
||||
}
|
||||
// Multiple matches — show picker with individual + all option
|
||||
pendingDMs.set(ctx.chat.id, { message, matches, selected: new Set() });
|
||||
const buttons = matches.map((p, i) => {
|
||||
const dir = p.cwd?.split("/").pop() ?? "?";
|
||||
const avatar = p.avatar ?? "🤖";
|
||||
return [{ text: `${avatar} ${p.displayName} (${dir})`, callback_data: `dm:${i}` }];
|
||||
});
|
||||
buttons.push([{ text: "📨 Send to ALL", callback_data: "dm:all" }]);
|
||||
await ctx.reply(`Multiple "${target}" peers online. Pick one or all:`, {
|
||||
reply_markup: { inline_keyboard: buttons },
|
||||
});
|
||||
});
|
||||
|
||||
bot.command("broadcast", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const message = ctx.match;
|
||||
if (!message) {
|
||||
await ctx.reply("Usage: /broadcast <message>");
|
||||
return;
|
||||
}
|
||||
const ok = await defaultBridge.sendMessage("*", `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? "✅ Broadcast sent" : "❌ Not connected");
|
||||
});
|
||||
|
||||
bot.command("group", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const text = ctx.match;
|
||||
if (!text) {
|
||||
await ctx.reply("Usage: /group <@group-name> <message>");
|
||||
return;
|
||||
}
|
||||
const spaceIdx = text.indexOf(" ");
|
||||
if (spaceIdx === -1) {
|
||||
await ctx.reply("Usage: /group <@group-name> <message>");
|
||||
return;
|
||||
}
|
||||
const target = text.slice(0, spaceIdx);
|
||||
const message = text.slice(spaceIdx + 1);
|
||||
const ok = await defaultBridge.sendMessage(target, `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? `✅ Sent to ${target}` : "❌ Not connected");
|
||||
});
|
||||
|
||||
bot.command("status", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const meshStatus = bridges.map(b =>
|
||||
`${b.isConnected() ? "🟢" : "🔴"} Connected`
|
||||
).join("\n");
|
||||
await ctx.reply(`*Claudemesh Telegram Bridge*\n${meshStatus}`, { parse_mode: "Markdown" });
|
||||
});
|
||||
|
||||
// --- File: get a mesh file by ID ---
|
||||
bot.command("file", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const fileId = ctx.match?.trim();
|
||||
if (!fileId) {
|
||||
await ctx.reply("Usage: /file <file-id>");
|
||||
return;
|
||||
}
|
||||
const file = await defaultBridge.getFileUrl(fileId);
|
||||
if (!file) {
|
||||
await ctx.reply(`❌ File ${fileId} not found`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(file.url, { signal: AbortSignal.timeout(30_000) });
|
||||
if (!resp.ok) { await ctx.reply(`❌ Download failed (${resp.status})`); return; }
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
await ctx.replyWithDocument(new InputFile(buf, file.name));
|
||||
} catch (e) {
|
||||
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("start", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) {
|
||||
await ctx.reply("⛔ Not authorized. Add your chat ID to TELEGRAM_CHAT_IDS.");
|
||||
return;
|
||||
}
|
||||
await ctx.reply(
|
||||
"🔗 *Claudemesh Telegram Bridge*\n\n" +
|
||||
"Commands:\n" +
|
||||
"• /peers — List online peers\n" +
|
||||
"• /dm <name> <msg> — DM a specific peer\n" +
|
||||
"• /broadcast <msg> — Message all peers\n" +
|
||||
"• /group @name <msg> — Message a group\n" +
|
||||
"• /file <id> — Download a mesh file\n" +
|
||||
"• /status — Bridge connection status\n\n" +
|
||||
"Send a photo/document to share it with the mesh.\n" +
|
||||
"Or just type a message to broadcast it.",
|
||||
{ parse_mode: "Markdown" },
|
||||
);
|
||||
});
|
||||
|
||||
// Handle inline keyboard callbacks for peer selection
|
||||
bot.on("callback_query:data", async (ctx) => {
|
||||
const data = ctx.callbackQuery.data;
|
||||
const chatId = ctx.chat?.id;
|
||||
if (!chatId || !data.startsWith("dm:")) {
|
||||
await ctx.answerCallbackQuery();
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingDMs.get(chatId);
|
||||
if (!pending) {
|
||||
await ctx.answerCallbackQuery({ text: "Session expired. Send /dm again." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === "dm:all") {
|
||||
// Send to all matches
|
||||
let sent = 0;
|
||||
for (const p of pending.matches) {
|
||||
const ok = await defaultBridge.sendMessage(p.pubkey, `[via Telegram] ${pending.message}`, "now");
|
||||
if (ok) sent++;
|
||||
}
|
||||
pendingDMs.delete(chatId);
|
||||
await ctx.answerCallbackQuery({ text: `Sent to ${sent} peers` });
|
||||
await ctx.editMessageText(`✅ Sent to all ${sent} ${pending.matches[0]?.displayName ?? "?"} peers`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single selection: dm:0, dm:1, etc.
|
||||
const idx = parseInt(data.slice(3));
|
||||
const peer = pending.matches[idx];
|
||||
if (!peer) {
|
||||
await ctx.answerCallbackQuery({ text: "Invalid selection" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await defaultBridge.sendMessage(peer.pubkey, `[via Telegram] ${pending.message}`, "now");
|
||||
pendingDMs.delete(chatId);
|
||||
const dir = peer.cwd?.split("/").pop() ?? "?";
|
||||
await ctx.answerCallbackQuery({ text: ok ? "Sent!" : "Failed" });
|
||||
await ctx.editMessageText(ok ? `✅ → ${peer.avatar ?? "🤖"} ${peer.displayName} (${dir})` : "❌ Not connected");
|
||||
});
|
||||
|
||||
// Handle photos from Telegram → share to mesh
|
||||
bot.on("message:photo", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const photo = ctx.message.photo.at(-1); // highest resolution
|
||||
if (!photo) return;
|
||||
try {
|
||||
const file = await ctx.api.getFile(photo.file_id);
|
||||
const url = `https://api.telegram.org/file/bot${BOT_TOKEN}/${file.file_path}`;
|
||||
const resp = await fetch(url);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
const name = `telegram-photo-${Date.now()}.jpg`;
|
||||
const fileId = await defaultBridge.uploadFile(buf, name, ["telegram", "photo"]);
|
||||
if (fileId) {
|
||||
const caption = ctx.message.caption ? ` — "${ctx.message.caption}"` : "";
|
||||
await defaultBridge.sendMessage("*", `[via Telegram] 📷 Photo shared${caption} (file: ${fileId})`, "next");
|
||||
await ctx.reply(`✅ Photo shared to mesh (${fileId})`);
|
||||
} else {
|
||||
await ctx.reply("❌ Upload failed");
|
||||
}
|
||||
} catch (e) {
|
||||
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle documents from Telegram → share to mesh
|
||||
bot.on("message:document", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const doc = ctx.message.document;
|
||||
if (!doc) return;
|
||||
try {
|
||||
const file = await ctx.api.getFile(doc.file_id);
|
||||
const url = `https://api.telegram.org/file/bot${BOT_TOKEN}/${file.file_path}`;
|
||||
const resp = await fetch(url);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
const name = doc.file_name ?? `telegram-file-${Date.now()}`;
|
||||
const fileId = await defaultBridge.uploadFile(buf, name, ["telegram", "document"]);
|
||||
if (fileId) {
|
||||
const caption = ctx.message.caption ? ` — "${ctx.message.caption}"` : "";
|
||||
await defaultBridge.sendMessage("*", `[via Telegram] 📎 File shared: ${name}${caption} (file: ${fileId})`, "next");
|
||||
await ctx.reply(`✅ File shared to mesh: ${name} (${fileId})`);
|
||||
} else {
|
||||
await ctx.reply("❌ Upload failed");
|
||||
}
|
||||
} catch (e) {
|
||||
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Default: any text without a command → broadcast
|
||||
bot.on("message:text", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const text = ctx.message.text;
|
||||
if (text.startsWith("/")) return; // Skip unknown commands
|
||||
|
||||
// Check for @mention pattern: "@PeerName message"
|
||||
const mentionMatch = text.match(/^@(\S+)\s+([\s\S]+)$/);
|
||||
if (mentionMatch) {
|
||||
const target = mentionMatch[1]!;
|
||||
const message = mentionMatch[2]!;
|
||||
const matches = await defaultBridge.findPeersByName(target);
|
||||
if (matches.length === 0) {
|
||||
await ctx.reply(`❌ No peer named "${target}"`);
|
||||
} else if (matches.length === 1) {
|
||||
const ok = await defaultBridge.sendMessage(matches[0]!.pubkey, `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}` : "❌ Not connected");
|
||||
} else {
|
||||
pendingDMs.set(ctx.chat.id, { message, matches, selected: new Set() });
|
||||
const buttons = matches.map((p, i) => {
|
||||
const dir = p.cwd?.split("/").pop() ?? "?";
|
||||
return [{ text: `${p.avatar ?? "🤖"} ${p.displayName} (${dir})`, callback_data: `dm:${i}` }];
|
||||
});
|
||||
buttons.push([{ text: "📨 Send to ALL", callback_data: "dm:all" }]);
|
||||
await ctx.reply(`Multiple "${target}" peers. Pick one or all:`, {
|
||||
reply_markup: { inline_keyboard: buttons },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No mention → broadcast
|
||||
const ok = await defaultBridge.sendMessage("*", `[via Telegram] ${text}`, "next");
|
||||
if (!ok) await ctx.reply("❌ Not connected to mesh");
|
||||
});
|
||||
|
||||
function isAllowed(chatId: number): boolean {
|
||||
// If no chat IDs configured, allow all (dev mode)
|
||||
if (ALLOWED_CHAT_IDS.length === 0) return true;
|
||||
return ALLOWED_CHAT_IDS.includes(chatId);
|
||||
}
|
||||
|
||||
// Start bot
|
||||
console.log("[tg] starting bot...");
|
||||
bot.start({
|
||||
onStart: () => console.log("[tg] bot running"),
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGINT", () => {
|
||||
console.log("[shutdown] closing...");
|
||||
bot.stop();
|
||||
bridges.forEach(b => b.close());
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("[shutdown] closing...");
|
||||
bot.stop();
|
||||
bridges.forEach(b => b.close());
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error("fatal:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -175,4 +175,12 @@ GOOGLE_GENERATIVE_AI_API_KEY="<your-google-generative-ai-api-key>"
|
||||
MISTRAL_API_KEY="<your-mistral-api-key>"
|
||||
|
||||
# Perplexity API key - required only if you use Perplexity as an AI provider
|
||||
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
|
||||
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
|
||||
|
||||
|
||||
##############################
|
||||
### CLI Sync config ###
|
||||
##############################
|
||||
|
||||
# Shared secret for CLI sync JWT signing (HS256) — must match the broker's CLI_SYNC_SECRET
|
||||
CLI_SYNC_SECRET="<your-cli-sync-secret>"
|
||||
@@ -10,7 +10,11 @@ RUN corepack enable && corepack prepare pnpm@10.25.0 --activate
|
||||
# pnpm workspace needs full context to resolve workspace:* + catalog:
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# --ignore-scripts skips sherif postinstall linting (exits 1 on warnings)
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts && \
|
||||
node node_modules/esbuild/install.js && \
|
||||
node node_modules/sharp/install/check.js || npm run --prefix node_modules/sharp build 2>/dev/null; \
|
||||
true
|
||||
|
||||
# Build — SKIP_ENV_VALIDATION lets missing runtime vars pass (validated at startup instead)
|
||||
ENV NODE_ENV=production
|
||||
@@ -25,6 +29,9 @@ ENV NEXT_PUBLIC_URL=$NEXT_PUBLIC_URL
|
||||
ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME
|
||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
|
||||
# Node ESM loader that stubs .css imports during route collection.
|
||||
# Payload CMS deps import .css files that Node can't handle outside webpack.
|
||||
ENV NODE_OPTIONS="--import /app/apps/web/css-stub-loader.mjs"
|
||||
RUN npx turbo run build --filter=web...
|
||||
|
||||
# Stage 2: runtime — standalone output only
|
||||
|
||||
33
apps/web/css-stub-loader.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Node.js ESM custom loader — stubs static asset imports as empty modules.
|
||||
*
|
||||
* Next.js 16 does route collection in raw Node ESM (not webpack/turbopack).
|
||||
* Payload CMS deps import .css, .scss, .svg, and other assets that Node
|
||||
* can't handle. This loader intercepts those and returns empty modules.
|
||||
*
|
||||
* Usage: NODE_OPTIONS="--import ./apps/web/css-stub-loader.mjs"
|
||||
*/
|
||||
|
||||
import { register } from "node:module";
|
||||
|
||||
register(
|
||||
"data:text/javascript," +
|
||||
encodeURIComponent(`
|
||||
const STYLE_RE = /\\.(css|scss|sass|less|svg|png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|otf)$/;
|
||||
|
||||
export function resolve(specifier, context, nextResolve) {
|
||||
if (STYLE_RE.test(specifier)) {
|
||||
return { url: 'data:text/javascript,export default {};', shortCircuit: true };
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
|
||||
export function load(url, context, nextLoad) {
|
||||
if (STYLE_RE.test(url)) {
|
||||
return { format: 'module', source: 'export default {};', shortCircuit: true };
|
||||
}
|
||||
return nextLoad(url, context);
|
||||
}
|
||||
`),
|
||||
import.meta.url,
|
||||
);
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { withPayload } = require("@payloadcms/next/withPayload");
|
||||
|
||||
import env from "./env.config";
|
||||
|
||||
const INTERNAL_PACKAGES = [
|
||||
@@ -80,6 +83,16 @@ const config: NextConfig = {
|
||||
serverExternalPackages: [
|
||||
"better-sqlite3",
|
||||
"@mapbox/node-pre-gyp",
|
||||
"esbuild",
|
||||
"payload",
|
||||
"@payloadcms/db-postgres",
|
||||
"@payloadcms/db-sqlite",
|
||||
"@payloadcms/richtext-lexical",
|
||||
"@payloadcms/next",
|
||||
"@payloadcms/ui",
|
||||
"react-image-crop",
|
||||
"sharp",
|
||||
"libsodium-wrappers",
|
||||
],
|
||||
turbopack: {
|
||||
rules: {
|
||||
@@ -90,6 +103,24 @@ const config: NextConfig = {
|
||||
},
|
||||
},
|
||||
|
||||
// Webpack SVG loader (used when TURBOPACK=0 for production builds).
|
||||
// Exclude app/ dir SVGs (icon.svg, opengraph-image) — Next.js metadata
|
||||
// loader handles those. Only process package SVGs (flags, logos).
|
||||
webpack(config) {
|
||||
const existingSvgRule = config.module.rules.find(
|
||||
(rule: { test?: RegExp }) => rule.test?.test?.(".svg"),
|
||||
);
|
||||
if (existingSvgRule) {
|
||||
existingSvgRule.exclude = /packages\/ui\/.*\.svg$/;
|
||||
}
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
include: /packages\/ui\//,
|
||||
use: ["@svgr/webpack"],
|
||||
});
|
||||
return config;
|
||||
},
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
@@ -124,4 +155,4 @@ const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
||||
enabled: env.ANALYZE,
|
||||
});
|
||||
|
||||
export default withBundleAnalyzer(config);
|
||||
export default withPayload(withBundleAnalyzer(config));
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"build": "next build --webpack",
|
||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||
"dev": "next dev",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
@@ -18,8 +18,12 @@
|
||||
"@anaralabs/lector": "3.7.3",
|
||||
"@formatjs/intl-localematcher": "0.6.2",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@next/bundle-analyzer": "16.0.10",
|
||||
"@next/bundle-analyzer": "16.2.2",
|
||||
"@number-flow/react": "0.5.10",
|
||||
"@payloadcms/db-postgres": "3.81.0",
|
||||
"@payloadcms/db-sqlite": "^3.81.0",
|
||||
"@payloadcms/next": "^3.81.0",
|
||||
"@payloadcms/richtext-lexical": "^3.81.0",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-devtools": "catalog:",
|
||||
"@tanstack/react-table": "catalog:",
|
||||
@@ -40,10 +44,11 @@
|
||||
"marked": "16.4.1",
|
||||
"motion": "12.23.24",
|
||||
"negotiator": "1.0.0",
|
||||
"next": "16.0.10",
|
||||
"next": "16.2.2",
|
||||
"next-i18n-router": "5.5.5",
|
||||
"next-themes": "0.4.6",
|
||||
"nuqs": "2.7.2",
|
||||
"payload": "^3.81.0",
|
||||
"pdfjs-dist": "5.4.530",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "catalog:react19",
|
||||
@@ -57,6 +62,7 @@
|
||||
"rehype-raw": "7.0.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"remark-math": "6.0.0",
|
||||
"sharp": "0.34.5",
|
||||
"sonner": "2.0.7",
|
||||
"zod": "catalog:",
|
||||
"zustand": "5.0.8"
|
||||
|
||||
212
apps/web/payload.config.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { buildConfig } from "payload";
|
||||
import { postgresAdapter } from "@payloadcms/db-postgres";
|
||||
import { sqliteAdapter } from "@payloadcms/db-sqlite";
|
||||
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import sharp from "sharp";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
// Use Postgres in production (DATABASE_URL), SQLite locally
|
||||
const usePostgres = !!process.env.DATABASE_URL;
|
||||
|
||||
export default buildConfig({
|
||||
secret: process.env.PAYLOAD_SECRET || "claudemesh-dev-secret-change-in-production",
|
||||
|
||||
routes: {
|
||||
admin: "/payload",
|
||||
},
|
||||
|
||||
admin: {
|
||||
user: "users",
|
||||
meta: {
|
||||
titleSuffix: "— claudemesh",
|
||||
},
|
||||
},
|
||||
|
||||
editor: lexicalEditor(),
|
||||
|
||||
db: usePostgres
|
||||
? postgresAdapter({
|
||||
pool: { connectionString: process.env.DATABASE_URL! },
|
||||
schemaName: "payload",
|
||||
})
|
||||
: sqliteAdapter({
|
||||
client: {
|
||||
url: process.env.PAYLOAD_DATABASE_URI || `file:${path.resolve(dirname, "payload.db")}`,
|
||||
},
|
||||
}),
|
||||
|
||||
sharp,
|
||||
|
||||
collections: [
|
||||
// --- Users (admin panel) ---
|
||||
{
|
||||
slug: "users",
|
||||
auth: true,
|
||||
admin: { useAsTitle: "email" },
|
||||
fields: [
|
||||
{ name: "name", type: "text" },
|
||||
{ name: "role", type: "select", options: ["admin", "editor"], defaultValue: "editor" },
|
||||
],
|
||||
},
|
||||
|
||||
// --- Media ---
|
||||
{
|
||||
slug: "media",
|
||||
upload: {
|
||||
staticDir: path.resolve(dirname, "public/media"),
|
||||
mimeTypes: ["image/*"],
|
||||
},
|
||||
admin: { useAsTitle: "alt" },
|
||||
fields: [
|
||||
{ name: "alt", type: "text", required: true },
|
||||
],
|
||||
},
|
||||
|
||||
// --- Authors ---
|
||||
{
|
||||
slug: "authors",
|
||||
admin: { useAsTitle: "name" },
|
||||
fields: [
|
||||
{ name: "name", type: "text", required: true },
|
||||
{ name: "slug", type: "text", required: true, unique: true },
|
||||
{ name: "bio", type: "textarea" },
|
||||
{ name: "role", type: "text" },
|
||||
{
|
||||
name: "avatar",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
},
|
||||
{
|
||||
name: "links",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "github", type: "text" },
|
||||
{ name: "twitter", type: "text" },
|
||||
{ name: "website", type: "text" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// --- Categories ---
|
||||
{
|
||||
slug: "categories",
|
||||
admin: { useAsTitle: "name" },
|
||||
fields: [
|
||||
{ name: "name", type: "text", required: true },
|
||||
{ name: "slug", type: "text", required: true, unique: true },
|
||||
{ name: "description", type: "textarea" },
|
||||
],
|
||||
},
|
||||
|
||||
// --- Blog Posts ---
|
||||
{
|
||||
slug: "posts",
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "status", "publishedAt", "author"],
|
||||
},
|
||||
versions: { drafts: true },
|
||||
fields: [
|
||||
{ name: "title", type: "text", required: true },
|
||||
{
|
||||
name: "slug",
|
||||
type: "text",
|
||||
required: true,
|
||||
unique: true,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
description: "URL-friendly identifier. Auto-generated from title if left blank.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "excerpt",
|
||||
type: "textarea",
|
||||
admin: { description: "1-2 sentence summary for cards and meta descriptions." },
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "richText",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "coverImage",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
type: "relationship",
|
||||
relationTo: "authors",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "categories",
|
||||
type: "relationship",
|
||||
relationTo: "categories",
|
||||
hasMany: true,
|
||||
},
|
||||
{
|
||||
name: "publishedAt",
|
||||
type: "date",
|
||||
admin: { position: "sidebar", date: { pickerAppearance: "dayOnly" } },
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Published", value: "published" },
|
||||
],
|
||||
defaultValue: "draft",
|
||||
admin: { position: "sidebar" },
|
||||
},
|
||||
{
|
||||
name: "seo",
|
||||
type: "group",
|
||||
fields: [
|
||||
{ name: "metaTitle", type: "text" },
|
||||
{ name: "metaDescription", type: "textarea" },
|
||||
{ name: "ogImage", type: "upload", relationTo: "media" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// --- Changelog ---
|
||||
{
|
||||
slug: "changelog",
|
||||
admin: {
|
||||
useAsTitle: "version",
|
||||
defaultColumns: ["version", "date", "type"],
|
||||
},
|
||||
fields: [
|
||||
{ name: "version", type: "text", required: true },
|
||||
{ name: "date", type: "date", required: true },
|
||||
{
|
||||
name: "type",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Feature", value: "feat" },
|
||||
{ label: "Fix", value: "fix" },
|
||||
{ label: "Docs", value: "docs" },
|
||||
{ label: "Breaking", value: "breaking" },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
{ name: "summary", type: "text", required: true },
|
||||
{ name: "body", type: "richText" },
|
||||
{ name: "npmUrl", type: "text" },
|
||||
{ name: "githubUrl", type: "text" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, "src/payload-types.ts"),
|
||||
},
|
||||
});
|
||||
BIN
apps/web/public/logo-wordmark.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/web/public/logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
0
apps/web/public/media/.gitkeep
Normal file
BIN
apps/web/public/media/blog-hero-mesh.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
53
apps/web/public/media/blog-hero-mesh.svg
Normal file
@@ -0,0 +1,53 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||
<rect width="1200" height="630" fill="#141413"/>
|
||||
|
||||
<!-- mesh connections -->
|
||||
<g stroke="#d97757" stroke-width="1" opacity="0.3">
|
||||
<line x1="180" y1="160" x2="420" y2="280"/>
|
||||
<line x1="420" y1="280" x2="700" y2="200"/>
|
||||
<line x1="700" y1="200" x2="950" y2="320"/>
|
||||
<line x1="180" y1="160" x2="300" y2="400"/>
|
||||
<line x1="300" y1="400" x2="550" y2="450"/>
|
||||
<line x1="550" y1="450" x2="700" y2="200"/>
|
||||
<line x1="550" y1="450" x2="950" y2="320"/>
|
||||
<line x1="420" y1="280" x2="300" y2="400"/>
|
||||
<line x1="700" y1="200" x2="850" y2="480"/>
|
||||
<line x1="950" y1="320" x2="850" y2="480"/>
|
||||
<line x1="300" y1="400" x2="150" y2="520"/>
|
||||
<line x1="550" y1="450" x2="850" y2="480"/>
|
||||
<line x1="1050" y1="150" x2="950" y2="320"/>
|
||||
<line x1="100" y1="350" x2="180" y2="160"/>
|
||||
<line x1="100" y1="350" x2="300" y2="400"/>
|
||||
</g>
|
||||
|
||||
<!-- encrypted data flow (dashed) -->
|
||||
<g stroke="#d97757" stroke-width="1.5" stroke-dasharray="6 8" opacity="0.15">
|
||||
<line x1="180" y1="160" x2="950" y2="320"/>
|
||||
<line x1="300" y1="400" x2="700" y2="200"/>
|
||||
<line x1="100" y1="350" x2="550" y2="450"/>
|
||||
<line x1="420" y1="280" x2="850" y2="480"/>
|
||||
</g>
|
||||
|
||||
<!-- nodes -->
|
||||
<g fill="#d97757">
|
||||
<circle cx="180" cy="160" r="5"/>
|
||||
<circle cx="420" cy="280" r="5"/>
|
||||
<circle cx="700" cy="200" r="5"/>
|
||||
<circle cx="950" cy="320" r="5"/>
|
||||
<circle cx="300" cy="400" r="5"/>
|
||||
<circle cx="550" cy="450" r="5"/>
|
||||
<circle cx="850" cy="480" r="4"/>
|
||||
<circle cx="1050" cy="150" r="3.5"/>
|
||||
<circle cx="100" cy="350" r="3.5"/>
|
||||
<circle cx="150" cy="520" r="3"/>
|
||||
</g>
|
||||
|
||||
<!-- node halos -->
|
||||
<g fill="none" stroke="#d97757" stroke-width="0.5" opacity="0.2">
|
||||
<circle cx="180" cy="160" r="16"/>
|
||||
<circle cx="420" cy="280" r="14"/>
|
||||
<circle cx="700" cy="200" r="18"/>
|
||||
<circle cx="950" cy="320" r="15"/>
|
||||
<circle cx="550" cy="450" r="12"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
14
apps/web/src/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import "@payloadcms/next/css";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const metadata = {
|
||||
title: "CMS — claudemesh",
|
||||
};
|
||||
|
||||
export default function PayloadLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
16
apps/web/src/app/(payload)/payload/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck — Payload generates these types at build time
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
import config from "@payload-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Args = { params: Promise<{ segments: string[] }> };
|
||||
|
||||
export const generateMetadata = ({ params }: Args) =>
|
||||
generatePageMetadata({ config, params });
|
||||
|
||||
export default function Page({ params }: Args) {
|
||||
return <RootPage config={config} params={params} importMap={importMap} />;
|
||||
}
|
||||
51
apps/web/src/app/(payload)/payload/importMap.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
173
apps/web/src/app/[locale]/(marketing)/about/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import Link from "next/link";
|
||||
import { Reveal, SectionIcon } from "~/modules/marketing/home/_reveal";
|
||||
|
||||
export const metadata = {
|
||||
title: "About — claudemesh",
|
||||
description:
|
||||
"claudemesh is built by Alejandro A. Gutiérrez Mourente — fighter pilot, AI business architect, solo builder.",
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<section className="mx-auto max-w-3xl px-6 py-24 md:py-32">
|
||||
<Reveal className="mb-6">
|
||||
<SectionIcon glyph="leaf" />
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={1}>
|
||||
<h1
|
||||
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
About
|
||||
</h1>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={2}>
|
||||
<div
|
||||
className="mt-10 space-y-6 text-[15px] leading-[1.8] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<p>
|
||||
claudemesh is built by{" "}
|
||||
<span className="font-medium text-[var(--cm-fg)]">
|
||||
Alejandro A. Gutiérrez Mourente
|
||||
</span>{" "}
|
||||
— a fighter pilot who builds production AI systems.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
A decade flying F-18s and serving as Operational Safety Officer
|
||||
in the Spanish Air Force taught one thing: systems either work
|
||||
under pressure or they fail people. That standard followed into
|
||||
software.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Before claudemesh, that meant shipping a document intelligence
|
||||
platform that replaced a manual process worth €5M/year (four
|
||||
extraction engines, contract generation, production-grade), AI
|
||||
backoffice modules for a multi-tenant enterprise platform, and
|
||||
end-to-end ERP integrations across automotive, aviation, fintech,
|
||||
legal, and defense — each designed, built, and presented to
|
||||
leadership by one person.
|
||||
</p>
|
||||
|
||||
<p className="text-[var(--cm-fg)]">
|
||||
claudemesh exists because Claude Code sessions are isolated. You
|
||||
close the terminal and the context dies. Your teammate re-solves
|
||||
the same bug. The insight never travels.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The fix: a peer mesh. End-to-end encrypted, delivered mid-turn,
|
||||
broker-never-decrypts. The{" "}
|
||||
<Link
|
||||
href="https://github.com/alezmad/claudemesh-cli"
|
||||
className="text-[var(--cm-clay)] hover:underline"
|
||||
>
|
||||
CLI is MIT-licensed
|
||||
</Link>
|
||||
. The{" "}
|
||||
<Link
|
||||
href="https://github.com/alezmad/claudemesh-cli/blob/main/PROTOCOL.md"
|
||||
className="text-[var(--cm-clay)] hover:underline"
|
||||
>
|
||||
wire protocol is documented
|
||||
</Link>
|
||||
. The{" "}
|
||||
<Link
|
||||
href="https://github.com/alezmad/claudemesh-cli/blob/main/THREAT_MODEL.md"
|
||||
className="text-[var(--cm-clay)] hover:underline"
|
||||
>
|
||||
threat model is public
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The same safety thinking that goes into clearing a formation
|
||||
through weather goes into deciding what untrusted text should and
|
||||
should not reach your AI agent. The stakes are lower. The method
|
||||
is the same: understand the failure modes first, then build the
|
||||
system that handles them.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={3}>
|
||||
<div className="mt-12 border-t border-[var(--cm-border)] pt-8">
|
||||
<h2
|
||||
className="mb-4 text-[18px] font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Background
|
||||
</h2>
|
||||
<div
|
||||
className="space-y-3 text-[13px] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>
|
||||
Fighter pilot · Spanish Air Force (Ejército del Aire) · F-18
|
||||
Hornet · Operational Safety Officer (QASO)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>
|
||||
AI Business Architect · document intelligence, ERP
|
||||
integration, multi-tenant enterprise platforms
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>
|
||||
Full-stack solo builder · TypeScript, Python, LLM
|
||||
orchestration, domain-driven design
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>
|
||||
Regulated industries · automotive, aviation, fintech, legal,
|
||||
defense
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>Las Palmas, Canarias, Spain</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={4}>
|
||||
<div className="mt-10 flex flex-wrap gap-4">
|
||||
<Link
|
||||
href="https://github.com/alezmad"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
GitHub
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.linkedin.com/in/alejandro-mourente/"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
LinkedIn
|
||||
</Link>
|
||||
<Link
|
||||
href="mailto:alex@mourente.ai"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Contact
|
||||
</Link>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
68
apps/web/src/app/[locale]/(marketing)/blog/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata = {
|
||||
title: "Blog — claudemesh",
|
||||
description: "Engineering notes on peer messaging, protocol design, and multi-agent security.",
|
||||
};
|
||||
|
||||
const POSTS = [
|
||||
{
|
||||
slug: "peer-messaging-claude-code",
|
||||
title: "Peer messaging for Claude Code: protocol, security, UX",
|
||||
excerpt:
|
||||
"How claudemesh connects Claude Code sessions over an encrypted mesh, using MCP dev-channels for real-time message injection.",
|
||||
date: "2026-04-06",
|
||||
},
|
||||
];
|
||||
|
||||
export default function BlogIndex() {
|
||||
return (
|
||||
<section className="mx-auto max-w-3xl px-6 py-24 md:py-32">
|
||||
<h1
|
||||
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Blog
|
||||
</h1>
|
||||
<p
|
||||
className="mt-4 text-[15px] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Engineering notes on protocol design, security, and multi-agent UX.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 space-y-10">
|
||||
{POSTS.map((post) => (
|
||||
<article key={post.slug} className="border-b border-[var(--cm-border)] pb-8">
|
||||
<time
|
||||
dateTime={post.date}
|
||||
className="text-[11px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{new Date(post.date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
<h2 className="mt-2">
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="text-[22px] font-medium leading-tight text-[var(--cm-fg)] transition-colors hover:text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</h2>
|
||||
<p
|
||||
className="mt-3 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{post.excerpt}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata = {
|
||||
title: "Peer messaging for Claude Code: protocol, security, UX — claudemesh",
|
||||
description:
|
||||
"How claudemesh connects Claude Code sessions over an encrypted mesh, using MCP dev-channels for real-time message injection. Wire protocol, threat model, and what's next.",
|
||||
openGraph: {
|
||||
title: "Peer messaging for Claude Code: protocol, security, UX",
|
||||
description: "How claudemesh connects Claude Code sessions over an encrypted mesh.",
|
||||
images: ["/media/blog-hero-mesh.png"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function BlogPost() {
|
||||
return (
|
||||
<article className="mx-auto max-w-3xl px-6 py-24 md:py-32">
|
||||
<header className="mb-12">
|
||||
<time
|
||||
dateTime="2026-04-06"
|
||||
className="text-[11px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
April 6, 2026
|
||||
</time>
|
||||
<h1
|
||||
className="mt-3 text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Peer messaging for Claude Code: protocol, security, UX
|
||||
</h1>
|
||||
<p
|
||||
className="mt-4 text-sm text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
by Alejandro A. Gutiérrez Mourente
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="space-y-5 text-[15px] leading-[1.8] text-[var(--cm-fg-secondary)] [&_h2]:mt-10 [&_h2]:mb-4 [&_h2]:text-[22px] [&_h2]:font-medium [&_h2]:text-[var(--cm-fg)] [&_a]:text-[var(--cm-clay)] [&_a]:hover:underline [&_code]:rounded [&_code]:bg-[var(--cm-gray-800)] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[13px] [&_code]:text-[var(--cm-fg-secondary)] [&_pre]:overflow-x-auto [&_pre]:rounded-[8px] [&_pre]:border [&_pre]:border-[var(--cm-border)] [&_pre]:bg-[var(--cm-gray-850)] [&_pre]:p-4 [&_pre]:text-[13px] [&_pre]:leading-[1.6] [&_strong]:font-medium [&_strong]:text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<p>
|
||||
Claude Code sessions are islands. You build context over an hour of conversation, close the
|
||||
tab, and that context dies. Two sessions side by side — one refactoring the API, one fixing
|
||||
the frontend — share a filesystem but not a thought. I spent a decade flying F-18s in the
|
||||
Spanish Air Force, where every formation member broadcasts position, fuel, and threat data
|
||||
in real time. Silence kills. I built{" "}
|
||||
<a href="https://github.com/alezmad/claudemesh-cli">claudemesh</a> to give Claude Code
|
||||
sessions the same link: an MCP server that connects them over an encrypted mesh, pushing
|
||||
messages directly into each other's context mid-turn.
|
||||
</p>
|
||||
<p>
|
||||
The CLI is MIT-licensed, on npm as <code>claudemesh-cli</code>. This post covers the wire
|
||||
protocol, the experimental Claude Code capability behind real-time injection, and the
|
||||
prompt-injection surface that deserves careful attention.
|
||||
</p>
|
||||
|
||||
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>The protocol</h2>
|
||||
<p>
|
||||
One owner's ed25519 public key defines a mesh. The owner generates signed invite links;
|
||||
each invitee verifies the signature, generates a fresh ed25519 keypair locally, and enrolls
|
||||
with a broker via <code>POST /join</code>. The client then opens a persistent WebSocket
|
||||
(<code>wss://</code> in production) and authenticates with a signed <code>hello</code>{" "}
|
||||
frame:
|
||||
</p>
|
||||
<pre><code>{`{
|
||||
"type": "hello",
|
||||
"meshId": "01HX...",
|
||||
"memberId": "01HX...",
|
||||
"pubkey": "64-hex-chars",
|
||||
"timestamp": 1735689600000,
|
||||
"signature": "128-hex-chars"
|
||||
}`}</code></pre>
|
||||
<p>
|
||||
The signature covers{" "}
|
||||
<code>{"${meshId}|${memberId}|${pubkey}|${timestamp}"}</code>. The broker verifies it
|
||||
against the registered public key and replies <code>hello_ack</code>. The connection is
|
||||
live.
|
||||
</p>
|
||||
<p>
|
||||
Direct messages use libsodium <code>crypto_box_easy</code> for end-to-end encryption —
|
||||
X25519 keys derived from ed25519 identity pairs via{" "}
|
||||
<code>crypto_sign_ed25519_pk_to_curve25519</code>. The broker routes ciphertext and never
|
||||
sees plaintext. Priority routing: <code>now</code> delivers immediately, <code>next</code>{" "}
|
||||
queues until idle, <code>low</code> waits for an explicit drain. The full specification
|
||||
lives in{" "}
|
||||
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/PROTOCOL.md">PROTOCOL.md</a>{" "}
|
||||
(453 lines).
|
||||
</p>
|
||||
|
||||
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>Dev channels: the missing piece</h2>
|
||||
<p>
|
||||
An experimental Claude Code capability fixes the polling problem:{" "}
|
||||
<code>notifications/claude/channel</code>. When an MCP server declares{" "}
|
||||
<code>{"{ experimental: { \"claude/channel\": {} } }"}</code> and Claude Code launches
|
||||
with <code>--dangerously-load-development-channels server:<name></code>, the server
|
||||
pushes notifications that arrive as <code>{"<channel source=\"claudemesh\">"}</code> system
|
||||
reminders mid-turn. Claude reacts immediately.
|
||||
</p>
|
||||
<p>
|
||||
<code>claudemesh launch</code> wraps this into one command. I tested with an echo-channel
|
||||
MCP server emitting a notification every 15 seconds — all three ticks arrived mid-turn and
|
||||
Claude responded inline. Confirmed on Claude Code v2.1.92.
|
||||
</p>
|
||||
|
||||
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>The prompt-injection question</h2>
|
||||
<p>
|
||||
This section matters most. claudemesh decrypts peer text and injects it into Claude's
|
||||
context. That text is untrusted input. A peer can send instruction overrides, tool-call
|
||||
steering, or confused-deputy attacks invoking other MCP servers through Claude. The same
|
||||
failure-mode analysis that clears a formation through weather applies here: enumerate every
|
||||
way the system breaks, then close each path.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Tool-approval prompts stay intact.</strong> claudemesh never disables Claude Code's
|
||||
permission system. A peer message can ask Claude to run a shell command; Claude still
|
||||
prompts the user.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Messages carry attribution.</strong> Each <code>{"<channel>"}</code> reminder
|
||||
includes <code>from_id</code>, <code>from_name</code>, and <code>mesh_slug</code>.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Membership requires a signed invite.</strong> An attacker needs a valid
|
||||
ed25519-signed invite from the mesh owner or a compromised member keypair.
|
||||
</p>
|
||||
<p>
|
||||
The residual risks are real. If a user blanket-approves tools, a malicious peer message
|
||||
reaches the shell without human review. The causal chain — peer message, Claude decision,
|
||||
tool call — has no persistent audit trail yet.{" "}
|
||||
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/THREAT_MODEL.md">
|
||||
THREAT_MODEL.md
|
||||
</a>{" "}
|
||||
(212 lines) documents all of this. Open questions I want to work through with the Claude
|
||||
Code team.
|
||||
</p>
|
||||
|
||||
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>What I'd do next</h2>
|
||||
<p>
|
||||
<strong>Shared-key channel crypto.</strong> Channel and broadcast messages are base64
|
||||
plaintext today. The upgrade is a KDF from <code>mesh_root_key</code> plus key rotation.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Causal audit log.</strong> When Claude calls a tool because of a peer message, that
|
||||
link should persist: which message, which tool call, what result.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Sender allowlists.</strong> Per-mesh config: accept messages only from these
|
||||
pubkeys. If a member's key is compromised, others exclude it locally.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Forward secrecy.</strong> <code>crypto_box</code> uses long-lived keys. A leaked
|
||||
key lets an attacker decrypt all past captured ciphertext. A double-ratchet would bound the
|
||||
damage window.
|
||||
</p>
|
||||
|
||||
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>Try it</h2>
|
||||
<pre><code>{`npm install -g claudemesh-cli
|
||||
claudemesh install
|
||||
claudemesh join https://claudemesh.com/join/<token>
|
||||
claudemesh launch`}</code></pre>
|
||||
<p>
|
||||
The code is at{" "}
|
||||
<a href="https://github.com/alezmad/claudemesh-cli">github.com/alezmad/claudemesh-cli</a>.
|
||||
The wire protocol is in{" "}
|
||||
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/PROTOCOL.md">PROTOCOL.md</a>.
|
||||
The threat model is in{" "}
|
||||
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/THREAT_MODEL.md">
|
||||
THREAT_MODEL.md
|
||||
</a>.
|
||||
Contributions welcome — see{" "}
|
||||
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/CONTRIBUTING.md">
|
||||
CONTRIBUTING.md
|
||||
</a>.
|
||||
</p>
|
||||
<p>
|
||||
If you work on Claude Code or the MCP ecosystem and this interests you, I'd like to hear
|
||||
from you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 border-t border-[var(--cm-border)] pt-8">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="text-sm text-[var(--cm-clay)] hover:underline"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
← Back to blog
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/app/[locale]/(marketing)/changelog/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
export const metadata = {
|
||||
title: "Changelog — claudemesh",
|
||||
description: "Release history for claudemesh-cli.",
|
||||
};
|
||||
|
||||
const ENTRIES = [
|
||||
{ version: "0.1.4", date: "2026-04-06", type: "feat", summary: "Stateful welcome screen, PROTOCOL.md, THREAT_MODEL.md, Windows CI matrix" },
|
||||
{ version: "0.1.3", date: "2026-04-05", type: "feat", summary: "claudemesh --version, status, doctor commands" },
|
||||
{ version: "0.1.2", date: "2026-04-05", type: "feat", summary: "claudemesh launch command, transparency banner, decrypt fix, Windows support" },
|
||||
];
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = { feat: "Feature", fix: "Fix", docs: "Docs" };
|
||||
const TYPE_COLORS: Record<string, string> = { feat: "bg-[var(--cm-clay)]", fix: "bg-[var(--cm-cactus)]", docs: "bg-[var(--cm-oat)]" };
|
||||
|
||||
export default function ChangelogPage() {
|
||||
return (
|
||||
<section className="mx-auto max-w-3xl px-6 py-24 md:py-32">
|
||||
<h1
|
||||
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Changelog
|
||||
</h1>
|
||||
<p
|
||||
className="mt-4 text-[15px] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Every shipped version of claudemesh-cli.
|
||||
</p>
|
||||
<div className="mt-12 space-y-8">
|
||||
{ENTRIES.map((entry) => (
|
||||
<article key={entry.version} className="border-b border-[var(--cm-border)] pb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`rounded-[4px] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--cm-bg)] ${TYPE_COLORS[entry.type] || "bg-[var(--cm-fg-tertiary)]"}`}
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{TYPE_LABELS[entry.type] || entry.type}
|
||||
</span>
|
||||
<span className="text-[18px] font-medium text-[var(--cm-fg)]" style={{ fontFamily: "var(--cm-font-serif)" }}>
|
||||
v{entry.version}
|
||||
</span>
|
||||
<time dateTime={entry.date} className="text-[11px] text-[var(--cm-fg-tertiary)]" style={{ fontFamily: "var(--cm-font-mono)" }}>
|
||||
{new Date(entry.date).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-2 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]" style={{ fontFamily: "var(--cm-font-sans)" }}>
|
||||
{entry.summary}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
458
apps/web/src/app/[locale]/(marketing)/getting-started/page.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Getting Started",
|
||||
description:
|
||||
"Install the CLI and launch your first peer session in two commands.",
|
||||
});
|
||||
|
||||
const STEP = ({
|
||||
n,
|
||||
title,
|
||||
children,
|
||||
cmd,
|
||||
note,
|
||||
}: {
|
||||
n: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
cmd?: string;
|
||||
note?: string;
|
||||
}) => (
|
||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-6 md:p-8">
|
||||
<div
|
||||
className="mb-4 flex items-center gap-3 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--cm-clay)]/15 text-[11px] font-medium">
|
||||
{n}
|
||||
</span>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className="text-[15px] leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{cmd && (
|
||||
<pre
|
||||
className="mt-4 overflow-x-auto rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-4 py-3 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{cmd}</code>
|
||||
</pre>
|
||||
)}
|
||||
{note && (
|
||||
<p
|
||||
className="mt-3 text-[12px] leading-[1.6] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const VERIFY_CHECKS = [
|
||||
"Node.js >= 20 installed",
|
||||
"claude binary on PATH",
|
||||
"~/.claudemesh/config.json parses + chmod 0600",
|
||||
"Mesh keypairs valid",
|
||||
"Broker connectivity",
|
||||
];
|
||||
|
||||
export default function GettingStartedPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-16 md:px-12 md:py-24">
|
||||
<div
|
||||
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
— getting started
|
||||
</div>
|
||||
<h1
|
||||
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
From zero to meshed in two minutes
|
||||
</h1>
|
||||
<p
|
||||
className="mt-4 max-w-xl text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Install the CLI and launch. Two commands — join is built into launch.
|
||||
</p>
|
||||
|
||||
{/* Prerequisites */}
|
||||
<div className="mt-14 mb-10">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Prerequisites
|
||||
</h2>
|
||||
<ul
|
||||
className="space-y-2 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>
|
||||
<strong className="text-[var(--cm-fg)]">Node.js 20+</strong> —{" "}
|
||||
<Link
|
||||
href="https://nodejs.org"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
nodejs.org
|
||||
</Link>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>
|
||||
<strong className="text-[var(--cm-fg)]">Claude Code 2.0+</strong>{" "}
|
||||
—{" "}
|
||||
<Link
|
||||
href="https://claude.com/claude-code"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
claude.com/claude-code
|
||||
</Link>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||
<span>
|
||||
<strong className="text-[var(--cm-fg)]">An invite link</strong> —
|
||||
from a mesh owner, or{" "}
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
create your own mesh
|
||||
</Link>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="space-y-6">
|
||||
<STEP
|
||||
n="1"
|
||||
title="Install the CLI"
|
||||
cmd="npm i -g claudemesh-cli"
|
||||
note="Requires Node.js 20+. Installs the claudemesh CLI globally."
|
||||
>
|
||||
<p>
|
||||
One command. If you get a permissions error, see{" "}
|
||||
<Link
|
||||
href="https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
npm docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</STEP>
|
||||
|
||||
<STEP
|
||||
n="2"
|
||||
title="Launch"
|
||||
cmd='claudemesh launch --name Alice --join https://claudemesh.com/join/eyJ2IjoxLC...'
|
||||
note="--join enrolls you in the mesh (first time only). On subsequent launches, drop the --join flag."
|
||||
>
|
||||
<p>
|
||||
This does everything: verifies the invite, generates your ed25519
|
||||
keypair, enrolls with the broker, and spawns Claude Code with
|
||||
real-time peer messaging. Your keys are stored in{" "}
|
||||
<code
|
||||
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
~/.claudemesh/config.json
|
||||
</code>{" "}
|
||||
(chmod 0600) — the broker never sees them.
|
||||
</p>
|
||||
</STEP>
|
||||
|
||||
<div
|
||||
className="py-3 text-center text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
next time, just:
|
||||
<code className="ml-2 rounded bg-[var(--cm-bg-elevated)] px-2 py-1 text-[var(--cm-fg-secondary)]">
|
||||
claudemesh launch --name Alice
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-4 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{`# Full example with all flags
|
||||
claudemesh launch \\
|
||||
--name Alice \\
|
||||
--join https://claudemesh.com/join/eyJ2IjoxLC... \\
|
||||
--role dev \\
|
||||
--groups "frontend:lead,reviewers" \\
|
||||
--message-mode push \\
|
||||
-y # skip permission confirmation`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Verify */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Verify your setup
|
||||
</h2>
|
||||
<p
|
||||
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Run the diagnostic check — it walks through every precondition and
|
||||
prints pass/fail with fix hints:
|
||||
</p>
|
||||
<pre
|
||||
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{`$ claudemesh doctor
|
||||
claudemesh doctor (v0.8.0)
|
||||
────────────────────────────────────────────────────────────
|
||||
✓ Node.js >= 20 (v22.15.0)
|
||||
✓ claude binary on PATH
|
||||
✓ ~/.claudemesh/config.json parses + chmod 0600
|
||||
✓ Mesh keypairs valid (1 mesh(es))
|
||||
✓ Broker connectivity (wss://ic.claudemesh.com/ws)
|
||||
|
||||
All checks passed.`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Invite a teammate */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Invite a teammate
|
||||
</h2>
|
||||
<p
|
||||
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Mesh owners generate invite links from the{" "}
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
dashboard
|
||||
</Link>
|
||||
. Each link is a signed ed25519 token with a mesh ID, broker URL,
|
||||
expiry, and role (admin or member). Share via Slack, email, or
|
||||
paste in chat.
|
||||
</p>
|
||||
<p
|
||||
className="text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
The recipient runs{" "}
|
||||
<code
|
||||
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
claudemesh launch --name Name --join <link>
|
||||
</code>{" "}
|
||||
— joins the mesh and launches in one step. No account creation
|
||||
needed. Identity is the ed25519 keypair.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Invite link formats */}
|
||||
<div className="mt-10">
|
||||
<h3
|
||||
className="mb-3 text-base font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Accepted invite formats
|
||||
</h3>
|
||||
<pre
|
||||
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.9] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{`# Join + launch in one step (recommended)
|
||||
claudemesh launch --name Alice --join https://claudemesh.com/join/eyJ2IjoxLC...
|
||||
|
||||
# Or join separately first
|
||||
claudemesh join https://claudemesh.com/join/eyJ2IjoxLC...
|
||||
claudemesh launch --name Alice
|
||||
|
||||
# All invite formats work with both join and --join:
|
||||
# https://claudemesh.com/join/eyJ2IjoxLC...
|
||||
# https://claudemesh.com/en/join/eyJ2IjoxLC...
|
||||
# ic://join/eyJ2IjoxLC...
|
||||
# eyJ2IjoxLC4uLg (raw token)`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Message modes */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Message modes
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
mode: "push",
|
||||
desc: "Real-time. Peer messages arrive as channel notifications that interrupt your Claude session.",
|
||||
when: "Default. Best for active collaboration.",
|
||||
},
|
||||
{
|
||||
mode: "inbox",
|
||||
desc: "Held until you check. You get a notification but messages queue until check_messages.",
|
||||
when: "Deep work. Check when ready.",
|
||||
},
|
||||
{
|
||||
mode: "off",
|
||||
desc: "No delivery. Tools still work — use check_messages to poll manually.",
|
||||
when: "Solo work on a shared mesh.",
|
||||
},
|
||||
].map((m) => (
|
||||
<div
|
||||
key={m.mode}
|
||||
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5"
|
||||
>
|
||||
<code
|
||||
className="mb-2 block text-sm font-medium text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
--message-mode {m.mode}
|
||||
</code>
|
||||
<p
|
||||
className="mb-2 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{m.desc}
|
||||
</p>
|
||||
<p
|
||||
className="text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.when}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* With vs without launch */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claudemesh launch</code> vs plain{" "}
|
||||
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claude</code>
|
||||
</h2>
|
||||
<div className="grid gap-px overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-border)] md:grid-cols-2">
|
||||
<div className="bg-[var(--cm-bg-elevated)] p-6">
|
||||
<div
|
||||
className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
claudemesh launch
|
||||
</div>
|
||||
<ul
|
||||
className="space-y-1.5 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<li>Real-time push messages from peers</li>
|
||||
<li>Native MCP entries for deployed mesh services</li>
|
||||
<li>Per-session ephemeral keypair</li>
|
||||
<li>Display name, groups, and roles</li>
|
||||
<li>Session config isolated in tmpdir</li>
|
||||
<li>MCP_TIMEOUT + output limits tuned for mesh</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="bg-[var(--cm-bg-elevated)] p-6">
|
||||
<div
|
||||
className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
plain claude
|
||||
</div>
|
||||
<ul
|
||||
className="space-y-1.5 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<li>All 43 MCP tools still work</li>
|
||||
<li>Messages are pull-only (check_messages)</li>
|
||||
<li>No real-time push delivery</li>
|
||||
<li>Uses member keypair (not ephemeral)</li>
|
||||
<li>No display name or group assignment</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uninstall */}
|
||||
<div className="mt-16">
|
||||
<h2
|
||||
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Uninstall
|
||||
</h2>
|
||||
<pre
|
||||
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.9] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<code>{`claudemesh uninstall # remove MCP server, hooks, and allowedTools
|
||||
npm uninstall -g claudemesh-cli
|
||||
rm -rf ~/.claudemesh # delete config + keypairs (irreversible)`}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-16 flex flex-col items-start gap-4 border-t border-[var(--cm-border)] pt-10">
|
||||
<p
|
||||
className="text-[15px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Need help? Run{" "}
|
||||
<code
|
||||
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
claudemesh doctor
|
||||
</code>{" "}
|
||||
to diagnose issues, or{" "}
|
||||
<Link
|
||||
href="https://github.com/alezmad/claudemesh-cli/issues"
|
||||
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
open an issue on GitHub
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<Link
|
||||
href="/auth/register"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-5 py-3 text-[15px] font-medium text-[var(--cm-fg)] transition-colors duration-300 hover:bg-[var(--cm-clay-hover)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
Create a mesh →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
import { Hero } from "~/modules/marketing/home/hero";
|
||||
import { Surfaces } from "~/modules/marketing/home/surfaces";
|
||||
import { Pricing } from "~/modules/marketing/home/pricing";
|
||||
import { LaptopToLaptop } from "~/modules/marketing/home/laptop-to-laptop";
|
||||
import { Features } from "~/modules/marketing/home/features";
|
||||
import { MeetsYou } from "~/modules/marketing/home/meets-you";
|
||||
import { BeyondTerminal } from "~/modules/marketing/home/beyond-terminal";
|
||||
import { DemoDashboard } from "~/modules/marketing/home/demo-dashboard";
|
||||
import { WhatIsClaudemesh } from "~/modules/marketing/home/what-is-claudemesh";
|
||||
import { Timeline } from "~/modules/marketing/home/timeline";
|
||||
import { Pricing } from "~/modules/marketing/home/pricing";
|
||||
import { FAQ } from "~/modules/marketing/home/faq";
|
||||
import { CallToAction } from "~/modules/marketing/home/cta";
|
||||
import { MeshStats } from "~/modules/marketing/home/mesh-stats";
|
||||
import { LatestNewsToaster } from "~/modules/marketing/home/toaster";
|
||||
|
||||
// Revalidate the page every 60s so the mesh-stats counter stays fresh
|
||||
// without hammering the DB. The /api/public/stats endpoint has its own
|
||||
// 60s in-memory cache too.
|
||||
export const revalidate = 60;
|
||||
|
||||
const HomePage = () => {
|
||||
@@ -24,14 +17,10 @@ const HomePage = () => {
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<Hero />
|
||||
<Surfaces />
|
||||
<Pricing />
|
||||
<LaptopToLaptop />
|
||||
<Features />
|
||||
<MeetsYou />
|
||||
<WhatIsClaudemesh />
|
||||
<DemoDashboard />
|
||||
<BeyondTerminal />
|
||||
<Timeline />
|
||||
<Pricing />
|
||||
<FAQ />
|
||||
<CallToAction />
|
||||
<MeshStats />
|
||||
|
||||
876
apps/web/src/app/[locale]/cli-auth/cli-auth-flow.tsx
Normal file
@@ -0,0 +1,876 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Mesh {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
myRole: "admin" | "member";
|
||||
isOwner: boolean;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
code: string | null;
|
||||
port: string | null;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const slugify = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
|
||||
const ease = [0.22, 0.61, 0.36, 1] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animated mesh node background
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MeshBackdrop() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{/* Radial glow */}
|
||||
<div
|
||||
className="absolute left-1/2 top-0 h-[600px] w-[900px] -translate-x-1/2 opacity-[0.06]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(ellipse at 50% 0%, var(--cm-clay) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
{/* Floating mesh nodes */}
|
||||
{[
|
||||
{ x: "12%", y: "18%", delay: 0, size: 3 },
|
||||
{ x: "85%", y: "14%", delay: 1.2, size: 2 },
|
||||
{ x: "72%", y: "55%", delay: 0.6, size: 4 },
|
||||
{ x: "8%", y: "65%", delay: 2.0, size: 2 },
|
||||
{ x: "45%", y: "80%", delay: 0.3, size: 3 },
|
||||
{ x: "92%", y: "78%", delay: 1.8, size: 2 },
|
||||
].map((node, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute rounded-full bg-[var(--cm-clay)]"
|
||||
style={{
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
width: node.size,
|
||||
height: node.size,
|
||||
}}
|
||||
animate={{
|
||||
opacity: [0.15, 0.4, 0.15],
|
||||
scale: [1, 1.5, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
ease: "easeInOut",
|
||||
repeat: Infinity,
|
||||
delay: node.delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Connecting lines (SVG) */}
|
||||
<svg className="absolute inset-0 h-full w-full opacity-[0.04]">
|
||||
<line
|
||||
x1="12%"
|
||||
y1="18%"
|
||||
x2="45%"
|
||||
y2="80%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="85%"
|
||||
y1="14%"
|
||||
x2="72%"
|
||||
y2="55%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="72%"
|
||||
y1="55%"
|
||||
x2="92%"
|
||||
y2="78%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="8%"
|
||||
y1="65%"
|
||||
x2="45%"
|
||||
y2="80%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal-style status indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusPulse({ status }: { status: "waiting" | "syncing" | "done" | "error" }) {
|
||||
const colors = {
|
||||
waiting: "bg-[var(--cm-clay)]",
|
||||
syncing: "bg-amber-400",
|
||||
done: "bg-emerald-400",
|
||||
error: "bg-red-400",
|
||||
};
|
||||
return (
|
||||
<span className="relative inline-flex h-2 w-2">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${colors[status]}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative inline-flex h-2 w-2 rounded-full ${colors[status]}`}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CliAuthFlow({ code, port, userId, userEmail }: Props) {
|
||||
const [meshes, setMeshes] = useState<Mesh[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [redirected, setRedirected] = useState(false);
|
||||
|
||||
// Create-mesh form state
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newSlug, setNewSlug] = useState("");
|
||||
const [slugDirty, setSlugDirty] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto-slug from name
|
||||
useEffect(() => {
|
||||
if (!slugDirty && newName) {
|
||||
setNewSlug(slugify(newName));
|
||||
}
|
||||
}, [newName, slugDirty]);
|
||||
|
||||
// Fetch user meshes
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await handle(api.my.meshes.$get, {
|
||||
schema: getMyMeshesResponseSchema,
|
||||
})({
|
||||
query: { page: "1", perPage: "50", sort: JSON.stringify([]) },
|
||||
});
|
||||
setMeshes(data);
|
||||
setSelected(new Set(data.map((m) => m.id)));
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error ? e.message : "Failed to load your meshes.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Auto-focus name input when no meshes
|
||||
useEffect(() => {
|
||||
if (!loading && meshes.length === 0 && nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
}
|
||||
}, [loading, meshes.length]);
|
||||
|
||||
const toggleMesh = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const status = token
|
||||
? redirected
|
||||
? "done"
|
||||
: "done"
|
||||
: syncing || creating
|
||||
? "syncing"
|
||||
: error
|
||||
? "error"
|
||||
: "waiting";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create mesh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim() || !newSlug.trim()) return;
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const createRes = await fetch("/api/my/meshes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: newName.trim(),
|
||||
slug: newSlug.trim(),
|
||||
visibility: "private",
|
||||
transport: "managed",
|
||||
}),
|
||||
});
|
||||
const res = (await createRes.json()) as
|
||||
| { id: string; slug: string }
|
||||
| { error: string };
|
||||
if (!createRes.ok || "error" in res) {
|
||||
setCreateError("error" in res ? res.error : "Failed to create mesh.");
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
await doSync(
|
||||
[{ id: res.id, slug: res.slug, role: "admin" as const }],
|
||||
"create",
|
||||
{ name: newName.trim(), slug: newSlug.trim() },
|
||||
);
|
||||
} catch (e) {
|
||||
setCreateError(e instanceof Error ? e.message : "Failed to create mesh.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const doSync = async (
|
||||
meshList: Array<{ id: string; slug: string; role: string }>,
|
||||
action: "sync" | "create" = "sync",
|
||||
newMesh?: { name: string; slug: string },
|
||||
) => {
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/cli-sync-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ meshes: meshList, action, newMesh }),
|
||||
});
|
||||
const data = (await res.json()) as { token?: string; error?: string };
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Failed to generate token.");
|
||||
setSyncing(false);
|
||||
return;
|
||||
}
|
||||
const jwt = data.token as string;
|
||||
setToken(jwt);
|
||||
if (port) {
|
||||
setRedirected(true);
|
||||
window.location.href = `http://localhost:${port}/callback?token=${encodeURIComponent(jwt)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to generate sync token.");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = () => {
|
||||
const selectedMeshes = meshes
|
||||
.filter((m) => selected.has(m.id))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
slug: m.slug,
|
||||
role: m.isOwner ? "admin" : m.myRole,
|
||||
}));
|
||||
if (selectedMeshes.length === 0) {
|
||||
setError("Select at least one mesh to sync.");
|
||||
return;
|
||||
}
|
||||
doSync(selectedMeshes, "sync");
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!token) return;
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<header className="relative z-20 border-b border-[var(--cm-border)] px-6 py-5 md:px-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="claudemesh home"
|
||||
className="group flex w-fit items-center gap-2.5"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="text-[var(--cm-clay)] transition-transform duration-300 group-hover:rotate-180"
|
||||
>
|
||||
<circle cx="12" cy="4" r="2" fill="currentColor" />
|
||||
<circle cx="4" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="20" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="12" cy="20" r="2" fill="currentColor" />
|
||||
<path
|
||||
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
opacity="0.45"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className="text-[17px] font-medium tracking-tight"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
claudemesh
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<StatusPulse status={status} />
|
||||
<span>
|
||||
{status === "waiting" && "awaiting sync"}
|
||||
{status === "syncing" && "generating token..."}
|
||||
{status === "done" && "synced"}
|
||||
{status === "error" && "error"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 mx-auto w-full max-w-2xl px-6 py-16 md:px-12 md:py-24">
|
||||
<MeshBackdrop />
|
||||
|
||||
{/* Section tag */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease }}
|
||||
className="mb-5 flex items-center gap-2 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="inline-block h-1 w-1 rounded-full bg-[var(--cm-clay)]" />
|
||||
— cli sync
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease, delay: 0.08 }}
|
||||
className="text-[clamp(2rem,4vw,2.75rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Sync with{" "}
|
||||
<span className="italic text-[var(--cm-clay)]">claudemesh CLI</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease, delay: 0.16 }}
|
||||
className="mt-4 text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Link your terminal session to your account and choose which meshes to
|
||||
sync.
|
||||
</motion.p>
|
||||
|
||||
{/* Pairing code */}
|
||||
<AnimatePresence>
|
||||
{code && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.5, ease, delay: 0.24 }}
|
||||
className="mt-10 overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20"
|
||||
>
|
||||
{/* Terminal-style header bar */}
|
||||
<div className="flex items-center gap-2 border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-4 py-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
</div>
|
||||
<span
|
||||
className="ml-2 text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
pairing verification
|
||||
</span>
|
||||
</div>
|
||||
{/* Code display */}
|
||||
<div className="bg-[var(--cm-bg-elevated)] px-5 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className="text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
code:
|
||||
</span>
|
||||
<motion.span
|
||||
className="text-4xl font-bold tracking-[0.2em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
>
|
||||
{code.split("").map((char, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.5 + i * 0.1, ease }}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
</div>
|
||||
<p
|
||||
className="mt-3 text-[13px] leading-relaxed text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Confirm this matches the code shown in your terminal.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
<AnimatePresence>
|
||||
{loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="mt-10 space-y-3"
|
||||
>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 animate-pulse rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Error */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="mt-6 flex items-start gap-3 rounded-[var(--cm-radius-md)] border border-red-500/20 bg-red-500/[0.06] p-4"
|
||||
>
|
||||
<span className="mt-0.5 text-red-400">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-sm text-red-400">{error}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Token result */}
|
||||
<AnimatePresence>
|
||||
{token && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease }}
|
||||
className="mt-10"
|
||||
>
|
||||
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-emerald-500/20">
|
||||
{/* Success header */}
|
||||
<div className="flex items-center gap-2 border-b border-emerald-500/10 bg-emerald-500/[0.06] px-4 py-3">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-emerald-400"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<span
|
||||
className="text-sm font-medium text-emerald-400"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{redirected ? "Redirecting to CLI..." : "Sync token generated"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Token body */}
|
||||
<div className="bg-[var(--cm-bg-elevated)] p-5">
|
||||
<p
|
||||
className="mb-3 text-[13px] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{redirected
|
||||
? "If your terminal didn\u2019t pick up the token, copy it manually:"
|
||||
: "Paste this token in your terminal when prompted:"}
|
||||
</p>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-text overflow-hidden text-ellipsis whitespace-nowrap rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3 py-2.5 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
onClick={(e) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(e.currentTarget);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}}
|
||||
>
|
||||
{token}
|
||||
</div>
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-4 py-2.5 text-sm font-medium text-[var(--cm-fg-secondary)] transition-all duration-200 hover:border-[var(--cm-clay)]/40 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
{copied ? (
|
||||
<span className="text-emerald-400">Copied</span>
|
||||
) : (
|
||||
"Copy"
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Mesh list */}
|
||||
{!loading && !token && meshes.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-10"
|
||||
>
|
||||
<h2
|
||||
className="mb-4 text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Your meshes
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{meshes.map((m, i) => (
|
||||
<motion.label
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.4, ease, delay: 0.35 + i * 0.06 }}
|
||||
className={`group flex cursor-pointer items-center gap-4 rounded-[var(--cm-radius-md)] border p-4 transition-all duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/[0.04]"
|
||||
: "border-[var(--cm-border)] hover:border-[var(--cm-clay)]/20 hover:bg-[var(--cm-bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
{/* Custom checkbox */}
|
||||
<div
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-all duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)] bg-[var(--cm-clay)]"
|
||||
: "border-[var(--cm-fg-tertiary)]/40 group-hover:border-[var(--cm-fg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
{selected.has(m.id) && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(m.id)}
|
||||
onChange={() => toggleMesh(m.id)}
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-[var(--cm-fg)]">
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.slug}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--cm-fg-tertiary)]">
|
||||
{m.memberCount}{" "}
|
||||
{m.memberCount === 1 ? "member" : "members"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] uppercase tracking-wider transition-colors duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)]/30 text-[var(--cm-clay)]"
|
||||
: "border-[var(--cm-border)] text-[var(--cm-fg-tertiary)]"
|
||||
}`}
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.isOwner ? "owner" : m.myRole}
|
||||
</span>
|
||||
</motion.label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.5 }}
|
||||
className="mt-8 flex items-center gap-4"
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleSync}
|
||||
disabled={syncing || selected.size === 0}
|
||||
className="group relative inline-flex items-center gap-2.5 overflow-hidden rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-7 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<motion.span
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="inline-block"
|
||||
>
|
||||
⟳
|
||||
</motion.span>
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sync to CLI
|
||||
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
→
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
<span className="text-xs text-[var(--cm-fg-tertiary)]">
|
||||
{selected.size} of {meshes.length} selected
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* No meshes — create form */}
|
||||
{!loading && !token && meshes.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease, delay: 0.3 }}
|
||||
className="mt-10"
|
||||
>
|
||||
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20">
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-5 py-4">
|
||||
<h2
|
||||
className="text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Create your first mesh
|
||||
</h2>
|
||||
<p
|
||||
className="mt-1 text-[13px] leading-relaxed text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
A mesh is the space where your Claude Code sessions talk to each
|
||||
other.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="space-y-5 bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="mesh-name"
|
||||
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
id="mesh-name"
|
||||
type="text"
|
||||
placeholder="Platform team"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="mesh-slug"
|
||||
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
|
||||
>
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
id="mesh-slug"
|
||||
type="text"
|
||||
placeholder="platform-team"
|
||||
value={newSlug}
|
||||
onChange={(e) => {
|
||||
setSlugDirty(true);
|
||||
setNewSlug(e.target.value);
|
||||
}}
|
||||
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
/>
|
||||
<p
|
||||
className="mt-1.5 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
lowercase · digits · hyphens
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{createError && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="text-sm text-red-400"
|
||||
>
|
||||
{createError}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !newName.trim() || !newSlug.trim()}
|
||||
className="group inline-flex w-full items-center justify-center gap-2.5 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-6 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<motion.span
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="inline-block"
|
||||
>
|
||||
⟳
|
||||
</motion.span>
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Create & sync to CLI
|
||||
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
→
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Footer security note */}
|
||||
<AnimatePresence>
|
||||
{!token && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="mt-16 flex items-start gap-3 text-[13px] leading-[1.7] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mt-0.5 shrink-0 text-[var(--cm-fg-tertiary)]/60"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<span>
|
||||
The sync token is valid for 15 minutes and can only be used once.
|
||||
Your ed25519 keys stay on your machine — the broker only sees
|
||||
ciphertext.
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
apps/web/src/app/[locale]/cli-auth/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getSession } from "~/lib/auth/server";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
|
||||
import { CliAuthFlow } from "./cli-auth-flow";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Sync with CLI",
|
||||
description: "Link your claudemesh CLI to your account.",
|
||||
});
|
||||
|
||||
export default async function CliAuthPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ code?: string; port?: string }>;
|
||||
}) {
|
||||
const { user } = await getSession();
|
||||
|
||||
if (!user) {
|
||||
const sp = await searchParams;
|
||||
const qs = new URLSearchParams();
|
||||
if (sp.code) qs.set("code", sp.code);
|
||||
if (sp.port) qs.set("port", sp.port);
|
||||
const returnTo = `/cli-auth${qs.size ? `?${qs}` : ""}`;
|
||||
return redirect(`/auth/login?redirectTo=${encodeURIComponent(returnTo)}`);
|
||||
}
|
||||
|
||||
const { code, port } = await searchParams;
|
||||
|
||||
return (
|
||||
<main
|
||||
className="min-h-screen bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<CliAuthFlow
|
||||
code={code ?? null}
|
||||
port={port ?? null}
|
||||
userId={user.id}
|
||||
userEmail={user.email}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
DashboardHeaderTitle,
|
||||
} from "~/modules/common/layout/dashboard/header";
|
||||
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
|
||||
import { PeerGraphPanel } from "~/modules/mesh/peer-graph-panel";
|
||||
import { ResourcePanel } from "~/modules/mesh/resource-panel";
|
||||
import { StateTimelinePanel } from "~/modules/mesh/state-timeline-panel";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Live mesh",
|
||||
@@ -63,7 +66,14 @@ export default async function LiveMeshPage({
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
|
||||
<LiveStreamPanel meshId={id} />
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<PeerGraphPanel meshId={id} />
|
||||
<LiveStreamPanel meshId={id} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<StateTimelinePanel meshId={id} />
|
||||
<ResourcePanel meshId={id} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
130
apps/web/src/app/api/cli-sync-token/route.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { auth } from "@turbostarter/auth/server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT signing (HS256 via Web Crypto — no external deps)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function base64UrlEncode(input: string | ArrayBuffer): string {
|
||||
const str =
|
||||
typeof input === "string"
|
||||
? btoa(input)
|
||||
: btoa(String.fromCharCode(...new Uint8Array(input)));
|
||||
return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function signJwt(
|
||||
payload: Record<string, unknown>,
|
||||
secret: string,
|
||||
): Promise<string> {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
||||
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
key,
|
||||
encoder.encode(`${headerB64}.${payloadB64}`),
|
||||
);
|
||||
|
||||
return `${headerB64}.${payloadB64}.${base64UrlEncode(signature)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route handler — POST /api/cli-sync-token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SyncTokenBody {
|
||||
meshes: Array<{ id: string; slug: string; role: string }>;
|
||||
action: "sync" | "create";
|
||||
newMesh?: { name: string; slug: string };
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// 1. Check auth
|
||||
const reqHeaders = new Headers(await headers());
|
||||
reqHeaders.set("x-client-platform", "web-server");
|
||||
|
||||
const session = await auth.api.getSession({ headers: reqHeaders });
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2. Parse body
|
||||
let body: SyncTokenBody;
|
||||
try {
|
||||
body = (await request.json()) as SyncTokenBody;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { meshes, action, newMesh } = body;
|
||||
|
||||
if (!Array.isArray(meshes)) {
|
||||
return NextResponse.json(
|
||||
{ error: "meshes must be an array" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action !== "sync" && action !== "create") {
|
||||
return NextResponse.json(
|
||||
{ error: 'action must be "sync" or "create"' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (action === "create" && (!newMesh?.name || !newMesh?.slug)) {
|
||||
return NextResponse.json(
|
||||
{ error: "newMesh.name and newMesh.slug are required for create action" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Validate meshes belong to user — fetch user's meshes via internal API
|
||||
// For now we trust the dashboard-authenticated user's selection since
|
||||
// the broker will independently verify membership when the CLI connects.
|
||||
// A full server-side ownership check can be added later.
|
||||
|
||||
// 4. Get secret
|
||||
const secret = process.env.CLI_SYNC_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
{ error: "CLI_SYNC_SECRET not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Build and sign JWT
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
sub: session.user.id,
|
||||
email: session.user.email,
|
||||
meshes: meshes.map((m) => ({
|
||||
id: m.id,
|
||||
slug: m.slug,
|
||||
role: m.role,
|
||||
})),
|
||||
action,
|
||||
...(action === "create" && newMesh ? { newMesh } : {}),
|
||||
jti: crypto.randomUUID(),
|
||||
iat: now,
|
||||
exp: now + 15 * 60, // 15 minutes
|
||||
};
|
||||
|
||||
const token = await signJwt(payload, secret);
|
||||
|
||||
return NextResponse.json({ token });
|
||||
}
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 497 B After Width: | Height: | Size: 211 B |
7
apps/web/src/app/icon.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="4" r="2" fill="#d97757"/>
|
||||
<circle cx="4" cy="12" r="2" fill="#d97757"/>
|
||||
<circle cx="20" cy="12" r="2" fill="#d97757"/>
|
||||
<circle cx="12" cy="20" r="2" fill="#d97757"/>
|
||||
<path d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20" stroke="#d97757" stroke-width="1.2" opacity="0.45"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 429 B |
135
apps/web/src/app/install/route.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* GET /install — shell installer for claudemesh-cli.
|
||||
*
|
||||
* curl -fsSL https://claudemesh.com/install | bash
|
||||
*
|
||||
* Tracks each fetch server-side (PostHog server event + console log).
|
||||
* curl doesn't execute JS, so client-side analytics can't track this.
|
||||
*/
|
||||
|
||||
import { headers } from "next/headers";
|
||||
|
||||
// In-memory counter (resets on deploy — good enough for a signal).
|
||||
// For persistent tracking, write to DB or use PostHog server SDK.
|
||||
let installFetches = 0;
|
||||
|
||||
const SCRIPT = `#!/usr/bin/env bash
|
||||
# claudemesh-cli installer
|
||||
# Source: https://claudemesh.com/install
|
||||
# Audit: curl -fsSL https://claudemesh.com/install | less
|
||||
set -euo pipefail
|
||||
|
||||
RED=$'\\033[31m'; GREEN=$'\\033[32m'; DIM=$'\\033[2m'; BOLD=$'\\033[1m'; RESET=$'\\033[0m'
|
||||
|
||||
say() { printf "%s\\n" "$*"; }
|
||||
ok() { printf "%s✓%s %s\\n" "\${GREEN}" "\${RESET}" "$*"; }
|
||||
err() { printf "%s✗%s %s\\n" "\${RED}" "\${RESET}" "$*" >&2; }
|
||||
|
||||
say ""
|
||||
say "\${BOLD}claudemesh-cli installer\${RESET}"
|
||||
say "$(printf '%.0s─' {1..40})"
|
||||
|
||||
# --- preflight ------------------------------------------------------
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
err "Node.js is not installed."
|
||||
say " Install Node.js 20 or newer: \${BOLD}https://nodejs.org\${RESET}"
|
||||
say " Or via nvm: \${DIM}curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash\${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]")
|
||||
if [ "$NODE_MAJOR" -lt 20 ]; then
|
||||
err "Node.js $(node -v) is too old — claudemesh-cli needs >= 20."
|
||||
say " Upgrade: \${BOLD}https://nodejs.org\${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
ok "Node.js $(node -v)"
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
err "npm is not installed (usually ships with Node)."
|
||||
exit 1
|
||||
fi
|
||||
ok "npm $(npm -v)"
|
||||
|
||||
# --- install --------------------------------------------------------
|
||||
|
||||
say ""
|
||||
say "Installing \${BOLD}claudemesh-cli\${RESET} from npm…"
|
||||
if ! npm install -g claudemesh-cli; then
|
||||
err "npm install failed."
|
||||
say " If this is a permissions error on macOS/Linux, try:"
|
||||
say " \${DIM}sudo npm install -g claudemesh-cli\${RESET}"
|
||||
say " or configure npm to use a user-owned prefix:"
|
||||
say " \${DIM}https://docs.npmjs.com/resolving-eacces-permissions-errors\${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
ok "claudemesh-cli installed ($(claudemesh --version))"
|
||||
|
||||
# --- register MCP + hooks ------------------------------------------
|
||||
|
||||
say ""
|
||||
say "Registering Claude Code MCP server + status hooks…"
|
||||
if ! claudemesh install; then
|
||||
err "claudemesh install failed — run it manually to see the error."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- done -----------------------------------------------------------
|
||||
|
||||
say ""
|
||||
say "\${GREEN}\${BOLD}Done.\${RESET}"
|
||||
say ""
|
||||
say "Next steps:"
|
||||
say " 1. Restart Claude Code so the MCP tools appear."
|
||||
say " 2. Join a mesh: \${BOLD}claudemesh join <invite-url>\${RESET}"
|
||||
say " 3. Launch with push: \${BOLD}claudemesh launch\${RESET}"
|
||||
say ""
|
||||
say "Need an invite? Visit \${BOLD}https://claudemesh.com\${RESET}"
|
||||
say ""
|
||||
`;
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
installFetches++;
|
||||
|
||||
// Log server-side for monitoring
|
||||
const h = await headers();
|
||||
const ua = h.get("user-agent") ?? "unknown";
|
||||
const ip = h.get("x-forwarded-for") ?? h.get("x-real-ip") ?? "unknown";
|
||||
const referer = h.get("referer") ?? "direct";
|
||||
|
||||
console.log(
|
||||
`[install] #${installFetches} | ip=${ip} | ua=${ua.slice(0, 80)} | ref=${referer}`,
|
||||
);
|
||||
|
||||
// PostHog server-side event (if configured)
|
||||
try {
|
||||
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
||||
const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST;
|
||||
if (posthogKey && posthogHost) {
|
||||
fetch(`${posthogHost}/capture/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: posthogKey,
|
||||
event: "install_script_fetched",
|
||||
distinct_id: ip,
|
||||
properties: {
|
||||
user_agent: ua,
|
||||
referer,
|
||||
install_count: installFetches,
|
||||
},
|
||||
}),
|
||||
}).catch(() => {}); // fire-and-forget
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return new Response(SCRIPT, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/x-shellscript; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=300, s-maxage=600",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -69,6 +69,7 @@ const pathsConfig = {
|
||||
},
|
||||
},
|
||||
marketing: {
|
||||
gettingStarted: "/getting-started",
|
||||
pricing: "/pricing",
|
||||
contact: "/contact",
|
||||
blog: {
|
||||
@@ -85,6 +86,7 @@ const pathsConfig = {
|
||||
updatePassword: `${AUTH_PREFIX}/password/update`,
|
||||
error: `${AUTH_PREFIX}/error`,
|
||||
},
|
||||
cliAuth: "/cli-auth",
|
||||
dashboard: {
|
||||
user: {
|
||||
index: DASHBOARD_PREFIX,
|
||||
|
||||
2401
apps/web/src/migrations/20260406_010735_initial.json
Normal file
301
apps/web/src/migrations/20260406_010735_initial.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "payload"."enum_users_role" AS ENUM('admin', 'editor');
|
||||
CREATE TYPE "payload"."enum_posts_status" AS ENUM('draft', 'published');
|
||||
CREATE TYPE "payload"."enum__posts_v_version_status" AS ENUM('draft', 'published');
|
||||
CREATE TYPE "payload"."enum_changelog_type" AS ENUM('feat', 'fix', 'docs', 'breaking');
|
||||
CREATE TABLE "payload"."users_sessions" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"created_at" timestamp(3) with time zone,
|
||||
"expires_at" timestamp(3) with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar,
|
||||
"role" "payload"."enum_users_role" DEFAULT 'editor',
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"reset_password_token" varchar,
|
||||
"reset_password_expiration" timestamp(3) with time zone,
|
||||
"salt" varchar,
|
||||
"hash" varchar,
|
||||
"login_attempts" numeric DEFAULT 0,
|
||||
"lock_until" timestamp(3) with time zone
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."media" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"alt" varchar NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"url" varchar,
|
||||
"thumbnail_u_r_l" varchar,
|
||||
"filename" varchar,
|
||||
"mime_type" varchar,
|
||||
"filesize" numeric,
|
||||
"width" numeric,
|
||||
"height" numeric,
|
||||
"focal_x" numeric,
|
||||
"focal_y" numeric
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."authors" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"slug" varchar NOT NULL,
|
||||
"bio" varchar,
|
||||
"role" varchar,
|
||||
"avatar_id" integer,
|
||||
"links_github" varchar,
|
||||
"links_twitter" varchar,
|
||||
"links_website" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."categories" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"slug" varchar NOT NULL,
|
||||
"description" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."posts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar,
|
||||
"slug" varchar,
|
||||
"excerpt" varchar,
|
||||
"content" jsonb,
|
||||
"cover_image_id" integer,
|
||||
"author_id" integer,
|
||||
"published_at" timestamp(3) with time zone,
|
||||
"status" "payload"."enum_posts_status" DEFAULT 'draft',
|
||||
"seo_meta_title" varchar,
|
||||
"seo_meta_description" varchar,
|
||||
"seo_og_image_id" integer,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"_status" "payload"."enum_posts_status" DEFAULT 'draft'
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."posts_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"categories_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."_posts_v" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"parent_id" integer,
|
||||
"version_title" varchar,
|
||||
"version_slug" varchar,
|
||||
"version_excerpt" varchar,
|
||||
"version_content" jsonb,
|
||||
"version_cover_image_id" integer,
|
||||
"version_author_id" integer,
|
||||
"version_published_at" timestamp(3) with time zone,
|
||||
"version_status" "payload"."enum__posts_v_version_status" DEFAULT 'draft',
|
||||
"version_seo_meta_title" varchar,
|
||||
"version_seo_meta_description" varchar,
|
||||
"version_seo_og_image_id" integer,
|
||||
"version_updated_at" timestamp(3) with time zone,
|
||||
"version_created_at" timestamp(3) with time zone,
|
||||
"version__status" "payload"."enum__posts_v_version_status" DEFAULT 'draft',
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"latest" boolean
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."_posts_v_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"categories_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."changelog" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"version" varchar NOT NULL,
|
||||
"date" timestamp(3) with time zone NOT NULL,
|
||||
"type" "payload"."enum_changelog_type" NOT NULL,
|
||||
"summary" varchar NOT NULL,
|
||||
"body" jsonb,
|
||||
"npm_url" varchar,
|
||||
"github_url" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."payload_kv" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar NOT NULL,
|
||||
"data" jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."payload_locked_documents" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"global_slug" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."payload_locked_documents_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"users_id" integer,
|
||||
"media_id" integer,
|
||||
"authors_id" integer,
|
||||
"categories_id" integer,
|
||||
"posts_id" integer,
|
||||
"changelog_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."payload_preferences" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar,
|
||||
"value" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."payload_preferences_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"users_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload"."payload_migrations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar,
|
||||
"batch" numeric,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "payload"."users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload"."authors" ADD CONSTRAINT "authors_avatar_id_media_id_fk" FOREIGN KEY ("avatar_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload"."posts" ADD CONSTRAINT "posts_cover_image_id_media_id_fk" FOREIGN KEY ("cover_image_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload"."posts" ADD CONSTRAINT "posts_author_id_authors_id_fk" FOREIGN KEY ("author_id") REFERENCES "payload"."authors"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload"."posts" ADD CONSTRAINT "posts_seo_og_image_id_media_id_fk" FOREIGN KEY ("seo_og_image_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload"."posts_rels" ADD CONSTRAINT "posts_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."posts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload"."posts_rels" ADD CONSTRAINT "posts_rels_categories_fk" FOREIGN KEY ("categories_id") REFERENCES "payload"."categories"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload"."_posts_v" ADD CONSTRAINT "_posts_v_parent_id_posts_id_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."posts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload"."_posts_v" ADD CONSTRAINT "_posts_v_version_cover_image_id_media_id_fk" FOREIGN KEY ("version_cover_image_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload"."_posts_v" ADD CONSTRAINT "_posts_v_version_author_id_authors_id_fk" FOREIGN KEY ("version_author_id") REFERENCES "payload"."authors"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload"."_posts_v" ADD CONSTRAINT "_posts_v_version_seo_og_image_id_media_id_fk" FOREIGN KEY ("version_seo_og_image_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload"."_posts_v_rels" ADD CONSTRAINT "_posts_v_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."_posts_v"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload"."_posts_v_rels" ADD CONSTRAINT "_posts_v_rels_categories_fk" FOREIGN KEY ("categories_id") REFERENCES "payload"."categories"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "payload"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_authors_fk" FOREIGN KEY ("authors_id") REFERENCES "payload"."authors"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_categories_fk" FOREIGN KEY ("categories_id") REFERENCES "payload"."categories"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_posts_fk" FOREIGN KEY ("posts_id") REFERENCES "payload"."posts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_changelog_fk" FOREIGN KEY ("changelog_id") REFERENCES "payload"."changelog"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload"."payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload"."payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "users_sessions_order_idx" ON "payload"."users_sessions" USING btree ("_order");
|
||||
CREATE INDEX "users_sessions_parent_id_idx" ON "payload"."users_sessions" USING btree ("_parent_id");
|
||||
CREATE INDEX "users_updated_at_idx" ON "payload"."users" USING btree ("updated_at");
|
||||
CREATE INDEX "users_created_at_idx" ON "payload"."users" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "users_email_idx" ON "payload"."users" USING btree ("email");
|
||||
CREATE INDEX "media_updated_at_idx" ON "payload"."media" USING btree ("updated_at");
|
||||
CREATE INDEX "media_created_at_idx" ON "payload"."media" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "media_filename_idx" ON "payload"."media" USING btree ("filename");
|
||||
CREATE UNIQUE INDEX "authors_slug_idx" ON "payload"."authors" USING btree ("slug");
|
||||
CREATE INDEX "authors_avatar_idx" ON "payload"."authors" USING btree ("avatar_id");
|
||||
CREATE INDEX "authors_updated_at_idx" ON "payload"."authors" USING btree ("updated_at");
|
||||
CREATE INDEX "authors_created_at_idx" ON "payload"."authors" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "categories_slug_idx" ON "payload"."categories" USING btree ("slug");
|
||||
CREATE INDEX "categories_updated_at_idx" ON "payload"."categories" USING btree ("updated_at");
|
||||
CREATE INDEX "categories_created_at_idx" ON "payload"."categories" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "posts_slug_idx" ON "payload"."posts" USING btree ("slug");
|
||||
CREATE INDEX "posts_cover_image_idx" ON "payload"."posts" USING btree ("cover_image_id");
|
||||
CREATE INDEX "posts_author_idx" ON "payload"."posts" USING btree ("author_id");
|
||||
CREATE INDEX "posts_seo_seo_og_image_idx" ON "payload"."posts" USING btree ("seo_og_image_id");
|
||||
CREATE INDEX "posts_updated_at_idx" ON "payload"."posts" USING btree ("updated_at");
|
||||
CREATE INDEX "posts_created_at_idx" ON "payload"."posts" USING btree ("created_at");
|
||||
CREATE INDEX "posts__status_idx" ON "payload"."posts" USING btree ("_status");
|
||||
CREATE INDEX "posts_rels_order_idx" ON "payload"."posts_rels" USING btree ("order");
|
||||
CREATE INDEX "posts_rels_parent_idx" ON "payload"."posts_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "posts_rels_path_idx" ON "payload"."posts_rels" USING btree ("path");
|
||||
CREATE INDEX "posts_rels_categories_id_idx" ON "payload"."posts_rels" USING btree ("categories_id");
|
||||
CREATE INDEX "_posts_v_parent_idx" ON "payload"."_posts_v" USING btree ("parent_id");
|
||||
CREATE INDEX "_posts_v_version_version_slug_idx" ON "payload"."_posts_v" USING btree ("version_slug");
|
||||
CREATE INDEX "_posts_v_version_version_cover_image_idx" ON "payload"."_posts_v" USING btree ("version_cover_image_id");
|
||||
CREATE INDEX "_posts_v_version_version_author_idx" ON "payload"."_posts_v" USING btree ("version_author_id");
|
||||
CREATE INDEX "_posts_v_version_seo_version_seo_og_image_idx" ON "payload"."_posts_v" USING btree ("version_seo_og_image_id");
|
||||
CREATE INDEX "_posts_v_version_version_updated_at_idx" ON "payload"."_posts_v" USING btree ("version_updated_at");
|
||||
CREATE INDEX "_posts_v_version_version_created_at_idx" ON "payload"."_posts_v" USING btree ("version_created_at");
|
||||
CREATE INDEX "_posts_v_version_version__status_idx" ON "payload"."_posts_v" USING btree ("version__status");
|
||||
CREATE INDEX "_posts_v_created_at_idx" ON "payload"."_posts_v" USING btree ("created_at");
|
||||
CREATE INDEX "_posts_v_updated_at_idx" ON "payload"."_posts_v" USING btree ("updated_at");
|
||||
CREATE INDEX "_posts_v_latest_idx" ON "payload"."_posts_v" USING btree ("latest");
|
||||
CREATE INDEX "_posts_v_rels_order_idx" ON "payload"."_posts_v_rels" USING btree ("order");
|
||||
CREATE INDEX "_posts_v_rels_parent_idx" ON "payload"."_posts_v_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "_posts_v_rels_path_idx" ON "payload"."_posts_v_rels" USING btree ("path");
|
||||
CREATE INDEX "_posts_v_rels_categories_id_idx" ON "payload"."_posts_v_rels" USING btree ("categories_id");
|
||||
CREATE INDEX "changelog_updated_at_idx" ON "payload"."changelog" USING btree ("updated_at");
|
||||
CREATE INDEX "changelog_created_at_idx" ON "payload"."changelog" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload"."payload_kv" USING btree ("key");
|
||||
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload"."payload_locked_documents" USING btree ("global_slug");
|
||||
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload"."payload_locked_documents" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload"."payload_locked_documents" USING btree ("created_at");
|
||||
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload"."payload_locked_documents_rels" USING btree ("order");
|
||||
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload"."payload_locked_documents_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload"."payload_locked_documents_rels" USING btree ("path");
|
||||
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("users_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("media_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_authors_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("authors_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_categories_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("categories_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_posts_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("posts_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_changelog_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("changelog_id");
|
||||
CREATE INDEX "payload_preferences_key_idx" ON "payload"."payload_preferences" USING btree ("key");
|
||||
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload"."payload_preferences" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_preferences_created_at_idx" ON "payload"."payload_preferences" USING btree ("created_at");
|
||||
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload"."payload_preferences_rels" USING btree ("order");
|
||||
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload"."payload_preferences_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload"."payload_preferences_rels" USING btree ("path");
|
||||
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload"."payload_preferences_rels" USING btree ("users_id");
|
||||
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload"."payload_migrations" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_migrations_created_at_idx" ON "payload"."payload_migrations" USING btree ("created_at");`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP TABLE "payload"."users_sessions" CASCADE;
|
||||
DROP TABLE "payload"."users" CASCADE;
|
||||
DROP TABLE "payload"."media" CASCADE;
|
||||
DROP TABLE "payload"."authors" CASCADE;
|
||||
DROP TABLE "payload"."categories" CASCADE;
|
||||
DROP TABLE "payload"."posts" CASCADE;
|
||||
DROP TABLE "payload"."posts_rels" CASCADE;
|
||||
DROP TABLE "payload"."_posts_v" CASCADE;
|
||||
DROP TABLE "payload"."_posts_v_rels" CASCADE;
|
||||
DROP TABLE "payload"."changelog" CASCADE;
|
||||
DROP TABLE "payload"."payload_kv" CASCADE;
|
||||
DROP TABLE "payload"."payload_locked_documents" CASCADE;
|
||||
DROP TABLE "payload"."payload_locked_documents_rels" CASCADE;
|
||||
DROP TABLE "payload"."payload_preferences" CASCADE;
|
||||
DROP TABLE "payload"."payload_preferences_rels" CASCADE;
|
||||
DROP TABLE "payload"."payload_migrations" CASCADE;
|
||||
DROP TYPE "payload"."enum_users_role";
|
||||
DROP TYPE "payload"."enum_posts_status";
|
||||
DROP TYPE "payload"."enum__posts_v_version_status";
|
||||
DROP TYPE "payload"."enum_changelog_type";`)
|
||||
}
|
||||