Compare commits
184 Commits
v0.1.9
...
5e97d48cd5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e97d48cd5 | ||
|
|
c8ae6462e3 | ||
|
|
fb7a84aed6 | ||
|
|
c1fa3bcb5c | ||
|
|
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 |
232
.artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Anthropic Vision: Meshes & Invitations
|
||||||
|
|
||||||
|
**Status:** in progress · partial implementation 2026-04-10
|
||||||
|
**Owner:** agutierrez
|
||||||
|
**Scope:** `apps/web`, `packages/api`, `packages/db`, `apps/broker` (future), `apps/cli` (future)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guiding principles
|
||||||
|
|
||||||
|
1. **Identity is opaque, display is free-form.** Humans pick any name; the system uses random IDs.
|
||||||
|
2. **Secrets never appear in URLs.** Links are capabilities, not credentials.
|
||||||
|
3. **Defaults are obvious; advanced options are discoverable but hidden.**
|
||||||
|
4. **Self-service wherever possible; admins don't become gatekeepers.**
|
||||||
|
5. **Every visible action is also an auditable event.**
|
||||||
|
|
||||||
|
These mirror how Anthropic builds its own org/workspace/project model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Meshes
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Global uniqueness on `mesh.slug` creates name collisions at scale. Two users picking "platform" or "test" fight for the slug. At 50k users this is the default state.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
**Drop the slug as an identity concept.** `mesh.id` (opaque, already random) is the canonical identifier everywhere (URLs, invites, broker lookups). `mesh.name` is a free-form display label, non-unique. `mesh.slug` is kept as a non-unique cosmetic string derived from the name at creation time, embedded in invite payloads for debugging.
|
||||||
|
|
||||||
|
### What this enables
|
||||||
|
- Two users can both name their mesh "platform-team" with zero friction
|
||||||
|
- URLs stay stable (`/meshes/{id}`) even if the user renames the mesh
|
||||||
|
- No "slug taken" error state exists in the product anymore
|
||||||
|
|
||||||
|
### Tradeoff explicitly accepted
|
||||||
|
Users lose the ability to type `claudemesh join platform-team` — but they never did, because the CLI takes signed invite tokens, not slugs. This capability was phantom.
|
||||||
|
|
||||||
|
### Implementation — DONE in this spec
|
||||||
|
- [x] Drop `UNIQUE` constraint on `mesh.slug` (migration `0017_mesh-slug-non-unique.sql`)
|
||||||
|
- [x] Remove `slug` field from `createMyMeshInputSchema`
|
||||||
|
- [x] Remove slug field from `CreateMeshForm`
|
||||||
|
- [x] Server-side `toSlug(name)` derives slug from name automatically
|
||||||
|
- [x] Schema comment documents the non-canonical role of `slug`
|
||||||
|
|
||||||
|
### Future (optional, not in v0.1.x)
|
||||||
|
- **Vanity slugs as a Pro feature:** one globally-unique handle per *account* (not per mesh), exposed as `claudemesh.com/@acme/...`. Sold as part of an org tier. This is where slug uniqueness actually pays for itself — against usernames, not against meshes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Invitations
|
||||||
|
|
||||||
|
### Problems with the current invite system
|
||||||
|
|
||||||
|
| # | Problem | Severity |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `mesh_root_key` is embedded in the invite URL as base64url JSON | 🔴 **Security** |
|
||||||
|
| 2 | Invite URLs are ~400 chars of opaque base64url | 🟡 UX |
|
||||||
|
| 3 | No invite-by-email; only shareable link | 🟡 UX |
|
||||||
|
| 4 | Required form fields (role, maxUses, expiresInDays) for every invite | 🟡 UX |
|
||||||
|
| 5 | Landing page does not clearly preview role/consent | 🟡 UX |
|
||||||
|
| 6 | No audit trail for invites received-but-never-clicked | 🟢 Polish |
|
||||||
|
| 7 | `ic://` link scheme is vestigial, nothing registers the handler | 🟢 Polish |
|
||||||
|
|
||||||
|
### Severity 🔴 — the root key leak
|
||||||
|
|
||||||
|
Current canonical invite bytes:
|
||||||
|
```
|
||||||
|
v | mesh_id | mesh_slug | broker_url | expires_at | mesh_root_key | role | owner_pubkey
|
||||||
|
```
|
||||||
|
|
||||||
|
`mesh_root_key` is a 32-byte shared secret used by all channel and broadcast encryption in the mesh. Once it lives in a URL:
|
||||||
|
- Slack/Telegram/Discord link previews fetch and cache the URL → root key is in those caches
|
||||||
|
- Browser history, sync, analytics pixels, error logs → root key persists anywhere URLs persist
|
||||||
|
- A screenshot of the invite link is a compromise
|
||||||
|
- Revoking the invite does **not** rotate the key, so exposure is permanent
|
||||||
|
|
||||||
|
**Anthropic would never do this.** The fix is a protocol change: the invite grants the *right* to receive the key, it is not the key itself.
|
||||||
|
|
||||||
|
### The v2 invite protocol (spec only in this doc — NOT implemented this session)
|
||||||
|
|
||||||
|
**Design goals**
|
||||||
|
1. No secret material in any user-visible string (URL, QR, paste buffer)
|
||||||
|
2. Invite URLs are short (<30 chars): `claudemesh.com/i/abc12345`
|
||||||
|
3. Existing v1 invites continue to work during a deprecation window
|
||||||
|
4. Revocation is clean and immediate
|
||||||
|
5. One recipient = one root-key-delivery capability
|
||||||
|
|
||||||
|
**Flow**
|
||||||
|
```
|
||||||
|
Admin creates invite (v2):
|
||||||
|
server generates short_code (base62, 8 chars, unique)
|
||||||
|
server stores in DB: {id, mesh_id, code, role, max_uses, expires_at, signed_capability}
|
||||||
|
signed_capability = ed25519_sign(canonical_v2_bytes, mesh.owner_secret_key)
|
||||||
|
canonical_v2_bytes = v=2 | mesh_id | invite_id | expires_at | role | owner_pubkey
|
||||||
|
NOTE: no root_key, no broker_url
|
||||||
|
returns: claudemesh.com/i/{code}
|
||||||
|
|
||||||
|
Recipient clicks the link:
|
||||||
|
web: GET /api/public/invites/code/{code}
|
||||||
|
returns {mesh_name, inviter_name, role, expires_at, member_count}
|
||||||
|
no secrets, no signature leaked
|
||||||
|
web: shows consent landing: "You are joining ACME as a Member"
|
||||||
|
recipient authenticates (sign up / log in) OR runs CLI
|
||||||
|
|
||||||
|
Recipient claims the invite:
|
||||||
|
CLI: generates session ed25519 keypair (ephemeral)
|
||||||
|
CLI: connects to broker ws://ic.claudemesh.com/ws
|
||||||
|
CLI: sends { type: "claim_invite", code, recipient_pubkey }
|
||||||
|
broker: looks up invite by code
|
||||||
|
broker: verifies signed_capability against mesh.owner_pubkey
|
||||||
|
broker: checks expires_at, max_uses vs used_count, revoked_at
|
||||||
|
broker: increments used_count, creates mesh.member row
|
||||||
|
broker: seals mesh.root_key with crypto_box_seal to recipient_pubkey
|
||||||
|
broker: returns { sealed_root_key, mesh_id, member_id }
|
||||||
|
CLI: unseals with its secret key → has root_key
|
||||||
|
CLI: starts normal mesh traffic
|
||||||
|
|
||||||
|
Revocation:
|
||||||
|
admin sets invite.revoked_at = now()
|
||||||
|
any future claim fails at broker with invite_revoked
|
||||||
|
root_key is NOT rotated — past members keep access
|
||||||
|
(for "kick a member" semantics, use a separate member revocation, which DOES rotate the key)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
- URL contains only `{code}` (8 chars base62)
|
||||||
|
- `signed_capability` lives server-side; leaks of the URL never expose the root key
|
||||||
|
- Screenshot of invite URL is harmless
|
||||||
|
- Link preview bots see nothing sensitive
|
||||||
|
- Broker DB is the source of truth for revocation
|
||||||
|
|
||||||
|
**Migration strategy (v1 → v2)**
|
||||||
|
- Add `invite.code`, `invite.v2_capability` columns (nullable for existing rows)
|
||||||
|
- `createMyInvite` generates BOTH v1 token (legacy) and v2 code
|
||||||
|
- Web invite UI displays the short URL by default, long URL as "Legacy format" disclosure
|
||||||
|
- Broker accepts both formats until v0.2.0
|
||||||
|
- Announce deprecation window; at v0.2.0 the long-format endpoints 410 Gone
|
||||||
|
|
||||||
|
**Status update 2026-04-10 — v2 is now being implemented in parallel**
|
||||||
|
|
||||||
|
The scope that was deferred at the top of the session is actively landing in a coordinated multi-agent push:
|
||||||
|
- Broker: new `/api/public/invites/:code/claim` endpoint, `crypto_box_seal` against recipient x25519 pubkey, signed capability verification, single-use accounting.
|
||||||
|
- DB: `mesh.invite.version` int, `mesh.invite.capability_v2` text nullable, `mesh.invite.claimed_by_pubkey` text nullable. New table `mesh.pending_invite` for email invites.
|
||||||
|
- CLI / web claim client: generates a fresh x25519 keypair (separate from the ed25519 identity), POSTs the pubkey, unseals the returned `sealed_root_key`, then verifies `canonical_v2` against `owner_pubkey`.
|
||||||
|
- Email invites (parallel track): Postmark delivery wired on top of `pending_invite`; the email body carries the same `claudemesh.com/i/{code}` short URL.
|
||||||
|
|
||||||
|
v1 invites continue to work throughout v0.1.x. v1 endpoints return `410 Gone` at v0.2.0.
|
||||||
|
|
||||||
|
Docs updated in the same session: `SPEC.md` §14b, `docs/protocol.md` (v2 invites subsection), `docs/roadmap.md` (in progress).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Severity 🟡 — implemented this session
|
||||||
|
|
||||||
|
#### Short invite codes (URL shortening, backward-compatible)
|
||||||
|
|
||||||
|
Additive: invites now get both a long token AND a short opaque code. The web app prefers the short URL.
|
||||||
|
|
||||||
|
**DB:** new nullable `invite.code` column, unique. New migration `0018_invite-short-code.sql`.
|
||||||
|
|
||||||
|
**API:** `createMyInvite` generates `code` (base62, 8 chars, collision-retry). Returns `shortUrl` alongside `inviteLink` / `joinUrl`.
|
||||||
|
|
||||||
|
**Web:** new server route `/i/[code]/page.tsx` that resolves the code server-side and redirects to the canonical `/join/[token]` page. Invite generator UI shows the short URL as the primary "Copy link" target.
|
||||||
|
|
||||||
|
**Backward compat:** existing invites without a `code` keep working via their long token. No broker/CLI changes.
|
||||||
|
|
||||||
|
**This is NOT the v2 protocol.** It only fixes the URL-length problem. The root key is still embedded in the long token that the short code resolves to. The short code is a URL shortener, not a capability boundary. Document this clearly so nobody confuses the two.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Collapsed advanced fields
|
||||||
|
|
||||||
|
The invite form asks for `role`, `max uses`, `expires in days` upfront. 90% of users only ever create `{ role: member, max_uses: 1, expires_in_days: 7 }`.
|
||||||
|
|
||||||
|
Change: defaults are pre-filled; the three fields are hidden behind an "Advanced" disclosure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Severity 🟡 — deferred
|
||||||
|
|
||||||
|
#### Invite by email
|
||||||
|
|
||||||
|
- Requires an `invitation_email` table or equivalent pending-invites state
|
||||||
|
- Requires wire-up to email delivery (already have Postmark via turbostarter)
|
||||||
|
- Out of scope this session; fits naturally on top of v2 invite protocol
|
||||||
|
|
||||||
|
#### Consent landing redesign
|
||||||
|
|
||||||
|
- The `/join/[token]` page should show: mesh name, inviter, role being granted, member count, expiry, explicit "Join as Member of ACME" button
|
||||||
|
- Needs a design pass
|
||||||
|
- Deferred
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Severity 🟢 — deferred
|
||||||
|
|
||||||
|
- Remove `ic://` scheme — it's dead, nothing handles it, safe to delete in v0.1.x cleanup
|
||||||
|
- Received-but-not-clicked audit — falls out of email invites for free
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary table
|
||||||
|
|
||||||
|
| Change | Status | File(s) |
|
||||||
|
|---|---|---|
|
||||||
|
| Drop global slug uniqueness | ✅ done | `packages/db/src/schema/mesh.ts`, migration `0017` |
|
||||||
|
| Remove slug from create-mesh form | ✅ done | `apps/web/src/modules/mesh/create-mesh-form.tsx` |
|
||||||
|
| Server-derived slug from name | ✅ done | `packages/api/src/modules/mesh/mutations.ts` |
|
||||||
|
| Short invite codes (URL shortener) | ✅ done | `packages/db` migration `0018`, api, web `/i/[code]` |
|
||||||
|
| Collapse invite advanced fields | ✅ done | `apps/web/src/modules/mesh/invite-generator.tsx` |
|
||||||
|
| v2 invite protocol (root key out of URL) | 🚧 in progress | broker `/api/public/invites/:code/claim`, `mesh.invite.version` + `capability_v2` + `claimed_by_pubkey`, CLI/web claim client |
|
||||||
|
| Invite by email | 🚧 in progress | `mesh.pending_invite` table, Postmark delivery |
|
||||||
|
| Consent landing redesign | 📝 spec only | (future PR) |
|
||||||
|
| Remove `ic://` scheme | 📝 spec only | (cleanup PR) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-goals (for clarity)
|
||||||
|
|
||||||
|
- Not adding per-user mesh namespaces (`alice/platform`) — opaque IDs are enough
|
||||||
|
- Not adding vanity slugs at v0.1.x — can come as a Pro tier later
|
||||||
|
- Not changing the broker wire protocol this session
|
||||||
|
- Not rewriting the CLI join flow this session
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-implementation checklist
|
||||||
|
|
||||||
|
- [x] Web builds without type errors on changed files
|
||||||
|
- [x] Migrations run on production DB (`0017` applied; `0018` after review)
|
||||||
|
- [x] No broker protocol change (backward compat verified)
|
||||||
|
- [x] Existing long-token invites continue to resolve
|
||||||
|
- [x] New invites expose `shortUrl` in the API response
|
||||||
@@ -16,3 +16,6 @@ URL="http://localhost:3000"
|
|||||||
|
|
||||||
# Default locale of the apps, can be overridden separately in each app.
|
# Default locale of the apps, can be overridden separately in each app.
|
||||||
DEFAULT_LOCALE="en"
|
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>"
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -45,6 +45,9 @@ yarn-error.log*
|
|||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
# secrets
|
||||||
|
.cli_sync_secret
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
|||||||
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
|
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))"
|
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)
|
# Non-root user (oven/bun image ships with 'bun' uid 1000)
|
||||||
|
|||||||
@@ -15,10 +15,14 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@turbostarter/prettier-config",
|
"prettier": "@turbostarter/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@qdrant/js-client-rest": "1.17.0",
|
||||||
"@turbostarter/db": "workspace:*",
|
"@turbostarter/db": "workspace:*",
|
||||||
"@turbostarter/shared": "workspace:*",
|
"@turbostarter/shared": "workspace:*",
|
||||||
"drizzle-orm": "0.44.7",
|
"drizzle-orm": "0.44.7",
|
||||||
|
"grammy": "^1.35.0",
|
||||||
"libsodium-wrappers": "0.7.15",
|
"libsodium-wrappers": "0.7.15",
|
||||||
|
"minio": "8.0.7",
|
||||||
|
"neo4j-driver": "6.0.1",
|
||||||
"ws": "8.20.0",
|
"ws": "8.20.0",
|
||||||
"zod": "catalog:"
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,7 +7,10 @@
|
|||||||
* current member of the claimed mesh.
|
* current member of the claimed mesh.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||||
import sodium from "libsodium-wrappers";
|
import sodium from "libsodium-wrappers";
|
||||||
|
import { db } from "./db";
|
||||||
|
import { invite as inviteTable, mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
||||||
|
|
||||||
let ready = false;
|
let ready = false;
|
||||||
async function ensureSodium(): Promise<typeof sodium> {
|
async function ensureSodium(): Promise<typeof sodium> {
|
||||||
@@ -69,6 +72,70 @@ export async function verifyEd25519(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical v2 invite bytes — signed by the mesh owner's ed25519 secret key.
|
||||||
|
* NOTE: deliberately does NOT include the root_key or broker_url; the v2
|
||||||
|
* protocol moves the root_key out of the URL entirely. Format is locked:
|
||||||
|
* `v=2|mesh_id|invite_id|expires_at|role|owner_pubkey` (no trailing newline).
|
||||||
|
*/
|
||||||
|
export function canonicalInviteV2(p: {
|
||||||
|
mesh_id: string;
|
||||||
|
invite_id: string;
|
||||||
|
expires_at: number; // unix seconds
|
||||||
|
role: "admin" | "member";
|
||||||
|
owner_pubkey: string; // hex
|
||||||
|
}): string {
|
||||||
|
return `v=2|${p.mesh_id}|${p.invite_id}|${p.expires_at}|${p.role}|${p.owner_pubkey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an ed25519 signature over the v2 canonical invite bytes against
|
||||||
|
* the mesh owner's public key. Returns true on valid signature.
|
||||||
|
*/
|
||||||
|
export async function verifyInviteV2(params: {
|
||||||
|
canonical: string;
|
||||||
|
signatureHex: string;
|
||||||
|
ownerPubkeyHex: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
return verifyEd25519(
|
||||||
|
params.canonical,
|
||||||
|
params.signatureHex,
|
||||||
|
params.ownerPubkeyHex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seal the mesh root_key to a recipient-provided x25519 public key using
|
||||||
|
* libsodium's sealed box (crypto_box_seal). Only the holder of the matching
|
||||||
|
* x25519 secret key can unseal.
|
||||||
|
*
|
||||||
|
* rootKeyBase64url is the mesh.root_key column value (base64url of 32 bytes).
|
||||||
|
* recipientX25519PubkeyBase64url is the 32-byte x25519 pubkey the recipient
|
||||||
|
* provided in its claim request. We do NOT convert an ed25519 pubkey here —
|
||||||
|
* the recipient generates a dedicated x25519 keypair and sends us the pubkey.
|
||||||
|
*
|
||||||
|
* Returns base64url of the sealed ciphertext.
|
||||||
|
*/
|
||||||
|
export async function sealRootKeyToRecipient(params: {
|
||||||
|
rootKeyBase64url: string;
|
||||||
|
recipientX25519PubkeyBase64url: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const rootKeyBytes = s.from_base64(
|
||||||
|
params.rootKeyBase64url,
|
||||||
|
s.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
);
|
||||||
|
const recipientPk = s.from_base64(
|
||||||
|
params.recipientX25519PubkeyBase64url,
|
||||||
|
s.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
);
|
||||||
|
if (recipientPk.length !== 32) {
|
||||||
|
throw new Error("recipient_x25519_pubkey must decode to 32 bytes");
|
||||||
|
}
|
||||||
|
const sealed = s.crypto_box_seal(rootKeyBytes, recipientPk);
|
||||||
|
return s.to_base64(sealed, s.base64_variants.URLSAFE_NO_PADDING);
|
||||||
|
}
|
||||||
|
|
||||||
export const HELLO_SKEW_MS = 60_000;
|
export const HELLO_SKEW_MS = 60_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,3 +185,185 @@ export async function verifyHelloSignature(args: {
|
|||||||
return { ok: false, reason: "malformed" };
|
return { ok: false, reason: "malformed" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// v2 invite claim core — exported for the HTTP handler in index.ts AND for
|
||||||
|
// tests that need to exercise the logic without spinning up the broker server.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// capabilityV2 column is stored as JSON:
|
||||||
|
// { "canonical": "v=2|mesh_id|invite_id|expires_at|role|owner_pubkey",
|
||||||
|
// "signature": "<hex ed25519 detached signature>" }
|
||||||
|
// The broker recomputes the canonical bytes from the invite row and verifies
|
||||||
|
// the signature against mesh.ownerPubkey. v1 rows (version === 1 OR
|
||||||
|
// capabilityV2 === null) skip verification — the legacy path still works
|
||||||
|
// during the deprecation window.
|
||||||
|
|
||||||
|
export type InviteClaimV2Result =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
status: 200;
|
||||||
|
body: {
|
||||||
|
sealed_root_key: string;
|
||||||
|
mesh_id: string;
|
||||||
|
member_id: string;
|
||||||
|
owner_pubkey: string;
|
||||||
|
canonical_v2: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { ok: false; status: 400 | 404 | 410; body: { error: string } };
|
||||||
|
|
||||||
|
export async function claimInviteV2Core(params: {
|
||||||
|
code: string;
|
||||||
|
recipientX25519PubkeyBase64url: string;
|
||||||
|
displayName?: string;
|
||||||
|
now?: number;
|
||||||
|
}): Promise<InviteClaimV2Result> {
|
||||||
|
const now = params.now ?? Date.now();
|
||||||
|
const recipientPk = params.recipientX25519PubkeyBase64url;
|
||||||
|
|
||||||
|
if (!recipientPk || typeof recipientPk !== "string" || recipientPk.length < 32) {
|
||||||
|
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Look up the invite by opaque code.
|
||||||
|
const [inv] = await db
|
||||||
|
.select()
|
||||||
|
.from(inviteTable)
|
||||||
|
.where(eq(inviteTable.code, params.code))
|
||||||
|
.limit(1);
|
||||||
|
if (!inv) return { ok: false, status: 404, body: { error: "not_found" } };
|
||||||
|
|
||||||
|
// 2. Lifecycle checks: revoked → expired → exhausted.
|
||||||
|
if (inv.revokedAt) {
|
||||||
|
return { ok: false, status: 410, body: { error: "revoked" } };
|
||||||
|
}
|
||||||
|
if (inv.expiresAt.getTime() < now) {
|
||||||
|
return { ok: false, status: 410, body: { error: "expired" } };
|
||||||
|
}
|
||||||
|
if (inv.usedCount >= inv.maxUses) {
|
||||||
|
return { ok: false, status: 410, body: { error: "exhausted" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Load the mesh for owner_pubkey + root_key.
|
||||||
|
const [m] = await db
|
||||||
|
.select({
|
||||||
|
id: mesh.id,
|
||||||
|
ownerPubkey: mesh.ownerPubkey,
|
||||||
|
rootKey: mesh.rootKey,
|
||||||
|
})
|
||||||
|
.from(mesh)
|
||||||
|
.where(and(eq(mesh.id, inv.meshId), isNull(mesh.archivedAt)))
|
||||||
|
.limit(1);
|
||||||
|
if (!m) return { ok: false, status: 404, body: { error: "not_found" } };
|
||||||
|
if (!m.ownerPubkey || !m.rootKey) {
|
||||||
|
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Compute canonical_v2 from the row (used in the response either way).
|
||||||
|
const expiresAtUnix = Math.floor(inv.expiresAt.getTime() / 1000);
|
||||||
|
const canonical = canonicalInviteV2({
|
||||||
|
mesh_id: inv.meshId,
|
||||||
|
invite_id: inv.id,
|
||||||
|
expires_at: expiresAtUnix,
|
||||||
|
role: inv.role as "admin" | "member",
|
||||||
|
owner_pubkey: m.ownerPubkey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inv.version === 2 && inv.capabilityV2) {
|
||||||
|
let storedCanonical: string | undefined;
|
||||||
|
let signatureHex: string | undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(inv.capabilityV2) as {
|
||||||
|
canonical?: string;
|
||||||
|
signature?: string;
|
||||||
|
};
|
||||||
|
storedCanonical = parsed.canonical;
|
||||||
|
signatureHex = parsed.signature;
|
||||||
|
} catch {
|
||||||
|
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||||
|
}
|
||||||
|
if (!storedCanonical || !signatureHex) {
|
||||||
|
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||||
|
}
|
||||||
|
// Broker-recomputed canonical must match the signed bytes exactly.
|
||||||
|
if (storedCanonical !== canonical) {
|
||||||
|
return { ok: false, status: 400, body: { error: "bad_signature" } };
|
||||||
|
}
|
||||||
|
const sigOk = await verifyInviteV2({
|
||||||
|
canonical: storedCanonical,
|
||||||
|
signatureHex,
|
||||||
|
ownerPubkeyHex: m.ownerPubkey,
|
||||||
|
});
|
||||||
|
if (!sigOk) {
|
||||||
|
return { ok: false, status: 400, body: { error: "bad_signature" } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// v1 rows: skip signature verification (legacy path during migration).
|
||||||
|
|
||||||
|
// 5. Atomic consume: increment used_count iff still under max_uses.
|
||||||
|
const [claimed] = await db
|
||||||
|
.update(inviteTable)
|
||||||
|
.set({
|
||||||
|
usedCount: sql`${inviteTable.usedCount} + 1`,
|
||||||
|
claimedByPubkey: recipientPk,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(inviteTable.id, inv.id),
|
||||||
|
lt(inviteTable.usedCount, inv.maxUses),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: inviteTable.id });
|
||||||
|
if (!claimed) {
|
||||||
|
return { ok: false, status: 410, body: { error: "exhausted" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Create a member row for the claimant.
|
||||||
|
const preset = (inv.preset as {
|
||||||
|
displayName?: string;
|
||||||
|
roleTag?: string;
|
||||||
|
groups?: Array<{ name: string; role?: string }>;
|
||||||
|
messageMode?: string;
|
||||||
|
} | null) ?? {};
|
||||||
|
const displayName =
|
||||||
|
preset.displayName ?? params.displayName ?? `member-${recipientPk.slice(0, 8)}`;
|
||||||
|
const [row] = await db
|
||||||
|
.insert(meshMember)
|
||||||
|
.values({
|
||||||
|
meshId: inv.meshId,
|
||||||
|
peerPubkey: recipientPk,
|
||||||
|
displayName,
|
||||||
|
role: inv.role,
|
||||||
|
roleTag: preset.roleTag ?? null,
|
||||||
|
defaultGroups: preset.groups ?? [],
|
||||||
|
messageMode: preset.messageMode ?? "push",
|
||||||
|
})
|
||||||
|
.returning({ id: meshMember.id });
|
||||||
|
if (!row) {
|
||||||
|
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Seal the mesh root_key to the recipient's x25519 pubkey.
|
||||||
|
let sealed: string;
|
||||||
|
try {
|
||||||
|
sealed = await sealRootKeyToRecipient({
|
||||||
|
rootKeyBase64url: m.rootKey,
|
||||||
|
recipientX25519PubkeyBase64url: recipientPk,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
sealed_root_key: sealed,
|
||||||
|
mesh_id: inv.meshId,
|
||||||
|
member_id: row.id,
|
||||||
|
owner_pubkey: m.ownerPubkey,
|
||||||
|
canonical_v2: canonical,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ const envSchema = z.object({
|
|||||||
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
|
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
|
||||||
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
|
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
|
||||||
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30),
|
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
|
NODE_ENV: z
|
||||||
.enum(["development", "production", "test"])
|
.enum(["development", "production", "test"])
|
||||||
.default("development"),
|
.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" } };
|
||||||
|
}
|
||||||
|
}
|
||||||
268
apps/broker/tests/invite-v2.test.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* v2 invite protocol — broker claim endpoint.
|
||||||
|
*
|
||||||
|
* Covers the sealed-root-key delivery flow added in
|
||||||
|
* .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md :
|
||||||
|
*
|
||||||
|
* - happy path: signed v2 invite claim returns a sealed root_key the
|
||||||
|
* recipient can unseal back to the mesh.rootKey column value
|
||||||
|
* - tampered signature → 400 bad_signature
|
||||||
|
* - expired invite → 410 expired
|
||||||
|
* - revoked invite → 410 revoked
|
||||||
|
* - exhausted invite (usedCount === maxUses) → 410 exhausted
|
||||||
|
* - round-trip: recipient-side crypto_box_seal_open recovers the real key
|
||||||
|
*
|
||||||
|
* Tests talk directly to claimInviteV2Core() to avoid spinning up the
|
||||||
|
* full broker HTTP server. The handler delegates to this function with
|
||||||
|
* zero extra logic, so coverage is equivalent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
|
import { db } from "../src/db";
|
||||||
|
import { invite, mesh } from "@turbostarter/db/schema/mesh";
|
||||||
|
import { canonicalInviteV2 } from "../src/crypto";
|
||||||
|
import { claimInviteV2Core } from "../src/index";
|
||||||
|
import {
|
||||||
|
cleanupAllTestMeshes,
|
||||||
|
setupTestMesh,
|
||||||
|
type TestMesh,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupAllTestMeshes();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await sodium.ready;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a random base64url root_key on an existing test mesh. The helpers
|
||||||
|
* don't set one by default, so v2 tests prime it per-mesh here.
|
||||||
|
*/
|
||||||
|
async function primeRootKey(meshId: string): Promise<Uint8Array> {
|
||||||
|
const key = sodium.randombytes_buf(32);
|
||||||
|
const b64 = sodium.to_base64(key, sodium.base64_variants.URLSAFE_NO_PADDING);
|
||||||
|
await db.update(mesh).set({ rootKey: b64 }).where(eq(mesh.id, meshId));
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a signed v2 invite row. Returns the opaque short code + the
|
||||||
|
* recipient x25519 keypair the test will use to unseal.
|
||||||
|
*/
|
||||||
|
async function insertV2Invite(
|
||||||
|
m: TestMesh,
|
||||||
|
opts: {
|
||||||
|
code: string;
|
||||||
|
expiresInSec?: number;
|
||||||
|
maxUses?: number;
|
||||||
|
role?: "admin" | "member";
|
||||||
|
tamper?: boolean; // corrupt the signature
|
||||||
|
revoked?: boolean;
|
||||||
|
used?: number;
|
||||||
|
},
|
||||||
|
): Promise<{ inviteId: string; canonical: string }> {
|
||||||
|
const expiresInSec = opts.expiresInSec ?? 3600;
|
||||||
|
const expiresAt = new Date(Date.now() + expiresInSec * 1000);
|
||||||
|
const maxUses = opts.maxUses ?? 1;
|
||||||
|
const role = opts.role ?? "member";
|
||||||
|
|
||||||
|
// Insert first with a placeholder capability so we have the invite id.
|
||||||
|
const [row] = await db
|
||||||
|
.insert(invite)
|
||||||
|
.values({
|
||||||
|
meshId: m.meshId,
|
||||||
|
token: `v2-test-token-${opts.code}`,
|
||||||
|
code: opts.code,
|
||||||
|
maxUses,
|
||||||
|
usedCount: opts.used ?? 0,
|
||||||
|
role,
|
||||||
|
expiresAt,
|
||||||
|
createdBy: "test-user-integration",
|
||||||
|
version: 2,
|
||||||
|
revokedAt: opts.revoked ? new Date() : null,
|
||||||
|
})
|
||||||
|
.returning({ id: invite.id });
|
||||||
|
if (!row) throw new Error("v2 invite insert failed");
|
||||||
|
|
||||||
|
// Now compute canonical_v2 using the real invite id and sign with the
|
||||||
|
// mesh owner's ed25519 secret key.
|
||||||
|
const expiresAtUnix = Math.floor(expiresAt.getTime() / 1000);
|
||||||
|
const canonical = canonicalInviteV2({
|
||||||
|
mesh_id: m.meshId,
|
||||||
|
invite_id: row.id,
|
||||||
|
expires_at: expiresAtUnix,
|
||||||
|
role,
|
||||||
|
owner_pubkey: m.ownerPubkey,
|
||||||
|
});
|
||||||
|
let signatureHex = sodium.to_hex(
|
||||||
|
sodium.crypto_sign_detached(
|
||||||
|
sodium.from_string(canonical),
|
||||||
|
sodium.from_hex(m.ownerSecretKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (opts.tamper) {
|
||||||
|
// Flip a single hex nibble — keeps length valid, invalidates signature.
|
||||||
|
const first = signatureHex[0] === "0" ? "1" : "0";
|
||||||
|
signatureHex = first + signatureHex.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const capability = JSON.stringify({
|
||||||
|
canonical,
|
||||||
|
signature: signatureHex,
|
||||||
|
});
|
||||||
|
await db
|
||||||
|
.update(invite)
|
||||||
|
.set({ capabilityV2: capability })
|
||||||
|
.where(eq(invite.id, row.id));
|
||||||
|
return { inviteId: row.id, canonical };
|
||||||
|
}
|
||||||
|
|
||||||
|
function genRecipientX25519(): { pk: string; sk: Uint8Array } {
|
||||||
|
const kp = sodium.crypto_box_keypair();
|
||||||
|
return {
|
||||||
|
pk: sodium.to_base64(kp.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING),
|
||||||
|
sk: kp.privateKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("claimInviteV2Core — v2 invite claim", () => {
|
||||||
|
let m: TestMesh;
|
||||||
|
afterEach(async () => m && (await m.cleanup()));
|
||||||
|
|
||||||
|
test("happy path: signed v2 invite returns sealed root_key and member row", async () => {
|
||||||
|
m = await setupTestMesh("v2-ok");
|
||||||
|
const rootKeyBytes = await primeRootKey(m.meshId);
|
||||||
|
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
const { inviteId, canonical } = await insertV2Invite(m, { code });
|
||||||
|
const recipient = genRecipientX25519();
|
||||||
|
|
||||||
|
const result = await claimInviteV2Core({
|
||||||
|
code,
|
||||||
|
recipientX25519PubkeyBase64url: recipient.pk,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(result.body.mesh_id).toBe(m.meshId);
|
||||||
|
expect(result.body.owner_pubkey).toBe(m.ownerPubkey);
|
||||||
|
expect(result.body.canonical_v2).toBe(canonical);
|
||||||
|
expect(result.body.member_id).toBeTruthy();
|
||||||
|
|
||||||
|
// Recipient unseals the sealed_root_key using its x25519 secret key.
|
||||||
|
const sealed = sodium.from_base64(
|
||||||
|
result.body.sealed_root_key,
|
||||||
|
sodium.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
);
|
||||||
|
const recipientPkBytes = sodium.from_base64(
|
||||||
|
recipient.pk,
|
||||||
|
sodium.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
);
|
||||||
|
const opened = sodium.crypto_box_seal_open(
|
||||||
|
sealed,
|
||||||
|
recipientPkBytes,
|
||||||
|
recipient.sk,
|
||||||
|
);
|
||||||
|
expect(opened).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(opened.length).toBe(32);
|
||||||
|
expect(Array.from(opened)).toEqual(Array.from(rootKeyBytes));
|
||||||
|
|
||||||
|
// usedCount incremented and claimedByPubkey recorded.
|
||||||
|
const [updated] = await db
|
||||||
|
.select({
|
||||||
|
usedCount: invite.usedCount,
|
||||||
|
claimedByPubkey: invite.claimedByPubkey,
|
||||||
|
})
|
||||||
|
.from(invite)
|
||||||
|
.where(eq(invite.id, inviteId));
|
||||||
|
expect(updated?.usedCount).toBe(1);
|
||||||
|
expect(updated?.claimedByPubkey).toBe(recipient.pk);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tampered signature → 400 bad_signature", async () => {
|
||||||
|
m = await setupTestMesh("v2-tampered");
|
||||||
|
await primeRootKey(m.meshId);
|
||||||
|
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
await insertV2Invite(m, { code, tamper: true });
|
||||||
|
const recipient = genRecipientX25519();
|
||||||
|
|
||||||
|
const result = await claimInviteV2Core({
|
||||||
|
code,
|
||||||
|
recipientX25519PubkeyBase64url: recipient.pk,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
expect(result.body.error).toBe("bad_signature");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired invite → 410 expired", async () => {
|
||||||
|
m = await setupTestMesh("v2-expired");
|
||||||
|
await primeRootKey(m.meshId);
|
||||||
|
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
await insertV2Invite(m, { code, expiresInSec: -60 });
|
||||||
|
const recipient = genRecipientX25519();
|
||||||
|
|
||||||
|
const result = await claimInviteV2Core({
|
||||||
|
code,
|
||||||
|
recipientX25519PubkeyBase64url: recipient.pk,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.status).toBe(410);
|
||||||
|
expect(result.body.error).toBe("expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("revoked invite → 410 revoked", async () => {
|
||||||
|
m = await setupTestMesh("v2-revoked");
|
||||||
|
await primeRootKey(m.meshId);
|
||||||
|
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
await insertV2Invite(m, { code, revoked: true });
|
||||||
|
const recipient = genRecipientX25519();
|
||||||
|
|
||||||
|
const result = await claimInviteV2Core({
|
||||||
|
code,
|
||||||
|
recipientX25519PubkeyBase64url: recipient.pk,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.status).toBe(410);
|
||||||
|
expect(result.body.error).toBe("revoked");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exhausted invite (usedCount >= maxUses) → 410 exhausted", async () => {
|
||||||
|
m = await setupTestMesh("v2-exhausted");
|
||||||
|
await primeRootKey(m.meshId);
|
||||||
|
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
await insertV2Invite(m, { code, maxUses: 1, used: 1 });
|
||||||
|
const recipient = genRecipientX25519();
|
||||||
|
|
||||||
|
const result = await claimInviteV2Core({
|
||||||
|
code,
|
||||||
|
recipientX25519PubkeyBase64url: recipient.pk,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.status).toBe(410);
|
||||||
|
expect(result.body.error).toBe("exhausted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown code → 404 not_found", async () => {
|
||||||
|
m = await setupTestMesh("v2-404");
|
||||||
|
await primeRootKey(m.meshId);
|
||||||
|
const recipient = genRecipientX25519();
|
||||||
|
|
||||||
|
const result = await claimInviteV2Core({
|
||||||
|
code: "nonexistent",
|
||||||
|
recipientX25519PubkeyBase64url: recipient.pk,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.status).toBe(404);
|
||||||
|
expect(result.body.error).toBe("not_found");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "0.1.9",
|
"version": "0.9.2",
|
||||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "1.27.1",
|
"@modelcontextprotocol/sdk": "1.27.1",
|
||||||
|
"citty": "0.2.2",
|
||||||
"libsodium-wrappers": "0.7.15",
|
"libsodium-wrappers": "0.7.15",
|
||||||
"ws": "8.20.0",
|
"ws": "8.20.0",
|
||||||
"zod": "4.1.13"
|
"zod": "4.1.13"
|
||||||
|
|||||||
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)}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { homedir, platform } from "node:os";
|
|||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { loadConfig } from "../state/config";
|
||||||
|
|
||||||
const MCP_NAME = "claudemesh";
|
const MCP_NAME = "claudemesh";
|
||||||
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
|
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
|
||||||
@@ -212,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,
|
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
|
||||||
* idempotent on the command string. Returns counts for reporting.
|
* idempotent on the command string. Returns counts for reporting.
|
||||||
@@ -321,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).
|
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
|
||||||
if (!skipHooks) {
|
if (!skipHooks) {
|
||||||
try {
|
try {
|
||||||
@@ -345,12 +452,35 @@ export function runInstall(args: string[] = []): void {
|
|||||||
console.log(dim("· Hooks skipped (--no-hooks)"));
|
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("");
|
||||||
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
|
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
|
||||||
console.log("");
|
|
||||||
console.log(
|
if (!hasMeshes) {
|
||||||
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
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("");
|
||||||
console.log(
|
console.log(
|
||||||
yellow("⚠ For real-time push messages from peers, launch with:"),
|
yellow("⚠ For real-time push messages from peers, launch with:"),
|
||||||
@@ -375,6 +505,20 @@ export function runUninstall(): void {
|
|||||||
console.log(`· MCP server "${MCP_NAME}" not present`);
|
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
|
// Hooks
|
||||||
try {
|
try {
|
||||||
const removed = uninstallHooks();
|
const removed = uninstallHooks();
|
||||||
|
|||||||
@@ -1,32 +1,123 @@
|
|||||||
/**
|
/**
|
||||||
* `claudemesh join <invite-link>` — full join flow.
|
* `claudemesh join <invite-link-or-code>` — full join flow.
|
||||||
*
|
*
|
||||||
* 1. Parse + validate the ic://join/... link
|
* Accepts either:
|
||||||
* 2. Generate a fresh ed25519 keypair (libsodium)
|
* - v2 short invite: `claudemesh.com/i/<code>` or bare `<code>`
|
||||||
* 3. POST /join to the broker → get member_id
|
* → POSTs to /api/public/invites/:code/claim, unseals root_key,
|
||||||
* 4. Persist the mesh + keypair to ~/.claudemesh/config.json (0600)
|
* persists mesh + fresh ed25519 identity.
|
||||||
* 5. Print success
|
* - v1 legacy invite: `ic://join/<token>` or `https://.../join/<token>`
|
||||||
|
* → parses signed payload, calls broker /join, persists.
|
||||||
*
|
*
|
||||||
* Signature verification + invite-token one-time-use land in Step 18.
|
* v1 continues to work throughout v0.1.x. v1 endpoints 410 Gone at v0.2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parseInviteLink } from "../invite/parse";
|
import { parseInviteLink } from "../invite/parse";
|
||||||
import { enrollWithBroker } from "../invite/enroll";
|
import { enrollWithBroker } from "../invite/enroll";
|
||||||
import { generateKeypair } from "../crypto/keypair";
|
import { generateKeypair } from "../crypto/keypair";
|
||||||
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
||||||
import { hostname } from "node:os";
|
import { claimInviteV2, parseV2InviteInput } from "../lib/invite-v2";
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
|
import { writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { homedir, hostname } from "node:os";
|
||||||
|
import { env } from "../env";
|
||||||
|
|
||||||
|
/** Derive the web app base URL from the broker URL, unless explicitly overridden. */
|
||||||
|
function deriveAppBaseUrl(): string {
|
||||||
|
const override = process.env.CLAUDEMESH_APP_URL;
|
||||||
|
if (override) return override.replace(/\/$/, "");
|
||||||
|
// Broker is `wss://ic.claudemesh.com/ws` → app is `https://claudemesh.com`.
|
||||||
|
// For self-hosted: honour the broker host's parent domain as best-effort.
|
||||||
|
try {
|
||||||
|
const u = new URL(env.CLAUDEMESH_BROKER_URL);
|
||||||
|
const host = u.host.replace(/^ic\./, "");
|
||||||
|
const scheme = u.protocol === "wss:" ? "https:" : "http:";
|
||||||
|
return `${scheme}//${host}`;
|
||||||
|
} catch {
|
||||||
|
return "https://claudemesh.com";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runJoinV2(code: string): Promise<void> {
|
||||||
|
const appBaseUrl = deriveAppBaseUrl();
|
||||||
|
console.log(`Claiming invite ${code} via ${appBaseUrl}…`);
|
||||||
|
|
||||||
|
let claim;
|
||||||
|
try {
|
||||||
|
claim = await claimInviteV2({ appBaseUrl, code });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a fresh ed25519 identity for this peer. The v2 claim
|
||||||
|
// endpoint creates the member row keyed on the x25519 pubkey we sent;
|
||||||
|
// the ed25519 keypair is what the `hello` handshake and future
|
||||||
|
// envelope signing will use. Stored locally only.
|
||||||
|
const keypair = await generateKeypair();
|
||||||
|
const displayName = `${hostname()}-${process.pid}`;
|
||||||
|
|
||||||
|
// Encode the unsealed 32-byte root key as URL-safe base64url (no pad)
|
||||||
|
// to match the format used everywhere else (broker stores it the
|
||||||
|
// same way in mesh.rootKey).
|
||||||
|
await sodium.ready;
|
||||||
|
const rootKeyB64 = sodium.to_base64(
|
||||||
|
claim.rootKey,
|
||||||
|
sodium.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist. We don't have a mesh_slug in the v2 response — the server
|
||||||
|
// derives slug from name and slug is no longer globally unique. Use a
|
||||||
|
// stable short derivative of the mesh id so `list` / `launch --mesh`
|
||||||
|
// still have something to match on.
|
||||||
|
const fallbackSlug = `mesh-${claim.meshId.slice(0, 8)}`;
|
||||||
|
const config = loadConfig();
|
||||||
|
config.meshes = config.meshes.filter((m) => m.meshId !== claim.meshId);
|
||||||
|
config.meshes.push({
|
||||||
|
meshId: claim.meshId,
|
||||||
|
memberId: claim.memberId,
|
||||||
|
slug: fallbackSlug,
|
||||||
|
name: fallbackSlug,
|
||||||
|
pubkey: keypair.publicKey,
|
||||||
|
secretKey: keypair.secretKey,
|
||||||
|
brokerUrl: env.CLAUDEMESH_BROKER_URL,
|
||||||
|
joinedAt: new Date().toISOString(),
|
||||||
|
rootKey: rootKeyB64,
|
||||||
|
inviteVersion: 2,
|
||||||
|
});
|
||||||
|
saveConfig(config);
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log(`✓ Joined mesh ${claim.meshId} via v2 invite`);
|
||||||
|
console.log(` member id: ${claim.memberId}`);
|
||||||
|
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}…`);
|
||||||
|
console.log(` broker: ${env.CLAUDEMESH_BROKER_URL}`);
|
||||||
|
console.log(` config: ${getConfigPath()}`);
|
||||||
|
console.log("");
|
||||||
|
console.log("Restart Claude Code to pick up the new mesh.");
|
||||||
|
}
|
||||||
|
|
||||||
export async function runJoin(args: string[]): Promise<void> {
|
export async function runJoin(args: string[]): Promise<void> {
|
||||||
const link = args[0];
|
const link = args[0];
|
||||||
if (!link) {
|
if (!link) {
|
||||||
console.error("Usage: claudemesh join <invite-url-or-token>");
|
console.error("Usage: claudemesh join <invite-url-or-code>");
|
||||||
console.error("");
|
console.error("");
|
||||||
console.error(
|
console.error("Examples:");
|
||||||
"Example: claudemesh join https://claudemesh.com/join/eyJ2IjoxLC4uLn0",
|
console.error(" claudemesh join https://claudemesh.com/i/abc12345");
|
||||||
);
|
console.error(" claudemesh join abc12345");
|
||||||
|
console.error(" claudemesh join ic://join/eyJ2IjoxLC4uLn0 (v1 legacy)");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try v2 first — short code / `/i/<code>` URL.
|
||||||
|
const v2Code = parseV2InviteInput(link);
|
||||||
|
if (v2Code) {
|
||||||
|
await runJoinV2(v2Code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Parse + verify signature client-side.
|
// 1. Parse + verify signature client-side.
|
||||||
let invite;
|
let invite;
|
||||||
try {
|
try {
|
||||||
@@ -78,6 +169,16 @@ export async function runJoin(args: string[]): Promise<void> {
|
|||||||
});
|
});
|
||||||
saveConfig(config);
|
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.
|
// 5. Report.
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
||||||
*
|
*
|
||||||
|
* Flags are defined in index.ts (citty command) — that is the source of
|
||||||
|
* truth. This file receives already-parsed flags and rawArgs.
|
||||||
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. Parse --name, --join, --mesh, --quiet flags
|
* 1. Receive parsed flags from citty + rawArgs for -- passthrough
|
||||||
* 2. If --join: run join flow first (accepts token or URL)
|
* 2. If --join: run join flow first
|
||||||
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
|
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
|
||||||
* 4. Write per-session config to tmpdir (isolates mesh selection)
|
* 4. Write per-session config to tmpdir (isolates mesh selection)
|
||||||
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
|
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
|
||||||
@@ -11,61 +14,29 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
import { randomUUID } from "node:crypto";
|
||||||
import { tmpdir, hostname } from "node:os";
|
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
|
||||||
|
import { tmpdir, hostname, homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
import { loadConfig, getConfigPath } from "../state/config";
|
import { loadConfig, getConfigPath } from "../state/config";
|
||||||
import type { Config, JoinedMesh } from "../state/config";
|
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
|
||||||
import { generateKeypair } from "../crypto/keypair";
|
import { startCallbackListener, openBrowser, generatePairingCode } from "../auth";
|
||||||
import { enrollWithBroker } from "../invite/enroll";
|
import { BrokerClient } from "../ws/client";
|
||||||
import { parseInviteLink } from "../invite/parse";
|
|
||||||
|
|
||||||
// --- Arg parsing ---
|
// Flags as parsed by citty (index.ts is the source of truth for definitions).
|
||||||
|
export interface LaunchFlags {
|
||||||
interface LaunchArgs {
|
name?: string;
|
||||||
name: string | null;
|
role?: string;
|
||||||
joinLink: string | null;
|
groups?: string;
|
||||||
meshSlug: string | null;
|
join?: string;
|
||||||
quiet: boolean;
|
mesh?: string;
|
||||||
claudeArgs: string[];
|
"message-mode"?: string;
|
||||||
}
|
"system-prompt"?: string;
|
||||||
|
resume?: string;
|
||||||
function parseArgs(argv: string[]): LaunchArgs {
|
continue?: boolean;
|
||||||
const result: LaunchArgs = {
|
yes?: boolean;
|
||||||
name: null,
|
quiet?: boolean;
|
||||||
joinLink: null,
|
|
||||||
meshSlug: null,
|
|
||||||
quiet: false,
|
|
||||||
claudeArgs: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
let i = 0;
|
|
||||||
while (i < argv.length) {
|
|
||||||
const arg = argv[i]!;
|
|
||||||
if (arg === "--name" && i + 1 < argv.length) {
|
|
||||||
result.name = argv[++i]!;
|
|
||||||
} else if (arg.startsWith("--name=")) {
|
|
||||||
result.name = arg.slice("--name=".length);
|
|
||||||
} else if (arg === "--join" && i + 1 < argv.length) {
|
|
||||||
result.joinLink = argv[++i]!;
|
|
||||||
} else if (arg.startsWith("--join=")) {
|
|
||||||
result.joinLink = arg.slice("--join=".length);
|
|
||||||
} else if (arg === "--mesh" && i + 1 < argv.length) {
|
|
||||||
result.meshSlug = argv[++i]!;
|
|
||||||
} else if (arg.startsWith("--mesh=")) {
|
|
||||||
result.meshSlug = arg.slice("--mesh=".length);
|
|
||||||
} else if (arg === "--quiet") {
|
|
||||||
result.quiet = true;
|
|
||||||
} else if (arg === "--") {
|
|
||||||
result.claudeArgs.push(...argv.slice(i + 1));
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
result.claudeArgs.push(arg);
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Interactive mesh picker ---
|
// --- Interactive mesh picker ---
|
||||||
@@ -94,18 +65,91 @@ async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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 ---
|
// --- Banner ---
|
||||||
|
|
||||||
function printBanner(name: string, meshSlug: string): void {
|
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
|
||||||
const useColor =
|
const useColor =
|
||||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
|
||||||
|
const roleSuffix = role ? ` (${role})` : "";
|
||||||
|
const groupTags = groups.length
|
||||||
|
? " [" + groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||||
|
: "";
|
||||||
|
|
||||||
const rule = "─".repeat(60);
|
const rule = "─".repeat(60);
|
||||||
console.log(bold(`claudemesh launch`) + dim(` — as ${name} on ${meshSlug}`));
|
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`));
|
||||||
console.log(rule);
|
console.log(rule);
|
||||||
console.log("Peer messages arrive as <channel> reminders in real-time.");
|
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("Peers send text only — they cannot call tools or read files.");
|
||||||
console.log(dim(`Config: ${getConfigPath()}`));
|
console.log(dim(`Config: ${getConfigPath()}`));
|
||||||
console.log(rule);
|
console.log(rule);
|
||||||
@@ -114,8 +158,28 @@ function printBanner(name: string, meshSlug: string): void {
|
|||||||
|
|
||||||
// --- Main ---
|
// --- Main ---
|
||||||
|
|
||||||
export async function runLaunch(extraArgs: string[]): Promise<void> {
|
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
|
||||||
const args = parseArgs(extraArgs);
|
// 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.
|
// 1. If --join, run join flow first.
|
||||||
if (args.joinLink) {
|
if (args.joinLink) {
|
||||||
@@ -153,10 +217,85 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
|
|
||||||
// 2. Load config, pick mesh.
|
// 2. Load config, pick mesh.
|
||||||
const config = loadConfig();
|
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) {
|
if (config.meshes.length === 0) {
|
||||||
console.error(
|
console.error("No meshes joined. Run `claudemesh join <url>` or use --join <url>.");
|
||||||
"No meshes joined. Run `claudemesh join <url>` or use --join <url>.",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,16 +313,111 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
mesh = await pickMesh(config.meshes);
|
mesh = await pickMesh(config.meshes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Set display name. Uses existing member identity — the broker
|
// 3. Session identity + role/groups.
|
||||||
// creates a separate presence row per session (sessionId + pid)
|
// The WS client auto-generates a per-session ephemeral keypair on
|
||||||
// and stores the per-session displayName override.
|
// connect (sent in hello as sessionPubkey). We set display name via env var.
|
||||||
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
||||||
|
|
||||||
// 4. Write session config to tmpdir (same mesh, same keypair).
|
// 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 tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
|
||||||
const sessionConfig: Config = {
|
const sessionConfig: Config = {
|
||||||
version: 1,
|
version: 1,
|
||||||
meshes: [mesh],
|
meshes: [mesh],
|
||||||
|
displayName,
|
||||||
|
...(role ? { role } : {}),
|
||||||
|
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
|
||||||
|
messageMode,
|
||||||
};
|
};
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(tmpDir, "config.json"),
|
join(tmpDir, "config.json"),
|
||||||
@@ -191,23 +425,96 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
// 5. Banner.
|
// 5. Banner + permission confirmation.
|
||||||
if (!args.quiet) printBanner(displayName, mesh.slug);
|
if (!args.quiet) {
|
||||||
|
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
|
||||||
|
// Auto-permissions confirmation — needed for autonomous peer messaging.
|
||||||
|
if (!args.skipPermConfirm) {
|
||||||
|
await confirmPermissions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Spawn claude with ephemeral config + dev channel + display name.
|
// --- Install native MCP entries for deployed mesh services ---
|
||||||
// Strip any user-supplied --dangerously-load-development-channels
|
const meshMcpEntries: Array<{ key: string; entry: unknown }> = [];
|
||||||
// to avoid duplicates — we always inject our own.
|
|
||||||
|
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[] = [];
|
const filtered: string[] = [];
|
||||||
for (let i = 0; i < args.claudeArgs.length; i++) {
|
for (let i = 0; i < args.claudeArgs.length; i++) {
|
||||||
if (args.claudeArgs[i] === "--dangerously-load-development-channels") {
|
if (args.claudeArgs[i] === "--dangerously-load-development-channels"
|
||||||
i++; // skip the next arg (the channel value) too
|
|| args.claudeArgs[i] === "--dangerously-skip-permissions") {
|
||||||
|
if (args.claudeArgs[i] === "--dangerously-load-development-channels") i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
filtered.push(args.claudeArgs[i]!);
|
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 = [
|
const claudeArgs = [
|
||||||
"--dangerously-load-development-channels",
|
"--dangerously-load-development-channels",
|
||||||
"server:claudemesh",
|
"server:claudemesh",
|
||||||
|
...(claudeSessionId ? ["--session-id", claudeSessionId] : []),
|
||||||
|
...(args.resume ? ["--resume", args.resume] : []),
|
||||||
|
...(args.continueSession ? ["--continue"] : []),
|
||||||
|
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
|
||||||
|
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
|
||||||
...filtered,
|
...filtered,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -219,11 +526,29 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
|||||||
...process.env,
|
...process.env,
|
||||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||||
CLAUDEMESH_DISPLAY_NAME: displayName,
|
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.
|
// 7. Cleanup on exit.
|
||||||
const cleanup = (): void => {
|
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 {
|
try {
|
||||||
rmSync(tmpDir, { recursive: true, force: true });
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
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)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
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,13 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* claudemesh-cli entry point.
|
* 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:
|
* Dispatches between two modes:
|
||||||
* - `claudemesh mcp` → MCP server (stdio transport)
|
* - `claudemesh mcp` → MCP server (stdio transport)
|
||||||
* - `claudemesh <subcommand>` → CLI subcommand
|
* - `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 { startMcpServer } from "./mcp/server";
|
||||||
import { runInstall, runUninstall } from "./commands/install";
|
import { runInstall, runUninstall } from "./commands/install";
|
||||||
import { runJoin } from "./commands/join";
|
import { runJoin } from "./commands/join";
|
||||||
@@ -19,98 +21,337 @@ import { runLaunch } from "./commands/launch";
|
|||||||
import { runStatus } from "./commands/status";
|
import { runStatus } from "./commands/status";
|
||||||
import { runDoctor } from "./commands/doctor";
|
import { runDoctor } from "./commands/doctor";
|
||||||
import { runWelcome } from "./commands/welcome";
|
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";
|
import { VERSION } from "./version";
|
||||||
|
|
||||||
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
Usage:
|
const install = defineCommand({
|
||||||
claudemesh <command> [args]
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
Commands:
|
const join = defineCommand({
|
||||||
install Register MCP + Stop/UserPromptSubmit status hooks
|
meta: {
|
||||||
(add --no-hooks for bare MCP registration)
|
name: "join",
|
||||||
uninstall Remove MCP server + hooks
|
description: "Join a mesh via invite URL or token",
|
||||||
launch [opts] Launch Claude Code with real-time push messages
|
},
|
||||||
--name <name> Display name for this session
|
args: {
|
||||||
--mesh <slug> Select mesh (picker if >1, omitted)
|
url: {
|
||||||
--join <url> Join a mesh before launching
|
type: "positional",
|
||||||
--quiet Skip the info banner
|
description: "Invite URL (`https://claudemesh.com/join/...`) or token",
|
||||||
-- <args> Pass remaining args to claude
|
required: true,
|
||||||
join <url> Join a mesh via https://claudemesh.com/join/... URL
|
},
|
||||||
list Show all joined meshes
|
},
|
||||||
leave <slug> Leave a joined mesh
|
run({ args }) {
|
||||||
status Health report: broker reachability per joined mesh
|
return runJoin([args.url]);
|
||||||
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:
|
const leave = defineCommand({
|
||||||
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws)
|
meta: {
|
||||||
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/)
|
name: "leave",
|
||||||
CLAUDEMESH_DEBUG=1 Verbose logging
|
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 cmd = process.argv[2];
|
const main = defineCommand({
|
||||||
const args = process.argv.slice(3);
|
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); }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
await runWelcome();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
// Friction reducer: if the user types `claudemesh --resume xxx` or any other
|
||||||
switch (cmd) {
|
// flag-first invocation, route it through `launch`. This keeps `claudemesh`
|
||||||
case "mcp":
|
// bare (welcome screen), `claudemesh <known-sub>` (dispatch normally), and
|
||||||
await startMcpServer();
|
// every flag-only form as implicit `launch`.
|
||||||
return;
|
const KNOWN_SUBCOMMANDS = new Set(Object.keys(main.subCommands ?? {}));
|
||||||
case "install":
|
// Flags citty handles on the root command — must not be rewritten to `launch`.
|
||||||
runInstall(args);
|
const ROOT_PASSTHROUGH_FLAGS = new Set(["--help", "-h", "--version", "-v"]);
|
||||||
return;
|
|
||||||
case "uninstall":
|
const argv = process.argv.slice(2);
|
||||||
runUninstall();
|
const first = argv[0];
|
||||||
return;
|
if (first && !ROOT_PASSTHROUGH_FLAGS.has(first) && !KNOWN_SUBCOMMANDS.has(first)) {
|
||||||
case "hook":
|
// Starts with a flag, or an unknown bareword → treat as launch args.
|
||||||
await runHook(args);
|
// (Unknown barewords that look like typos would otherwise hit citty's
|
||||||
return;
|
// "unknown command" path; forwarding to launch lets claude surface the
|
||||||
case "launch":
|
// error if it's a real claude flag, and launch's own parser rejects junk.)
|
||||||
await runLaunch(args);
|
process.argv.splice(2, 0, "launch");
|
||||||
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":
|
|
||||||
console.log(HELP);
|
|
||||||
return;
|
|
||||||
case undefined:
|
|
||||||
runWelcome();
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
console.error(`Unknown command: ${cmd}`);
|
|
||||||
console.error("Run `claudemesh --help` for usage.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
runMain(main);
|
||||||
console.error(`claudemesh: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|||||||
217
apps/cli/src/lib/invite-v2.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* v2 invite claim client.
|
||||||
|
*
|
||||||
|
* The v2 invite URL is a short opaque code (e.g. `claudemesh.com/i/abc12345`).
|
||||||
|
* The mesh root key is NOT embedded. Instead:
|
||||||
|
*
|
||||||
|
* 1. Client generates a fresh x25519 keypair (separate from the peer's
|
||||||
|
* ed25519 identity) just for this claim.
|
||||||
|
* 2. Client POSTs `recipient_x25519_pubkey` to
|
||||||
|
* `${appBaseUrl}/api/public/invites/:code/claim`.
|
||||||
|
* 3. Server responds with `sealed_root_key` (crypto_box_seal of the real
|
||||||
|
* mesh root key to the recipient pubkey) + mesh metadata +
|
||||||
|
* `canonical_v2` (the signed capability bytes).
|
||||||
|
* 4. Client unseals the root key with its x25519 secret key.
|
||||||
|
*
|
||||||
|
* Wire contract is LOCKED — see `docs/protocol.md` §v2 invites and
|
||||||
|
* `apps/broker/tests/invite-v2.test.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
|
|
||||||
|
async function ensureSodium(): Promise<typeof sodium> {
|
||||||
|
await sodium.ready;
|
||||||
|
return sodium;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fresh x25519 (Curve25519) keypair suitable for
|
||||||
|
* `crypto_box_seal`. This is intentionally distinct from the peer's
|
||||||
|
* long-lived ed25519 identity — we do NOT want the mesh root key sealed
|
||||||
|
* against a key that's reused for signing.
|
||||||
|
*
|
||||||
|
* Returns the public key as URL-safe base64url (no padding) to match
|
||||||
|
* the format used by the broker's `sealed_root_key` response.
|
||||||
|
*/
|
||||||
|
export async function generateX25519Keypair(): Promise<{
|
||||||
|
publicKeyB64: string;
|
||||||
|
secretKey: Uint8Array;
|
||||||
|
}> {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const kp = s.crypto_box_keypair();
|
||||||
|
const publicKeyB64 = s.to_base64(
|
||||||
|
kp.publicKey,
|
||||||
|
s.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
);
|
||||||
|
return { publicKeyB64, secretKey: kp.privateKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimV2Result {
|
||||||
|
meshId: string;
|
||||||
|
memberId: string;
|
||||||
|
ownerPubkey: string;
|
||||||
|
canonicalV2: string;
|
||||||
|
/** Unsealed mesh root key, 32 raw bytes. */
|
||||||
|
rootKey: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClaimResponseBody {
|
||||||
|
sealed_root_key?: string;
|
||||||
|
mesh_id?: string;
|
||||||
|
member_id?: string;
|
||||||
|
owner_pubkey?: string;
|
||||||
|
canonical_v2?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClaimErrorBody {
|
||||||
|
error?: string;
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim a v2 invite by its short code. Performs the x25519 keypair
|
||||||
|
* generation, POST, and local unseal of the returned `sealed_root_key`.
|
||||||
|
*
|
||||||
|
* Throws with a descriptive message on 4xx/5xx or on seal-open failure.
|
||||||
|
*/
|
||||||
|
export async function claimInviteV2(opts: {
|
||||||
|
appBaseUrl: string; // e.g. "https://claudemesh.com"
|
||||||
|
code: string;
|
||||||
|
}): Promise<ClaimV2Result> {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const { publicKeyB64, secretKey } = await generateX25519Keypair();
|
||||||
|
const publicKeyBytes = s.from_base64(
|
||||||
|
publicKeyB64,
|
||||||
|
s.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
);
|
||||||
|
|
||||||
|
const base = opts.appBaseUrl.replace(/\/$/, "");
|
||||||
|
const code = encodeURIComponent(opts.code);
|
||||||
|
const url = `${base}/api/public/invites/${code}/claim`;
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ recipient_x25519_pubkey: publicKeyB64 }),
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`claim request failed (network): ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse body first — server returns JSON for both success and error.
|
||||||
|
let parsed: unknown = null;
|
||||||
|
try {
|
||||||
|
parsed = await res.json();
|
||||||
|
} catch {
|
||||||
|
// fall through with parsed=null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (parsed ?? {}) as ClaimErrorBody;
|
||||||
|
const reason =
|
||||||
|
err.error ?? err.code ?? err.message ?? `HTTP ${res.status}`;
|
||||||
|
switch (res.status) {
|
||||||
|
case 400:
|
||||||
|
throw new Error(`invite claim rejected: ${reason}`);
|
||||||
|
case 404:
|
||||||
|
throw new Error(`invite not found: ${reason}`);
|
||||||
|
case 410:
|
||||||
|
throw new Error(`invite no longer usable: ${reason}`);
|
||||||
|
default:
|
||||||
|
throw new Error(`invite claim failed (${res.status}): ${reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (parsed ?? {}) as ClaimResponseBody;
|
||||||
|
if (
|
||||||
|
!body.sealed_root_key ||
|
||||||
|
!body.mesh_id ||
|
||||||
|
!body.member_id ||
|
||||||
|
!body.owner_pubkey ||
|
||||||
|
!body.canonical_v2
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`invite claim response malformed: missing required field(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unseal the root key with our x25519 secret.
|
||||||
|
let rootKey: Uint8Array;
|
||||||
|
try {
|
||||||
|
const sealed = s.from_base64(
|
||||||
|
body.sealed_root_key,
|
||||||
|
s.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
);
|
||||||
|
const opened = s.crypto_box_seal_open(sealed, publicKeyBytes, secretKey);
|
||||||
|
if (!opened) throw new Error("crypto_box_seal_open returned empty");
|
||||||
|
rootKey = opened;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`failed to unseal root key (server sealed to wrong pubkey?): ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rootKey.length !== 32) {
|
||||||
|
throw new Error(
|
||||||
|
`unsealed root key has wrong length: ${rootKey.length} (expected 32)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(v0.1.5): when the claim response grows a `signature` field,
|
||||||
|
// re-verify canonical_v2 against owner_pubkey locally as a
|
||||||
|
// belt-and-suspenders check against a compromised broker.
|
||||||
|
// For v0.1.x the broker is trusted: it verified capability_v2 before
|
||||||
|
// sealing, and a malicious broker could already lie about mesh_id.
|
||||||
|
|
||||||
|
return {
|
||||||
|
meshId: body.mesh_id,
|
||||||
|
memberId: body.member_id,
|
||||||
|
ownerPubkey: body.owner_pubkey,
|
||||||
|
canonicalV2: body.canonical_v2,
|
||||||
|
rootKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a v2 invite input (bare code or full URL) into a short code.
|
||||||
|
*
|
||||||
|
* Accepted forms:
|
||||||
|
* - `abc12345`
|
||||||
|
* - `claudemesh.com/i/abc12345`
|
||||||
|
* - `https://claudemesh.com/i/abc12345`
|
||||||
|
* - `https://claudemesh.com/es/i/abc12345` (locale prefix)
|
||||||
|
*
|
||||||
|
* Returns `null` if the input doesn't look like a v2 code/URL — callers
|
||||||
|
* should fall back to the v1 `ic://join/...` parser in that case.
|
||||||
|
*/
|
||||||
|
export function parseV2InviteInput(input: string): string | null {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
|
||||||
|
// Full URL with /i/<code>
|
||||||
|
const urlMatch = trimmed.match(
|
||||||
|
/^https?:\/\/[^/]+(?:\/[a-z]{2})?\/i\/([A-Za-z0-9]+)\/?$/,
|
||||||
|
);
|
||||||
|
if (urlMatch) return urlMatch[1]!;
|
||||||
|
|
||||||
|
// Schemeless "claudemesh.com/i/<code>"
|
||||||
|
const schemelessMatch = trimmed.match(
|
||||||
|
/^[^/]+(?:\/[a-z]{2})?\/i\/([A-Za-z0-9]+)\/?$/,
|
||||||
|
);
|
||||||
|
if (schemelessMatch) return schemelessMatch[1]!;
|
||||||
|
|
||||||
|
// Bare short code — base62, typically 8 chars. Be a little lenient
|
||||||
|
// (6-16) to accommodate future tweaks but stay tight enough not to
|
||||||
|
// collide with a v1 base64url token (which contains `-` / `_` and is
|
||||||
|
// much longer).
|
||||||
|
if (/^[A-Za-z0-9]{6,16}$/.test(trimmed)) return trimmed;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -12,13 +12,16 @@ export const TOOLS: Tool[] = [
|
|||||||
{
|
{
|
||||||
name: "send_message",
|
name: "send_message",
|
||||||
description:
|
description:
|
||||||
"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, `#channel`, or `*` for broadcast. `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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
to: {
|
to: {
|
||||||
type: "string",
|
oneOf: [
|
||||||
description: "Peer name, pubkey, or #channel",
|
{ 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" },
|
message: { type: "string", description: "Message text" },
|
||||||
priority: {
|
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",
|
name: "check_messages",
|
||||||
description:
|
description:
|
||||||
@@ -78,4 +96,925 @@ export const TOOLS: Tool[] = [
|
|||||||
required: ["status"],
|
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 type PeerStatus = "idle" | "working" | "dnd";
|
||||||
|
|
||||||
export interface SendMessageArgs {
|
export interface SendMessageArgs {
|
||||||
to: string; // peer name, pubkey, or #channel
|
to: string | string[]; // peer name, pubkey, @group, or array of targets
|
||||||
message: string;
|
message: string;
|
||||||
priority?: Priority;
|
priority?: Priority;
|
||||||
}
|
}
|
||||||
@@ -22,3 +22,60 @@ export interface SetSummaryArgs {
|
|||||||
export interface SetStatusArgs {
|
export interface SetStatusArgs {
|
||||||
status: PeerStatus;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,11 +26,30 @@ export interface JoinedMesh {
|
|||||||
secretKey: string; // ed25519 hex (64 bytes = 128 chars)
|
secretKey: string; // ed25519 hex (64 bytes = 128 chars)
|
||||||
brokerUrl: string;
|
brokerUrl: string;
|
||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
|
/**
|
||||||
|
* Mesh root key (32 bytes) as URL-safe base64url, no padding.
|
||||||
|
* Present for v2 invite joins (sealed then unsealed client-side).
|
||||||
|
* Absent for v1 joins, where the root key lives inside the saved
|
||||||
|
* invite token on disk instead. Used by channel/group `crypto_secretbox`.
|
||||||
|
*/
|
||||||
|
rootKey?: string;
|
||||||
|
/** Invite protocol version used to join. `2` for v2, omitted/`1` for legacy. */
|
||||||
|
inviteVersion?: 1 | 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupEntry {
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
version: 1;
|
version: 1;
|
||||||
meshes: JoinedMesh[];
|
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_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||||
@@ -46,7 +65,7 @@ export function loadConfig(): Config {
|
|||||||
if (!parsed || !Array.isArray(parsed.meshes)) {
|
if (!parsed || !Array.isArray(parsed.meshes)) {
|
||||||
return { version: 1, meshes: [] };
|
return { version: 1, meshes: [] };
|
||||||
}
|
}
|
||||||
return { version: 1, meshes: parsed.meshes };
|
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode, accountId: parsed.accountId };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
`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";
|
import { env } from "../env";
|
||||||
|
|
||||||
const clients = new Map<string, BrokerClient>();
|
const clients = new Map<string, BrokerClient>();
|
||||||
|
let configDisplayName: string | undefined;
|
||||||
|
let configGroups: Config["groups"] = [];
|
||||||
|
|
||||||
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
||||||
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||||
const existing = clients.get(mesh.meshId);
|
const existing = clients.get(mesh.meshId);
|
||||||
if (existing) return existing;
|
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);
|
clients.set(mesh.meshId, client);
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
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 {
|
} catch {
|
||||||
// Connect failed → client is in "reconnecting" state, leave it
|
// Connect failed → client is in "reconnecting" state, leave it
|
||||||
// wired so tool calls can surface the status.
|
// 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. */
|
/** Start clients for every joined mesh. Called once on MCP server start. */
|
||||||
export async function startClients(config: Config): Promise<void> {
|
export async function startClients(config: Config): Promise<void> {
|
||||||
|
configDisplayName = config.displayName;
|
||||||
|
configGroups = config.groups ?? [];
|
||||||
await Promise.allSettled(config.meshes.map(ensureClient));
|
await Promise.allSettled(config.meshes.map(ensureClient));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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>"
|
MISTRAL_API_KEY="<your-mistral-api-key>"
|
||||||
|
|
||||||
# Perplexity API key - required only if you use Perplexity as an AI provider
|
# 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:
|
# pnpm workspace needs full context to resolve workspace:* + catalog:
|
||||||
COPY . .
|
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)
|
# Build — SKIP_ENV_VALIDATION lets missing runtime vars pass (validated at startup instead)
|
||||||
ENV NODE_ENV=production
|
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_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME
|
||||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
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...
|
RUN npx turbo run build --filter=web...
|
||||||
|
|
||||||
# Stage 2: runtime — standalone output only
|
# 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";
|
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";
|
import env from "./env.config";
|
||||||
|
|
||||||
const INTERNAL_PACKAGES = [
|
const INTERNAL_PACKAGES = [
|
||||||
@@ -85,7 +88,11 @@ const config: NextConfig = {
|
|||||||
"@payloadcms/db-postgres",
|
"@payloadcms/db-postgres",
|
||||||
"@payloadcms/db-sqlite",
|
"@payloadcms/db-sqlite",
|
||||||
"@payloadcms/richtext-lexical",
|
"@payloadcms/richtext-lexical",
|
||||||
|
"@payloadcms/next",
|
||||||
|
"@payloadcms/ui",
|
||||||
|
"react-image-crop",
|
||||||
"sharp",
|
"sharp",
|
||||||
|
"libsodium-wrappers",
|
||||||
],
|
],
|
||||||
turbopack: {
|
turbopack: {
|
||||||
rules: {
|
rules: {
|
||||||
@@ -96,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: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
@@ -130,4 +155,4 @@ const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
|||||||
enabled: env.ANALYZE,
|
enabled: env.ANALYZE,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default withBundleAnalyzer(config);
|
export default withPayload(withBundleAnalyzer(config));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build --webpack",
|
||||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
|
|||||||
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 |
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
|
||||||
|
}
|
||||||
@@ -153,14 +153,14 @@ export default function AboutPage() {
|
|||||||
GitHub
|
GitHub
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="https://www.linkedin.com/in/alejandrogutierrezmourente/"
|
href="https://www.linkedin.com/in/alejandro-mourente/"
|
||||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
|
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)" }}
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
>
|
>
|
||||||
LinkedIn
|
LinkedIn
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="mailto:info@whyrating.com"
|
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)]"
|
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)" }}
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
>
|
>
|
||||||
|
|||||||
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,14 @@
|
|||||||
import { Hero } from "~/modules/marketing/home/hero";
|
import { HeroWithMesh } from "~/modules/marketing/home/hero-with-mesh";
|
||||||
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 { Features } from "~/modules/marketing/home/features";
|
||||||
import { MeetsYou } from "~/modules/marketing/home/meets-you";
|
import { WhereMeshFits } from "~/modules/marketing/home/where-mesh-fits";
|
||||||
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 { 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 { FAQ } from "~/modules/marketing/home/faq";
|
||||||
import { CallToAction } from "~/modules/marketing/home/cta";
|
import { CallToAction } from "~/modules/marketing/home/cta";
|
||||||
import { MeshStats } from "~/modules/marketing/home/mesh-stats";
|
import { MeshStats } from "~/modules/marketing/home/mesh-stats";
|
||||||
import { LatestNewsToaster } from "~/modules/marketing/home/toaster";
|
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;
|
export const revalidate = 60;
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
@@ -23,15 +17,12 @@ const HomePage = () => {
|
|||||||
className="bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
className="bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
||||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
>
|
>
|
||||||
<Hero />
|
<HeroWithMesh />
|
||||||
<Surfaces />
|
|
||||||
<Pricing />
|
|
||||||
<LaptopToLaptop />
|
|
||||||
<Features />
|
<Features />
|
||||||
<MeetsYou />
|
<WhereMeshFits />
|
||||||
<WhatIsClaudemesh />
|
<WhatIsClaudemesh />
|
||||||
<DemoDashboard />
|
<Timeline />
|
||||||
<BeyondTerminal />
|
<Pricing />
|
||||||
<FAQ />
|
<FAQ />
|
||||||
<CallToAction />
|
<CallToAction />
|
||||||
<MeshStats />
|
<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,
|
DashboardHeaderTitle,
|
||||||
} from "~/modules/common/layout/dashboard/header";
|
} from "~/modules/common/layout/dashboard/header";
|
||||||
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
|
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({
|
export const generateMetadata = getMetadata({
|
||||||
title: "Live mesh",
|
title: "Live mesh",
|
||||||
@@ -63,7 +66,14 @@ export default async function LiveMeshPage({
|
|||||||
</div>
|
</div>
|
||||||
</DashboardHeader>
|
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
48
apps/web/src/app/[locale]/i/[code]/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { api } from "~/lib/api/server";
|
||||||
|
import { getMetadata } from "~/lib/metadata";
|
||||||
|
|
||||||
|
export const generateMetadata = getMetadata({
|
||||||
|
title: "Join a mesh",
|
||||||
|
description: "You've been invited to a claudemesh mesh.",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short invite URL: /i/{code}
|
||||||
|
*
|
||||||
|
* Resolves the short code to the canonical long token server-side and
|
||||||
|
* redirects to `/join/[token]`. Keeps the rest of the join UX in a single
|
||||||
|
* place and leaves the broker protocol untouched.
|
||||||
|
*
|
||||||
|
* This is a URL shortener, NOT a security boundary — the long token still
|
||||||
|
* carries the mesh root_key. See the v2 invite protocol spec:
|
||||||
|
* .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md
|
||||||
|
*/
|
||||||
|
export default async function ShortInvitePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; code: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale, code } = await params;
|
||||||
|
|
||||||
|
// Hit the public resolver. Returns {found, token} or 404.
|
||||||
|
const res = await api.public["invite-code"][":code"]
|
||||||
|
.$get({ param: { code } })
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await res.json()) as
|
||||||
|
| { found: true; token: string }
|
||||||
|
| { found: false };
|
||||||
|
|
||||||
|
if (!body.found) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// next/navigation `redirect` throws — no need to return anything after.
|
||||||
|
redirect(`/${locale}/join/${body.token}`);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { handle } from "@turbostarter/api/utils";
|
|||||||
import { api } from "~/lib/api/server";
|
import { api } from "~/lib/api/server";
|
||||||
import { getMetadata } from "~/lib/metadata";
|
import { getMetadata } from "~/lib/metadata";
|
||||||
import { InstallToggle } from "~/modules/join/install-toggle";
|
import { InstallToggle } from "~/modules/join/install-toggle";
|
||||||
|
import { InviteCard } from "~/modules/join/invite-card";
|
||||||
|
|
||||||
export const generateMetadata = getMetadata({
|
export const generateMetadata = getMetadata({
|
||||||
title: "Join a mesh",
|
title: "Join a mesh",
|
||||||
@@ -112,42 +113,29 @@ export default async function JoinPage({
|
|||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="mx-auto w-full max-w-2xl px-6 py-16 md:px-12 md:py-24">
|
<div className="mx-auto w-full max-w-2xl px-6 py-12 md:px-12 md:py-20">
|
||||||
{invite.valid ? (
|
{invite.valid ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<InviteCard
|
||||||
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
meshName={invite.meshName}
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
inviterName={invite.inviterName}
|
||||||
>
|
role={invite.role}
|
||||||
— invitation
|
memberCount={invite.memberCount}
|
||||||
</div>
|
expiresAt={new Date(invite.expiresAt)}
|
||||||
<h1
|
/>
|
||||||
className="text-[clamp(2rem,4vw,2.75rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
|
||||||
>
|
|
||||||
You're invited to{" "}
|
|
||||||
<span className="italic text-[var(--cm-clay)]">
|
|
||||||
{invite.meshName}
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
className="mt-4 text-lg leading-[1.6] text-[var(--cm-fg-secondary)]"
|
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
|
||||||
>
|
|
||||||
{invite.inviterName
|
|
||||||
? `${invite.inviterName} added you as a ${invite.role}.`
|
|
||||||
: `You've been added as a ${invite.role}.`}{" "}
|
|
||||||
{invite.memberCount} other{" "}
|
|
||||||
{invite.memberCount === 1 ? "peer is" : "peers are"} already on
|
|
||||||
the mesh.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-12">
|
<div id="install" className="mt-14 scroll-mt-24">
|
||||||
|
<div
|
||||||
|
className="mb-4 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
— to accept, run this in your terminal
|
||||||
|
</div>
|
||||||
<InstallToggle token={invite.token} />
|
<InstallToggle token={invite.token} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="mt-14 rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-5 text-[13px] leading-[1.65] text-[var(--cm-fg-tertiary)]"
|
className="mt-12 rounded-[var(--cm-radius-md)] border border-dashed border-[var(--cm-border)] p-5 text-[13px] leading-[1.65] text-[var(--cm-fg-tertiary)]"
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
By joining, you'll be known as a peer with an ed25519
|
By joining, you'll be known as a peer with an ed25519
|
||||||
@@ -163,24 +151,27 @@ export default async function JoinPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
className="mt-8 text-xs text-[var(--cm-fg-tertiary)]"
|
className="mt-6 text-xs text-[var(--cm-fg-tertiary)]"
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
>
|
>
|
||||||
expires {new Date(invite.expiresAt).toLocaleDateString()} ·{" "}
|
|
||||||
{invite.maxUses - invite.usedCount} of {invite.maxUses} uses
|
{invite.maxUses - invite.usedCount} of {invite.maxUses} uses
|
||||||
remaining
|
remaining
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<section
|
||||||
|
aria-labelledby="invite-error-heading"
|
||||||
|
className="rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 p-7 md:p-9"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[#c46686]"
|
className="text-[11px] uppercase tracking-[0.22em] text-[#c46686]"
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
>
|
>
|
||||||
— invitation unavailable
|
— invitation unavailable
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="text-[clamp(1.75rem,3.5vw,2.25rem)] font-medium leading-[1.15] text-[var(--cm-fg)]"
|
id="invite-error-heading"
|
||||||
|
className="mt-4 text-[clamp(1.75rem,3.5vw,2.25rem)] font-medium leading-[1.15] text-[var(--cm-fg)]"
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
{ERROR_COPY[invite.reason].title}
|
{ERROR_COPY[invite.reason].title}
|
||||||
@@ -210,7 +201,7 @@ export default async function JoinPage({
|
|||||||
← claudemesh.com
|
← claudemesh.com
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
130
apps/web/src/app/api/cli-sync-token/route.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
import { auth } from "@turbostarter/auth/server";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JWT signing (HS256 via Web Crypto — no external deps)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function base64UrlEncode(input: string | ArrayBuffer): string {
|
||||||
|
const str =
|
||||||
|
typeof input === "string"
|
||||||
|
? btoa(input)
|
||||||
|
: btoa(String.fromCharCode(...new Uint8Array(input)));
|
||||||
|
return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signJwt(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
secret: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const header = { alg: "HS256", typ: "JWT" };
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
||||||
|
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign(
|
||||||
|
"HMAC",
|
||||||
|
key,
|
||||||
|
encoder.encode(`${headerB64}.${payloadB64}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${headerB64}.${payloadB64}.${base64UrlEncode(signature)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Route handler — POST /api/cli-sync-token
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SyncTokenBody {
|
||||||
|
meshes: Array<{ id: string; slug: string; role: string }>;
|
||||||
|
action: "sync" | "create";
|
||||||
|
newMesh?: { name: string; slug: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
// 1. Check auth
|
||||||
|
const reqHeaders = new Headers(await headers());
|
||||||
|
reqHeaders.set("x-client-platform", "web-server");
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: reqHeaders });
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse body
|
||||||
|
let body: SyncTokenBody;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as SyncTokenBody;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { meshes, action, newMesh } = body;
|
||||||
|
|
||||||
|
if (!Array.isArray(meshes)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "meshes must be an array" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action !== "sync" && action !== "create") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'action must be "sync" or "create"' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "create" && (!newMesh?.name || !newMesh?.slug)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "newMesh.name and newMesh.slug are required for create action" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Validate meshes belong to user — fetch user's meshes via internal API
|
||||||
|
// For now we trust the dashboard-authenticated user's selection since
|
||||||
|
// the broker will independently verify membership when the CLI connects.
|
||||||
|
// A full server-side ownership check can be added later.
|
||||||
|
|
||||||
|
// 4. Get secret
|
||||||
|
const secret = process.env.CLI_SYNC_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "CLI_SYNC_SECRET not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build and sign JWT
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload = {
|
||||||
|
sub: session.user.id,
|
||||||
|
email: session.user.email,
|
||||||
|
meshes: meshes.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
slug: m.slug,
|
||||||
|
role: m.role,
|
||||||
|
})),
|
||||||
|
action,
|
||||||
|
...(action === "create" && newMesh ? { newMesh } : {}),
|
||||||
|
jti: crypto.randomUUID(),
|
||||||
|
iat: now,
|
||||||
|
exp: now + 15 * 60, // 15 minutes
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = await signJwt(payload, secret);
|
||||||
|
|
||||||
|
return NextResponse.json({ token });
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 497 B After Width: | Height: | Size: 211 B |
7
apps/web/src/app/icon.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="4" r="2" fill="#d97757"/>
|
||||||
|
<circle cx="4" cy="12" r="2" fill="#d97757"/>
|
||||||
|
<circle cx="20" cy="12" r="2" fill="#d97757"/>
|
||||||
|
<circle cx="12" cy="20" r="2" fill="#d97757"/>
|
||||||
|
<path d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20" stroke="#d97757" stroke-width="1.2" opacity="0.45"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 429 B |
@@ -1,17 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* GET /install — serves a shell installer for claudemesh-cli.
|
* GET /install — shell installer for claudemesh-cli.
|
||||||
*
|
*
|
||||||
* Intended to be piped into bash:
|
* curl -fsSL https://claudemesh.com/install | bash
|
||||||
* curl -fsSL https://claudemesh.com/install | bash
|
|
||||||
*
|
*
|
||||||
* The script is kept short + auditable. It does not try to install
|
* Tracks each fetch server-side (PostHog server event + console log).
|
||||||
* Node for the user — it checks for a compatible Node + npm and
|
* curl doesn't execute JS, so client-side analytics can't track this.
|
||||||
* directs them to install Node themselves if missing. Running `bash`
|
|
||||||
* against a domain you do not fully trust is always a risk; publishing
|
|
||||||
* the script this way (rather than obfuscating it behind a binary
|
|
||||||
* blob) lets security-conscious users inspect before executing.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
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
|
const SCRIPT = `#!/usr/bin/env bash
|
||||||
# claudemesh-cli installer
|
# claudemesh-cli installer
|
||||||
# Source: https://claudemesh.com/install
|
# Source: https://claudemesh.com/install
|
||||||
@@ -88,7 +89,41 @@ say "Need an invite? Visit \${BOLD}https://claudemesh.com\${RESET}"
|
|||||||
say ""
|
say ""
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function GET(): Response {
|
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, {
|
return new Response(SCRIPT, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const pathsConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
marketing: {
|
marketing: {
|
||||||
|
gettingStarted: "/getting-started",
|
||||||
pricing: "/pricing",
|
pricing: "/pricing",
|
||||||
contact: "/contact",
|
contact: "/contact",
|
||||||
blog: {
|
blog: {
|
||||||
@@ -85,6 +86,7 @@ const pathsConfig = {
|
|||||||
updatePassword: `${AUTH_PREFIX}/password/update`,
|
updatePassword: `${AUTH_PREFIX}/password/update`,
|
||||||
error: `${AUTH_PREFIX}/error`,
|
error: `${AUTH_PREFIX}/error`,
|
||||||
},
|
},
|
||||||
|
cliAuth: "/cli-auth",
|
||||||
dashboard: {
|
dashboard: {
|
||||||
user: {
|
user: {
|
||||||
index: DASHBOARD_PREFIX,
|
index: DASHBOARD_PREFIX,
|
||||||
|
|||||||
45
apps/web/src/modules/join/consent-summary.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const BULLETS = [
|
||||||
|
"Send and receive end-to-end encrypted messages with every peer on the mesh",
|
||||||
|
"Read the shared audit log of mesh events",
|
||||||
|
"Generate a local ed25519 keypair — your secret key never leaves your machine",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ConsentSummary() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]">
|
||||||
|
Joining this mesh will let you
|
||||||
|
</div>
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{BULLETS.map((text) => (
|
||||||
|
<li
|
||||||
|
key={text}
|
||||||
|
className="flex items-start gap-2.5 text-[13.5px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-[3px] shrink-0 text-[var(--cm-clay)]"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M5 12l4 4 10-10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,9 @@ interface Props {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LAUNCH_CMD = (token: string) => `claudemesh launch --name YourName --join ${token}`;
|
||||||
const JOIN_CMD = (token: string) => `claudemesh join ${token}`;
|
const JOIN_CMD = (token: string) => `claudemesh join ${token}`;
|
||||||
const INSTALL_CMD = "npx claudemesh@latest init";
|
const INSTALL_CMD = "npm i -g claudemesh-cli";
|
||||||
|
|
||||||
export const InstallToggle = ({ token }: Props) => {
|
export const InstallToggle = ({ token }: Props) => {
|
||||||
const [hasCli, setHasCli] = useState<"unknown" | "yes" | "no">("unknown");
|
const [hasCli, setHasCli] = useState<"unknown" | "yes" | "no">("unknown");
|
||||||
@@ -60,7 +61,7 @@ export const InstallToggle = ({ token }: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasCli === "yes") {
|
if (hasCli === "yes") {
|
||||||
const cmd = JOIN_CMD(token);
|
const cmd = LAUNCH_CMD(token);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||||
@@ -68,7 +69,7 @@ export const InstallToggle = ({ token }: Props) => {
|
|||||||
className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
>
|
>
|
||||||
run this in your terminal
|
join + launch in one step
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code
|
<code
|
||||||
@@ -96,7 +97,7 @@ export const InstallToggle = ({ token }: Props) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const joinCmd = JOIN_CMD(token);
|
const launchCmd = LAUNCH_CMD(token);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ol className="space-y-3">
|
<ol className="space-y-3">
|
||||||
@@ -106,7 +107,7 @@ export const InstallToggle = ({ token }: Props) => {
|
|||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
>
|
>
|
||||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span>
|
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span>
|
||||||
install + init
|
install the CLI
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code
|
<code
|
||||||
@@ -127,8 +128,7 @@ export const InstallToggle = ({ token }: Props) => {
|
|||||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
Generates your ed25519 keypair locally and wires claudemesh into
|
Requires Node.js 20+.
|
||||||
your Claude Code config. You own the keys.
|
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
|
||||||
@@ -137,38 +137,28 @@ export const InstallToggle = ({ token }: Props) => {
|
|||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
>
|
>
|
||||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">2</span>
|
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">2</span>
|
||||||
join the mesh
|
join + launch
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code
|
<code
|
||||||
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
>
|
>
|
||||||
{joinCmd}
|
{launchCmd}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
onClick={() => copy(joinCmd, "join")}
|
onClick={() => copy(launchCmd, "join")}
|
||||||
className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-3 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
|
className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-3 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
|
||||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
>
|
>
|
||||||
{copiedKey === "join" ? "Copied ✓" : "Copy"}
|
{copiedKey === "join" ? "Copied ✓" : "Copy"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
|
||||||
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5">
|
|
||||||
<div
|
|
||||||
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-fg-tertiary)]"
|
|
||||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
|
||||||
>
|
|
||||||
<span className="rounded-full bg-[var(--cm-border)] px-1.5">3</span>
|
|
||||||
verify
|
|
||||||
</div>
|
|
||||||
<p
|
<p
|
||||||
className="text-sm text-[var(--cm-fg-secondary)]"
|
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
Your Claude Code session will announce itself to the mesh. Other
|
Joins the mesh and launches Claude Code in one step.
|
||||||
peers see you appear as a green dot in their dashboard.
|
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
119
apps/web/src/modules/join/invite-card.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { ConsentSummary } from "./consent-summary";
|
||||||
|
import { InviterLine } from "./inviter-line";
|
||||||
|
import { RoleBadge, roleLabel } from "./role-badge";
|
||||||
|
|
||||||
|
interface InviteCardProps {
|
||||||
|
meshName: string;
|
||||||
|
inviterName: string | null;
|
||||||
|
role: "admin" | "member";
|
||||||
|
memberCount: number;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteCard({
|
||||||
|
meshName,
|
||||||
|
inviterName,
|
||||||
|
role,
|
||||||
|
memberCount,
|
||||||
|
expiresAt,
|
||||||
|
}: InviteCardProps) {
|
||||||
|
const peerWord = memberCount === 1 ? "peer" : "peers";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="invite-heading"
|
||||||
|
className="relative overflow-hidden rounded-[var(--cm-radius-lg)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]/60 p-7 md:p-9"
|
||||||
|
>
|
||||||
|
{/* Eyebrow */}
|
||||||
|
<div
|
||||||
|
className="text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
— invitation
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<h1
|
||||||
|
id="invite-heading"
|
||||||
|
className="mt-4 text-[clamp(1.9rem,3.6vw,2.65rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
You've been invited to join{" "}
|
||||||
|
<span className="italic text-[var(--cm-clay)]">{meshName}</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Inviter + stats row */}
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<InviterLine inviterName={inviterName} />
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 text-[12.5px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="inline-block h-1.5 w-1.5 rounded-full bg-[var(--cm-cactus)]"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{memberCount} {peerWord} · private mesh
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role badge */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<RoleBadge role={role} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consent bullets */}
|
||||||
|
<div className="mt-5">
|
||||||
|
<ConsentSummary />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary action block */}
|
||||||
|
<div className="mt-8 flex flex-col gap-3">
|
||||||
|
<a
|
||||||
|
href="#install"
|
||||||
|
className="inline-flex w-full items-center justify-center gap-2 rounded-[var(--cm-radius-md)] bg-[var(--cm-clay)] px-6 py-4 text-[15px] font-medium text-[var(--cm-gray-050)] transition-colors hover:bg-[var(--cm-clay-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--cm-clay)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--cm-bg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
aria-label={`Join ${meshName} as ${roleLabel(role)}`}
|
||||||
|
>
|
||||||
|
Join {meshName} as {roleLabel(role)}
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M5 12h14M13 5l7 7-7 7"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<p
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 text-[11.5px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
valid until{" "}
|
||||||
|
{expiresAt.toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="/auth/logout"
|
||||||
|
className="underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Not you? Sign out
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/web/src/modules/join/inviter-line.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
interface InviterLineProps {
|
||||||
|
inviterName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviterLine({ inviterName }: InviterLineProps) {
|
||||||
|
const initial = (inviterName ?? "?").trim().charAt(0).toUpperCase() || "?";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] text-[13px] font-medium text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col leading-tight">
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.16em] text-[var(--cm-fg-tertiary)]">
|
||||||
|
Invited by
|
||||||
|
</span>
|
||||||
|
<span className="text-[14.5px] font-medium text-[var(--cm-fg)]">
|
||||||
|
{inviterName ?? "the mesh owner"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
apps/web/src/modules/join/role-badge.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
type Role = "admin" | "member";
|
||||||
|
|
||||||
|
const ROLE_CONFIG: Record<
|
||||||
|
Role,
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
accent: string;
|
||||||
|
dot: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
admin: {
|
||||||
|
label: "Admin",
|
||||||
|
description:
|
||||||
|
"Full control: invite and remove peers, manage settings, send and receive messages.",
|
||||||
|
// subtle warning treatment — fig (pinkish) accent, not alarming
|
||||||
|
accent: "#c46686",
|
||||||
|
dot: "#c46686",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 2l3 6 6 1-4.5 4.5L18 20l-6-3-6 3 1.5-6.5L3 9l6-1 3-6z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
label: "Member",
|
||||||
|
description:
|
||||||
|
"Send and receive messages, read the shared audit log, participate in mesh traffic.",
|
||||||
|
accent: "var(--cm-clay)",
|
||||||
|
dot: "var(--cm-clay)",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path
|
||||||
|
d="M4 20c0-4 4-6 8-6s8 2 8 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RoleBadgeProps {
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleBadge({ role }: RoleBadgeProps) {
|
||||||
|
const cfg = ROLE_CONFIG[role];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-3 rounded-[var(--cm-radius-md)] border p-4"
|
||||||
|
style={{
|
||||||
|
borderColor: cfg.accent,
|
||||||
|
backgroundColor:
|
||||||
|
"color-mix(in srgb, var(--cm-bg-elevated) 70%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
|
||||||
|
style={{
|
||||||
|
color: cfg.accent,
|
||||||
|
backgroundColor: "color-mix(in srgb, var(--cm-bg) 60%, transparent)",
|
||||||
|
border: `1px solid ${cfg.accent}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cfg.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 text-[13px] font-medium"
|
||||||
|
style={{ color: cfg.accent, fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
<span className="uppercase tracking-[0.14em]">
|
||||||
|
You'll join as {cfg.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="mt-1 text-[13.5px] leading-[1.55] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
{cfg.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roleLabel(role: Role) {
|
||||||
|
return ROLE_CONFIG[role].label;
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ export const CallToAction = () => {
|
|||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
Anthropic built Claude Code per developer. The next unlock is
|
Anthropic built Claude Code per developer. The next unlock is
|
||||||
between developers. Build the layer with us.
|
between developers. 43 tools, five databases, E2E encryption —
|
||||||
|
open-source and ready now.
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
<Reveal delay={3}>
|
<Reveal delay={3}>
|
||||||
|
|||||||
@@ -133,10 +133,10 @@ export const DemoDashboard = () => {
|
|||||||
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
className="mx-auto mt-6 max-w-2xl text-center text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
>
|
>
|
||||||
Real conversation between peers. No one typed these — they're
|
Real conversation between peers. No one typed these — AI
|
||||||
AI sessions referencing each other's work across repos,
|
sessions messaging, sharing files, and querying shared state
|
||||||
machines, and surfaces. Hover any message to see what the broker
|
across repos and machines. Hover any message to see what the
|
||||||
sees.
|
broker sees: ciphertext only.
|
||||||
</p>
|
</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { fccTheme } from "./theme";
|
||||||
|
|
||||||
|
export type ClawdPose = "default" | "arms-up" | "look-left" | "look-right";
|
||||||
|
|
||||||
|
const APPLE_EYES: Record<ClawdPose, string> = {
|
||||||
|
default: " \u2597 \u2596 ",
|
||||||
|
"look-left": " \u2598 \u2598 ",
|
||||||
|
"look-right": " \u259d \u259d ",
|
||||||
|
"arms-up": " \u2597 \u2596 ",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Clawd({ pose = "default" }: { pose?: ClawdPose }) {
|
||||||
|
const monoStyle: React.CSSProperties = {
|
||||||
|
fontFamily: fccTheme.fontMono,
|
||||||
|
color: fccTheme.clawdBody,
|
||||||
|
lineHeight: 1,
|
||||||
|
letterSpacing: 0,
|
||||||
|
fontVariantLigatures: "none",
|
||||||
|
fontFeatureSettings: '"liga" 0, "calt" 0',
|
||||||
|
whiteSpace: "pre",
|
||||||
|
};
|
||||||
|
|
||||||
|
const eyesStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: fccTheme.clawdBody,
|
||||||
|
color: fccTheme.clawdBackground,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyRowStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: fccTheme.clawdBody,
|
||||||
|
color: fccTheme.clawdBody,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
...monoStyle,
|
||||||
|
}}
|
||||||
|
aria-label="Claude Code mascot"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span>{"\u2597"}</span>
|
||||||
|
<span style={eyesStyle}>{APPLE_EYES[pose]}</span>
|
||||||
|
<span>{"\u2596"}</span>
|
||||||
|
</div>
|
||||||
|
<div style={bodyRowStyle}>{" "}</div>
|
||||||
|
<div>{"\u2598\u2598 \u259d\u259d"}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Session, type SessionEvent, type SessionStep } from "./session";
|
||||||
|
import { fccTheme } from "./theme";
|
||||||
|
|
||||||
|
type Point = { x: number; y: number };
|
||||||
|
|
||||||
|
type SessionConfig = {
|
||||||
|
id: string;
|
||||||
|
/** Display name used by mesh-send `to` fields to route particles */
|
||||||
|
displayName: string;
|
||||||
|
title: string;
|
||||||
|
cwd: string;
|
||||||
|
script: SessionStep[];
|
||||||
|
startDelayMs?: number;
|
||||||
|
position: {
|
||||||
|
xPct: number;
|
||||||
|
yPct: number;
|
||||||
|
scale?: number;
|
||||||
|
rotate?: number;
|
||||||
|
opacity?: number;
|
||||||
|
zIndex?: number;
|
||||||
|
/** 0..1 — 1 is full color, 0 is grayscale */
|
||||||
|
saturate?: number;
|
||||||
|
/** pixels — adds depth-of-field bokeh blur to background peers */
|
||||||
|
blurPx?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ArcConfig = {
|
||||||
|
fromId: string;
|
||||||
|
toId: string;
|
||||||
|
triggerStepKind: "mesh-send";
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlyingParticle = {
|
||||||
|
id: number;
|
||||||
|
fromId: string;
|
||||||
|
toId: string;
|
||||||
|
bornAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MeshHeroProps = {
|
||||||
|
sessions: SessionConfig[];
|
||||||
|
arcs?: ArcConfig[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PARTICLE_LIFE_MS = 1100;
|
||||||
|
const TRAIL_SEGMENTS = 18;
|
||||||
|
const TRAIL_SPAN = 0.34;
|
||||||
|
const ICON_W = 38;
|
||||||
|
const ICON_H = 26;
|
||||||
|
|
||||||
|
export function MeshHero({
|
||||||
|
sessions,
|
||||||
|
arcs = [],
|
||||||
|
width = 1440,
|
||||||
|
height = 720,
|
||||||
|
}: MeshHeroProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const anchorsRef = useRef<Record<string, Point>>({});
|
||||||
|
const [particles, setParticles] = useState<FlyingParticle[]>([]);
|
||||||
|
const particleIdRef = useRef(0);
|
||||||
|
const [, forceTick] = useState(0);
|
||||||
|
const [reactions, setReactions] = useState<
|
||||||
|
Record<string, { nonce: number; kind: "receive" | "send" | "arrive" }>
|
||||||
|
>({});
|
||||||
|
const reactionTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const arrivedParticlesRef = useRef<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const bumpReaction = (
|
||||||
|
sessionId: string,
|
||||||
|
kind: "receive" | "send" | "arrive",
|
||||||
|
) => {
|
||||||
|
setReactions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[sessionId]: { nonce: (prev[sessionId]?.nonce ?? 0) + 1, kind },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let raf = 0;
|
||||||
|
const loop = () => {
|
||||||
|
forceTick((n) => (n + 1) % 1_000_000);
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (particles.length === 0) return;
|
||||||
|
const now = performance.now();
|
||||||
|
const next = particles.filter((p) => now - p.bornAt < PARTICLE_LIFE_MS);
|
||||||
|
if (next.length !== particles.length) setParticles(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEvent = (e: SessionEvent) => {
|
||||||
|
if (e.kind !== "mesh-send") return;
|
||||||
|
// Resolve destination by matching the mesh-send `to` field against
|
||||||
|
// session displayNames. Fall back to the configured arcs if provided.
|
||||||
|
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
|
const target = normalize(e.to);
|
||||||
|
const toSession = sessions.find(
|
||||||
|
(s) => normalize(s.displayName) === target,
|
||||||
|
);
|
||||||
|
let fromId = e.sessionId;
|
||||||
|
let toId = toSession?.id;
|
||||||
|
if (!toId) {
|
||||||
|
const arc = arcs.find((a) => a.fromId === e.sessionId);
|
||||||
|
if (!arc) return;
|
||||||
|
toId = arc.toId;
|
||||||
|
}
|
||||||
|
if (fromId === toId) return;
|
||||||
|
bumpReaction(fromId, "send");
|
||||||
|
const id = particleIdRef.current++;
|
||||||
|
setParticles((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
fromId,
|
||||||
|
toId,
|
||||||
|
bornAt: performance.now(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => bumpReaction(toId!, "arrive"),
|
||||||
|
PARTICLE_LIFE_MS - 60,
|
||||||
|
);
|
||||||
|
reactionTimersRef.current[`${id}`] = timer;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
Object.values(reactionTimersRef.current).forEach(clearTimeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAnchor = (id: string) => (el: HTMLDivElement | null) => {
|
||||||
|
if (!el || !containerRef.current) return;
|
||||||
|
const container = containerRef.current.getBoundingClientRect();
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
anchorsRef.current[id] = {
|
||||||
|
x: rect.left - container.left + rect.width / 2,
|
||||||
|
y: rect.top - container.top + rect.height / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const arcForParticle = (fromId: string, toId: string) => {
|
||||||
|
const from = anchorsRef.current[fromId];
|
||||||
|
const to = anchorsRef.current[toId];
|
||||||
|
if (!from || !to) return null;
|
||||||
|
const midX = (from.x + to.x) / 2;
|
||||||
|
const midY = (from.y + to.y) / 2 - Math.abs(to.x - from.x) * 0.08 - 30;
|
||||||
|
return { from, to, midX, midY };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
background:
|
||||||
|
"radial-gradient(ellipse at 50% 40%, rgba(215,119,87,0.07) 0%, rgba(0,0,0,0) 55%), #0a0a0a",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sessions.map((s) => {
|
||||||
|
const left = (s.position.xPct / 100) * width;
|
||||||
|
const top = (s.position.yPct / 100) * height;
|
||||||
|
const scale = s.position.scale ?? 1;
|
||||||
|
const rotate = s.position.rotate ?? 0;
|
||||||
|
const opacity = s.position.opacity ?? 1;
|
||||||
|
const zIndex = s.position.zIndex ?? 1;
|
||||||
|
const saturate = s.position.saturate ?? 1;
|
||||||
|
const blurPx = s.position.blurPx ?? 0;
|
||||||
|
const filters = [
|
||||||
|
"drop-shadow(0 30px 50px rgba(0,0,0,0.6))",
|
||||||
|
saturate !== 1 ? `saturate(${saturate})` : "",
|
||||||
|
blurPx > 0 ? `blur(${blurPx}px)` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
ref={setAnchor(s.id)}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
transform: `translate(-50%, -50%) scale(${scale}) rotate(${rotate}deg)`,
|
||||||
|
transformOrigin: "center center",
|
||||||
|
filter: filters,
|
||||||
|
opacity,
|
||||||
|
zIndex,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Session
|
||||||
|
sessionId={s.id}
|
||||||
|
script={s.script}
|
||||||
|
title={s.title}
|
||||||
|
cwd={s.cwd}
|
||||||
|
width={720}
|
||||||
|
height={480}
|
||||||
|
startDelayMs={s.startDelayMs}
|
||||||
|
onEvent={handleEvent}
|
||||||
|
reactionNonce={reactions[s.id]?.nonce ?? 0}
|
||||||
|
reactionKind={reactions[s.id]?.kind ?? "receive"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
pointerEvents: "none",
|
||||||
|
mixBlendMode: "screen",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<filter id="meshGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<symbol id="meshMsgIcon" viewBox="0 0 38 26">
|
||||||
|
<rect
|
||||||
|
x="1.5"
|
||||||
|
y="1.5"
|
||||||
|
width="35"
|
||||||
|
height="23"
|
||||||
|
rx="3"
|
||||||
|
ry="3"
|
||||||
|
fill={fccTheme.clawdBody}
|
||||||
|
stroke={fccTheme.claudeShimmer}
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 4 5 L 19 15 L 34 5"
|
||||||
|
stroke={fccTheme.clawdBackground}
|
||||||
|
strokeWidth="2.2"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</symbol>
|
||||||
|
</defs>
|
||||||
|
{particles.map((p) => {
|
||||||
|
const arc = arcForParticle(p.fromId, p.toId);
|
||||||
|
if (!arc) return null;
|
||||||
|
const age = (performance.now() - p.bornAt) / PARTICLE_LIFE_MS;
|
||||||
|
if (age > 1) return null;
|
||||||
|
const head = Math.min(1, Math.max(0, age));
|
||||||
|
|
||||||
|
const pointAt = (t: number) => {
|
||||||
|
const tt = Math.max(0, Math.min(1, t));
|
||||||
|
const inv = 1 - tt;
|
||||||
|
return {
|
||||||
|
x:
|
||||||
|
inv * inv * arc.from.x +
|
||||||
|
2 * inv * tt * arc.midX +
|
||||||
|
tt * tt * arc.to.x,
|
||||||
|
y:
|
||||||
|
inv * inv * arc.from.y +
|
||||||
|
2 * inv * tt * arc.midY +
|
||||||
|
tt * tt * arc.to.y,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const trailNodes = Array.from({ length: TRAIL_SEGMENTS }, (_, i) => {
|
||||||
|
const frac = i / TRAIL_SEGMENTS;
|
||||||
|
const t = head - frac * TRAIL_SPAN;
|
||||||
|
if (t < 0) return null;
|
||||||
|
const pt = pointAt(t);
|
||||||
|
const falloff = Math.pow(1 - frac, 2.2);
|
||||||
|
return {
|
||||||
|
x: pt.x,
|
||||||
|
y: pt.y,
|
||||||
|
r: 2 + falloff * 5,
|
||||||
|
opacity: 0.75 * falloff,
|
||||||
|
};
|
||||||
|
}).filter((n): n is NonNullable<typeof n> => n !== null);
|
||||||
|
|
||||||
|
const headPt = pointAt(head);
|
||||||
|
const iconOpacity = Math.min(1, Math.sin(head * Math.PI) * 1.2 + 0.15);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={p.id} filter="url(#meshGlow)">
|
||||||
|
{trailNodes.map((n, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={n.x}
|
||||||
|
cy={n.y}
|
||||||
|
r={n.r}
|
||||||
|
fill={fccTheme.clawdBody}
|
||||||
|
opacity={n.opacity}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<use
|
||||||
|
href="#meshMsgIcon"
|
||||||
|
x={headPt.x - ICON_W / 2}
|
||||||
|
y={headPt.y - ICON_H / 2}
|
||||||
|
width={ICON_W}
|
||||||
|
height={ICON_H}
|
||||||
|
opacity={iconOpacity}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||