Compare commits
169 Commits
v0.5.8
...
1a42c2ef09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a42c2ef09 | ||
|
|
43b70013c5 | ||
|
|
b8d8b5469b | ||
|
|
ab7fb6bd31 | ||
|
|
b2999878c4 | ||
|
|
a890a1d92e | ||
|
|
80a6b8b50f | ||
|
|
465ff9a10e | ||
|
|
0f46c787a7 | ||
|
|
a365fef170 | ||
|
|
ca441dae45 | ||
|
|
ac709dbe92 | ||
|
|
d0fbc64e7e | ||
|
|
f1d35b10da | ||
|
|
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 |
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="en"
|
||||
|
||||
# Shared secret for CLI sync JWT signing (HS256) — must match between broker and web app
|
||||
CLI_SYNC_SECRET="<your-cli-sync-secret>"
|
||||
|
||||
5
.gitignore
vendored
@@ -45,6 +45,9 @@ yarn-error.log*
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# secrets
|
||||
.cli_sync_secret
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -72,3 +75,5 @@ dist/
|
||||
apps/web/payload.db
|
||||
apps/web/public/media/*
|
||||
!apps/web/public/media/.gitkeep
|
||||
.env.local
|
||||
apps/cli-v2/
|
||||
|
||||
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`
|
||||
66
SPEC.md
@@ -931,6 +931,72 @@ The session keypair generates once on first connect and survives reconnects. Mes
|
||||
|
||||
---
|
||||
|
||||
## 14b. Invites (v2 protocol)
|
||||
|
||||
### Why v2
|
||||
|
||||
The v1 invite token embeds `mesh_root_key` (32-byte shared secret) inside a base64url URL. Any path that caches URLs — link previews, browser history, sync, screenshots, analytics pixels, error logs — is a permanent compromise of the mesh key. Revoking the invite does not rotate the key. The URL *is* the secret.
|
||||
|
||||
v2 removes all secret material from the URL. The invite becomes a short opaque code that grants the *right* to receive the key, not the key itself. The server only releases the key after the recipient proves they can receive it, sealed to a public key the recipient controls.
|
||||
|
||||
### Canonical bytes
|
||||
|
||||
The mesh owner ed25519 secret key signs:
|
||||
|
||||
```
|
||||
v=2|mesh_id|invite_id|expires_at_unix|role|owner_pubkey_hex
|
||||
```
|
||||
|
||||
No `root_key`, no `broker_url`. The signed capability lives in the broker DB. The user-visible URL is `claudemesh.com/i/{code}` — base62, 8 chars.
|
||||
|
||||
### Claim flow
|
||||
|
||||
```
|
||||
1. Admin mints invite
|
||||
broker stores {id, mesh_id, code, role, max_uses, expires_at,
|
||||
signed_capability, version=2}
|
||||
returns claudemesh.com/i/{code}
|
||||
|
||||
2. Recipient lands on /i/{code}
|
||||
web resolves the code, shows consent: mesh name, inviter, role,
|
||||
expiry, member count. No secrets in the response.
|
||||
|
||||
3. Recipient generates a fresh x25519 keypair
|
||||
(separate from its ed25519 identity — distinct curve, distinct use)
|
||||
|
||||
4. Recipient POSTs its x25519 public key
|
||||
POST /api/public/invites/{code}/claim
|
||||
body: { recipient_x25519_pubkey }
|
||||
|
||||
5. Broker validates and seals
|
||||
verifies signed_capability against mesh.owner_pubkey
|
||||
checks expires_at, max_uses vs used_count, revoked_at
|
||||
creates mesh.member row, increments used_count
|
||||
sealed_root_key = crypto_box_seal(root_key, recipient_x25519_pubkey)
|
||||
returns { sealed_root_key, mesh_id, member_id, owner_pubkey,
|
||||
canonical_v2 }
|
||||
|
||||
6. Recipient unseals with its x25519 secret
|
||||
root_key = crypto_box_seal_open(sealed_root_key, recipient_x25519_sk)
|
||||
joins normal mesh traffic
|
||||
```
|
||||
|
||||
The server never sees the recipient's private key. `crypto_box_seal` is anonymous — no sender identity, no interaction beyond the single HTTP round trip.
|
||||
|
||||
### v1 deprecation timeline
|
||||
|
||||
- v0.1.x: the broker, CLI, and web accept both v1 (long token with embedded key) and v2 (short code + sealed key delivery). New invites default to v2.
|
||||
- v0.2.0: v1 endpoints return `410 Gone`. Existing members already in a mesh are unaffected — the key rotation story is orthogonal to invite format.
|
||||
|
||||
### DB additions
|
||||
|
||||
- `mesh.invite.version` int default 1
|
||||
- `mesh.invite.capability_v2` text nullable — the canonical signed bytes
|
||||
- `mesh.invite.claimed_by_pubkey` text nullable — the recipient x25519 pubkey used at claim time (audit trail, single-use enforcement)
|
||||
- `mesh.pending_invite` new table for email invites: `{id, meshId, email, code, sentAt, acceptedAt, revokedAt, createdBy, createdAt}`. Email delivery goes through Postmark (already wired via turbostarter).
|
||||
|
||||
---
|
||||
|
||||
## 14. Production hardening (implemented)
|
||||
|
||||
| Feature | Description |
|
||||
|
||||
@@ -37,7 +37,7 @@ COPY --from=deps --chown=bun:bun /deploy /app
|
||||
|
||||
EXPOSE 7900
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
|
||||
CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||
|
||||
# Non-root user (oven/bun image ships with 'bun' uid 1000)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@turbostarter/db": "workspace:*",
|
||||
"@turbostarter/shared": "workspace:*",
|
||||
"drizzle-orm": "0.44.7",
|
||||
"grammy": "^1.35.0",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"minio": "8.0.7",
|
||||
"neo4j-driver": "6.0.1",
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,15 @@ import {
|
||||
mesh,
|
||||
meshFile,
|
||||
meshFileAccess,
|
||||
meshFileKey,
|
||||
meshContext,
|
||||
meshMember as memberTable,
|
||||
meshMemory,
|
||||
meshState,
|
||||
meshService,
|
||||
meshSkill,
|
||||
meshStream,
|
||||
meshVaultEntry,
|
||||
meshTask,
|
||||
messageQueue,
|
||||
pendingStatus,
|
||||
@@ -395,6 +399,7 @@ export async function listPeersInMesh(
|
||||
summary: string | null;
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
connectedAt: Date;
|
||||
}>
|
||||
> {
|
||||
@@ -408,6 +413,7 @@ export async function listPeersInMesh(
|
||||
summary: presence.summary,
|
||||
groups: presence.groups,
|
||||
sessionId: presence.sessionId,
|
||||
cwd: presence.cwd,
|
||||
connectedAt: presence.connectedAt,
|
||||
})
|
||||
.from(presence)
|
||||
@@ -427,6 +433,7 @@ export async function listPeersInMesh(
|
||||
summary: r.summary,
|
||||
groups: (r.groups ?? []) as Array<{ name: string; role?: string }>,
|
||||
sessionId: r.sessionId,
|
||||
cwd: r.cwd,
|
||||
connectedAt: r.connectedAt,
|
||||
}));
|
||||
}
|
||||
@@ -700,6 +707,182 @@ export async function forgetMemory(
|
||||
);
|
||||
}
|
||||
|
||||
// --- Skills ---
|
||||
|
||||
/**
|
||||
* Upsert a skill in a mesh. If a skill with the same name exists, it is updated.
|
||||
*/
|
||||
export async function shareSkill(
|
||||
meshId: string,
|
||||
name: string,
|
||||
description: string,
|
||||
instructions: string,
|
||||
tags: string[],
|
||||
memberId?: string,
|
||||
memberName?: string,
|
||||
manifest?: unknown,
|
||||
): Promise<string> {
|
||||
const existing = await db
|
||||
.select({ id: meshSkill.id })
|
||||
.from(meshSkill)
|
||||
.where(and(eq(meshSkill.meshId, meshId), eq(meshSkill.name, name)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(meshSkill)
|
||||
.set({
|
||||
description,
|
||||
instructions,
|
||||
tags,
|
||||
manifest: manifest ?? null,
|
||||
authorMemberId: memberId ?? null,
|
||||
authorName: memberName ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(meshSkill.id, existing[0]!.id));
|
||||
return existing[0]!.id;
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.insert(meshSkill)
|
||||
.values({
|
||||
meshId,
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
tags,
|
||||
manifest: manifest ?? null,
|
||||
authorMemberId: memberId ?? null,
|
||||
authorName: memberName ?? null,
|
||||
})
|
||||
.returning({ id: meshSkill.id });
|
||||
if (!row) throw new Error("failed to insert skill");
|
||||
return row.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a skill by name in a mesh.
|
||||
*/
|
||||
export async function getSkill(
|
||||
meshId: string,
|
||||
name: string,
|
||||
): Promise<{
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
manifest: unknown;
|
||||
createdAt: Date;
|
||||
} | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
name: meshSkill.name,
|
||||
description: meshSkill.description,
|
||||
instructions: meshSkill.instructions,
|
||||
tags: meshSkill.tags,
|
||||
authorName: meshSkill.authorName,
|
||||
manifest: meshSkill.manifest,
|
||||
createdAt: meshSkill.createdAt,
|
||||
})
|
||||
.from(meshSkill)
|
||||
.where(and(eq(meshSkill.meshId, meshId), eq(meshSkill.name, name)))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
const r = rows[0]!;
|
||||
return {
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
instructions: r.instructions,
|
||||
tags: r.tags ?? [],
|
||||
author: r.authorName ?? "unknown",
|
||||
manifest: r.manifest,
|
||||
createdAt: r.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List skills in a mesh, optionally filtering by keyword across name, description, and tags.
|
||||
*/
|
||||
export async function listSkills(
|
||||
meshId: string,
|
||||
query?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
createdAt: Date;
|
||||
}>
|
||||
> {
|
||||
if (query) {
|
||||
const pattern = `%${query}%`;
|
||||
const rows = await db
|
||||
.select({
|
||||
name: meshSkill.name,
|
||||
description: meshSkill.description,
|
||||
tags: meshSkill.tags,
|
||||
authorName: meshSkill.authorName,
|
||||
createdAt: meshSkill.createdAt,
|
||||
})
|
||||
.from(meshSkill)
|
||||
.where(
|
||||
and(
|
||||
eq(meshSkill.meshId, meshId),
|
||||
or(
|
||||
sql`${meshSkill.name} ILIKE ${pattern}`,
|
||||
sql`${meshSkill.description} ILIKE ${pattern}`,
|
||||
sql`EXISTS (SELECT 1 FROM unnest(${meshSkill.tags}) AS t WHERE t ILIKE ${pattern})`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(meshSkill.name));
|
||||
return rows.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
tags: r.tags ?? [],
|
||||
author: r.authorName ?? "unknown",
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
name: meshSkill.name,
|
||||
description: meshSkill.description,
|
||||
tags: meshSkill.tags,
|
||||
authorName: meshSkill.authorName,
|
||||
createdAt: meshSkill.createdAt,
|
||||
})
|
||||
.from(meshSkill)
|
||||
.where(eq(meshSkill.meshId, meshId))
|
||||
.orderBy(asc(meshSkill.name));
|
||||
return rows.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
tags: r.tags ?? [],
|
||||
author: r.authorName ?? "unknown",
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a skill by name in a mesh. Returns true if a row was deleted.
|
||||
*/
|
||||
export async function removeSkill(
|
||||
meshId: string,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
const result = await db
|
||||
.delete(meshSkill)
|
||||
.where(and(eq(meshSkill.meshId, meshId), eq(meshSkill.name, name)))
|
||||
.returning({ id: meshSkill.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
// --- File sharing ---
|
||||
|
||||
/**
|
||||
@@ -717,6 +900,8 @@ export async function uploadFile(args: {
|
||||
uploadedByMember?: string;
|
||||
targetSpec?: string;
|
||||
expiresAt?: Date;
|
||||
encrypted?: boolean;
|
||||
ownerPubkey?: string;
|
||||
}): Promise<string> {
|
||||
const [row] = await db
|
||||
.insert(meshFile)
|
||||
@@ -732,6 +917,8 @@ export async function uploadFile(args: {
|
||||
uploadedByMember: args.uploadedByMember ?? null,
|
||||
targetSpec: args.targetSpec ?? null,
|
||||
expiresAt: args.expiresAt ?? null,
|
||||
encrypted: args.encrypted ?? false,
|
||||
ownerPubkey: args.ownerPubkey ?? null,
|
||||
})
|
||||
.returning({ id: meshFile.id });
|
||||
if (!row) throw new Error("failed to insert file row");
|
||||
@@ -755,6 +942,8 @@ export async function getFile(
|
||||
uploadedByName: string | null;
|
||||
targetSpec: string | null;
|
||||
uploadedAt: Date;
|
||||
encrypted: boolean;
|
||||
ownerPubkey: string | null;
|
||||
} | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
@@ -768,6 +957,8 @@ export async function getFile(
|
||||
uploadedByName: meshFile.uploadedByName,
|
||||
targetSpec: meshFile.targetSpec,
|
||||
uploadedAt: meshFile.uploadedAt,
|
||||
encrypted: meshFile.encrypted,
|
||||
ownerPubkey: meshFile.ownerPubkey,
|
||||
})
|
||||
.from(meshFile)
|
||||
.where(
|
||||
@@ -782,6 +973,8 @@ export async function getFile(
|
||||
return {
|
||||
...row,
|
||||
tags: (row.tags ?? []) as string[],
|
||||
encrypted: row.encrypted,
|
||||
ownerPubkey: row.ownerPubkey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -801,6 +994,7 @@ export async function listFiles(
|
||||
uploadedBy: string;
|
||||
uploadedAt: Date;
|
||||
persistent: boolean;
|
||||
encrypted: boolean;
|
||||
}>
|
||||
> {
|
||||
const conditions = [
|
||||
@@ -822,6 +1016,7 @@ export async function listFiles(
|
||||
uploadedByName: meshFile.uploadedByName,
|
||||
uploadedAt: meshFile.uploadedAt,
|
||||
persistent: meshFile.persistent,
|
||||
encrypted: meshFile.encrypted,
|
||||
})
|
||||
.from(meshFile)
|
||||
.where(and(...conditions))
|
||||
@@ -835,6 +1030,7 @@ export async function listFiles(
|
||||
uploadedBy: r.uploadedByName ?? "unknown",
|
||||
uploadedAt: r.uploadedAt,
|
||||
persistent: r.persistent,
|
||||
encrypted: r.encrypted,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -892,11 +1088,62 @@ export async function deleteFile(
|
||||
);
|
||||
}
|
||||
|
||||
/** Insert encrypted key blobs for a newly uploaded E2E file. */
|
||||
export async function insertFileKeys(
|
||||
fileId: string,
|
||||
keys: Array<{ peerPubkey: string; sealedKey: string; grantedByPubkey?: string }>,
|
||||
): Promise<void> {
|
||||
if (keys.length === 0) return;
|
||||
await db.insert(meshFileKey).values(
|
||||
keys.map((k) => ({
|
||||
fileId,
|
||||
peerPubkey: k.peerPubkey,
|
||||
sealedKey: k.sealedKey,
|
||||
grantedByPubkey: k.grantedByPubkey ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Get the sealed key for a specific peer, or null if not authorized. */
|
||||
export async function getFileKey(
|
||||
fileId: string,
|
||||
peerPubkey: string,
|
||||
): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ sealedKey: meshFileKey.sealedKey })
|
||||
.from(meshFileKey)
|
||||
.where(
|
||||
and(eq(meshFileKey.fileId, fileId), eq(meshFileKey.peerPubkey, peerPubkey)),
|
||||
);
|
||||
return row?.sealedKey ?? null;
|
||||
}
|
||||
|
||||
/** Grant a peer access to an encrypted file (upsert their key blob). */
|
||||
export async function grantFileKey(
|
||||
fileId: string,
|
||||
peerPubkey: string,
|
||||
sealedKey: string,
|
||||
grantedByPubkey: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insert(meshFileKey)
|
||||
.values({ fileId, peerPubkey, sealedKey, grantedByPubkey })
|
||||
.onConflictDoUpdate({
|
||||
target: [meshFileKey.fileId, meshFileKey.peerPubkey],
|
||||
set: { sealedKey, grantedByPubkey, grantedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Context sharing ---
|
||||
|
||||
/**
|
||||
* Upsert a context snapshot for a peer. Each (meshId, presenceId) pair
|
||||
* has at most one context row — repeated calls update it in place.
|
||||
* Upsert a context snapshot for a peer. When `memberId` is provided the
|
||||
* row is keyed on (meshId, memberId) — a stable identifier that survives
|
||||
* reconnects. This prevents stale rows from accumulating every time a
|
||||
* session reconnects with a fresh ephemeral presenceId.
|
||||
*
|
||||
* Falls back to (meshId, presenceId) lookup when memberId is absent
|
||||
* (e.g. legacy callers or anonymous connections).
|
||||
*/
|
||||
export async function shareContext(
|
||||
meshId: string,
|
||||
@@ -906,24 +1153,27 @@ export async function shareContext(
|
||||
filesRead?: string[],
|
||||
keyFindings?: string[],
|
||||
tags?: string[],
|
||||
memberId?: string,
|
||||
): Promise<string> {
|
||||
const now = new Date();
|
||||
// Try to find existing context for this presence in this mesh.
|
||||
|
||||
// Build the WHERE clause: prefer stable memberId, fall back to presenceId.
|
||||
const lookupWhere = memberId
|
||||
? and(eq(meshContext.meshId, meshId), eq(meshContext.memberId, memberId))
|
||||
: and(eq(meshContext.meshId, meshId), eq(meshContext.presenceId, presenceId));
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: meshContext.id })
|
||||
.from(meshContext)
|
||||
.where(
|
||||
and(
|
||||
eq(meshContext.meshId, meshId),
|
||||
eq(meshContext.presenceId, presenceId),
|
||||
),
|
||||
)
|
||||
.where(lookupWhere)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(meshContext)
|
||||
.set({
|
||||
// Keep presenceId current so it reflects the latest connection.
|
||||
presenceId,
|
||||
peerName: peerName ?? null,
|
||||
summary,
|
||||
filesRead: filesRead ?? [],
|
||||
@@ -939,6 +1189,7 @@ export async function shareContext(
|
||||
.insert(meshContext)
|
||||
.values({
|
||||
meshId,
|
||||
memberId: memberId ?? null,
|
||||
presenceId,
|
||||
peerName: peerName ?? null,
|
||||
summary,
|
||||
@@ -1188,16 +1439,22 @@ export async function createStream(
|
||||
name: string,
|
||||
createdByName: string,
|
||||
): Promise<string> {
|
||||
const existing = await db
|
||||
// Atomic upsert: INSERT ... ON CONFLICT DO NOTHING to avoid TOCTOU race
|
||||
// when two callers concurrently attempt to create the same stream.
|
||||
const [inserted] = await db
|
||||
.insert(meshStream)
|
||||
.values({ meshId, name, createdByName })
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: meshStream.id });
|
||||
|
||||
if (inserted) return inserted.id;
|
||||
|
||||
// Row already existed — fetch the id.
|
||||
const [existing] = await db
|
||||
.select({ id: meshStream.id })
|
||||
.from(meshStream)
|
||||
.where(and(eq(meshStream.meshId, meshId), eq(meshStream.name, name)));
|
||||
if (existing.length > 0) return existing[0]!.id;
|
||||
const [row] = await db
|
||||
.insert(meshStream)
|
||||
.values({ meshId, name, createdByName })
|
||||
.returning({ id: meshStream.id });
|
||||
return row!.id;
|
||||
return existing!.id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1302,11 +1559,28 @@ export async function drainForMember(
|
||||
);
|
||||
|
||||
// Build group target matching: @all (broadcast alias) + @<groupname>
|
||||
// for each group the peer belongs to.
|
||||
// for each group the peer belongs to, expanded to all ancestor paths.
|
||||
//
|
||||
// Hierarchical routing (downward propagation):
|
||||
// A peer in "flexicar/core" also matches messages sent to "@flexicar".
|
||||
// A peer in "flexicar/core/backend" matches "@flexicar/core" and "@flexicar".
|
||||
// This lets leads send to a parent group and reach all sub-teams.
|
||||
//
|
||||
// Resolution happens at drain time (pull model) — no duplicates stored,
|
||||
// no schema changes, fully backward-compatible.
|
||||
const groupTargets = ["@all"];
|
||||
if (memberGroups) {
|
||||
const seen = new Set<string>();
|
||||
for (const g of memberGroups) {
|
||||
groupTargets.push(`@${g}`);
|
||||
const parts = g.split("/");
|
||||
// Add the group itself + every ancestor prefix.
|
||||
for (let depth = parts.length; depth > 0; depth--) {
|
||||
const ancestor = parts.slice(0, depth).join("/");
|
||||
if (!seen.has(ancestor)) {
|
||||
seen.add(ancestor);
|
||||
groupTargets.push(`@${ancestor}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const groupTargetList = sql.raw(
|
||||
@@ -1337,7 +1611,7 @@ export async function drainForMember(
|
||||
AND delivered_at IS NULL
|
||||
AND priority::text IN (${priorityList})
|
||||
AND (target_spec = ${memberPubkey} OR target_spec = '*'${sessionPubkey ? sql` OR target_spec = ${sessionPubkey}` : sql``} OR target_spec IN (${groupTargetList}))
|
||||
${excludeSenderSessionPubkey ? sql`AND (sender_session_pubkey IS NULL OR sender_session_pubkey != ${excludeSenderSessionPubkey})` : sql``}
|
||||
${excludeSenderSessionPubkey ? sql`AND NOT (target_spec IN ('*') AND sender_session_pubkey = ${excludeSenderSessionPubkey})` : sql``}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
@@ -1532,13 +1806,18 @@ export async function joinMesh(args: {
|
||||
if (!claimed) return { ok: false, error: "invite_exhausted" };
|
||||
|
||||
// 6. Insert the member with the role from the payload.
|
||||
// Apply invite preset overrides (displayName, roleTag, groups, messageMode).
|
||||
const preset = (inv.preset as any) ?? {};
|
||||
const [row] = await db
|
||||
.insert(memberTable)
|
||||
.values({
|
||||
meshId: invitePayload.mesh_id,
|
||||
peerPubkey,
|
||||
displayName,
|
||||
displayName: preset.displayName ?? displayName,
|
||||
role: invitePayload.role,
|
||||
roleTag: preset.roleTag ?? null,
|
||||
defaultGroups: preset.groups ?? [],
|
||||
messageMode: preset.messageMode ?? "push",
|
||||
})
|
||||
.returning({ id: memberTable.id });
|
||||
if (!row) return { ok: false, error: "member_insert_failed" };
|
||||
@@ -1552,12 +1831,24 @@ export async function joinMesh(args: {
|
||||
export async function findMemberByPubkey(
|
||||
meshId: string,
|
||||
pubkey: string,
|
||||
): Promise<{ id: string; displayName: string; role: string } | null> {
|
||||
): Promise<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
roleTag: string | null;
|
||||
defaultGroups: Array<{ name: string; role?: string }>;
|
||||
messageMode: string | null;
|
||||
dashboardUserId: string | null;
|
||||
} | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: memberTable.id,
|
||||
displayName: memberTable.displayName,
|
||||
role: memberTable.role,
|
||||
roleTag: memberTable.roleTag,
|
||||
defaultGroups: memberTable.defaultGroups,
|
||||
messageMode: memberTable.messageMode,
|
||||
dashboardUserId: memberTable.dashboardUserId,
|
||||
})
|
||||
.from(memberTable)
|
||||
.where(
|
||||
@@ -1685,3 +1976,91 @@ export async function meshSchema(
|
||||
}
|
||||
return [...tables.entries()].map(([name, columns]) => ({ name, columns }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function vaultSet(meshId: string, memberId: string, key: string, ciphertext: string, nonce: string, sealedKey: string, entryType: "env" | "file", mountPath?: string, description?: string): Promise<string> {
|
||||
const existing = await db.select({ id: meshVaultEntry.id }).from(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId), eq(meshVaultEntry.key, key))).limit(1);
|
||||
if (existing.length > 0) {
|
||||
await db.update(meshVaultEntry).set({ ciphertext, nonce, sealedKey, entryType, mountPath: mountPath ?? null, description: description ?? null, updatedAt: new Date() }).where(eq(meshVaultEntry.id, existing[0]!.id));
|
||||
return existing[0]!.id;
|
||||
}
|
||||
const [row] = await db.insert(meshVaultEntry).values({ meshId, memberId, key, ciphertext, nonce, sealedKey, entryType, mountPath: mountPath ?? null, description: description ?? null }).returning({ id: meshVaultEntry.id });
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
export async function vaultList(meshId: string, memberId: string) {
|
||||
return db.select({ key: meshVaultEntry.key, entryType: meshVaultEntry.entryType, mountPath: meshVaultEntry.mountPath, description: meshVaultEntry.description, updatedAt: meshVaultEntry.updatedAt }).from(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId)));
|
||||
}
|
||||
|
||||
export async function vaultDelete(meshId: string, memberId: string, key: string): Promise<boolean> {
|
||||
const deleted = await db.delete(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId), eq(meshVaultEntry.key, key))).returning({ id: meshVaultEntry.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
|
||||
export async function vaultGetEntries(meshId: string, memberId: string, keys: string[]) {
|
||||
if (keys.length === 0) return [];
|
||||
return db.select({ key: meshVaultEntry.key, ciphertext: meshVaultEntry.ciphertext, nonce: meshVaultEntry.nonce, sealedKey: meshVaultEntry.sealedKey, entryType: meshVaultEntry.entryType, mountPath: meshVaultEntry.mountPath }).from(meshVaultEntry).where(and(eq(meshVaultEntry.meshId, meshId), eq(meshVaultEntry.memberId, memberId), inArray(meshVaultEntry.key, keys)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service catalog operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function upsertService(meshId: string, name: string, data: { type: "mcp" | "skill"; sourceType: string; description: string; sourceFileId?: string; sourceGitUrl?: string; sourceGitBranch?: string; sourceGitSha?: string; instructions?: string; toolsSchema?: unknown; manifest?: unknown; runtime?: string; status?: string; config?: unknown; scope?: unknown; deployedBy?: string; deployedByName?: string }): Promise<string> {
|
||||
// Whitelist allowed fields — prevent mass-assignment of id, meshId, createdAt, etc.
|
||||
const fields: Record<string, unknown> = {
|
||||
type: data.type,
|
||||
sourceType: data.sourceType,
|
||||
description: data.description,
|
||||
...(data.sourceFileId !== undefined && { sourceFileId: data.sourceFileId }),
|
||||
...(data.sourceGitUrl !== undefined && { sourceGitUrl: data.sourceGitUrl }),
|
||||
...(data.sourceGitBranch !== undefined && { sourceGitBranch: data.sourceGitBranch }),
|
||||
...(data.sourceGitSha !== undefined && { sourceGitSha: data.sourceGitSha }),
|
||||
...(data.instructions !== undefined && { instructions: data.instructions }),
|
||||
...(data.toolsSchema !== undefined && { toolsSchema: data.toolsSchema }),
|
||||
...(data.manifest !== undefined && { manifest: data.manifest }),
|
||||
...(data.runtime !== undefined && { runtime: data.runtime }),
|
||||
...(data.status !== undefined && { status: data.status }),
|
||||
...(data.config !== undefined && { config: data.config }),
|
||||
...(data.scope !== undefined && { scope: data.scope }),
|
||||
...(data.deployedBy !== undefined && { deployedBy: data.deployedBy }),
|
||||
...(data.deployedByName !== undefined && { deployedByName: data.deployedByName }),
|
||||
};
|
||||
|
||||
const existing = await db.select({ id: meshService.id }).from(meshService).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name))).limit(1);
|
||||
if (existing.length > 0) {
|
||||
await db.update(meshService).set({ ...fields, updatedAt: new Date() } as any).where(eq(meshService.id, existing[0]!.id));
|
||||
return existing[0]!.id;
|
||||
}
|
||||
const [row] = await db.insert(meshService).values({ meshId, name, ...fields } as any).returning({ id: meshService.id });
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
export async function updateServiceStatus(meshId: string, name: string, status: string, extra?: { toolsSchema?: unknown; restartCount?: number; lastHealth?: Date }) {
|
||||
await db.update(meshService).set({ status, ...(extra ?? {}), updatedAt: new Date() } as any).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name)));
|
||||
}
|
||||
|
||||
export async function updateServiceScope(meshId: string, name: string, scope: unknown) {
|
||||
await db.update(meshService).set({ scope, updatedAt: new Date() } as any).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name)));
|
||||
}
|
||||
|
||||
export async function getService(meshId: string, name: string) {
|
||||
const rows = await db.select().from(meshService).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name))).limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function listDbMeshServices(meshId: string) {
|
||||
return db.select().from(meshService).where(eq(meshService.meshId, meshId));
|
||||
}
|
||||
|
||||
export async function deleteService(meshId: string, name: string): Promise<boolean> {
|
||||
const deleted = await db.delete(meshService).where(and(eq(meshService.meshId, meshId), eq(meshService.name, name))).returning({ id: meshService.id });
|
||||
return deleted.length > 0;
|
||||
}
|
||||
|
||||
export async function getRunningServices(meshId: string) {
|
||||
return db.select().from(meshService).where(and(eq(meshService.meshId, meshId), inArray(meshService.status, ["running", "failed", "crashed", "restarting"])));
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import { db } from "./db";
|
||||
import { invite as inviteTable, mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
||||
|
||||
let ready = false;
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -118,3 +185,185 @@ export async function verifyHelloSignature(args: {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,11 +23,17 @@ const envSchema = z.object({
|
||||
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.coerce.boolean().default(false),
|
||||
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
|
||||
QDRANT_URL: z.string().default("http://qdrant:6333"),
|
||||
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
|
||||
NEO4J_USER: z.string().default("neo4j"),
|
||||
NEO4J_PASSWORD: z.string().default("changeme"),
|
||||
RUNNER_URL: z.string().default("http://runner:7901"),
|
||||
CLAUDEMESH_SERVICES_DIR: z.string().default("/var/claudemesh/services"),
|
||||
BROKER_ENCRYPTION_KEY: z.string().default(""), // 64 hex chars (32 bytes). Auto-generated if empty.
|
||||
CLI_SYNC_SECRET: z.string().default(""), // HS256 shared secret for dashboard→broker sync JWTs. Required for /cli-sync.
|
||||
MAX_SERVICES_PER_MESH: z.coerce.number().int().positive().default(20),
|
||||
MAX_SERVICE_ZIP_BYTES: z.coerce.number().int().positive().default(50 * 1024 * 1024),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
|
||||
146
apps/broker/src/jwt.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* JWT verification for CLI sync tokens.
|
||||
*
|
||||
* Sync tokens are HS256 JWTs issued by the dashboard after OAuth,
|
||||
* shared secret between dashboard and broker via env var.
|
||||
*
|
||||
* JTI dedup: tracks used token IDs in a TTL-evicted Set to prevent replay.
|
||||
*/
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface SyncTokenPayload {
|
||||
sub: string; // dashboard user ID
|
||||
email: string;
|
||||
meshes: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
role: "admin" | "member";
|
||||
}>;
|
||||
action: "sync" | "create";
|
||||
newMesh?: {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
jti: string; // unique token ID for replay prevention
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// --- JTI dedup ---
|
||||
|
||||
const usedJtis = new Map<string, number>(); // jti → expiry timestamp (ms)
|
||||
|
||||
// Sweep expired JTIs every 5 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [jti, exp] of usedJtis) {
|
||||
if (exp < now) usedJtis.delete(jti);
|
||||
}
|
||||
}, 5 * 60_000);
|
||||
|
||||
// --- Verification ---
|
||||
|
||||
/**
|
||||
* Verify and decode a sync token JWT.
|
||||
* Returns the decoded payload on success, or an error string on failure.
|
||||
*/
|
||||
export async function verifySyncToken(
|
||||
token: string,
|
||||
): Promise<{ ok: true; payload: SyncTokenPayload } | { ok: false; error: string }> {
|
||||
// Get shared secret from env
|
||||
const secret = env.CLI_SYNC_SECRET;
|
||||
if (!secret) {
|
||||
return { ok: false, error: "CLI_SYNC_SECRET not configured on broker" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode JWT manually (HS256)
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
return { ok: false, error: "malformed JWT" };
|
||||
}
|
||||
|
||||
const headerB64 = parts[0]!;
|
||||
const payloadB64 = parts[1]!;
|
||||
const signatureB64 = parts[2]!;
|
||||
|
||||
// Verify signature (HS256)
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
|
||||
const signatureInput = encoder.encode(`${headerB64}.${payloadB64}`);
|
||||
const signature = base64UrlDecode(signatureB64);
|
||||
|
||||
const valid = await crypto.subtle.verify("HMAC", key, signature, signatureInput);
|
||||
if (!valid) {
|
||||
return { ok: false, error: "invalid signature" };
|
||||
}
|
||||
|
||||
// Decode header — must be HS256
|
||||
const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64)));
|
||||
if (header.alg !== "HS256") {
|
||||
return { ok: false, error: `unsupported algorithm: ${header.alg}` };
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(payloadB64)),
|
||||
) as SyncTokenPayload;
|
||||
|
||||
// Check expiry
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp && payload.exp < now) {
|
||||
return { ok: false, error: "token expired" };
|
||||
}
|
||||
|
||||
// Check iat not in the future (30s tolerance)
|
||||
if (payload.iat && payload.iat > now + 30) {
|
||||
return { ok: false, error: "token issued in the future" };
|
||||
}
|
||||
|
||||
// JTI dedup
|
||||
if (!payload.jti) {
|
||||
return { ok: false, error: "missing jti" };
|
||||
}
|
||||
if (usedJtis.has(payload.jti)) {
|
||||
return { ok: false, error: "token already used" };
|
||||
}
|
||||
// Mark as used with expiry time
|
||||
usedJtis.set(payload.jti, (payload.exp ?? now + 900) * 1000);
|
||||
|
||||
// Basic validation
|
||||
if (!payload.sub || !payload.email) {
|
||||
return { ok: false, error: "missing sub or email" };
|
||||
}
|
||||
if (!Array.isArray(payload.meshes)) {
|
||||
return { ok: false, error: "missing meshes array" };
|
||||
}
|
||||
|
||||
return { ok: true, payload };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function base64UrlDecode(input: string): Uint8Array {
|
||||
// Add padding
|
||||
let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
while (base64.length % 4) base64 += "=";
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
284
apps/broker/src/member-api.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Member profile REST API handlers.
|
||||
*
|
||||
* PATCH /mesh/:meshId/member/:memberId — update member profile
|
||||
* GET /mesh/:meshId/members — list all members with online status
|
||||
* PATCH /mesh/:meshId/settings — update mesh settings (selfEditable)
|
||||
*
|
||||
* These are standalone handler functions. Route wiring happens in index.ts.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import {
|
||||
mesh as meshTable,
|
||||
meshMember as memberTable,
|
||||
presence as presenceTable,
|
||||
} from "@turbostarter/db/schema/mesh";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface MemberProfileUpdate {
|
||||
displayName?: string;
|
||||
roleTag?: string;
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
messageMode?: "push" | "inbox" | "off";
|
||||
}
|
||||
|
||||
export interface MemberPermissionUpdate {
|
||||
permission?: "admin" | "member"; // only admins can change this
|
||||
}
|
||||
|
||||
export type MemberUpdateRequest = MemberProfileUpdate & MemberPermissionUpdate;
|
||||
|
||||
interface SelfEditablePolicy {
|
||||
displayName: boolean;
|
||||
roleTag: boolean;
|
||||
groups: boolean;
|
||||
messageMode: boolean;
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
/**
|
||||
* Update a member's profile fields.
|
||||
*
|
||||
* Authorization:
|
||||
* - If caller is the target member: check mesh.selfEditable for each field
|
||||
* - If caller is a mesh admin: allow all fields
|
||||
* - permission field: admin-only always
|
||||
*
|
||||
* Returns: { ok: true, member: {...} } or { ok: false, error: string }
|
||||
*/
|
||||
export async function updateMemberProfile(
|
||||
meshId: string,
|
||||
memberId: string,
|
||||
callerMemberId: string, // from auth header or WS connection
|
||||
updates: MemberUpdateRequest,
|
||||
): Promise<
|
||||
| { ok: true; member: Record<string, unknown>; changes: MemberProfileUpdate }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
// 1. Load mesh for selfEditable policy
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id, selfEditable: meshTable.selfEditable })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) return { ok: false, error: "mesh not found" };
|
||||
|
||||
// 2. Load caller's member row to check permission
|
||||
const [caller] = await db
|
||||
.select({ id: memberTable.id, role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.id, callerMemberId),
|
||||
eq(memberTable.meshId, meshId),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!caller) return { ok: false, error: "caller not a member of this mesh" };
|
||||
|
||||
const isAdmin = caller.role === "admin";
|
||||
const isSelf = callerMemberId === memberId;
|
||||
|
||||
if (!isAdmin && !isSelf) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "not authorized — only admins or self can edit",
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Check self-edit permissions for non-admin self-edits
|
||||
const policy: SelfEditablePolicy =
|
||||
(m.selfEditable as SelfEditablePolicy) ?? {
|
||||
displayName: true,
|
||||
roleTag: true,
|
||||
groups: true,
|
||||
messageMode: true,
|
||||
};
|
||||
|
||||
const rejected: string[] = [];
|
||||
if (!isAdmin && isSelf) {
|
||||
if (updates.displayName !== undefined && !policy.displayName)
|
||||
rejected.push("displayName");
|
||||
if (updates.roleTag !== undefined && !policy.roleTag)
|
||||
rejected.push("roleTag");
|
||||
if (updates.groups !== undefined && !policy.groups)
|
||||
rejected.push("groups");
|
||||
if (updates.messageMode !== undefined && !policy.messageMode)
|
||||
rejected.push("messageMode");
|
||||
if (updates.permission !== undefined) rejected.push("permission");
|
||||
}
|
||||
|
||||
if (rejected.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `admin-managed fields: ${rejected.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Build update set
|
||||
const set: Record<string, unknown> = {};
|
||||
const changes: MemberProfileUpdate = {};
|
||||
|
||||
if (updates.displayName !== undefined) {
|
||||
set.displayName = updates.displayName;
|
||||
changes.displayName = updates.displayName;
|
||||
}
|
||||
if (updates.roleTag !== undefined) {
|
||||
set.roleTag = updates.roleTag;
|
||||
changes.roleTag = updates.roleTag;
|
||||
}
|
||||
if (updates.groups !== undefined) {
|
||||
set.defaultGroups = updates.groups;
|
||||
changes.groups = updates.groups;
|
||||
}
|
||||
if (updates.messageMode !== undefined) {
|
||||
set.messageMode = updates.messageMode;
|
||||
changes.messageMode = updates.messageMode;
|
||||
}
|
||||
if (updates.permission !== undefined && isAdmin) {
|
||||
set.role = updates.permission;
|
||||
}
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
return { ok: false, error: "no fields to update" };
|
||||
}
|
||||
|
||||
// 5. Update member row
|
||||
await db.update(memberTable).set(set).where(eq(memberTable.id, memberId));
|
||||
|
||||
// 6. Read back the updated member
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(eq(memberTable.id, memberId));
|
||||
|
||||
if (!updated) return { ok: false, error: "member not found after update" };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
member: {
|
||||
id: updated.id,
|
||||
displayName: updated.displayName,
|
||||
roleTag: updated.roleTag,
|
||||
groups: updated.defaultGroups,
|
||||
messageMode: updated.messageMode,
|
||||
permission: updated.role,
|
||||
dashboardUserId: updated.dashboardUserId,
|
||||
joinedAt: updated.joinedAt,
|
||||
lastSeenAt: updated.lastSeenAt,
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all members of a mesh with online status.
|
||||
*/
|
||||
export async function listMeshMembers(
|
||||
meshId: string,
|
||||
): Promise<
|
||||
| { ok: true; members: Array<Record<string, unknown>> }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
// Verify mesh exists
|
||||
const [m] = await db
|
||||
.select({ id: meshTable.id })
|
||||
.from(meshTable)
|
||||
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
||||
|
||||
if (!m) return { ok: false, error: "mesh not found" };
|
||||
|
||||
// Get all non-revoked members
|
||||
const members = await db
|
||||
.select()
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(eq(memberTable.meshId, meshId), isNull(memberTable.revokedAt)),
|
||||
);
|
||||
|
||||
// Early return for empty member list (avoids invalid SQL IN clause)
|
||||
if (members.length === 0) {
|
||||
return { ok: true, members: [] };
|
||||
}
|
||||
|
||||
// Get active presences for online status
|
||||
const activePresences = await db
|
||||
.select({
|
||||
memberId: presenceTable.memberId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(presenceTable)
|
||||
.where(
|
||||
and(
|
||||
isNull(presenceTable.disconnectedAt),
|
||||
sql`${presenceTable.memberId} IN (${sql.join(
|
||||
members.map((m) => sql`${m.id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
)
|
||||
.groupBy(presenceTable.memberId);
|
||||
|
||||
const onlineMap = new Map(
|
||||
activePresences.map((p) => [p.memberId, p.count]),
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
members: members.map((member) => ({
|
||||
id: member.id,
|
||||
displayName: member.displayName,
|
||||
roleTag: member.roleTag,
|
||||
groups: member.defaultGroups,
|
||||
messageMode: member.messageMode,
|
||||
permission: member.role,
|
||||
dashboardUserId: member.dashboardUserId,
|
||||
joinedAt: member.joinedAt?.toISOString(),
|
||||
lastSeenAt: member.lastSeenAt?.toISOString(),
|
||||
online: onlineMap.has(member.id),
|
||||
sessionCount: onlineMap.get(member.id) ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mesh settings (currently: selfEditable policy).
|
||||
* Admin-only.
|
||||
*/
|
||||
export async function updateMeshSettings(
|
||||
meshId: string,
|
||||
callerMemberId: string,
|
||||
settings: { selfEditable?: SelfEditablePolicy },
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
// Check caller is admin
|
||||
const [caller] = await db
|
||||
.select({ role: memberTable.role })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.id, callerMemberId),
|
||||
eq(memberTable.meshId, meshId),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (!caller || caller.role !== "admin") {
|
||||
return { ok: false, error: "admin access required" };
|
||||
}
|
||||
|
||||
const set: Record<string, unknown> = {};
|
||||
if (settings.selfEditable) set.selfEditable = settings.selfEditable;
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
return { ok: false, error: "no settings to update" };
|
||||
}
|
||||
|
||||
await db.update(meshTable).set(set).where(eq(meshTable.id, meshId));
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
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}`;
|
||||
}
|
||||
@@ -57,6 +57,14 @@ export interface WSHelloMessage {
|
||||
sessionId: string;
|
||||
pid: number;
|
||||
cwd: string;
|
||||
/** OS hostname — used to detect same-machine peers for direct file access. */
|
||||
hostname?: string;
|
||||
/** Peer type: ai session, human user, or external connector. */
|
||||
peerType?: "ai" | "human" | "connector";
|
||||
/** Channel the peer connected from (e.g. "claude-code", "telegram", "slack", "web"). */
|
||||
channel?: string;
|
||||
/** AI model identifier (e.g. "opus-4", "sonnet-4"). */
|
||||
model?: string;
|
||||
/** Initial groups to join on connect. */
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
/** ms epoch; broker rejects if outside ±60s of its own clock. */
|
||||
@@ -86,6 +94,13 @@ export interface WSPushMessage {
|
||||
nonce: string;
|
||||
ciphertext: string;
|
||||
createdAt: string;
|
||||
/** Optional semantic tag — "reminder" when delivered by the scheduler,
|
||||
* "system" for broker-originated topology events (peer join/leave). */
|
||||
subtype?: "reminder" | "system";
|
||||
/** Machine-readable event name (e.g. "peer_joined", "peer_left"). */
|
||||
event?: string;
|
||||
/** Structured payload for the event. */
|
||||
eventData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Client → broker: manual status override (dnd, forced idle). */
|
||||
@@ -105,6 +120,36 @@ export interface WSSetSummaryMessage {
|
||||
summary: string;
|
||||
}
|
||||
|
||||
|
||||
/** Client → broker: toggle visibility in the mesh. */
|
||||
export interface WSSetVisibleMessage {
|
||||
type: "set_visible";
|
||||
visible: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: set public profile metadata. */
|
||||
export interface WSSetProfileMessage {
|
||||
type: "set_profile";
|
||||
avatar?: string; // emoji or URL
|
||||
title?: string; // short role label
|
||||
bio?: string; // one-liner
|
||||
capabilities?: string[]; // what I can help with
|
||||
_reqId?: string;
|
||||
}
|
||||
/** Client → broker: self-report resource usage stats. */
|
||||
export interface WSSetStatsMessage {
|
||||
type: "set_stats";
|
||||
stats: {
|
||||
messagesIn?: number;
|
||||
messagesOut?: number;
|
||||
toolCalls?: number;
|
||||
uptime?: number; // seconds since session start
|
||||
errors?: number;
|
||||
};
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: join a group with optional role. */
|
||||
export interface WSJoinGroupMessage {
|
||||
type: "join_group";
|
||||
@@ -161,6 +206,7 @@ export interface WSAckMessage {
|
||||
id: string; // echoes client-side correlation id
|
||||
messageId: string;
|
||||
queued: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: hello handshake acknowledgement. */
|
||||
@@ -168,6 +214,17 @@ export interface WSHelloAckMessage {
|
||||
type: "hello_ack";
|
||||
presenceId: string;
|
||||
memberDisplayName: string;
|
||||
/** True when the broker restored persisted state from a previous session. */
|
||||
restored?: boolean;
|
||||
/** Last summary set before disconnect (only when restored). */
|
||||
lastSummary?: string;
|
||||
/** ISO timestamp of last disconnect (only when restored). */
|
||||
lastSeenAt?: string;
|
||||
/** Restored groups from previous session (only when restored and hello had no groups). */
|
||||
restoredGroups?: Array<{ name: string; role?: string }>;
|
||||
/** Restored cumulative stats (only when restored). */
|
||||
restoredStats?: { messagesIn: number; messagesOut: number; toolCalls: number; errors: number };
|
||||
services?: Array<{ name: string; description: string; status: string; tools: Array<{ name: string; description: string; inputSchema: object }>; deployed_by: string }>;
|
||||
}
|
||||
|
||||
/** Broker → client: list of connected peers in the same mesh. */
|
||||
@@ -181,7 +238,27 @@ export interface WSPeersListMessage {
|
||||
groups: Array<{ name: string; role?: string }>;
|
||||
sessionId: string;
|
||||
connectedAt: string;
|
||||
cwd?: string;
|
||||
hostname?: string;
|
||||
peerType?: "ai" | "human" | "connector";
|
||||
channel?: string;
|
||||
model?: string;
|
||||
stats?: {
|
||||
messagesIn?: number;
|
||||
messagesOut?: number;
|
||||
toolCalls?: number;
|
||||
uptime?: number;
|
||||
errors?: number;
|
||||
};
|
||||
visible?: boolean;
|
||||
profile?: {
|
||||
avatar?: string;
|
||||
title?: string;
|
||||
bio?: string;
|
||||
capabilities?: string[];
|
||||
};
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: a state key was changed by another peer. */
|
||||
@@ -199,6 +276,7 @@ export interface WSStateResultMessage {
|
||||
value: unknown;
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_state. */
|
||||
@@ -210,12 +288,14 @@ export interface WSStateListMessage {
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for a remember. */
|
||||
export interface WSMemoryStoredMessage {
|
||||
type: "memory_stored";
|
||||
id: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to recall. */
|
||||
@@ -228,6 +308,7 @@ export interface WSMemoryResultsMessage {
|
||||
rememberedBy: string;
|
||||
rememberedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Vector storage messages ---
|
||||
@@ -295,6 +376,13 @@ export interface WSMeshSchemaMessage {
|
||||
|
||||
// --- Vector/Graph response messages ---
|
||||
|
||||
/** Broker → client: confirmation that a vector point was stored. */
|
||||
export interface WSVectorStoredMessage {
|
||||
type: "vector_stored";
|
||||
id: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: vector search results. */
|
||||
export interface WSVectorResultsMessage {
|
||||
type: "vector_results";
|
||||
@@ -304,18 +392,21 @@ export interface WSVectorResultsMessage {
|
||||
score: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of vector collections. */
|
||||
export interface WSCollectionListMessage {
|
||||
type: "collection_list";
|
||||
collections: string[];
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: graph query results. */
|
||||
export interface WSGraphResultMessage {
|
||||
type: "graph_result";
|
||||
records: Array<Record<string, unknown>>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: mesh SQL query results. */
|
||||
@@ -324,6 +415,7 @@ export interface WSMeshQueryResultMessage {
|
||||
columns: string[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
rowCount: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: mesh schema introspection results. */
|
||||
@@ -333,6 +425,7 @@ export interface WSMeshSchemaResultMessage {
|
||||
name: string;
|
||||
columns: Array<{ name: string; type: string; nullable: boolean }>;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: get full mesh overview. */
|
||||
@@ -355,6 +448,7 @@ export interface WSMeshInfoResultMessage {
|
||||
collections: string[];
|
||||
yourName: string;
|
||||
yourGroups: Array<{ name: string; role?: string }>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: check delivery status of a message. */
|
||||
@@ -375,6 +469,7 @@ export interface WSMessageStatusResultMessage {
|
||||
pubkey: string;
|
||||
status: "delivered" | "held" | "disconnected";
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- File sharing messages ---
|
||||
@@ -404,12 +499,23 @@ export interface WSDeleteFileMessage {
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
/** Client → broker: grant a peer access to an encrypted file. */
|
||||
export interface WSGrantFileAccessMessage {
|
||||
type: "grant_file_access";
|
||||
fileId: string;
|
||||
peerPubkey: string;
|
||||
sealedKey: string;
|
||||
}
|
||||
|
||||
/** Broker → client: presigned URL for downloading a file. */
|
||||
export interface WSFileUrlMessage {
|
||||
type: "file_url";
|
||||
fileId: string;
|
||||
url: string;
|
||||
name: string;
|
||||
encrypted?: boolean;
|
||||
sealedKey?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of files in the mesh. */
|
||||
@@ -423,7 +529,17 @@ export interface WSFileListMessage {
|
||||
uploadedBy: string;
|
||||
uploadedAt: string;
|
||||
persistent: boolean;
|
||||
encrypted: boolean;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for grant_file_access. */
|
||||
export interface WSGrantFileAccessOkMessage {
|
||||
type: "grant_file_access_ok";
|
||||
fileId: string;
|
||||
peerPubkey: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: access log for a file. */
|
||||
@@ -434,6 +550,7 @@ export interface WSFileStatusResultMessage {
|
||||
peerName: string;
|
||||
accessedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Context sharing messages ---
|
||||
@@ -475,6 +592,7 @@ export interface WSContextResultsMessage {
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_contexts. */
|
||||
@@ -486,6 +604,7 @@ export interface WSContextListMessage {
|
||||
tags: string[];
|
||||
updatedAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Task messages ---
|
||||
@@ -523,6 +642,7 @@ export interface WSListTasksMessage {
|
||||
export interface WSTaskCreatedMessage {
|
||||
type: "task_created";
|
||||
id: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_tasks, claim_task, complete_task. */
|
||||
@@ -539,6 +659,7 @@ export interface WSTaskListMessage {
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Stream messages ---
|
||||
@@ -578,6 +699,7 @@ export interface WSStreamCreatedMessage {
|
||||
type: "stream_created";
|
||||
id: string;
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: real-time data pushed from a stream. */
|
||||
@@ -588,6 +710,13 @@ export interface WSStreamDataMessage {
|
||||
publishedBy: string;
|
||||
}
|
||||
|
||||
/** Broker → client: confirmation that a stream subscription was registered. */
|
||||
export interface WSSubscribedMessage {
|
||||
type: "subscribed";
|
||||
stream: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_streams. */
|
||||
export interface WSStreamListMessage {
|
||||
type: "stream_list";
|
||||
@@ -598,6 +727,202 @@ export interface WSStreamListMessage {
|
||||
createdAt: string;
|
||||
subscriberCount: number;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- MCP proxy messages ---
|
||||
|
||||
/** Client → broker: register an MCP server with the mesh. */
|
||||
export interface WSMcpRegisterMessage {
|
||||
type: "mcp_register";
|
||||
serverName: string;
|
||||
description: string;
|
||||
tools: Array<{ name: string; description: string; inputSchema: Record<string, unknown> }>;
|
||||
persistent?: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: unregister an MCP server. */
|
||||
export interface WSMcpUnregisterMessage {
|
||||
type: "mcp_unregister";
|
||||
serverName: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list all MCP servers in the mesh. */
|
||||
export interface WSMcpListMessage {
|
||||
type: "mcp_list";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: call a tool on a mesh-registered MCP server. */
|
||||
export interface WSMcpCallMessage {
|
||||
type: "mcp_call";
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: response to a forwarded MCP call. */
|
||||
export interface WSMcpCallResponseMessage {
|
||||
type: "mcp_call_response";
|
||||
callId: string;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for mcp_register. */
|
||||
export interface WSMcpRegisterAckMessage {
|
||||
type: "mcp_register_ack";
|
||||
serverName: string;
|
||||
toolCount: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of MCP servers in the mesh. */
|
||||
export interface WSMcpListResultMessage {
|
||||
type: "mcp_list_result";
|
||||
servers: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
hostedBy: string;
|
||||
tools: Array<{ name: string; description: string }>;
|
||||
online: boolean;
|
||||
offlineSince?: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: result of an MCP tool call. */
|
||||
export interface WSMcpCallResultMessage {
|
||||
type: "mcp_call_result";
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: forwarded MCP tool call to execute locally. */
|
||||
export interface WSMcpCallForwardMessage {
|
||||
type: "mcp_call_forward";
|
||||
callId: string;
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
callerName: string;
|
||||
}
|
||||
|
||||
// --- Webhook CRUD messages ---
|
||||
|
||||
/** Client → broker: create an inbound webhook. */
|
||||
export interface WSCreateWebhookMessage {
|
||||
type: "create_webhook";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list webhooks for the mesh. */
|
||||
export interface WSListWebhooksMessage {
|
||||
type: "list_webhooks";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: deactivate a webhook. */
|
||||
export interface WSDeleteWebhookMessage {
|
||||
type: "delete_webhook";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for create_webhook. */
|
||||
export interface WSWebhookAckMessage {
|
||||
type: "webhook_ack";
|
||||
name: string;
|
||||
url: string;
|
||||
secret: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of webhooks for the mesh. */
|
||||
export interface WSWebhookListMessage {
|
||||
type: "webhook_list";
|
||||
webhooks: Array<{ name: string; url: string; active: boolean; createdAt: string }>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Peer file sharing (relay) messages ---
|
||||
|
||||
/** Client → broker: request a file from a peer's local filesystem. */
|
||||
export interface WSPeerFileRequestMessage {
|
||||
type: "peer_file_request";
|
||||
targetPubkey: string;
|
||||
filePath: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → target peer: forwarded file request from another peer. */
|
||||
export interface WSPeerFileRequestForwardMessage {
|
||||
type: "peer_file_request_forward";
|
||||
requesterPubkey: string;
|
||||
filePath: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Target peer → broker: response with file content (or error). */
|
||||
export interface WSPeerFileResponseMessage {
|
||||
type: "peer_file_response";
|
||||
requesterPubkey: string;
|
||||
filePath: string;
|
||||
content?: string; // base64 encoded
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → requester: forwarded file content from target peer. */
|
||||
export interface WSPeerFileResponseForwardMessage {
|
||||
type: "peer_file_response_forward";
|
||||
filePath: string;
|
||||
content?: string;
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: request a directory listing from a peer. */
|
||||
export interface WSPeerDirRequestMessage {
|
||||
type: "peer_dir_request";
|
||||
targetPubkey: string;
|
||||
dirPath: string;
|
||||
pattern?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → target peer: forwarded directory listing request. */
|
||||
export interface WSPeerDirRequestForwardMessage {
|
||||
type: "peer_dir_request_forward";
|
||||
requesterPubkey: string;
|
||||
dirPath: string;
|
||||
pattern?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Target peer → broker: directory listing response. */
|
||||
export interface WSPeerDirResponseMessage {
|
||||
type: "peer_dir_response";
|
||||
requesterPubkey: string;
|
||||
dirPath: string;
|
||||
entries?: string[];
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → requester: forwarded directory listing from target peer. */
|
||||
export interface WSPeerDirResponseForwardMessage {
|
||||
type: "peer_dir_response_forward";
|
||||
dirPath: string;
|
||||
entries?: string[];
|
||||
error?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: structured error. */
|
||||
@@ -606,14 +931,200 @@ export interface WSErrorMessage {
|
||||
code: string;
|
||||
message: string;
|
||||
id?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Audit log messages ---
|
||||
|
||||
/** Client → broker: query paginated audit entries for a mesh. */
|
||||
export interface WSAuditQueryMessage {
|
||||
type: "audit_query";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
eventType?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: verify the hash chain for the mesh audit log. */
|
||||
export interface WSAuditVerifyMessage {
|
||||
type: "audit_verify";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: paginated audit log entries. */
|
||||
export interface WSAuditResultMessage {
|
||||
type: "audit_result";
|
||||
entries: Array<{
|
||||
id: number;
|
||||
eventType: string;
|
||||
actor: string;
|
||||
payload: Record<string, unknown>;
|
||||
hash: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
total: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: result of hash chain verification. */
|
||||
export interface WSAuditVerifyResultMessage {
|
||||
type: "audit_verify_result";
|
||||
valid: boolean;
|
||||
entries: number;
|
||||
brokenAt?: number;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Simulation clock messages ---
|
||||
|
||||
/** Client → broker: set the simulation clock speed. */
|
||||
export interface WSSetClockMessage {
|
||||
type: "set_clock";
|
||||
speed: number; // multiplier: 1, 2, 5, 10, 50, 100
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: pause the simulation clock. */
|
||||
export interface WSPauseClockMessage {
|
||||
type: "pause_clock";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: resume a paused simulation clock. */
|
||||
export interface WSResumeClockMessage {
|
||||
type: "resume_clock";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: get current clock status. */
|
||||
export interface WSGetClockMessage {
|
||||
type: "get_clock";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: current simulation clock status. */
|
||||
export interface WSClockStatusMessage {
|
||||
type: "clock_status";
|
||||
speed: number;
|
||||
paused: boolean;
|
||||
tick: number;
|
||||
simTime: string; // ISO timestamp
|
||||
startedAt: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
// --- Scheduled messages ---
|
||||
|
||||
/** Client → broker: schedule a message for future delivery. */
|
||||
export interface WSScheduleMessage {
|
||||
type: "schedule";
|
||||
to: string;
|
||||
message: string;
|
||||
/** Unix timestamp (ms) when to deliver. Ignored for cron schedules. */
|
||||
deliverAt: number;
|
||||
/** Optional semantic tag — "reminder" surfaces differently to the receiver. */
|
||||
subtype?: "reminder";
|
||||
/** Standard 5-field cron expression for recurring delivery. */
|
||||
cron?: string;
|
||||
/** Whether this is a recurring schedule. Implied true when `cron` is set. */
|
||||
recurring?: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list pending scheduled messages for this member. */
|
||||
export interface WSListScheduledMessage {
|
||||
type: "list_scheduled";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: cancel a scheduled message by id. */
|
||||
export interface WSCancelScheduledMessage {
|
||||
type: "cancel_scheduled";
|
||||
scheduledId: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for schedule, carries the assigned id. */
|
||||
export interface WSScheduledAckMessage {
|
||||
type: "scheduled_ack";
|
||||
scheduledId: string;
|
||||
deliverAt: number;
|
||||
/** Present for cron schedules — echoes the expression. */
|
||||
cron?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: list of pending scheduled messages. */
|
||||
export interface WSScheduledListMessage {
|
||||
type: "scheduled_list";
|
||||
messages: Array<{
|
||||
id: string;
|
||||
to: string;
|
||||
message: string;
|
||||
deliverAt: number;
|
||||
createdAt: number;
|
||||
/** Present for cron/recurring entries. */
|
||||
cron?: string;
|
||||
/** Number of times the cron entry has fired so far. */
|
||||
firedCount?: number;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: cancel confirmation. */
|
||||
export interface WSCancelScheduledAckMessage {
|
||||
type: "cancel_scheduled_ack";
|
||||
scheduledId: string;
|
||||
ok: boolean;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: deploy an MCP server from zip or git. */
|
||||
export interface WSMcpDeployMessage { type: "mcp_deploy"; server_name: string; source: { type: "zip"; file_id: string } | { type: "git"; url: string; branch?: string; auth?: string }; config?: { env?: Record<string, string>; memory_mb?: number; cpus?: number; network_allow?: string[]; runtime?: "node" | "python" | "bun" }; scope?: "peer" | "mesh" | { peers: string[] } | { group: string } | { groups: string[] } | { role: string }; _reqId?: string; }
|
||||
/** Client → broker: stop and remove a managed MCP server. */
|
||||
export interface WSMcpUndeployMessage { type: "mcp_undeploy"; server_name: string; _reqId?: string; }
|
||||
/** Client → broker: pull + rebuild + restart a git-sourced MCP. */
|
||||
export interface WSMcpUpdateMessage { type: "mcp_update"; server_name: string; _reqId?: string; }
|
||||
/** Client → broker: get logs from a managed MCP. */
|
||||
export interface WSMcpLogsMessage { type: "mcp_logs"; server_name: string; lines?: number; _reqId?: string; }
|
||||
/** Client → broker: get or set visibility scope. */
|
||||
export interface WSMcpScopeMessage { type: "mcp_scope"; server_name: string; scope?: "peer" | "mesh" | { peers: string[] } | { group: string } | { groups: string[] } | { role: string }; _reqId?: string; }
|
||||
/** Client → broker: inspect tool schemas for a deployed service. */
|
||||
export interface WSMcpSchemaMessage { type: "mcp_schema"; server_name: string; tool_name?: string; _reqId?: string; }
|
||||
/** Client → broker: list all deployed services. */
|
||||
export interface WSMcpCatalogMessage { type: "mcp_catalog"; _reqId?: string; }
|
||||
/** Client → broker: deploy a skill bundle from zip or git. */
|
||||
export interface WSSkillDeployMessage { type: "skill_deploy"; source: { type: "zip"; file_id: string } | { type: "git"; url: string; branch?: string; auth?: string }; _reqId?: string; }
|
||||
/** Client → broker: store encrypted credential. */
|
||||
export interface WSVaultSetMessage { type: "vault_set"; key: string; ciphertext: string; nonce: string; sealed_key: string; entry_type: "env" | "file"; mount_path?: string; description?: string; _reqId?: string; }
|
||||
/** Client → broker: list vault entries. */
|
||||
export interface WSVaultListMessage { type: "vault_list"; _reqId?: string; }
|
||||
/** Client → broker: delete vault entry. */
|
||||
export interface WSVaultDeleteMessage { type: "vault_delete"; key: string; _reqId?: string; }
|
||||
/** Client → broker: fetch encrypted vault entries for local decryption. */
|
||||
export interface WSVaultGetMessage { type: "vault_get"; keys: string[]; _reqId?: string; }
|
||||
|
||||
/** Client → broker: start watching a URL for changes. */
|
||||
export interface WSWatchMessage { type: "watch"; url: string; mode?: "hash" | "json" | "status"; extract?: string; interval?: number; notify_on?: string; headers?: Record<string, string>; label?: string; _reqId?: string; }
|
||||
/** Client → broker: stop watching. */
|
||||
export interface WSUnwatchMessage { type: "unwatch"; watchId: string; _reqId?: string; }
|
||||
/** Client → broker: list active watches. */
|
||||
export interface WSWatchListMessage { type: "watch_list"; _reqId?: string; }
|
||||
/** Broker → client: watch created acknowledgement. */
|
||||
export interface WSWatchAckMessage { type: "watch_ack"; watchId: string; url: string; mode: string; interval: number; _reqId?: string; }
|
||||
/** Broker → client: watch list response. */
|
||||
export interface WSWatchListResultMessage { type: "watch_list_result"; watches: Array<{ id: string; url: string; mode: string; label?: string; interval: number; lastHash?: string; lastValue?: string; lastCheck?: string; createdAt: string }>; _reqId?: string; }
|
||||
/** Broker → client: URL change detected. */
|
||||
export interface WSWatchTriggeredMessage { type: "watch_triggered"; watchId: string; url: string; label?: string; mode: string; oldValue: string; newValue: string; timestamp: string; }
|
||||
|
||||
export type WSClientMessage =
|
||||
| WSHelloMessage
|
||||
| WSSendMessage
|
||||
| WSSetStatusMessage
|
||||
| WSListPeersMessage
|
||||
| WSSetSummaryMessage
|
||||
| WSSetVisibleMessage
|
||||
| WSSetProfileMessage
|
||||
| WSJoinGroupMessage
|
||||
| WSLeaveGroupMessage
|
||||
| WSSetStateMessage
|
||||
@@ -627,6 +1138,7 @@ export type WSClientMessage =
|
||||
| WSListFilesMessage
|
||||
| WSFileStatusMessage
|
||||
| WSDeleteFileMessage
|
||||
| WSGrantFileAccessMessage
|
||||
| WSShareContextMessage
|
||||
| WSGetContextMessage
|
||||
| WSListContextsMessage
|
||||
@@ -648,7 +1160,135 @@ export type WSClientMessage =
|
||||
| WSSubscribeMessage
|
||||
| WSUnsubscribeMessage
|
||||
| WSListStreamsMessage
|
||||
| WSMeshInfoMessage;
|
||||
| WSMeshInfoMessage
|
||||
| WSSetClockMessage
|
||||
| WSPauseClockMessage
|
||||
| WSResumeClockMessage
|
||||
| WSGetClockMessage
|
||||
| WSScheduleMessage
|
||||
| WSListScheduledMessage
|
||||
| WSCancelScheduledMessage
|
||||
| WSMcpRegisterMessage
|
||||
| WSMcpUnregisterMessage
|
||||
| WSMcpListMessage
|
||||
| WSMcpCallMessage
|
||||
| WSMcpCallResponseMessage
|
||||
| WSShareSkillMessage
|
||||
| WSGetSkillMessage
|
||||
| WSListSkillsMessage
|
||||
| WSRemoveSkillMessage
|
||||
| WSSetStatsMessage
|
||||
| WSCreateWebhookMessage
|
||||
| WSListWebhooksMessage
|
||||
| WSDeleteWebhookMessage
|
||||
| WSPeerFileRequestMessage
|
||||
| WSPeerFileResponseMessage
|
||||
| WSPeerDirRequestMessage
|
||||
| WSPeerDirResponseMessage
|
||||
| WSAuditQueryMessage
|
||||
| WSAuditVerifyMessage
|
||||
| WSMcpDeployMessage
|
||||
| WSMcpUndeployMessage
|
||||
| WSMcpUpdateMessage
|
||||
| WSMcpLogsMessage
|
||||
| WSMcpScopeMessage
|
||||
| WSMcpSchemaMessage
|
||||
| WSMcpCatalogMessage
|
||||
| WSSkillDeployMessage
|
||||
| WSVaultSetMessage
|
||||
| WSVaultListMessage
|
||||
| WSVaultDeleteMessage
|
||||
| WSVaultGetMessage
|
||||
| WSWatchMessage
|
||||
| WSUnwatchMessage
|
||||
| WSWatchListMessage;
|
||||
|
||||
// --- Skill messages ---
|
||||
|
||||
/** Client → broker: publish or update a skill. */
|
||||
export interface WSShareSkillMessage {
|
||||
type: "share_skill";
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
tags?: string[];
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: load a skill by name. */
|
||||
export interface WSGetSkillMessage {
|
||||
type: "get_skill";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: list skills, optionally filtered by keyword. */
|
||||
export interface WSListSkillsMessage {
|
||||
type: "list_skills";
|
||||
query?: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Client → broker: remove a skill by name. */
|
||||
export interface WSRemoveSkillMessage {
|
||||
type: "remove_skill";
|
||||
name: string;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: acknowledgement for share_skill or remove_skill. */
|
||||
export interface WSSkillAckMessage {
|
||||
type: "skill_ack";
|
||||
name: string;
|
||||
action: "shared" | "removed" | "not_found";
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to get_skill with full skill data. */
|
||||
export interface WSSkillDataMessage {
|
||||
type: "skill_data";
|
||||
skill: {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
createdAt: string;
|
||||
} | null;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: response to list_skills. */
|
||||
export interface WSSkillListMessage {
|
||||
type: "skill_list";
|
||||
skills: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
author: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
_reqId?: string;
|
||||
}
|
||||
|
||||
/** Broker → client: deployment progress/result. */
|
||||
export interface WSMcpDeployStatusMessage { type: "mcp_deploy_status"; server_name: string; status: "building" | "installing" | "running" | "failed"; tools?: Array<{ name: string; description: string; inputSchema: object }>; error?: string; _reqId?: string; }
|
||||
/** Broker → client: service log output. */
|
||||
export interface WSMcpLogsResultMessage { type: "mcp_logs_result"; server_name: string; lines: string[]; _reqId?: string; }
|
||||
/** Broker → client: tool schema introspection result. */
|
||||
export interface WSMcpSchemaResultMessage { type: "mcp_schema_result"; server_name: string; tools: Array<{ name: string; description: string; inputSchema: object }>; _reqId?: string; }
|
||||
/** Broker → client: full service catalog. */
|
||||
export interface WSMcpCatalogResultMessage { type: "mcp_catalog_result"; services: Array<{ name: string; type: "mcp" | "skill"; description: string; status: string; tool_count: number; deployed_by: string; scope: { type: string; [key: string]: unknown }; source_type: string; runtime?: string; created_at: string }>; _reqId?: string; }
|
||||
/** Broker → client: scope query/set result. */
|
||||
export interface WSMcpScopeResultMessage { type: "mcp_scope_result"; server_name: string; scope: { type: string; [key: string]: unknown }; deployed_by: string; _reqId?: string; }
|
||||
/** Broker → client: skill deploy acknowledgement. */
|
||||
export interface WSSkillDeployAckMessage { type: "skill_deploy_ack"; name: string; files: string[]; _reqId?: string; }
|
||||
/** Broker → client: vault operation acknowledgement. */
|
||||
export interface WSVaultAckMessage { type: "vault_ack"; key: string; action: "stored" | "deleted" | "not_found"; _reqId?: string; }
|
||||
/** Broker → client: vault entry listing. */
|
||||
export interface WSVaultListResultMessage { type: "vault_list_result"; entries: Array<{ key: string; entry_type: "env" | "file"; mount_path?: string; description?: string; updated_at: string }>; _reqId?: string; }
|
||||
/** Broker → client: encrypted vault entries for local decryption. */
|
||||
export interface WSVaultGetResultMessage { type: "vault_get_result"; entries: Array<{ key: string; ciphertext: string; nonce: string; sealed_key: string; entry_type: string; mount_path?: string }>; _reqId?: string; }
|
||||
|
||||
export type WSServerMessage =
|
||||
| WSHelloAckMessage
|
||||
@@ -664,11 +1304,13 @@ export type WSServerMessage =
|
||||
| WSFileUrlMessage
|
||||
| WSFileListMessage
|
||||
| WSFileStatusResultMessage
|
||||
| WSGrantFileAccessOkMessage
|
||||
| WSContextSharedMessage
|
||||
| WSContextResultsMessage
|
||||
| WSContextListMessage
|
||||
| WSTaskCreatedMessage
|
||||
| WSTaskListMessage
|
||||
| WSVectorStoredMessage
|
||||
| WSVectorResultsMessage
|
||||
| WSCollectionListMessage
|
||||
| WSGraphResultMessage
|
||||
@@ -676,6 +1318,38 @@ export type WSServerMessage =
|
||||
| WSMeshSchemaResultMessage
|
||||
| WSStreamCreatedMessage
|
||||
| WSStreamDataMessage
|
||||
| WSSubscribedMessage
|
||||
| WSStreamListMessage
|
||||
| WSMeshInfoResultMessage
|
||||
| WSScheduledAckMessage
|
||||
| WSScheduledListMessage
|
||||
| WSCancelScheduledAckMessage
|
||||
| WSMcpRegisterAckMessage
|
||||
| WSMcpListResultMessage
|
||||
| WSMcpCallResultMessage
|
||||
| WSMcpCallForwardMessage
|
||||
| WSClockStatusMessage
|
||||
| WSSkillAckMessage
|
||||
| WSSkillDataMessage
|
||||
| WSSkillListMessage
|
||||
| WSWebhookAckMessage
|
||||
| WSWebhookListMessage
|
||||
| WSPeerFileRequestForwardMessage
|
||||
| WSPeerFileResponseForwardMessage
|
||||
| WSPeerDirRequestForwardMessage
|
||||
| WSPeerDirResponseForwardMessage
|
||||
| WSAuditResultMessage
|
||||
| WSAuditVerifyResultMessage
|
||||
| WSMcpDeployStatusMessage
|
||||
| WSMcpLogsResultMessage
|
||||
| WSMcpSchemaResultMessage
|
||||
| WSMcpCatalogResultMessage
|
||||
| WSMcpScopeResultMessage
|
||||
| WSSkillDeployAckMessage
|
||||
| WSVaultAckMessage
|
||||
| WSVaultListResultMessage
|
||||
| WSVaultGetResultMessage
|
||||
| WSWatchAckMessage
|
||||
| WSWatchListResultMessage
|
||||
| WSWatchTriggeredMessage
|
||||
| WSErrorMessage;
|
||||
|
||||
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",
|
||||
"version": "0.5.8",
|
||||
"version": "0.10.6",
|
||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"citty": "0.2.2",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"ws": "8.20.0",
|
||||
"zod": "4.1.13"
|
||||
|
||||
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 { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { loadConfig } from "../state/config";
|
||||
|
||||
const MCP_NAME = "claudemesh";
|
||||
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
|
||||
@@ -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,
|
||||
* 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).
|
||||
if (!skipHooks) {
|
||||
try {
|
||||
@@ -345,12 +452,35 @@ export function runInstall(args: string[] = []): void {
|
||||
console.log(dim("· Hooks skipped (--no-hooks)"));
|
||||
}
|
||||
|
||||
// Check if user has any meshes joined — nudge them if not.
|
||||
let hasMeshes = false;
|
||||
try {
|
||||
const meshConfig = loadConfig();
|
||||
hasMeshes = meshConfig.meshes.length > 0;
|
||||
} catch {
|
||||
// Config missing or corrupt — treat as no meshes.
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
|
||||
console.log("");
|
||||
console.log(
|
||||
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
||||
);
|
||||
|
||||
if (!hasMeshes) {
|
||||
console.log("");
|
||||
console.log(yellow("No meshes joined.") + " To connect with peers:");
|
||||
console.log(
|
||||
` ${bold("claudemesh join <invite-url>")}` +
|
||||
dim(" — join an existing mesh"),
|
||||
);
|
||||
console.log(
|
||||
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
|
||||
);
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(
|
||||
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(
|
||||
yellow("⚠ For real-time push messages from peers, launch with:"),
|
||||
@@ -375,6 +505,20 @@ export function runUninstall(): void {
|
||||
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
|
||||
try {
|
||||
const removed = uninstallHooks();
|
||||
|
||||
@@ -1,35 +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
|
||||
* 2. Generate a fresh ed25519 keypair (libsodium)
|
||||
* 3. POST /join to the broker → get member_id
|
||||
* 4. Persist the mesh + keypair to ~/.claudemesh/config.json (0600)
|
||||
* 5. Print success
|
||||
* Accepts either:
|
||||
* - v2 short invite: `claudemesh.com/i/<code>` or bare `<code>`
|
||||
* → POSTs to /api/public/invites/:code/claim, unseals root_key,
|
||||
* persists mesh + fresh ed25519 identity.
|
||||
* - 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 { enrollWithBroker } from "../invite/enroll";
|
||||
import { generateKeypair } from "../crypto/keypair";
|
||||
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
||||
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> {
|
||||
const link = args[0];
|
||||
if (!link) {
|
||||
console.error("Usage: claudemesh join <invite-url-or-token>");
|
||||
console.error("Usage: claudemesh join <invite-url-or-code>");
|
||||
console.error("");
|
||||
console.error(
|
||||
"Example: claudemesh join https://claudemesh.com/join/eyJ2IjoxLC4uLn0",
|
||||
);
|
||||
console.error("Examples:");
|
||||
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);
|
||||
}
|
||||
|
||||
// Try v2 first — short code / `/i/<code>` URL.
|
||||
const v2Code = parseV2InviteInput(link);
|
||||
if (v2Code) {
|
||||
await runJoinV2(v2Code);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Parse + verify signature client-side.
|
||||
let invite;
|
||||
try {
|
||||
|
||||
@@ -1,90 +1,42 @@
|
||||
/**
|
||||
* `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:
|
||||
* 1. Parse --name, --join, --mesh, --quiet flags
|
||||
* 2. If --join: run join flow first (accepts token or URL)
|
||||
* 1. Receive parsed flags from citty + rawArgs for -- passthrough
|
||||
* 2. If --join: run join flow first
|
||||
* 3. Load config → pick mesh (auto if 1, interactive picker if >1)
|
||||
* 4. Write per-session config to tmpdir (isolates mesh selection)
|
||||
* 5. Spawn claude with CLAUDEMESH_CONFIG_DIR + CLAUDEMESH_DISPLAY_NAME
|
||||
* 6. On exit: cleanup tmpdir
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync } from "node:fs";
|
||||
import { tmpdir, hostname } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync, readFileSync } from "node:fs";
|
||||
import { tmpdir, hostname, homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { loadConfig, getConfigPath } from "../state/config";
|
||||
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
|
||||
import { startCallbackListener, openBrowser, generatePairingCode } from "../auth";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
|
||||
// --- Arg parsing ---
|
||||
|
||||
interface LaunchArgs {
|
||||
name: string | null;
|
||||
role: string | null;
|
||||
groups: string | null; // comma-separated, e.g. "frontend:lead,reviewers:member"
|
||||
joinLink: string | null;
|
||||
meshSlug: string | null;
|
||||
messageMode: "push" | "inbox" | "off" | null;
|
||||
quiet: boolean;
|
||||
skipPermConfirm: boolean;
|
||||
claudeArgs: string[];
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): LaunchArgs {
|
||||
const result: LaunchArgs = {
|
||||
name: null,
|
||||
role: null,
|
||||
groups: null,
|
||||
joinLink: null,
|
||||
meshSlug: null,
|
||||
messageMode: null,
|
||||
quiet: false,
|
||||
skipPermConfirm: 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 === "--role" && i + 1 < argv.length) {
|
||||
result.role = argv[++i]!;
|
||||
} else if (arg.startsWith("--role=")) {
|
||||
result.role = arg.slice("--role=".length);
|
||||
} else if (arg === "--groups" && i + 1 < argv.length) {
|
||||
result.groups = argv[++i]!;
|
||||
} else if (arg.startsWith("--groups=")) {
|
||||
result.groups = arg.slice("--groups=".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 === "--inbox") {
|
||||
result.messageMode = "inbox";
|
||||
} else if (arg === "--no-messages") {
|
||||
result.messageMode = "off";
|
||||
} else if (arg === "--quiet") {
|
||||
result.quiet = true;
|
||||
} else if (arg === "-y" || arg === "--yes") {
|
||||
result.skipPermConfirm = true;
|
||||
} else if (arg === "--") {
|
||||
result.claudeArgs.push(...argv.slice(i + 1));
|
||||
break;
|
||||
} else {
|
||||
result.claudeArgs.push(arg);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return result;
|
||||
// Flags as parsed by citty (index.ts is the source of truth for definitions).
|
||||
export interface LaunchFlags {
|
||||
name?: string;
|
||||
role?: string;
|
||||
groups?: string;
|
||||
join?: string;
|
||||
mesh?: string;
|
||||
"message-mode"?: string;
|
||||
"system-prompt"?: string;
|
||||
resume?: string;
|
||||
continue?: boolean;
|
||||
yes?: boolean;
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
// --- Interactive mesh picker ---
|
||||
@@ -151,12 +103,12 @@ async function confirmPermissions(): Promise<void> {
|
||||
|
||||
console.log(yellow(bold(" Autonomous mode")));
|
||||
console.log("");
|
||||
console.log(" Claude will send and receive peer messages without asking");
|
||||
console.log(" you first. Peers exchange text only — no file access,");
|
||||
console.log(" no tool calls, no code execution.");
|
||||
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(" Same as: claude --dangerously-skip-permissions"));
|
||||
console.log(dim(" Skip this prompt: claudemesh launch -y"));
|
||||
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 });
|
||||
@@ -177,6 +129,192 @@ async function confirmPermissions(): Promise<void> {
|
||||
|
||||
// --- Banner ---
|
||||
|
||||
import {
|
||||
bold as tBold, dim as tDim, green as tGreen, orange as tOrange,
|
||||
boldOrange, HIDE_CURSOR, SHOW_CURSOR,
|
||||
} from "../tui/colors";
|
||||
import {
|
||||
enterFullScreen, exitFullScreen, writeCentered, termSize,
|
||||
drawTopBar, drawBottomBar, menuSelect, textInput, confirmPrompt,
|
||||
} from "../tui/screen";
|
||||
import { createSpinner, FRAME_HEIGHT } from "../tui/spinner";
|
||||
|
||||
interface LaunchWizardResult {
|
||||
mesh: JoinedMesh;
|
||||
role: string | null;
|
||||
groups: GroupEntry[];
|
||||
messageMode: "push" | "inbox" | "off";
|
||||
skipPermissions: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen launch wizard — spinning logo + interactive config.
|
||||
* Mesh selection, role, groups, message mode, permissions — all in one TUI.
|
||||
* Falls back to plain text on non-TTY.
|
||||
*/
|
||||
async function runLaunchWizard(opts: {
|
||||
displayName: string;
|
||||
meshes: JoinedMesh[];
|
||||
selectedMesh: JoinedMesh | null;
|
||||
existingRole: string | null;
|
||||
existingGroups: GroupEntry[];
|
||||
existingMessageMode: "push" | "inbox" | "off" | null;
|
||||
skipPermConfirm: boolean;
|
||||
}): Promise<LaunchWizardResult> {
|
||||
if (!process.stdout.isTTY) {
|
||||
return {
|
||||
mesh: opts.selectedMesh ?? opts.meshes[0]!,
|
||||
role: opts.existingRole,
|
||||
groups: opts.existingGroups,
|
||||
messageMode: opts.existingMessageMode ?? "push",
|
||||
skipPermissions: opts.skipPermConfirm,
|
||||
};
|
||||
}
|
||||
|
||||
const { rows } = termSize();
|
||||
enterFullScreen();
|
||||
drawTopBar();
|
||||
|
||||
// Spinning logo centered in upper portion
|
||||
const logoTop = Math.floor((rows - FRAME_HEIGHT - 16) / 2);
|
||||
const brandRow = logoTop + FRAME_HEIGHT + 1;
|
||||
const subtitleRow = brandRow + 1;
|
||||
const formRow = subtitleRow + 2;
|
||||
|
||||
writeCentered(brandRow, boldOrange("claudemesh"));
|
||||
writeCentered(subtitleRow, tDim("peer mesh for Claude Code"));
|
||||
|
||||
const spinner = createSpinner({
|
||||
render(lines) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
writeCentered(logoTop + i, lines[i]!);
|
||||
}
|
||||
},
|
||||
interval: 70,
|
||||
});
|
||||
spinner.start();
|
||||
|
||||
// Show detected info
|
||||
let row = formRow;
|
||||
writeCentered(row, `Directory ${tGreen("✓")} ${process.cwd()}`);
|
||||
row++;
|
||||
writeCentered(row, `Name ${tGreen("✓")} ${opts.displayName}`);
|
||||
row += 2;
|
||||
|
||||
// Mesh selection
|
||||
let mesh: JoinedMesh;
|
||||
if (opts.selectedMesh) {
|
||||
mesh = opts.selectedMesh;
|
||||
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
|
||||
row++;
|
||||
} else if (opts.meshes.length === 1) {
|
||||
mesh = opts.meshes[0]!;
|
||||
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
|
||||
row++;
|
||||
} else {
|
||||
spinner.stop();
|
||||
const choice = await menuSelect({
|
||||
title: "Select mesh",
|
||||
items: opts.meshes.map(m => m.slug),
|
||||
row,
|
||||
});
|
||||
mesh = opts.meshes[choice]!;
|
||||
// Redraw as confirmed
|
||||
for (let i = 0; i < opts.meshes.length + 1; i++) {
|
||||
writeCentered(row + i, " ");
|
||||
}
|
||||
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
|
||||
spinner.start();
|
||||
row++;
|
||||
}
|
||||
|
||||
row++;
|
||||
|
||||
// Interactive fields
|
||||
let role = opts.existingRole;
|
||||
let groups = opts.existingGroups;
|
||||
let messageMode = opts.existingMessageMode ?? "push" as "push" | "inbox" | "off";
|
||||
|
||||
// Role input
|
||||
if (role === null) {
|
||||
spinner.stop();
|
||||
const answer = await textInput({ label: "Role", row, placeholder: "optional — press Enter to skip" });
|
||||
if (answer) role = answer;
|
||||
spinner.start();
|
||||
row++;
|
||||
} else {
|
||||
writeCentered(row, `Role ${tGreen("✓")} ${role}`);
|
||||
row++;
|
||||
}
|
||||
|
||||
// Groups input
|
||||
if (groups.length === 0) {
|
||||
spinner.stop();
|
||||
const answer = await textInput({ label: "Groups", row, placeholder: "comma-separated, optional" });
|
||||
if (answer) groups = parseGroupsString(answer);
|
||||
spinner.start();
|
||||
row++;
|
||||
} else {
|
||||
const tags = groups.map(g => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ");
|
||||
writeCentered(row, `Groups ${tGreen("✓")} ${tags}`);
|
||||
row++;
|
||||
}
|
||||
|
||||
// Message mode selection
|
||||
if (opts.existingMessageMode === null) {
|
||||
row++;
|
||||
spinner.stop();
|
||||
const choice = await menuSelect({
|
||||
title: "Message mode",
|
||||
items: [
|
||||
"Push (real-time, peers can interrupt)",
|
||||
"Inbox (held until you check)",
|
||||
"Off (tools only, no messages)",
|
||||
],
|
||||
row,
|
||||
});
|
||||
messageMode = (["push", "inbox", "off"] as const)[choice];
|
||||
spinner.start();
|
||||
row += 5;
|
||||
} else {
|
||||
writeCentered(row, `Messages ${tGreen("✓")} ${messageMode}`);
|
||||
row++;
|
||||
}
|
||||
|
||||
// Permissions confirmation
|
||||
let skipPermissions = opts.skipPermConfirm;
|
||||
if (!skipPermissions) {
|
||||
row++;
|
||||
spinner.stop();
|
||||
writeCentered(row, tDim("Claude will run with --dangerously-skip-permissions,"));
|
||||
writeCentered(row + 1, tDim("bypassing ALL permission prompts — not just claudemesh."));
|
||||
row += 3;
|
||||
const confirmed = await confirmPrompt({
|
||||
message: boldOrange("Autonomous mode?"),
|
||||
row,
|
||||
defaultYes: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
exitFullScreen();
|
||||
console.log(" Run without autonomous mode:");
|
||||
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
|
||||
process.exit(0);
|
||||
}
|
||||
skipPermissions = true;
|
||||
spinner.start();
|
||||
}
|
||||
|
||||
// Final animation
|
||||
row += 2;
|
||||
writeCentered(row, tDim("Launching Claude Code..."));
|
||||
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
spinner.stop();
|
||||
exitFullScreen();
|
||||
|
||||
return { mesh, role, groups, messageMode, skipPermissions };
|
||||
}
|
||||
|
||||
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
@@ -206,8 +344,28 @@ function printBanner(name: string, meshSlug: string, role: string | null, groups
|
||||
|
||||
// --- Main ---
|
||||
|
||||
export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
const args = parseArgs(extraArgs);
|
||||
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
|
||||
// Extract args that follow "--" — passed straight through to claude.
|
||||
const dashIdx = rawArgs.indexOf("--");
|
||||
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
|
||||
|
||||
// Normalise flags into the internal shape used below.
|
||||
const args = {
|
||||
name: flags.name ?? null,
|
||||
role: flags.role ?? null,
|
||||
groups: flags.groups ?? null,
|
||||
joinLink: flags.join ?? null,
|
||||
meshSlug: flags.mesh ?? null,
|
||||
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
|
||||
? flags["message-mode"] as "push" | "inbox" | "off"
|
||||
: null),
|
||||
systemPrompt: flags["system-prompt"] ?? null,
|
||||
resume: flags.resume ?? null,
|
||||
continueSession: flags.continue ?? false,
|
||||
quiet: flags.quiet ?? false,
|
||||
skipPermConfirm: flags.yes ?? false,
|
||||
claudeArgs: claudePassthrough,
|
||||
};
|
||||
|
||||
// 1. If --join, run join flow first.
|
||||
if (args.joinLink) {
|
||||
@@ -245,13 +403,89 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
|
||||
// 2. Load config, pick mesh.
|
||||
const config = loadConfig();
|
||||
let justSynced = false;
|
||||
|
||||
if (config.meshes.length === 0 && !args.joinLink) {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
|
||||
const code = generatePairingCode();
|
||||
const listener = await startCallbackListener();
|
||||
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
|
||||
|
||||
console.log(`\n ${bold("Welcome to claudemesh!")} No meshes found.`);
|
||||
console.log(` Opening browser to sign in...\n`);
|
||||
|
||||
const opened = await openBrowser(url);
|
||||
if (!opened) {
|
||||
console.log(` Couldn't open browser automatically.`);
|
||||
}
|
||||
console.log(` ${dim(`Visit: ${url}`)}`);
|
||||
console.log(` ${dim(`Or join with invite: claudemesh launch --join <url>`)}\n`);
|
||||
|
||||
// Race: localhost callback vs manual paste vs timeout
|
||||
const manualPromise = new Promise<string>((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question(" Paste sync token (or wait for browser): ", (answer) => {
|
||||
rl.close();
|
||||
if (answer.trim()) resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), 15 * 60_000);
|
||||
});
|
||||
|
||||
const syncToken = await Promise.race([
|
||||
listener.token,
|
||||
manualPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
listener.close();
|
||||
|
||||
if (!syncToken) {
|
||||
console.error("\n Timed out waiting for sign-in.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Generate keypair and sync with broker
|
||||
const { generateKeypair } = await import("../crypto/keypair");
|
||||
const keypair = await generateKeypair();
|
||||
const displayNameForSync = args.name ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
const { syncWithBroker } = await import("../auth/sync-with-broker");
|
||||
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
|
||||
|
||||
// Write all meshes to config
|
||||
const { saveConfig } = await import("../state/config");
|
||||
for (const m of result.meshes) {
|
||||
config.meshes.push({
|
||||
meshId: m.mesh_id,
|
||||
memberId: m.member_id,
|
||||
slug: m.slug,
|
||||
name: m.slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: m.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
justSynced = true;
|
||||
|
||||
console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`);
|
||||
}
|
||||
|
||||
if (config.meshes.length === 0) {
|
||||
console.error(
|
||||
"No meshes joined. Run `claudemesh join <url>` or use --join <url>.",
|
||||
);
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` or use --join <url>.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve mesh — by flag, auto (if 1), or defer to wizard (if >1)
|
||||
let mesh: JoinedMesh;
|
||||
if (args.meshSlug) {
|
||||
const found = config.meshes.find((m) => m.slug === args.meshSlug);
|
||||
@@ -262,43 +496,38 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
mesh = found;
|
||||
} else if (config.meshes.length === 1) {
|
||||
mesh = config.meshes[0]!;
|
||||
} else {
|
||||
mesh = await pickMesh(config.meshes);
|
||||
// Multiple meshes — wizard will handle selection
|
||||
mesh = null as unknown as JoinedMesh; // set by wizard below
|
||||
}
|
||||
|
||||
// 3. Session identity + role/groups.
|
||||
// The WS client auto-generates a per-session ephemeral keypair on
|
||||
// connect (sent in hello as sessionPubkey). We set display name via env var.
|
||||
// 3. Session identity + role/groups via TUI wizard.
|
||||
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
// Interactive wizard for role & groups (when not provided via flags and not --quiet).
|
||||
let role: string | null = args.role;
|
||||
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
|
||||
|
||||
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
|
||||
|
||||
if (!args.quiet) {
|
||||
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("");
|
||||
if (!args.quiet && !justSynced) {
|
||||
const wizardResult = await runLaunchWizard({
|
||||
displayName,
|
||||
meshes: config.meshes,
|
||||
selectedMesh: mesh ?? null,
|
||||
existingRole: args.role,
|
||||
existingGroups: parsedGroups,
|
||||
existingMessageMode: args.messageMode ?? null,
|
||||
skipPermConfirm: args.skipPermConfirm,
|
||||
});
|
||||
mesh = wizardResult.mesh;
|
||||
role = wizardResult.role;
|
||||
parsedGroups = wizardResult.groups;
|
||||
messageMode = wizardResult.messageMode;
|
||||
args.skipPermConfirm = wizardResult.skipPermissions;
|
||||
} else if (!mesh) {
|
||||
// Quiet mode + multiple meshes — fall back to old picker
|
||||
mesh = await pickMesh(config.meshes);
|
||||
}
|
||||
|
||||
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)
|
||||
@@ -312,12 +541,63 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// Clean up stale mesh MCP entries from crashed sessions
|
||||
try {
|
||||
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||
if (existsSync(claudeConfigPath)) {
|
||||
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||
const mcpServers = claudeConfig.mcpServers ?? {};
|
||||
let cleaned = 0;
|
||||
for (const key of Object.keys(mcpServers)) {
|
||||
if (!key.startsWith("mesh:")) continue;
|
||||
const meta = mcpServers[key]?._meshSession;
|
||||
if (!meta?.pid) continue;
|
||||
// Check if the PID is still alive
|
||||
try {
|
||||
process.kill(meta.pid, 0); // signal 0 = check existence
|
||||
} catch {
|
||||
// PID is dead — remove stale entry
|
||||
delete mcpServers[key];
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
if (cleaned > 0) {
|
||||
claudeConfig.mcpServers = mcpServers;
|
||||
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// --- Fetch deployed services for native MCP entries ---
|
||||
let serviceCatalog: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
tools: Array<{ name: string; description: string; inputSchema: object }>;
|
||||
deployed_by: string;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const tmpClient = new BrokerClient(mesh, { displayName });
|
||||
await tmpClient.connect();
|
||||
// Wait briefly for hello_ack with service catalog
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
serviceCatalog = tmpClient.serviceCatalog;
|
||||
tmpClient.close();
|
||||
} catch {
|
||||
// Non-fatal — launch without native service entries
|
||||
if (!args.quiet) {
|
||||
console.log(" (Could not fetch service catalog — mesh services won't be natively available)");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Write session config to tmpdir (isolates mesh selection).
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
|
||||
const sessionConfig: Config = {
|
||||
version: 1,
|
||||
meshes: [mesh],
|
||||
displayName,
|
||||
...(role ? { role } : {}),
|
||||
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
|
||||
messageMode,
|
||||
};
|
||||
@@ -327,12 +607,61 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// 5. Banner + permission confirmation.
|
||||
// 5. Print summary banner (wizard already handled all interactive config).
|
||||
if (!args.quiet) {
|
||||
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
|
||||
// Auto-permissions confirmation — needed for autonomous peer messaging.
|
||||
if (!args.skipPermConfirm) {
|
||||
await confirmPermissions();
|
||||
}
|
||||
|
||||
// --- Install native MCP entries for deployed mesh services ---
|
||||
const meshMcpEntries: Array<{ key: string; entry: unknown }> = [];
|
||||
|
||||
if (serviceCatalog.length > 0) {
|
||||
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||
|
||||
// Read-modify-write: only touch mesh:* entries in mcpServers
|
||||
let claudeConfig: Record<string, unknown> = {};
|
||||
try {
|
||||
claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||
} catch {
|
||||
claudeConfig = {};
|
||||
}
|
||||
|
||||
const mcpServers = (claudeConfig.mcpServers ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Session-scoped key: mesh:<service>:<sessionId>
|
||||
const sessionTag = `${process.pid}`;
|
||||
|
||||
for (const svc of serviceCatalog) {
|
||||
if (svc.status !== "running") continue;
|
||||
const entryKey = `mesh:${svc.name}:${sessionTag}`;
|
||||
const entry = {
|
||||
command: "claudemesh",
|
||||
args: ["mcp", "--service", svc.name],
|
||||
env: {
|
||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||
},
|
||||
_meshSession: {
|
||||
pid: process.pid,
|
||||
meshSlug: mesh.slug,
|
||||
serviceName: svc.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
mcpServers[entryKey] = entry;
|
||||
meshMcpEntries.push({ key: entryKey, entry });
|
||||
}
|
||||
|
||||
claudeConfig.mcpServers = mcpServers;
|
||||
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||
|
||||
if (!args.quiet && meshMcpEntries.length > 0) {
|
||||
console.log(` ${meshMcpEntries.length} mesh service(s) registered as native MCPs:`);
|
||||
for (const { key } of meshMcpEntries) {
|
||||
const svcName = key.split(":")[1];
|
||||
const svc = serviceCatalog.find(s => s.name === svcName);
|
||||
console.log(` ${svcName} (${svc?.tools.length ?? 0} tools)`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,55 +676,142 @@ export async function runLaunch(extraArgs: string[]): Promise<void> {
|
||||
}
|
||||
filtered.push(args.claudeArgs[i]!);
|
||||
}
|
||||
// --dangerously-skip-permissions is only added when the user explicitly
|
||||
// passes -y / --yes. Without it, claudemesh tools still work because
|
||||
// `claudemesh install` pre-approves them via allowedTools in settings.json.
|
||||
// This keeps permissions tight for multi-person meshes.
|
||||
// Session identity: --resume reuses existing session, otherwise generate new.
|
||||
// When resuming, Claude Code reuses the session ID so the mesh peer identity persists.
|
||||
const isResume = args.resume !== null || args.continueSession;
|
||||
const claudeSessionId = isResume ? undefined : randomUUID();
|
||||
|
||||
const claudeArgs = [
|
||||
"--dangerously-load-development-channels",
|
||||
"server:claudemesh",
|
||||
"--dangerously-skip-permissions",
|
||||
...(claudeSessionId ? ["--session-id", claudeSessionId] : []),
|
||||
...(args.resume ? ["--resume", args.resume] : []),
|
||||
...(args.continueSession ? ["--continue"] : []),
|
||||
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
|
||||
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
|
||||
...filtered,
|
||||
];
|
||||
|
||||
// Resolve the full path to `claude` — when launched from a non-interactive
|
||||
// shell (e.g. nvm node shebang), ~/.local/bin may not be in PATH.
|
||||
const isWindows = process.platform === "win32";
|
||||
const child = spawn("claude", claudeArgs, {
|
||||
let claudeBin = "claude";
|
||||
if (!isWindows) {
|
||||
const candidates = [
|
||||
join(homedir(), ".local", "bin", "claude"),
|
||||
"/usr/local/bin/claude",
|
||||
join(homedir(), ".claude", "bin", "claude"),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (existsSync(c)) { claudeBin = c; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Define cleanup — runs on every exit path via process.on('exit').
|
||||
// Synchronous-only (rmSync + writeFileSync) so it works inside the
|
||||
// 'exit' event, which does not allow async work.
|
||||
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 */ }
|
||||
}
|
||||
// Ephemeral config dir
|
||||
try {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch { /* best effort */ }
|
||||
};
|
||||
|
||||
// Register cleanup on every exit path — including normal exit, uncaught
|
||||
// throws, and fatal signals. process.on('exit') fires synchronously, which
|
||||
// is what the rmSync + writeFileSync above need.
|
||||
process.on("exit", cleanup);
|
||||
|
||||
// 8. Hard-reset the TTY before handing control to claude.
|
||||
//
|
||||
// Every interactive element in the pre-launch flow — the full-screen
|
||||
// wizard (tui/screen.ts), the permission confirmation, the callback-
|
||||
// listener paste prompt, the mesh picker — attaches listeners to
|
||||
// process.stdin, toggles raw mode, hides the cursor, and sometimes
|
||||
// enters the alt-screen. Those helpers do best-effort cleanup in their
|
||||
// own finally blocks, but any leak — an orphaned 'data' listener, a
|
||||
// still-raw TTY, a pending render paint — means the parent node process
|
||||
// keeps competing with claude's Ink TUI for the same keystrokes and
|
||||
// stdout frames. Symptoms: dropped keystrokes at the claude prompt, or
|
||||
// the wizard visibly repainting on top of claude after launch.
|
||||
//
|
||||
// Defensive reset here is cheap and guarantees a clean TTY regardless
|
||||
// of what the wizard helpers did or didn't restore.
|
||||
if (process.stdin.isTTY) {
|
||||
try { process.stdin.setRawMode(false); } catch { /* not a TTY under some parents */ }
|
||||
}
|
||||
process.stdin.removeAllListeners("data");
|
||||
process.stdin.removeAllListeners("keypress");
|
||||
process.stdin.removeAllListeners("readable");
|
||||
process.stdin.pause();
|
||||
if (process.stdout.isTTY) {
|
||||
process.stdout.write("\x1b[?25h"); // show cursor
|
||||
process.stdout.write("\x1b[?1049l"); // exit alt-screen if any wizard step entered it
|
||||
}
|
||||
|
||||
// 9. Block-and-wait on claude with spawnSync.
|
||||
//
|
||||
// Why spawnSync instead of spawn + child.on('exit'):
|
||||
// - spawn keeps the parent node event loop running alongside claude.
|
||||
// Any stray listener, setImmediate, or async wizard tail-end can
|
||||
// still fire during claude's lifetime, stealing input or painting
|
||||
// over claude's TUI.
|
||||
// - spawnSync blocks the parent event loop completely until claude
|
||||
// exits. No listeners fire. Nothing paints. The parent is effectively
|
||||
// suspended, and claude has exclusive ownership of the TTY.
|
||||
//
|
||||
// Signal forwarding: claude inherits the TTY process group via
|
||||
// stdio: "inherit". When the user hits Ctrl-C, the terminal sends
|
||||
// SIGINT to the whole group. Claude handles it (Ink unmounts, exits
|
||||
// cleanly); spawnSync returns with result.signal='SIGINT'. We re-raise
|
||||
// the same signal on the parent so it dies the same way.
|
||||
const result = spawnSync(claudeBin, claudeArgs, {
|
||||
stdio: "inherit",
|
||||
shell: isWindows,
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||
CLAUDEMESH_DISPLAY_NAME: displayName,
|
||||
...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}),
|
||||
MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000",
|
||||
MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000",
|
||||
...(role ? { CLAUDEMESH_ROLE: role } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// 7. Cleanup on exit.
|
||||
const cleanup = (): void => {
|
||||
try {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
};
|
||||
|
||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
||||
cleanup();
|
||||
// 10. Handle the result. Cleanup runs automatically via process.on('exit').
|
||||
if (result.error) {
|
||||
const err = result.error as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT") {
|
||||
console.error(
|
||||
"✗ `claude` not found on PATH. Install Claude Code first.",
|
||||
);
|
||||
console.error("✗ `claude` not found on PATH. Install Claude Code first.");
|
||||
} else {
|
||||
console.error(`✗ failed to launch claude: ${err.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
cleanup();
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
if (result.signal) {
|
||||
// Re-raise the same signal so the parent dies the same way the child did.
|
||||
process.kill(process.pid, result.signal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cleanup on parent signals too.
|
||||
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
||||
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
||||
process.exit(result.status ?? 0);
|
||||
}
|
||||
|
||||
63
apps/cli/src/commands/memory.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* `claudemesh remember <text> [--tags tag1,tag2]` — store a memory in the mesh.
|
||||
* `claudemesh recall <query>` — search mesh memory.
|
||||
*
|
||||
* Useful for AI agents using bash when the MCP server isn't active.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface MemoryFlags {
|
||||
mesh?: string;
|
||||
tags?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runRemember(flags: MemoryFlags, content: string): Promise<void> {
|
||||
const tags = flags.tags
|
||||
? flags.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const id = await client.remember(content, tags);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ id, content, tags }));
|
||||
return;
|
||||
}
|
||||
if (id) {
|
||||
console.log(`✓ Remembered (${id.slice(0, 8)})`);
|
||||
} else {
|
||||
console.error("✗ Failed to store memory");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function runRecall(flags: MemoryFlags, query: string): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const memories = await client.recall(query);
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(memories, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log(dim("No memories found."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const m of memories) {
|
||||
const tags = m.tags.length ? dim(` [${m.tags.join(", ")}]`) : "";
|
||||
console.log(`${bold(m.id.slice(0, 8))}${tags}`);
|
||||
console.log(` ${m.content}`);
|
||||
console.log(dim(` ${m.rememberedBy} · ${new Date(m.rememberedAt).toLocaleString()}`));
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
}
|
||||
55
apps/cli/src/commands/peers.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* `claudemesh peers` — list connected peers in the mesh.
|
||||
*
|
||||
* Connects, fetches the peer list, prints it, disconnects.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface PeersFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runPeers(flags: PeersFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const peers = await client.listPeers();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(peers, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (peers.length === 0) {
|
||||
console.log(dim(`No peers connected on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(`Peers on ${mesh.slug}`) + dim(` (${peers.length})`));
|
||||
console.log("");
|
||||
for (const p of peers) {
|
||||
const groups = p.groups.length
|
||||
? " [" + p.groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||
: "";
|
||||
const statusIcon = p.status === "working" ? yellow("●") : green("●");
|
||||
const name = bold(p.displayName);
|
||||
const meta: string[] = [];
|
||||
if (p.peerType) meta.push(p.peerType);
|
||||
if (p.channel) meta.push(p.channel);
|
||||
if (p.model) meta.push(p.model);
|
||||
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
|
||||
const cwdStr = p.cwd ? dim(` cwd: ${p.cwd}`) : "";
|
||||
const summary = p.summary ? dim(` ${p.summary}`) : "";
|
||||
console.log(` ${statusIcon} ${name}${groups}${metaStr}${summary}`);
|
||||
if (cwdStr) console.log(` ${cwdStr}`);
|
||||
}
|
||||
console.log("");
|
||||
});
|
||||
}
|
||||
114
apps/cli/src/commands/profile.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* `claudemesh profile` — view or edit your member profile.
|
||||
*
|
||||
* Profile fields (roleTag, groups, messageMode, displayName) are persistent
|
||||
* on the server. Changes are pushed to active sessions in real-time.
|
||||
*/
|
||||
|
||||
import { loadConfig } from "../state/config";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
|
||||
export interface ProfileFlags {
|
||||
mesh?: string;
|
||||
"role-tag"?: string;
|
||||
groups?: string;
|
||||
"message-mode"?: string;
|
||||
name?: string;
|
||||
member?: string; // admin only: edit another member
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runProfile(flags: ProfileFlags): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
|
||||
const config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Pick mesh
|
||||
const mesh = flags.mesh
|
||||
? config.meshes.find(m => m.slug === flags.mesh)
|
||||
: config.meshes[0]!;
|
||||
|
||||
if (!mesh) {
|
||||
console.error(`Mesh "${flags.mesh}" not found. Joined: ${config.meshes.map(m => m.slug).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Derive broker HTTP URL from WSS URL
|
||||
const brokerUrl = mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace(/\/ws\/?$/, "");
|
||||
|
||||
const hasEdits = flags["role-tag"] !== undefined || flags.groups !== undefined || flags["message-mode"] !== undefined || flags.name !== undefined;
|
||||
|
||||
if (hasEdits) {
|
||||
// PATCH member profile
|
||||
const targetMemberId = flags.member ?? mesh.memberId; // TODO: resolve --member by name
|
||||
const body: Record<string, unknown> = {};
|
||||
if (flags.name !== undefined) body.displayName = flags.name;
|
||||
if (flags["role-tag"] !== undefined) body.roleTag = flags["role-tag"];
|
||||
if (flags.groups !== undefined) {
|
||||
body.groups = flags.groups.split(",").map(s => {
|
||||
const [name, role] = s.trim().split(":");
|
||||
return role ? { name: name!, role } : { name: name! };
|
||||
});
|
||||
}
|
||||
if (flags["message-mode"] !== undefined) body.messageMode = flags["message-mode"];
|
||||
|
||||
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/member/${targetMemberId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Member-Id": mesh.memberId,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await res.json() as Record<string, unknown>;
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else if (result.ok) {
|
||||
console.log(green("✓ Profile updated"));
|
||||
const member = result.member as Record<string, unknown>;
|
||||
printProfile(member, dim);
|
||||
} else {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// GET members list, show current user's profile
|
||||
const res = await fetch(`${brokerUrl}/mesh/${mesh.meshId}/members`);
|
||||
const result = await res.json() as { ok: boolean; members?: Array<Record<string, unknown>>; error?: string };
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(`Error: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const me = result.members?.find(m => m.id === mesh.memberId);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(me ?? {}, null, 2));
|
||||
} else if (me) {
|
||||
printProfile(me, dim);
|
||||
} else {
|
||||
console.log("Member not found in mesh.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printProfile(member: Record<string, unknown>, dim: (s: string) => string): void {
|
||||
const groups = member.groups as Array<{ name: string; role?: string }> | undefined;
|
||||
const groupStr = groups?.length
|
||||
? groups.map(g => g.role ? `${g.name} (${g.role})` : g.name).join(", ")
|
||||
: dim("(none)");
|
||||
|
||||
console.log(` Name: ${member.displayName ?? dim("(not set)")}`);
|
||||
console.log(` Role: ${member.roleTag ?? dim("(not set)")}`);
|
||||
console.log(` Groups: ${groupStr}`);
|
||||
console.log(` Messages: ${member.messageMode ?? "push"}`);
|
||||
console.log(` Access: ${member.permission ?? "member"}`);
|
||||
console.log(` Mesh: ${dim(String(member.id ?? ""))}`);
|
||||
}
|
||||
142
apps/cli/src/commands/remind.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* `claudemesh remind <message> --in <duration> | --at <time>`
|
||||
* `claudemesh remind list`
|
||||
* `claudemesh remind cancel <id>`
|
||||
*
|
||||
* Human-facing interface to the broker's scheduled message delivery.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface RemindFlags {
|
||||
mesh?: string;
|
||||
in?: string; // e.g. "2h", "30m", "90s"
|
||||
at?: string; // ISO or HH:MM
|
||||
cron?: string; // 5-field cron expression for recurring
|
||||
to?: string; // default: self
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function parseDuration(raw: string): number | null {
|
||||
const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*(s|sec|m|min|h|hr|d|day)?$/i);
|
||||
if (!m) return null;
|
||||
const n = parseFloat(m[1]!);
|
||||
const unit = (m[2] ?? "s").toLowerCase();
|
||||
if (unit.startsWith("d")) return n * 86_400_000;
|
||||
if (unit.startsWith("h")) return n * 3_600_000;
|
||||
if (unit.startsWith("m")) return n * 60_000;
|
||||
return n * 1_000;
|
||||
}
|
||||
|
||||
function parseDeliverAt(flags: RemindFlags): number | null {
|
||||
if (flags.in) {
|
||||
const ms = parseDuration(flags.in);
|
||||
if (ms === null) return null;
|
||||
return Date.now() + ms;
|
||||
}
|
||||
if (flags.at) {
|
||||
// Try HH:MM first
|
||||
const hm = flags.at.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (hm) {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
target.setHours(parseInt(hm[1]!, 10), parseInt(hm[2]!, 10), 0, 0);
|
||||
if (target <= now) target.setDate(target.getDate() + 1); // next occurrence
|
||||
return target.getTime();
|
||||
}
|
||||
const ts = Date.parse(flags.at);
|
||||
return isNaN(ts) ? null : ts;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function runRemind(
|
||||
flags: RemindFlags,
|
||||
positional: string[],
|
||||
): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const action = positional[0];
|
||||
|
||||
// claudemesh remind list
|
||||
if (action === "list") {
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const scheduled = await client.listScheduled();
|
||||
if (flags.json) { console.log(JSON.stringify(scheduled, null, 2)); return; }
|
||||
if (scheduled.length === 0) { console.log(dim("No pending reminders.")); return; }
|
||||
for (const m of scheduled) {
|
||||
const when = new Date(m.deliverAt).toLocaleString();
|
||||
const to = m.to === client.getSessionPubkey() ? dim("(self)") : m.to;
|
||||
console.log(` ${bold(m.id.slice(0, 8))} → ${to} at ${when}`);
|
||||
console.log(` ${dim(m.message.slice(0, 80))}`);
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind cancel <id>
|
||||
if (action === "cancel") {
|
||||
const id = positional[1];
|
||||
if (!id) { console.error("Usage: claudemesh remind cancel <id>"); process.exit(1); }
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const ok = await client.cancelScheduled(id);
|
||||
if (ok) console.log(`✓ Cancelled ${id}`);
|
||||
else { console.error(`✗ Not found or already fired: ${id}`); process.exit(1); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// claudemesh remind <message> --in <duration> | --at <time> | --cron <expr>
|
||||
const message = action ?? positional.join(" ");
|
||||
if (!message) {
|
||||
console.error("Usage: claudemesh remind <message> --in <duration>");
|
||||
console.error(" claudemesh remind <message> --at <time>");
|
||||
console.error(' claudemesh remind <message> --cron "0 */2 * * *"');
|
||||
console.error(" claudemesh remind list");
|
||||
console.error(" claudemesh remind cancel <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const isCron = !!flags.cron;
|
||||
const deliverAt = isCron ? 0 : parseDeliverAt(flags);
|
||||
if (!isCron && deliverAt === null) {
|
||||
console.error('Specify when: --in <duration> (e.g. "2h", "30m"), --at <time> (e.g. "15:00"), or --cron <expression>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
// Determine target: --to flag or self
|
||||
let targetSpec: string;
|
||||
if (flags.to && flags.to !== "self") {
|
||||
if (flags.to.startsWith("@") || flags.to === "*" || /^[0-9a-f]{64}$/i.test(flags.to)) {
|
||||
targetSpec = flags.to;
|
||||
} else {
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find((p) => p.displayName.toLowerCase() === flags.to!.toLowerCase());
|
||||
if (!match) {
|
||||
console.error(`Peer "${flags.to}" not found. Online: ${peers.map((p) => p.displayName).join(", ") || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
} else {
|
||||
targetSpec = client.getSessionPubkey() ?? "*";
|
||||
}
|
||||
|
||||
const result = await client.scheduleMessage(targetSpec, message, deliverAt ?? 0, false, flags.cron);
|
||||
if (!result) { console.error("✗ Broker did not acknowledge — check connection"); process.exit(1); }
|
||||
|
||||
if (flags.json) { console.log(JSON.stringify(result)); return; }
|
||||
const toLabel = !flags.to || flags.to === "self" ? "yourself" : flags.to;
|
||||
if (isCron) {
|
||||
const nextFire = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Recurring reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} — cron: ${flags.cron}, next fire: ${nextFire}`);
|
||||
} else {
|
||||
const when = new Date(result.deliverAt).toLocaleString();
|
||||
console.log(`✓ Reminder set (${result.scheduledId.slice(0, 8)}): "${message}" → ${toLabel} at ${when}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
51
apps/cli/src/commands/send.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* `claudemesh send <to> <message>` — send a message to a peer or group.
|
||||
*
|
||||
* <to> can be:
|
||||
* - a display name ("Mou")
|
||||
* - a pubkey hex ("abc123...")
|
||||
* - @group ("@flexicar")
|
||||
* - * (broadcast to all)
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import type { Priority } from "../ws/client";
|
||||
|
||||
export interface SendFlags {
|
||||
mesh?: string;
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
export async function runSend(flags: SendFlags, to: string, message: string): Promise<void> {
|
||||
const priority: Priority =
|
||||
flags.priority === "now" ? "now"
|
||||
: flags.priority === "low" ? "low"
|
||||
: "next";
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
// Resolve display name → pubkey for direct messages.
|
||||
// If `to` starts with @, *, or looks like a hex pubkey, use as-is.
|
||||
let targetSpec = to;
|
||||
if (!to.startsWith("@") && to !== "*" && !/^[0-9a-f]{64}$/i.test(to)) {
|
||||
// Treat as display name — look up pubkey via list_peers.
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find(
|
||||
(p) => p.displayName.toLowerCase() === to.toLowerCase(),
|
||||
);
|
||||
if (!match) {
|
||||
const names = peers.map((p) => p.displayName).join(", ");
|
||||
console.error(`Peer "${to}" not found. Online: ${names || "(none)"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
targetSpec = match.pubkey;
|
||||
}
|
||||
|
||||
const result = await client.send(targetSpec, message, priority);
|
||||
if (result.ok) {
|
||||
console.log(`✓ Sent to ${to}${result.messageId ? ` (${result.messageId.slice(0, 8)})` : ""}`);
|
||||
} else {
|
||||
console.error(`✗ Send failed: ${result.error ?? "unknown error"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
75
apps/cli/src/commands/state.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* `claudemesh state get <key>` — read a shared state value
|
||||
* `claudemesh state set <key> <value>` — write a shared state value
|
||||
* `claudemesh state list` — list all state entries
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface StateFlags {
|
||||
mesh?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runStateGet(flags: StateFlags, key: string): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const entry = await client.getState(key);
|
||||
if (!entry) {
|
||||
console.log(dim(`(not set)`));
|
||||
return;
|
||||
}
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(entry, null, 2));
|
||||
return;
|
||||
}
|
||||
const val = typeof entry.value === "string" ? entry.value : JSON.stringify(entry.value);
|
||||
console.log(val);
|
||||
console.log(dim(` set by ${entry.updatedBy} at ${new Date(entry.updatedAt).toLocaleString()}`));
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStateSet(flags: StateFlags, key: string, value: string): Promise<void> {
|
||||
// Try to parse as JSON so numbers/booleans/objects work; fall back to string.
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
parsed = value;
|
||||
}
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
await client.setState(key, parsed);
|
||||
console.log(`✓ ${key} = ${JSON.stringify(parsed)}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStateList(flags: StateFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const entries = await client.listState();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(entries, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(dim(`No state on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const val = typeof e.value === "string" ? e.value : JSON.stringify(e.value);
|
||||
console.log(`${bold(e.key)}: ${val}`);
|
||||
console.log(dim(` ${e.updatedBy} · ${new Date(e.updatedAt).toLocaleString()}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
88
apps/cli/src/commands/sync.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* `claudemesh sync` — re-sync meshes from dashboard account.
|
||||
*
|
||||
* Opens browser for OAuth, receives sync token, calls broker /cli-sync,
|
||||
* merges new meshes into local config.
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { hostname } from "node:os";
|
||||
import { loadConfig, saveConfig } from "../state/config";
|
||||
import { startCallbackListener, openBrowser, generatePairingCode, syncWithBroker } from "../auth";
|
||||
import { generateKeypair } from "../crypto/keypair";
|
||||
|
||||
export async function runSync(args: { force?: boolean }): Promise<void> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const code = generatePairingCode();
|
||||
const listener = await startCallbackListener();
|
||||
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
|
||||
|
||||
console.log(`Opening browser to sync meshes...`);
|
||||
console.log(dim(`Visit: ${url}`));
|
||||
await openBrowser(url);
|
||||
|
||||
// Race: localhost callback vs manual paste vs timeout
|
||||
const manualPromise = new Promise<string>((resolve) => {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.question("Paste sync token (or wait for browser): ", (answer) => {
|
||||
rl.close();
|
||||
if (answer.trim()) resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), 15 * 60_000);
|
||||
});
|
||||
|
||||
const syncToken = await Promise.race([
|
||||
listener.token,
|
||||
manualPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
listener.close();
|
||||
|
||||
if (!syncToken) {
|
||||
console.error("Timed out waiting for sign-in.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Use existing keypair from first mesh, or generate new
|
||||
const keypair = config.meshes.length > 0
|
||||
? { publicKey: config.meshes[0]!.pubkey, secretKey: config.meshes[0]!.secretKey }
|
||||
: await generateKeypair();
|
||||
|
||||
const displayName = config.displayName ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
const result = await syncWithBroker(syncToken, keypair.publicKey, displayName);
|
||||
|
||||
// Merge: add new meshes, skip duplicates
|
||||
let added = 0;
|
||||
for (const m of result.meshes) {
|
||||
if (config.meshes.some(existing => existing.meshId === m.mesh_id)) continue;
|
||||
config.meshes.push({
|
||||
meshId: m.mesh_id,
|
||||
memberId: m.member_id,
|
||||
slug: m.slug,
|
||||
name: m.slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: m.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
added++;
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
|
||||
if (added > 0) {
|
||||
console.log(green(`✓ Added ${added} new mesh(es)`));
|
||||
} else {
|
||||
console.log(`Already up to date (${config.meshes.length} meshes)`);
|
||||
}
|
||||
}
|
||||
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.
|
||||
*
|
||||
* Uses citty to define commands and flags. --help is generated from
|
||||
* the command definitions — the flag list here IS the documentation.
|
||||
*
|
||||
* Dispatches between two modes:
|
||||
* - `claudemesh mcp` → MCP server (stdio transport)
|
||||
* - `claudemesh <subcommand>` → CLI subcommand
|
||||
*
|
||||
* Claude Code invokes the `mcp` mode via stdio. Humans use all others.
|
||||
*/
|
||||
|
||||
import { defineCommand, runMain } from "citty";
|
||||
import { startMcpServer } from "./mcp/server";
|
||||
import { runInstall, runUninstall } from "./commands/install";
|
||||
import { runJoin } from "./commands/join";
|
||||
@@ -19,98 +21,337 @@ import { runLaunch } from "./commands/launch";
|
||||
import { runStatus } from "./commands/status";
|
||||
import { runDoctor } from "./commands/doctor";
|
||||
import { runWelcome } from "./commands/welcome";
|
||||
import { runPeers } from "./commands/peers";
|
||||
import { runSend } from "./commands/send";
|
||||
import { runInbox } from "./commands/inbox";
|
||||
import { runStateGet, runStateSet, runStateList } from "./commands/state";
|
||||
import { runRemember, runRecall } from "./commands/memory";
|
||||
import { runInfo } from "./commands/info";
|
||||
import { runRemind } from "./commands/remind";
|
||||
import { runCreate } from "./commands/create";
|
||||
import { runSync } from "./commands/sync";
|
||||
import { runProfile, type ProfileFlags } from "./commands/profile";
|
||||
import { connectTelegram } from "./commands/connect-telegram";
|
||||
import { disconnectTelegram } from "./commands/disconnect-telegram";
|
||||
import { VERSION } from "./version";
|
||||
|
||||
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
|
||||
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:
|
||||
claudemesh <command> [args]
|
||||
const install = defineCommand({
|
||||
meta: {
|
||||
name: "install",
|
||||
description: "Register MCP server and status hooks with Claude Code",
|
||||
},
|
||||
args: {
|
||||
"no-hooks": {
|
||||
type: "boolean",
|
||||
description: "Register MCP server only, skip hooks",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
run({ rawArgs }) {
|
||||
runInstall(rawArgs);
|
||||
},
|
||||
});
|
||||
|
||||
Commands:
|
||||
install Register MCP + Stop/UserPromptSubmit status hooks
|
||||
(add --no-hooks for bare MCP registration)
|
||||
uninstall Remove MCP server + hooks
|
||||
launch [opts] Launch Claude Code with real-time push messages
|
||||
--name <name> Display name for this session
|
||||
--mesh <slug> Select mesh (picker if >1, omitted)
|
||||
--join <url> Join a mesh before launching
|
||||
--quiet Skip the info banner
|
||||
-- <args> Pass remaining args to claude
|
||||
join <url> Join a mesh via https://claudemesh.com/join/... URL
|
||||
list Show all joined meshes
|
||||
leave <slug> Leave a joined mesh
|
||||
status Health report: broker reachability per joined mesh
|
||||
doctor Diagnostic checks (install, config, keypairs, PATH)
|
||||
seed-test-mesh Dev-only: inject a mesh into config (skips invite flow)
|
||||
mcp Start MCP server (stdio) — invoked by Claude Code
|
||||
--help, -h Show this help
|
||||
--version, -v Show the CLI version
|
||||
const join = defineCommand({
|
||||
meta: {
|
||||
name: "join",
|
||||
description: "Join a mesh via invite URL or token",
|
||||
},
|
||||
args: {
|
||||
url: {
|
||||
type: "positional",
|
||||
description: "Invite URL (`https://claudemesh.com/join/...`) or token",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
run({ args }) {
|
||||
return runJoin([args.url]);
|
||||
},
|
||||
});
|
||||
|
||||
Environment:
|
||||
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws)
|
||||
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/)
|
||||
CLAUDEMESH_DEBUG=1 Verbose logging
|
||||
`;
|
||||
const leave = defineCommand({
|
||||
meta: {
|
||||
name: "leave",
|
||||
description: "Leave a joined mesh and remove its local keypair",
|
||||
},
|
||||
args: {
|
||||
slug: {
|
||||
type: "positional",
|
||||
description: "Mesh slug to leave (see `claudemesh list`)",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
run({ args }) {
|
||||
runLeave([args.slug]);
|
||||
},
|
||||
});
|
||||
|
||||
const cmd = process.argv[2];
|
||||
const args = process.argv.slice(3);
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
name: "claudemesh",
|
||||
version: VERSION,
|
||||
description: "Peer mesh for Claude Code sessions",
|
||||
},
|
||||
subCommands: {
|
||||
launch,
|
||||
create: defineCommand({
|
||||
meta: { name: "create", description: "Create a new mesh from a template" },
|
||||
args: {
|
||||
template: { type: "string", description: "Template name: `dev-team`, `research`, `ops-incident`, `simulation`, `personal`" },
|
||||
"list-templates": { type: "boolean", description: "List available templates and exit", default: false },
|
||||
},
|
||||
run({ args }) { runCreate(args); },
|
||||
}),
|
||||
install,
|
||||
uninstall: defineCommand({
|
||||
meta: { name: "uninstall", description: "Remove MCP server and hooks from Claude Code config" },
|
||||
run() { runUninstall(); },
|
||||
}),
|
||||
join,
|
||||
list: defineCommand({
|
||||
meta: { name: "list", description: "Show joined meshes, slugs, and local identities" },
|
||||
run() { runList(); },
|
||||
}),
|
||||
leave,
|
||||
peers: defineCommand({
|
||||
meta: { name: "peers", description: "List online peers with status, summary, and groups" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runPeers(args); },
|
||||
}),
|
||||
send: defineCommand({
|
||||
meta: { name: "send", description: "Send a message to a peer, group, or all peers" },
|
||||
args: {
|
||||
to: { type: "positional", description: "Recipient: display name, `@group`, `*` (broadcast), or pubkey hex", required: true },
|
||||
message: { type: "positional", description: "Message text", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
priority: { type: "string", description: '`"now"` | `"next"` (default) | `"low"`' },
|
||||
},
|
||||
async run({ args }) { await runSend(args, args.to, args.message); },
|
||||
}),
|
||||
inbox: defineCommand({
|
||||
meta: { name: "inbox", description: "Drain pending inbound messages" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
wait: { type: "string", description: "Seconds to wait for broker delivery (default: `1`)" },
|
||||
},
|
||||
async run({ args }) {
|
||||
await runInbox({ ...args, wait: args.wait ? parseInt(args.wait, 10) : undefined });
|
||||
},
|
||||
}),
|
||||
state: defineCommand({
|
||||
meta: { name: "state", description: "Get, set, or list shared key-value state in the mesh" },
|
||||
args: {
|
||||
action: { type: "positional", description: "`get <key>` | `set <key> <value>` | `list`", required: true },
|
||||
key: { type: "positional", description: "State key (required for `get` and `set`)" },
|
||||
value: { type: "positional", description: "Value to store (required for `set`)" },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) {
|
||||
if (args.action === "list") {
|
||||
await runStateList(args);
|
||||
} else if (args.action === "get") {
|
||||
if (!args.key) { console.error("Usage: claudemesh state get <key>"); process.exit(1); }
|
||||
await runStateGet(args, args.key);
|
||||
} else if (args.action === "set") {
|
||||
if (!args.key || !args.value) { console.error("Usage: claudemesh state set <key> <value>"); process.exit(1); }
|
||||
await runStateSet(args, args.key, args.value);
|
||||
} else {
|
||||
console.error(`Unknown action "${args.action}". Use: get, set, list`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
}),
|
||||
info: defineCommand({
|
||||
meta: { name: "info", description: "Show mesh overview: slug, broker, peer count, state keys" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runInfo(args); },
|
||||
}),
|
||||
remember: defineCommand({
|
||||
meta: { name: "remember", description: "Store a persistent memory visible to all peers" },
|
||||
args: {
|
||||
content: { type: "positional", description: "Text to store", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
tags: { type: "string", description: "Comma-separated tags, e.g. `task,context`" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runRemember(args, args.content); },
|
||||
}),
|
||||
recall: defineCommand({
|
||||
meta: { name: "recall", description: "Search mesh memories by keyword or phrase" },
|
||||
args: {
|
||||
query: { type: "positional", description: "Full-text search query", required: true },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runRecall(args, args.query); },
|
||||
}),
|
||||
remind: defineCommand({
|
||||
meta: { name: "remind", description: "Schedule a delayed message. Also: `remind list`, `remind cancel <id>`" },
|
||||
args: {
|
||||
message: { type: "positional", description: "Message text — or `list` / `cancel <id>` to manage reminders", required: false },
|
||||
extra: { type: "positional", description: "Reminder ID for `cancel`", required: false },
|
||||
in: { type: "string", description: 'Deliver after duration: `"2h"`, `"30m"`, `"90s"`' },
|
||||
at: { type: "string", description: 'Deliver at time: `"15:00"` or ISO timestamp' },
|
||||
cron: { type: "string", description: 'Recurring cron expression: `"0 */2 * * *"` (every 2h), `"30 9 * * 1-5"` (9:30 weekdays)' },
|
||||
to: { type: "string", description: "Recipient (default: self). Name, `@group`, `*`, or pubkey" },
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args, rawArgs }) {
|
||||
// Collect positional args from rawArgs (before any flags)
|
||||
const positionals = rawArgs.filter((a) => !a.startsWith("-"));
|
||||
await runRemind(args, positionals);
|
||||
},
|
||||
}),
|
||||
sync: defineCommand({
|
||||
meta: { name: "sync", description: "Sync meshes from your dashboard account" },
|
||||
args: {
|
||||
force: { type: "boolean", description: "Re-link account even if already linked", default: false },
|
||||
},
|
||||
async run({ args }) { await runSync(args); },
|
||||
}),
|
||||
profile: defineCommand({
|
||||
meta: { name: "profile", description: "View or edit your member profile" },
|
||||
args: {
|
||||
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||
"role-tag": { type: "string", description: "Set role tag (e.g. 'backend-dev', 'lead')" },
|
||||
groups: { type: "string", description: "Set groups as 'group:role,...' (e.g. 'eng:lead,review')" },
|
||||
"message-mode": { type: "string", description: "'push' | 'inbox' | 'off'" },
|
||||
name: { type: "string", description: "Set display name" },
|
||||
member: { type: "string", description: "Edit another member (admin only)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
},
|
||||
async run({ args }) { await runProfile(args as ProfileFlags); },
|
||||
}),
|
||||
status: defineCommand({
|
||||
meta: { name: "status", description: "Check broker connectivity for each joined mesh" },
|
||||
async run() { await runStatus(); },
|
||||
}),
|
||||
doctor: defineCommand({
|
||||
meta: { name: "doctor", description: "Diagnose install, config, keypairs, and PATH issues" },
|
||||
async run() { await runDoctor(); },
|
||||
}),
|
||||
mcp: defineCommand({
|
||||
meta: { name: "mcp", description: "Start MCP server on stdio (called by Claude Code, not users)" },
|
||||
async run() { await startMcpServer(); },
|
||||
}),
|
||||
"seed-test-mesh": defineCommand({
|
||||
meta: { name: "seed-test-mesh", description: "Dev: inject a mesh into local config, skip invite flow" },
|
||||
run({ rawArgs }) { runSeedTestMesh(rawArgs); },
|
||||
}),
|
||||
hook: defineCommand({
|
||||
meta: { name: "hook", description: "Internal: handle Claude Code hook events" },
|
||||
async run({ rawArgs }) { await runHook(rawArgs); },
|
||||
}),
|
||||
connect: defineCommand({
|
||||
meta: { name: "connect", description: "Connect an integration (e.g. telegram)" },
|
||||
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
|
||||
async run({ args }) {
|
||||
if (args.target === "telegram") await connectTelegram(process.argv.slice(process.argv.indexOf("telegram") + 1));
|
||||
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
|
||||
},
|
||||
}),
|
||||
disconnect: defineCommand({
|
||||
meta: { name: "disconnect", description: "Disconnect an integration (e.g. telegram)" },
|
||||
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
|
||||
async run({ args }) {
|
||||
if (args.target === "telegram") await disconnectTelegram();
|
||||
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
|
||||
},
|
||||
}),
|
||||
},
|
||||
async run() {
|
||||
await runWelcome();
|
||||
},
|
||||
});
|
||||
|
||||
async function main(): Promise<void> {
|
||||
switch (cmd) {
|
||||
case "mcp":
|
||||
await startMcpServer();
|
||||
return;
|
||||
case "install":
|
||||
runInstall(args);
|
||||
return;
|
||||
case "uninstall":
|
||||
runUninstall();
|
||||
return;
|
||||
case "hook":
|
||||
await runHook(args);
|
||||
return;
|
||||
case "launch":
|
||||
await runLaunch(args);
|
||||
return;
|
||||
case "join":
|
||||
await runJoin(args);
|
||||
return;
|
||||
case "list":
|
||||
runList();
|
||||
return;
|
||||
case "leave":
|
||||
runLeave(args);
|
||||
return;
|
||||
case "status":
|
||||
await runStatus();
|
||||
return;
|
||||
case "doctor":
|
||||
await runDoctor();
|
||||
return;
|
||||
case "seed-test-mesh":
|
||||
runSeedTestMesh(args);
|
||||
return;
|
||||
case "--version":
|
||||
case "-v":
|
||||
case "version":
|
||||
console.log(VERSION);
|
||||
return;
|
||||
case "--help":
|
||||
case "-h":
|
||||
case "help":
|
||||
console.log(HELP);
|
||||
return;
|
||||
case undefined:
|
||||
runWelcome();
|
||||
return;
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd}`);
|
||||
console.error("Run `claudemesh --help` for usage.");
|
||||
process.exit(1);
|
||||
}
|
||||
// Friction reducer: if the user types `claudemesh --resume xxx` or any other
|
||||
// flag-first invocation, route it through `launch`. This keeps `claudemesh`
|
||||
// bare (welcome screen), `claudemesh <known-sub>` (dispatch normally), and
|
||||
// every flag-only form as implicit `launch`.
|
||||
const KNOWN_SUBCOMMANDS = new Set(Object.keys(main.subCommands ?? {}));
|
||||
// Flags citty handles on the root command — must not be rewritten to `launch`.
|
||||
const ROOT_PASSTHROUGH_FLAGS = new Set(["--help", "-h", "--version", "-v"]);
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const first = argv[0];
|
||||
if (first && !ROOT_PASSTHROUGH_FLAGS.has(first) && !KNOWN_SUBCOMMANDS.has(first)) {
|
||||
// Starts with a flag, or an unknown bareword → treat as launch args.
|
||||
// (Unknown barewords that look like typos would otherwise hit citty's
|
||||
// "unknown command" path; forwarding to launch lets claude surface the
|
||||
// error if it's a real claude flag, and launch's own parser rejects junk.)
|
||||
process.argv.splice(2, 0, "launch");
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(`claudemesh: ${e instanceof Error ? e.message : String(e)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
runMain(main);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -96,6 +96,48 @@ export const TOOLS: Tool[] = [
|
||||
required: ["status"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_visible",
|
||||
description:
|
||||
"Control your visibility in the mesh. When hidden, you won't appear in list_peers and won't receive broadcasts — but direct messages still reach you.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
visible: {
|
||||
type: "boolean",
|
||||
description: "true to be visible (default), false to hide",
|
||||
},
|
||||
},
|
||||
required: ["visible"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_profile",
|
||||
description:
|
||||
"Set your public profile — what other peers see about you. Avatar (emoji), title, bio, and capabilities list.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
avatar: {
|
||||
type: "string",
|
||||
description: "Emoji or URL for your avatar",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Short role label (e.g. 'Frontend Lead', 'DevOps')",
|
||||
},
|
||||
bio: {
|
||||
type: "string",
|
||||
description: "One-liner about yourself",
|
||||
},
|
||||
capabilities: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "What you can help with",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "join_group",
|
||||
description:
|
||||
@@ -203,7 +245,7 @@ export const TOOLS: Tool[] = [
|
||||
{
|
||||
name: "share_file",
|
||||
description:
|
||||
"Share a persistent file with the mesh. All current and future peers can access it.",
|
||||
"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: {
|
||||
@@ -217,6 +259,10 @@ export const TOOLS: Tool[] = [
|
||||
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"],
|
||||
},
|
||||
@@ -269,6 +315,18 @@ export const TOOLS: Tool[] = [
|
||||
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 ---
|
||||
{
|
||||
@@ -548,6 +606,43 @@ export const TOOLS: Tool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// --- 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",
|
||||
@@ -556,6 +651,181 @@ export const TOOLS: Tool[] = [
|
||||
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",
|
||||
@@ -572,4 +842,179 @@ export const TOOLS: Tool[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// --- 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: {} },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -22,3 +22,60 @@ export interface SetSummaryArgs {
|
||||
export interface SetStatusArgs {
|
||||
status: PeerStatus;
|
||||
}
|
||||
|
||||
// --- Service deployment types ---
|
||||
|
||||
export type ServiceScope =
|
||||
| "peer"
|
||||
| "mesh"
|
||||
| { peers: string[] }
|
||||
| { group: string }
|
||||
| { groups: string[] }
|
||||
| { role: string };
|
||||
|
||||
export interface ServiceInfo {
|
||||
name: string;
|
||||
type: "mcp" | "skill";
|
||||
description: string;
|
||||
status: string;
|
||||
tool_count: number;
|
||||
deployed_by: string;
|
||||
scope: ServiceScope;
|
||||
source_type: string;
|
||||
runtime?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ServiceToolSchema {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface VaultEntry {
|
||||
key: string;
|
||||
entry_type: "env" | "file";
|
||||
mount_path?: string;
|
||||
description?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MeshMcpDeployArgs {
|
||||
server_name: string;
|
||||
file_id?: string;
|
||||
git_url?: string;
|
||||
git_branch?: string;
|
||||
env?: Record<string, string>;
|
||||
runtime?: "node" | "python" | "bun";
|
||||
memory_mb?: number;
|
||||
network_allow?: string[];
|
||||
scope?: ServiceScope;
|
||||
}
|
||||
|
||||
export interface VaultSetArgs {
|
||||
key: string;
|
||||
value: string;
|
||||
type?: "env" | "file";
|
||||
mount_path?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ export interface JoinedMesh {
|
||||
secretKey: string; // ed25519 hex (64 bytes = 128 chars)
|
||||
brokerUrl: 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 {
|
||||
@@ -37,8 +46,10 @@ export interface Config {
|
||||
version: 1;
|
||||
meshes: JoinedMesh[];
|
||||
displayName?: string; // per-session override, written by `claudemesh launch --name`
|
||||
role?: string; // per-session role tag (display + hello)
|
||||
groups?: GroupEntry[];
|
||||
messageMode?: "push" | "inbox" | "off";
|
||||
accountId?: string; // linked dashboard user ID (from CLI sync flow)
|
||||
}
|
||||
|
||||
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||
@@ -54,7 +65,7 @@ export function loadConfig(): Config {
|
||||
if (!parsed || !Array.isArray(parsed.meshes)) {
|
||||
return { version: 1, meshes: [] };
|
||||
}
|
||||
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, groups: parsed.groups, messageMode: parsed.messageMode };
|
||||
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode, accountId: parsed.accountId };
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
|
||||
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."
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { env } from "../env";
|
||||
|
||||
const clients = new Map<string, BrokerClient>();
|
||||
let configDisplayName: string | undefined;
|
||||
let configGroups: Config["groups"] = [];
|
||||
|
||||
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
||||
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
@@ -21,6 +22,10 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
clients.set(mesh.meshId, client);
|
||||
try {
|
||||
await client.connect();
|
||||
// Auto-join groups declared at launch time (--groups flag or config).
|
||||
for (const g of configGroups ?? []) {
|
||||
try { await client.joinGroup(g.name, g.role); } catch { /* best effort */ }
|
||||
}
|
||||
} catch {
|
||||
// Connect failed → client is in "reconnecting" state, leave it
|
||||
// wired so tool calls can surface the status.
|
||||
@@ -31,6 +36,7 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||
/** Start clients for every joined mesh. Called once on MCP server start. */
|
||||
export async function startClients(config: Config): Promise<void> {
|
||||
configDisplayName = config.displayName;
|
||||
configGroups = config.groups ?? [];
|
||||
await Promise.allSettled(config.meshes.map(ensureClient));
|
||||
}
|
||||
|
||||
|
||||
38
apps/runner/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# claudemesh runner — executes deployed MCP servers as child processes.
|
||||
# Multi-runtime: Node 22 + Python 3.12 + uv + Bun
|
||||
#
|
||||
# The runner supervisor (Node) listens on HTTP :7901 for commands from
|
||||
# the broker (load, call, unload, health, list_tools). Each deployed
|
||||
# MCP server runs as a child process with its own stdio pipe.
|
||||
|
||||
FROM node:22-slim AS base
|
||||
|
||||
# Install Python 3.12 + uv (fast pip replacement) + git
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-venv \
|
||||
curl ca-certificates git unzip \
|
||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& ln -sf /root/.local/bin/uv /usr/local/bin/uv \
|
||||
&& ln -sf /root/.local/bin/uvx /usr/local/bin/uvx \
|
||||
&& curl -fsSL https://bun.sh/install | bash \
|
||||
&& ln -sf /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
&& ln -sf /root/.bun/bin/bunx /usr/local/bin/bunx \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the runner supervisor
|
||||
COPY supervisor.mjs /app/supervisor.mjs
|
||||
|
||||
# Services directory (shared volume with broker)
|
||||
RUN mkdir -p /var/claudemesh/services
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV RUNNER_PORT=7901
|
||||
|
||||
EXPOSE 7901
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD node -e "fetch('http://localhost:7901/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "supervisor.mjs"]
|
||||
365
apps/runner/supervisor.mjs
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* claudemesh runner supervisor — manages MCP server child processes.
|
||||
*
|
||||
* HTTP API (called by broker):
|
||||
* POST /load { name, sourcePath, env, runtime } → spawn MCP, return tools
|
||||
* POST /call { name, tool, args } → route tool call
|
||||
* POST /unload { name } → kill process
|
||||
* GET /health → { ok, services }
|
||||
* GET /list { name? } → tools for a service
|
||||
*
|
||||
* Each MCP server is a child process with its own stdio pipe.
|
||||
* The supervisor talks MCP JSON-RPC over stdin/stdout to each child.
|
||||
*/
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const PORT = parseInt(process.env.RUNNER_PORT || "7901", 10);
|
||||
const CALL_TIMEOUT_MS = 25_000;
|
||||
const LOG_BUFFER_SIZE = 500;
|
||||
|
||||
// --- Service registry ---
|
||||
|
||||
const services = new Map();
|
||||
let callIdCounter = 0;
|
||||
|
||||
// --- Runtime detection ---
|
||||
|
||||
function detectRuntime(sourcePath) {
|
||||
if (existsSync(join(sourcePath, "bun.lockb")) || existsSync(join(sourcePath, "bunfig.toml"))) return "bun";
|
||||
if (existsSync(join(sourcePath, "package.json"))) return "node";
|
||||
if (existsSync(join(sourcePath, "pyproject.toml")) || existsSync(join(sourcePath, "requirements.txt"))) return "python";
|
||||
return "node";
|
||||
}
|
||||
|
||||
function detectEntry(sourcePath, runtime) {
|
||||
if (runtime === "python") {
|
||||
for (const e of ["server.py", "src/server.py", "main.py", "src/main.py"]) {
|
||||
if (existsSync(join(sourcePath, e))) return { cmd: "python3", args: [e] };
|
||||
}
|
||||
if (existsSync(join(sourcePath, "pyproject.toml"))) return { cmd: "python3", args: ["-m", "server"] };
|
||||
return { cmd: "python3", args: ["server.py"] };
|
||||
}
|
||||
const cmd = runtime === "bun" ? "bun" : "node";
|
||||
if (existsSync(join(sourcePath, "package.json"))) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(sourcePath, "package.json"), "utf-8"));
|
||||
if (pkg.main) return { cmd, args: [pkg.main] };
|
||||
if (pkg.bin) {
|
||||
const bin = typeof pkg.bin === "string" ? pkg.bin : Object.values(pkg.bin)[0];
|
||||
if (bin) return { cmd, args: [bin] };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
for (const e of ["dist/index.js", "src/index.js", "src/index.ts", "index.js"]) {
|
||||
if (existsSync(join(sourcePath, e))) return { cmd, args: [e] };
|
||||
}
|
||||
return { cmd, args: ["src/index.js"] };
|
||||
}
|
||||
|
||||
// --- Install deps ---
|
||||
|
||||
function installDeps(sourcePath, runtime) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cmd, args;
|
||||
if (runtime === "python") {
|
||||
if (existsSync(join(sourcePath, "requirements.txt"))) {
|
||||
cmd = "pip3"; args = ["install", "--no-cache-dir", "-r", "requirements.txt"];
|
||||
} else { cmd = "pip3"; args = ["install", "--no-cache-dir", "."]; }
|
||||
} else if (runtime === "bun") {
|
||||
cmd = "bun"; args = ["install"];
|
||||
} else {
|
||||
cmd = "npm"; args = ["install", "--production", "--legacy-peer-deps"];
|
||||
}
|
||||
const child = spawn(cmd, args, { cwd: sourcePath, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", d => { stderr += d.toString(); });
|
||||
child.on("exit", code => code === 0 ? resolve() : reject(new Error(`${cmd} install exit ${code}: ${stderr.slice(-300)}`)));
|
||||
child.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// --- MCP JSON-RPC ---
|
||||
|
||||
function sendMcpRequest(svc, method, params) {
|
||||
return new Promise(resolve => {
|
||||
if (!svc.process?.stdin?.writable) { resolve({ error: "not running" }); return; }
|
||||
const id = `c_${++callIdCounter}`;
|
||||
const timer = setTimeout(() => { svc.pending.delete(id); resolve({ error: "timeout" }); }, CALL_TIMEOUT_MS);
|
||||
svc.pending.set(id, { resolve, timer });
|
||||
try {
|
||||
svc.process.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, ...(params ? { params } : {}) }) + "\n");
|
||||
} catch (e) {
|
||||
clearTimeout(timer); svc.pending.delete(id);
|
||||
resolve({ error: e.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initMcp(svc) {
|
||||
const init = await sendMcpRequest(svc, "initialize", {
|
||||
protocolVersion: "2024-11-05", capabilities: {},
|
||||
clientInfo: { name: "claudemesh-runner", version: "0.1.0" },
|
||||
});
|
||||
if (init.error) throw new Error(`init failed: ${init.error}`);
|
||||
if (svc.process?.stdin?.writable) {
|
||||
svc.process.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n");
|
||||
}
|
||||
const tools = await sendMcpRequest(svc, "tools/list", {});
|
||||
if (tools.error) throw new Error(`tools/list failed: ${tools.error}`);
|
||||
return tools.result?.tools ?? [];
|
||||
}
|
||||
|
||||
// --- Spawn ---
|
||||
|
||||
function spawnService(svc) {
|
||||
// npx/uvx packages have pre-resolved entry points
|
||||
let cmd, args;
|
||||
if (svc._pythonModule) {
|
||||
// Python MCPs: run via venv python -m <module>
|
||||
cmd = svc._venvPython;
|
||||
args = ["-m", svc._pythonModule];
|
||||
} else if (svc._npxBin) {
|
||||
cmd = "node";
|
||||
args = [svc._npxBin];
|
||||
} else {
|
||||
({ cmd, args } = detectEntry(svc.sourcePath, svc.runtime));
|
||||
}
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: svc.sourcePath,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, ...svc.env, NODE_ENV: "production" },
|
||||
});
|
||||
svc.process = child;
|
||||
svc.pid = child.pid;
|
||||
svc.status = "running";
|
||||
svc.healthFailures = 0;
|
||||
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
rl.on("line", line => {
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id && svc.pending.has(String(msg.id))) {
|
||||
const p = svc.pending.get(String(msg.id));
|
||||
clearTimeout(p.timer); svc.pending.delete(String(msg.id));
|
||||
p.resolve(msg.error ? { error: msg.error.message ?? JSON.stringify(msg.error) } : { result: msg.result });
|
||||
}
|
||||
} catch { svc.logs.push(`[stdout] ${line}`); if (svc.logs.length > LOG_BUFFER_SIZE) svc.logs.shift(); }
|
||||
});
|
||||
|
||||
const errRl = createInterface({ input: child.stderr });
|
||||
errRl.on("line", line => { svc.logs.push(`[stderr] ${line}`); if (svc.logs.length > LOG_BUFFER_SIZE) svc.logs.shift(); });
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
console.log(`[runner] ${svc.name} exited code=${code} signal=${signal} restarts=${svc.restarts}`);
|
||||
for (const [, p] of svc.pending) { clearTimeout(p.timer); p.resolve({ error: "crashed" }); }
|
||||
svc.pending.clear(); svc.process = null; svc.pid = null;
|
||||
if (svc.status === "running" && svc.restarts < 5) {
|
||||
svc.restarts++;
|
||||
svc.status = "restarting";
|
||||
setTimeout(() => spawnService(svc), 1000 * svc.restarts);
|
||||
} else if (svc.status === "running") { svc.status = "crashed"; }
|
||||
});
|
||||
|
||||
child.on("error", err => { console.error(`[runner] ${svc.name} spawn error: ${err.message}`); svc.status = "failed"; });
|
||||
console.log(`[runner] spawned ${svc.name} pid=${child.pid} cmd=${cmd} ${args.join(" ")}`);
|
||||
}
|
||||
|
||||
// --- HTTP API ---
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on("data", c => chunks.push(c));
|
||||
req.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch (e) { reject(e); } });
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
const svcs = [];
|
||||
for (const [name, svc] of services) {
|
||||
svcs.push({ name, status: svc.status, pid: svc.pid, tools: svc.tools.length, restarts: svc.restarts });
|
||||
}
|
||||
return json(res, 200, { ok: true, services: svcs });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url?.startsWith("/list")) {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const name = url.searchParams.get("name");
|
||||
if (name) {
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: `service "${name}" not found` });
|
||||
return json(res, 200, { tools: svc.tools });
|
||||
}
|
||||
const all = {};
|
||||
for (const [n, s] of services) all[n] = s.tools;
|
||||
return json(res, 200, all);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url?.startsWith("/logs")) {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const name = url.searchParams.get("name");
|
||||
const lines = parseInt(url.searchParams.get("lines") || "50", 10);
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: "not found" });
|
||||
return json(res, 200, { lines: svc.logs.slice(-lines) });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/load") {
|
||||
const body = await readBody(req);
|
||||
const { name, sourcePath, gitUrl, gitBranch, npxPackage, env: svcEnv, runtime: rt } = body;
|
||||
if (!name) return json(res, 400, { error: "name required" });
|
||||
|
||||
// Kill existing
|
||||
const existing = services.get(name);
|
||||
if (existing?.process) { existing.status = "stopped"; existing.process.kill("SIGTERM"); await new Promise(r => setTimeout(r, 1000)); }
|
||||
|
||||
// Determine source path — git clone, npx, or pre-existing path
|
||||
let svcSourcePath = sourcePath;
|
||||
let svcRuntime = rt;
|
||||
|
||||
if (gitUrl) {
|
||||
// Git clone into runner's local storage
|
||||
svcSourcePath = join("/var/claudemesh/services", name);
|
||||
const { execSync } = await import("node:child_process");
|
||||
mkdirSync(svcSourcePath, { recursive: true });
|
||||
try {
|
||||
// Clean existing clone
|
||||
execSync(`rm -rf ${svcSourcePath}/*`, { timeout: 10_000 });
|
||||
execSync(`git clone --depth 1 ${gitBranch ? `--branch ${gitBranch}` : ""} ${gitUrl} .`, { cwd: svcSourcePath, timeout: 120_000, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
console.log(`[runner] git clone complete: ${gitUrl} -> ${svcSourcePath}`);
|
||||
} catch (e) {
|
||||
return json(res, 500, { error: `git clone failed: ${e.message}` });
|
||||
}
|
||||
} else if (npxPackage) {
|
||||
// npx-based: create a minimal package.json that depends on the package
|
||||
svcSourcePath = join("/var/claudemesh/services", name);
|
||||
mkdirSync(svcSourcePath, { recursive: true });
|
||||
const pkg = { name: `mcp-${name}`, private: true, dependencies: { [npxPackage]: "*" } };
|
||||
writeFileSync(join(svcSourcePath, "package.json"), JSON.stringify(pkg, null, 2));
|
||||
svcRuntime = svcRuntime || "node";
|
||||
} else if (body.uvxPackage) {
|
||||
// uvx-based Python MCP: install via uv and find the entry point
|
||||
svcSourcePath = join("/var/claudemesh/services", name);
|
||||
mkdirSync(svcSourcePath, { recursive: true });
|
||||
const { execSync } = await import("node:child_process");
|
||||
try {
|
||||
execSync(`uv venv --clear ${join(svcSourcePath, ".venv")}`, { timeout: 30_000, stdio: "pipe" });
|
||||
execSync(`uv pip install --python ${join(svcSourcePath, ".venv/bin/python")} "${body.uvxPackage}" "mcp[cli]"`, { timeout: 120_000, stdio: "pipe" });
|
||||
console.log(`[runner] uvx package installed: ${body.uvxPackage}`);
|
||||
} catch (e) {
|
||||
return json(res, 500, { error: `uvx install failed: ${e.message}` });
|
||||
}
|
||||
// For Python MCPs: run via `python -m <module>` using the venv python.
|
||||
// The module name is derived from the package name: mcp-server-time → mcp_server_time
|
||||
const venvPython = join(svcSourcePath, ".venv/bin/python");
|
||||
const moduleName = body.uvxPackage.replace(/-/g, "_");
|
||||
svcRuntime = "python";
|
||||
// _pythonModule signals spawnService to use `python -m <module>` instead of binary
|
||||
const svc2 = { name, sourcePath: svcSourcePath, runtime: svcRuntime, env: svcEnv || {}, process: null, pid: null, tools: [], status: "running", pending: new Map(), logs: [], restarts: 0, healthFailures: 0, _venvPython: venvPython, _pythonModule: moduleName };
|
||||
services.set(name, svc2);
|
||||
spawnService(svc2);
|
||||
// Python MCPs take longer to start — retry init with backoff
|
||||
let initErr = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 1500 + attempt * 1000));
|
||||
try {
|
||||
svc2.tools = await initMcp(svc2);
|
||||
console.log(`[runner] ${name} ready (uvx), ${svc2.tools.length} tools`);
|
||||
return json(res, 200, { status: "running", tools: svc2.tools });
|
||||
} catch (e) { initErr = e; }
|
||||
}
|
||||
svc2.status = "failed"; svc2.logs.push(`MCP init failed after 3 attempts: ${initErr?.message}`);
|
||||
return json(res, 500, { error: initErr?.message, logs: svc2.logs.slice(-10) });
|
||||
} else if (!svcSourcePath) {
|
||||
return json(res, 400, { error: "one of sourcePath, gitUrl, or npxPackage required" });
|
||||
}
|
||||
|
||||
const runtime = svcRuntime || detectRuntime(svcSourcePath);
|
||||
const svc = { name, sourcePath: svcSourcePath, runtime, env: svcEnv || {}, process: null, pid: null, tools: [], status: "installing", pending: new Map(), logs: [], restarts: 0, healthFailures: 0 };
|
||||
services.set(name, svc);
|
||||
|
||||
// Install deps
|
||||
try { await installDeps(svcSourcePath, runtime); } catch (e) {
|
||||
svc.status = "failed"; svc.logs.push(`install failed: ${e.message}`);
|
||||
return json(res, 500, { error: e.message });
|
||||
}
|
||||
|
||||
// For npx packages: find the binary in node_modules/.bin
|
||||
if (npxPackage) {
|
||||
const binDir = join(svcSourcePath, "node_modules", ".bin");
|
||||
if (existsSync(binDir)) {
|
||||
const bins = readdirSync(binDir).filter(b => !["node-which", "which", "semver", "resolve"].includes(b));
|
||||
// Prefer binary matching the package name
|
||||
const pkgShort = npxPackage.split("/").pop().replace(/^@/, "");
|
||||
const match = bins.find(b => b === pkgShort || b.includes(pkgShort)) || bins[0];
|
||||
if (match) {
|
||||
svc._npxBin = join(binDir, match);
|
||||
console.log(`[runner] npx binary resolved: ${match}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn + MCP handshake
|
||||
spawnService(svc);
|
||||
await new Promise(r => setTimeout(r, 1000)); // npx packages may need more startup time
|
||||
try {
|
||||
svc.tools = await initMcp(svc);
|
||||
console.log(`[runner] ${name} ready, ${svc.tools.length} tools`);
|
||||
return json(res, 200, { status: "running", tools: svc.tools });
|
||||
} catch (e) {
|
||||
svc.status = "failed"; svc.logs.push(`MCP init failed: ${e.message}`);
|
||||
return json(res, 500, { error: e.message, logs: svc.logs.slice(-10) });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/call") {
|
||||
const body = await readBody(req);
|
||||
const { name, tool, args } = body;
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: `service "${name}" not found` });
|
||||
if (svc.status !== "running") return json(res, 503, { error: `service is ${svc.status}` });
|
||||
const result = await sendMcpRequest(svc, "tools/call", { name: tool, arguments: args || {} });
|
||||
return json(res, 200, result);
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/unload") {
|
||||
const body = await readBody(req);
|
||||
const { name } = body;
|
||||
const svc = services.get(name);
|
||||
if (!svc) return json(res, 404, { error: "not found" });
|
||||
svc.status = "stopped";
|
||||
if (svc.process) { svc.process.kill("SIGTERM"); await new Promise(r => setTimeout(r, 2000)); if (svc.process) svc.process.kill("SIGKILL"); }
|
||||
for (const [, p] of svc.pending) { clearTimeout(p.timer); p.resolve({ error: "unloaded" }); }
|
||||
services.delete(name);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
json(res, 404, { error: "not found" });
|
||||
} catch (e) {
|
||||
console.error("[runner] request error:", e);
|
||||
json(res, 500, { error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`[runner] supervisor listening on :${PORT}`);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("[runner] shutting down...");
|
||||
for (const [, svc] of services) { svc.status = "stopped"; svc.process?.kill("SIGTERM"); }
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
15
apps/telegram/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# Telegram bridge for claudemesh
|
||||
# Node 22 runtime with tsx for TypeScript execution
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src/ ./src/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["npx", "tsx", "src/index.ts"]
|
||||
20
apps/telegram/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@claudemesh/telegram",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "bun src/index.ts",
|
||||
"dev": "bun --hot src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"grammy": "^1.35.0",
|
||||
"ws": "^8.18.0",
|
||||
"libsodium": "^0.7.15",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"tsx": "^4.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.13",
|
||||
"@types/libsodium-wrappers": "^0.7.14"
|
||||
}
|
||||
}
|
||||
759
apps/telegram/src/index.ts
Normal file
@@ -0,0 +1,759 @@
|
||||
/**
|
||||
* Claudemesh ↔ Telegram Bridge
|
||||
*
|
||||
* Joins the mesh as a peer named "telegram-bridge", relays messages
|
||||
* between a Telegram chat and mesh peers.
|
||||
*
|
||||
* Telegram → Mesh:
|
||||
* "@Mou fix the bug" → send_message(to: "Mou", message: "fix the bug")
|
||||
* "/peers" → list_peers → reply with online list
|
||||
* "/broadcast hello" → send_message(to: "*", message: "hello")
|
||||
* "any text" → send_message(to: "*", message: text) (broadcast)
|
||||
*
|
||||
* Mesh → Telegram:
|
||||
* Any push message addressed to this peer → forward to Telegram chat
|
||||
*/
|
||||
|
||||
import { Bot, InputFile } from "grammy";
|
||||
import WebSocket from "ws";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
// --- Config ---
|
||||
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
|
||||
const ALLOWED_CHAT_IDS = (process.env.TELEGRAM_CHAT_IDS ?? "").split(",").filter(Boolean).map(Number);
|
||||
const CONFIG_DIR = process.env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||
const DISPLAY_NAME = process.env.BRIDGE_NAME ?? "telegram-bridge";
|
||||
|
||||
if (!BOT_TOKEN) {
|
||||
console.error("TELEGRAM_BOT_TOKEN required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- Load mesh config ---
|
||||
interface JoinedMesh {
|
||||
meshId: string;
|
||||
memberId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
pubkey: string;
|
||||
secretKey: string;
|
||||
brokerUrl: string;
|
||||
}
|
||||
|
||||
function loadMeshConfig(): JoinedMesh[] {
|
||||
// Support env-based config for Docker/VPS deployment
|
||||
if (process.env.MESH_ID && process.env.MESH_MEMBER_ID && process.env.MESH_PUBKEY && process.env.MESH_SECRET_KEY) {
|
||||
return [{
|
||||
meshId: process.env.MESH_ID,
|
||||
memberId: process.env.MESH_MEMBER_ID,
|
||||
slug: process.env.MESH_SLUG ?? "mesh",
|
||||
name: process.env.MESH_NAME ?? "mesh",
|
||||
pubkey: process.env.MESH_PUBKEY,
|
||||
secretKey: process.env.MESH_SECRET_KEY,
|
||||
brokerUrl: process.env.MESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
}];
|
||||
}
|
||||
// Fall back to config file
|
||||
const path = join(CONFIG_DIR, "config.json");
|
||||
if (!existsSync(path)) {
|
||||
console.error(`No config at ${path} — set MESH_ID/MESH_MEMBER_ID/MESH_PUBKEY/MESH_SECRET_KEY env vars or run 'claudemesh join' first`);
|
||||
process.exit(1);
|
||||
}
|
||||
const config = JSON.parse(readFileSync(path, "utf-8"));
|
||||
return config.meshes ?? [];
|
||||
}
|
||||
|
||||
// --- Crypto ---
|
||||
let sodiumReady = false;
|
||||
|
||||
async function ensureSodium() {
|
||||
if (!sodiumReady) {
|
||||
await sodium.ready;
|
||||
sodiumReady = true;
|
||||
}
|
||||
return sodium;
|
||||
}
|
||||
|
||||
async function generateSessionKeypair() {
|
||||
const s = await ensureSodium();
|
||||
const kp = s.crypto_sign_keypair();
|
||||
return {
|
||||
publicKey: s.to_hex(kp.publicKey),
|
||||
secretKey: s.to_hex(kp.privateKey),
|
||||
};
|
||||
}
|
||||
|
||||
async function signHello(meshId: string, memberId: string, pubkey: string, secretKeyHex: string) {
|
||||
const s = await ensureSodium();
|
||||
const timestamp = Date.now();
|
||||
const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`;
|
||||
const sig = s.crypto_sign_detached(s.from_string(canonical), s.from_hex(secretKeyHex));
|
||||
return { timestamp, signature: s.to_hex(sig) };
|
||||
}
|
||||
|
||||
/** Decrypt a direct message envelope using crypto_box (X25519). */
|
||||
async function decryptDirect(
|
||||
nonce: string,
|
||||
ciphertext: string,
|
||||
senderPubkeyHex: string,
|
||||
recipientSecretKeyHex: string,
|
||||
): Promise<string | null> {
|
||||
const s = await ensureSodium();
|
||||
try {
|
||||
const senderPub = s.crypto_sign_ed25519_pk_to_curve25519(s.from_hex(senderPubkeyHex));
|
||||
const recipientSec = s.crypto_sign_ed25519_sk_to_curve25519(s.from_hex(recipientSecretKeyHex));
|
||||
const nonceBytes = s.from_base64(nonce, s.base64_variants.ORIGINAL);
|
||||
const ciphertextBytes = s.from_base64(ciphertext, s.base64_variants.ORIGINAL);
|
||||
const plain = s.crypto_box_open_easy(ciphertextBytes, nonceBytes, senderPub, recipientSec);
|
||||
return s.to_string(plain);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mesh WS Client (simplified) ---
|
||||
interface PeerInfo {
|
||||
displayName: string;
|
||||
pubkey: string;
|
||||
status: string;
|
||||
summary?: string;
|
||||
cwd?: string;
|
||||
groups?: string[];
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
class MeshBridge {
|
||||
private ws: WebSocket | null = null;
|
||||
private mesh: JoinedMesh;
|
||||
private sessionPubkey: string | null = null;
|
||||
private sessionSecretKey: string | null = null;
|
||||
private connected = false;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private reconnectAttempt = 0;
|
||||
private onMessage: (from: string, text: string, priority: string) => void;
|
||||
private resolvers = new Map<string, { resolve: (v: any) => void; timer: NodeJS.Timeout }>();
|
||||
/** Map pubkey → {name, avatar}, populated from list_peers */
|
||||
private peerInfo = new Map<string, { name: string; avatar?: string }>();
|
||||
|
||||
constructor(mesh: JoinedMesh, onMessage: (from: string, text: string, priority: string) => void) {
|
||||
this.mesh = mesh;
|
||||
this.onMessage = onMessage;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const sessionKP = await generateSessionKeypair();
|
||||
this.sessionPubkey = sessionKP.publicKey;
|
||||
this.sessionSecretKey = sessionKP.secretKey;
|
||||
return this._connect();
|
||||
}
|
||||
|
||||
private _connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.mesh.brokerUrl);
|
||||
this.ws = ws;
|
||||
|
||||
ws.on("open", async () => {
|
||||
try {
|
||||
const { timestamp, signature } = await signHello(
|
||||
this.mesh.meshId, this.mesh.memberId,
|
||||
this.mesh.pubkey, this.mesh.secretKey,
|
||||
);
|
||||
ws.send(JSON.stringify({
|
||||
type: "hello",
|
||||
meshId: this.mesh.meshId,
|
||||
memberId: this.mesh.memberId,
|
||||
pubkey: this.mesh.pubkey,
|
||||
sessionPubkey: this.sessionPubkey,
|
||||
displayName: DISPLAY_NAME,
|
||||
sessionId: `telegram-${process.pid}-${Date.now()}`,
|
||||
pid: process.pid,
|
||||
cwd: process.cwd(),
|
||||
hostname: require("os").hostname(),
|
||||
peerType: "bridge",
|
||||
channel: "telegram",
|
||||
timestamp,
|
||||
signature,
|
||||
}));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
const helloTimeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new Error("hello_ack timeout"));
|
||||
}, 10_000);
|
||||
|
||||
ws.on("message", async (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type !== "hello_ack" && msg.type !== "ack") {
|
||||
console.log(`[mesh] recv: ${msg.type}${msg.subtype ? '/' + msg.subtype : ''}${msg.event ? '/' + msg.event : ''}`);
|
||||
}
|
||||
|
||||
if (msg.type === "hello_ack") {
|
||||
clearTimeout(helloTimeout);
|
||||
this.connected = true;
|
||||
this.reconnectAttempt = 0;
|
||||
console.log(`[mesh] connected to ${this.mesh.slug} as ${DISPLAY_NAME}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Push messages from peers
|
||||
if (msg.type === "push") {
|
||||
let text: string | null = null;
|
||||
const senderPubkey = msg.senderPubkey ?? msg.senderSessionPubkey;
|
||||
|
||||
// System messages (no encryption)
|
||||
if (msg.subtype === "system") {
|
||||
const event = msg.event ?? "";
|
||||
const data = msg.eventData ?? {};
|
||||
if (event === "peer_joined") text = `[joined] ${data.displayName ?? "peer"}`;
|
||||
else if (event === "peer_left") text = `[left] ${data.displayName ?? "peer"}`;
|
||||
else if (event === "peer_returned") text = `[returned] ${data.name ?? "peer"}`;
|
||||
else text = msg.plaintext ?? `[${event}]`;
|
||||
}
|
||||
// Encrypted direct message
|
||||
else if (senderPubkey && msg.nonce && msg.ciphertext) {
|
||||
// Try session key first, then mesh member key
|
||||
text = await decryptDirect(msg.nonce, msg.ciphertext, senderPubkey, this.sessionSecretKey!)
|
||||
?? await decryptDirect(msg.nonce, msg.ciphertext, senderPubkey, this.mesh.secretKey);
|
||||
if (!text) text = "[could not decrypt]";
|
||||
}
|
||||
// Plaintext fallback (broadcasts, legacy)
|
||||
else if (msg.plaintext) {
|
||||
text = msg.plaintext;
|
||||
}
|
||||
// Base64 ciphertext without nonce (legacy broadcast)
|
||||
else if (msg.ciphertext && !msg.nonce) {
|
||||
try { text = Buffer.from(msg.ciphertext, "base64").toString("utf-8"); } catch { text = "[decode error]"; }
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const info = senderPubkey ? this.peerInfo.get(senderPubkey) : null;
|
||||
const fromName = info?.name ?? (senderPubkey?.slice(0, 12) ?? "system");
|
||||
const avatar = info?.avatar ?? "🤖";
|
||||
console.log(`[mesh] push from ${avatar} ${fromName}: ${text.slice(0, 80)}`);
|
||||
this.onMessage(`${avatar} ${fromName}`, text, msg.priority ?? "next");
|
||||
} else {
|
||||
console.log(`[mesh] push with no text. subtype=${msg.subtype}, hasSender=${!!senderPubkey}, hasNonce=${!!msg.nonce}, hasCipher=${!!msg.ciphertext}, hasPlain=${!!msg.plaintext}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve pending requests
|
||||
const reqId = msg._reqId;
|
||||
if (reqId && this.resolvers.has(reqId)) {
|
||||
const r = this.resolvers.get(reqId)!;
|
||||
clearTimeout(r.timer);
|
||||
this.resolvers.delete(reqId);
|
||||
r.resolve(msg);
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
this.connected = false;
|
||||
this.ws = null;
|
||||
if (this.reconnectTimer) return;
|
||||
const delays = [1000, 2000, 4000, 8000, 16000, 30000];
|
||||
const delay = delays[Math.min(this.reconnectAttempt, delays.length - 1)]!;
|
||||
this.reconnectAttempt++;
|
||||
console.log(`[mesh] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this._connect().catch(e => console.error("[mesh] reconnect failed:", e));
|
||||
}, delay);
|
||||
});
|
||||
|
||||
ws.on("error", (err) => {
|
||||
console.error("[mesh] ws error:", err.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private makeReqId(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
private request(msg: Record<string, unknown>, timeout = 10_000): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
const reqId = this.makeReqId();
|
||||
const timer = setTimeout(() => {
|
||||
this.resolvers.delete(reqId);
|
||||
resolve(null);
|
||||
}, timeout);
|
||||
this.resolvers.set(reqId, { resolve, timer });
|
||||
this.ws?.send(JSON.stringify({ ...msg, _reqId: reqId }));
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(to: string, message: string, priority: string = "next"): Promise<boolean> {
|
||||
if (!this.ws || !this.connected) return false;
|
||||
|
||||
// For direct targets (pubkeys), use crypto_box encryption.
|
||||
// For broadcasts/groups, use base64-encoded plaintext (legacy format).
|
||||
let nonce = "";
|
||||
let ciphertext = "";
|
||||
const isDirect = /^[0-9a-f]{64}$/.test(to);
|
||||
if (isDirect) {
|
||||
const s = await ensureSodium();
|
||||
const recipientPub = s.crypto_sign_ed25519_pk_to_curve25519(s.from_hex(to));
|
||||
const senderSec = s.crypto_sign_ed25519_sk_to_curve25519(s.from_hex(this.sessionSecretKey!));
|
||||
const nonceBytes = s.randombytes_buf(s.crypto_box_NONCEBYTES);
|
||||
const ciphertextBytes = s.crypto_box_easy(s.from_string(message), nonceBytes, recipientPub, senderSec);
|
||||
nonce = s.to_base64(nonceBytes, s.base64_variants.ORIGINAL);
|
||||
ciphertext = s.to_base64(ciphertextBytes, s.base64_variants.ORIGINAL);
|
||||
} else {
|
||||
// Broadcast/group: base64 plaintext (CLI decodes this when no nonce present)
|
||||
ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||
}
|
||||
|
||||
const id = this.makeReqId();
|
||||
console.log(`[mesh] sending to ${to.slice(0, 16)}…, encrypted=${isDirect}`);
|
||||
this.ws.send(JSON.stringify({
|
||||
type: "send",
|
||||
id,
|
||||
targetSpec: to,
|
||||
priority,
|
||||
nonce,
|
||||
ciphertext,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Find all peers matching a display name. */
|
||||
async findPeersByName(name: string): Promise<PeerInfo[]> {
|
||||
const peers = await this.listPeers();
|
||||
return peers.filter(p => p.displayName.toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
/** Upload a file to the mesh via broker HTTP. Returns file ID. */
|
||||
async uploadFile(data: Buffer, fileName: string, tags?: string[]): Promise<string | null> {
|
||||
const brokerHttp = this.mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
try {
|
||||
const res = await fetch(`${brokerHttp}/upload`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-Mesh-Id": this.mesh.meshId,
|
||||
"X-Member-Id": this.mesh.memberId,
|
||||
"X-File-Name": fileName,
|
||||
"X-Tags": JSON.stringify(tags ?? ["telegram"]),
|
||||
"X-Persistent": "true",
|
||||
},
|
||||
body: data,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const body = await res.json() as { ok?: boolean; fileId?: string; error?: string };
|
||||
if (!res.ok || !body.fileId) return null;
|
||||
return body.fileId;
|
||||
} catch (e) {
|
||||
console.error("[mesh] upload failed:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a download URL for a mesh file. */
|
||||
async getFileUrl(fileId: string): Promise<{ url: string; name: string } | null> {
|
||||
const resp = await this.request({ type: "get_file", fileId });
|
||||
if (!resp?.url) return null;
|
||||
return { url: resp.url, name: resp.name ?? "file" };
|
||||
}
|
||||
|
||||
async listPeers(): Promise<PeerInfo[]> {
|
||||
const resp = await this.request({ type: "list_peers" });
|
||||
if (!resp?.peers) return [];
|
||||
return resp.peers.map((p: any) => {
|
||||
const name = p.displayName ?? p.pubkey?.slice(0, 12) ?? "?";
|
||||
const avatar = p.profile?.avatar;
|
||||
// Cache pubkey → info for push message attribution
|
||||
const info = { name, avatar };
|
||||
if (p.pubkey) this.peerInfo.set(p.pubkey, info);
|
||||
if (p.sessionPubkey) this.peerInfo.set(p.sessionPubkey, info);
|
||||
return {
|
||||
displayName: name,
|
||||
pubkey: p.pubkey ?? "",
|
||||
status: p.status ?? "unknown",
|
||||
summary: p.summary,
|
||||
cwd: p.cwd,
|
||||
groups: p.groups?.map((g: any) => g.name) ?? [],
|
||||
avatar: avatar,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Refresh peer name cache. Called periodically. */
|
||||
async refreshPeerNames(): Promise<void> {
|
||||
await this.listPeers();
|
||||
}
|
||||
|
||||
async setSummary(summary: string): Promise<void> {
|
||||
this.ws?.send(JSON.stringify({ type: "set_summary", summary }));
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
this.ws?.close();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resolve display name from peers ---
|
||||
async function resolveTarget(bridge: MeshBridge, name: string): Promise<string> {
|
||||
// If it starts with @, it's a group
|
||||
if (name.startsWith("@")) return name;
|
||||
// If *, broadcast
|
||||
if (name === "*") return "*";
|
||||
// Otherwise resolve as display name — the broker handles this via targetSpec
|
||||
return name;
|
||||
}
|
||||
|
||||
// --- Telegram Bot ---
|
||||
async function main() {
|
||||
const meshes = loadMeshConfig();
|
||||
if (meshes.length === 0) {
|
||||
console.error("No meshes joined — run 'claudemesh join' first");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const bot = new Bot(BOT_TOKEN);
|
||||
const bridges: MeshBridge[] = [];
|
||||
|
||||
// One bridge per mesh
|
||||
for (const mesh of meshes) {
|
||||
const bridge = new MeshBridge(mesh, (from, text, priority) => {
|
||||
// Forward mesh messages to all allowed Telegram chats
|
||||
const prefix = `[${mesh.slug}] ${from}`;
|
||||
const formatted = `💬 *${prefix}*\n${text}`;
|
||||
for (const chatId of ALLOWED_CHAT_IDS) {
|
||||
bot.api.sendMessage(chatId, formatted, { parse_mode: "Markdown" }).catch(e => {
|
||||
console.error(`[tg] failed to send to ${chatId}:`, e.message);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await bridge.connect();
|
||||
await bridge.setSummary("Telegram bridge — relays messages between Telegram and mesh peers");
|
||||
await bridge.refreshPeerNames();
|
||||
bridges.push(bridge);
|
||||
// Refresh peer names every 30s for display name resolution on pushes
|
||||
setInterval(() => bridge.refreshPeerNames().catch(() => {}), 30_000);
|
||||
} catch (e) {
|
||||
console.error(`[mesh] failed to connect to ${mesh.slug}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (bridges.length === 0) {
|
||||
console.error("Failed to connect to any mesh");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const defaultBridge = bridges[0]!;
|
||||
|
||||
// --- Bot commands ---
|
||||
|
||||
bot.command("peers", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const peers = await defaultBridge.listPeers();
|
||||
if (peers.length === 0) {
|
||||
await ctx.reply("No peers online.");
|
||||
return;
|
||||
}
|
||||
const lines = peers.map(p => {
|
||||
const status = p.status === "idle" ? "🟢" : p.status === "working" ? "🟡" : "🔴";
|
||||
const summary = p.summary ? ` — _${p.summary}_` : "";
|
||||
return `${status} *${p.displayName}*${summary}`;
|
||||
});
|
||||
await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
|
||||
});
|
||||
|
||||
// Pending messages waiting for peer selection (chatId → {message, matches})
|
||||
const pendingDMs = new Map<number, { message: string; matches: PeerInfo[]; selected: Set<number> }>();
|
||||
|
||||
bot.command("dm", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const text = ctx.match;
|
||||
if (!text) {
|
||||
await ctx.reply("Usage: /dm <peer-name> <message>");
|
||||
return;
|
||||
}
|
||||
const spaceIdx = text.indexOf(" ");
|
||||
if (spaceIdx === -1) {
|
||||
await ctx.reply("Usage: /dm <peer-name> <message>");
|
||||
return;
|
||||
}
|
||||
const target = text.slice(0, spaceIdx);
|
||||
const message = text.slice(spaceIdx + 1);
|
||||
|
||||
// Find matching peers
|
||||
const matches = await defaultBridge.findPeersByName(target);
|
||||
if (matches.length === 0) {
|
||||
await ctx.reply(`❌ No peer named "${target}" found.`);
|
||||
return;
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
// Single match — send directly
|
||||
const ok = await defaultBridge.sendMessage(matches[0]!.pubkey, `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}` : "❌ Not connected");
|
||||
return;
|
||||
}
|
||||
// Multiple matches — show picker with individual + all option
|
||||
pendingDMs.set(ctx.chat.id, { message, matches, selected: new Set() });
|
||||
const buttons = matches.map((p, i) => {
|
||||
const dir = p.cwd?.split("/").pop() ?? "?";
|
||||
const avatar = p.avatar ?? "🤖";
|
||||
return [{ text: `${avatar} ${p.displayName} (${dir})`, callback_data: `dm:${i}` }];
|
||||
});
|
||||
buttons.push([{ text: "📨 Send to ALL", callback_data: "dm:all" }]);
|
||||
await ctx.reply(`Multiple "${target}" peers online. Pick one or all:`, {
|
||||
reply_markup: { inline_keyboard: buttons },
|
||||
});
|
||||
});
|
||||
|
||||
bot.command("broadcast", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const message = ctx.match;
|
||||
if (!message) {
|
||||
await ctx.reply("Usage: /broadcast <message>");
|
||||
return;
|
||||
}
|
||||
const ok = await defaultBridge.sendMessage("*", `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? "✅ Broadcast sent" : "❌ Not connected");
|
||||
});
|
||||
|
||||
bot.command("group", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const text = ctx.match;
|
||||
if (!text) {
|
||||
await ctx.reply("Usage: /group <@group-name> <message>");
|
||||
return;
|
||||
}
|
||||
const spaceIdx = text.indexOf(" ");
|
||||
if (spaceIdx === -1) {
|
||||
await ctx.reply("Usage: /group <@group-name> <message>");
|
||||
return;
|
||||
}
|
||||
const target = text.slice(0, spaceIdx);
|
||||
const message = text.slice(spaceIdx + 1);
|
||||
const ok = await defaultBridge.sendMessage(target, `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? `✅ Sent to ${target}` : "❌ Not connected");
|
||||
});
|
||||
|
||||
bot.command("status", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const meshStatus = bridges.map(b =>
|
||||
`${b.isConnected() ? "🟢" : "🔴"} Connected`
|
||||
).join("\n");
|
||||
await ctx.reply(`*Claudemesh Telegram Bridge*\n${meshStatus}`, { parse_mode: "Markdown" });
|
||||
});
|
||||
|
||||
// --- File: get a mesh file by ID ---
|
||||
bot.command("file", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const fileId = ctx.match?.trim();
|
||||
if (!fileId) {
|
||||
await ctx.reply("Usage: /file <file-id>");
|
||||
return;
|
||||
}
|
||||
const file = await defaultBridge.getFileUrl(fileId);
|
||||
if (!file) {
|
||||
await ctx.reply(`❌ File ${fileId} not found`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(file.url, { signal: AbortSignal.timeout(30_000) });
|
||||
if (!resp.ok) { await ctx.reply(`❌ Download failed (${resp.status})`); return; }
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
await ctx.replyWithDocument(new InputFile(buf, file.name));
|
||||
} catch (e) {
|
||||
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
bot.command("start", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) {
|
||||
await ctx.reply("⛔ Not authorized. Add your chat ID to TELEGRAM_CHAT_IDS.");
|
||||
return;
|
||||
}
|
||||
await ctx.reply(
|
||||
"🔗 *Claudemesh Telegram Bridge*\n\n" +
|
||||
"Commands:\n" +
|
||||
"• /peers — List online peers\n" +
|
||||
"• /dm <name> <msg> — DM a specific peer\n" +
|
||||
"• /broadcast <msg> — Message all peers\n" +
|
||||
"• /group @name <msg> — Message a group\n" +
|
||||
"• /file <id> — Download a mesh file\n" +
|
||||
"• /status — Bridge connection status\n\n" +
|
||||
"Send a photo/document to share it with the mesh.\n" +
|
||||
"Or just type a message to broadcast it.",
|
||||
{ parse_mode: "Markdown" },
|
||||
);
|
||||
});
|
||||
|
||||
// Handle inline keyboard callbacks for peer selection
|
||||
bot.on("callback_query:data", async (ctx) => {
|
||||
const data = ctx.callbackQuery.data;
|
||||
const chatId = ctx.chat?.id;
|
||||
if (!chatId || !data.startsWith("dm:")) {
|
||||
await ctx.answerCallbackQuery();
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingDMs.get(chatId);
|
||||
if (!pending) {
|
||||
await ctx.answerCallbackQuery({ text: "Session expired. Send /dm again." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === "dm:all") {
|
||||
// Send to all matches
|
||||
let sent = 0;
|
||||
for (const p of pending.matches) {
|
||||
const ok = await defaultBridge.sendMessage(p.pubkey, `[via Telegram] ${pending.message}`, "now");
|
||||
if (ok) sent++;
|
||||
}
|
||||
pendingDMs.delete(chatId);
|
||||
await ctx.answerCallbackQuery({ text: `Sent to ${sent} peers` });
|
||||
await ctx.editMessageText(`✅ Sent to all ${sent} ${pending.matches[0]?.displayName ?? "?"} peers`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single selection: dm:0, dm:1, etc.
|
||||
const idx = parseInt(data.slice(3));
|
||||
const peer = pending.matches[idx];
|
||||
if (!peer) {
|
||||
await ctx.answerCallbackQuery({ text: "Invalid selection" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await defaultBridge.sendMessage(peer.pubkey, `[via Telegram] ${pending.message}`, "now");
|
||||
pendingDMs.delete(chatId);
|
||||
const dir = peer.cwd?.split("/").pop() ?? "?";
|
||||
await ctx.answerCallbackQuery({ text: ok ? "Sent!" : "Failed" });
|
||||
await ctx.editMessageText(ok ? `✅ → ${peer.avatar ?? "🤖"} ${peer.displayName} (${dir})` : "❌ Not connected");
|
||||
});
|
||||
|
||||
// Handle photos from Telegram → share to mesh
|
||||
bot.on("message:photo", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const photo = ctx.message.photo.at(-1); // highest resolution
|
||||
if (!photo) return;
|
||||
try {
|
||||
const file = await ctx.api.getFile(photo.file_id);
|
||||
const url = `https://api.telegram.org/file/bot${BOT_TOKEN}/${file.file_path}`;
|
||||
const resp = await fetch(url);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
const name = `telegram-photo-${Date.now()}.jpg`;
|
||||
const fileId = await defaultBridge.uploadFile(buf, name, ["telegram", "photo"]);
|
||||
if (fileId) {
|
||||
const caption = ctx.message.caption ? ` — "${ctx.message.caption}"` : "";
|
||||
await defaultBridge.sendMessage("*", `[via Telegram] 📷 Photo shared${caption} (file: ${fileId})`, "next");
|
||||
await ctx.reply(`✅ Photo shared to mesh (${fileId})`);
|
||||
} else {
|
||||
await ctx.reply("❌ Upload failed");
|
||||
}
|
||||
} catch (e) {
|
||||
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle documents from Telegram → share to mesh
|
||||
bot.on("message:document", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const doc = ctx.message.document;
|
||||
if (!doc) return;
|
||||
try {
|
||||
const file = await ctx.api.getFile(doc.file_id);
|
||||
const url = `https://api.telegram.org/file/bot${BOT_TOKEN}/${file.file_path}`;
|
||||
const resp = await fetch(url);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
const name = doc.file_name ?? `telegram-file-${Date.now()}`;
|
||||
const fileId = await defaultBridge.uploadFile(buf, name, ["telegram", "document"]);
|
||||
if (fileId) {
|
||||
const caption = ctx.message.caption ? ` — "${ctx.message.caption}"` : "";
|
||||
await defaultBridge.sendMessage("*", `[via Telegram] 📎 File shared: ${name}${caption} (file: ${fileId})`, "next");
|
||||
await ctx.reply(`✅ File shared to mesh: ${name} (${fileId})`);
|
||||
} else {
|
||||
await ctx.reply("❌ Upload failed");
|
||||
}
|
||||
} catch (e) {
|
||||
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Default: any text without a command → broadcast
|
||||
bot.on("message:text", async (ctx) => {
|
||||
if (!isAllowed(ctx.chat.id)) return;
|
||||
const text = ctx.message.text;
|
||||
if (text.startsWith("/")) return; // Skip unknown commands
|
||||
|
||||
// Check for @mention pattern: "@PeerName message"
|
||||
const mentionMatch = text.match(/^@(\S+)\s+([\s\S]+)$/);
|
||||
if (mentionMatch) {
|
||||
const target = mentionMatch[1]!;
|
||||
const message = mentionMatch[2]!;
|
||||
const matches = await defaultBridge.findPeersByName(target);
|
||||
if (matches.length === 0) {
|
||||
await ctx.reply(`❌ No peer named "${target}"`);
|
||||
} else if (matches.length === 1) {
|
||||
const ok = await defaultBridge.sendMessage(matches[0]!.pubkey, `[via Telegram] ${message}`, "now");
|
||||
await ctx.reply(ok ? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}` : "❌ Not connected");
|
||||
} else {
|
||||
pendingDMs.set(ctx.chat.id, { message, matches, selected: new Set() });
|
||||
const buttons = matches.map((p, i) => {
|
||||
const dir = p.cwd?.split("/").pop() ?? "?";
|
||||
return [{ text: `${p.avatar ?? "🤖"} ${p.displayName} (${dir})`, callback_data: `dm:${i}` }];
|
||||
});
|
||||
buttons.push([{ text: "📨 Send to ALL", callback_data: "dm:all" }]);
|
||||
await ctx.reply(`Multiple "${target}" peers. Pick one or all:`, {
|
||||
reply_markup: { inline_keyboard: buttons },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No mention → broadcast
|
||||
const ok = await defaultBridge.sendMessage("*", `[via Telegram] ${text}`, "next");
|
||||
if (!ok) await ctx.reply("❌ Not connected to mesh");
|
||||
});
|
||||
|
||||
function isAllowed(chatId: number): boolean {
|
||||
// If no chat IDs configured, allow all (dev mode)
|
||||
if (ALLOWED_CHAT_IDS.length === 0) return true;
|
||||
return ALLOWED_CHAT_IDS.includes(chatId);
|
||||
}
|
||||
|
||||
// Start bot
|
||||
console.log("[tg] starting bot...");
|
||||
bot.start({
|
||||
onStart: () => console.log("[tg] bot running"),
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGINT", () => {
|
||||
console.log("[shutdown] closing...");
|
||||
bot.stop();
|
||||
bridges.forEach(b => b.close());
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
console.log("[shutdown] closing...");
|
||||
bot.stop();
|
||||
bridges.forEach(b => b.close());
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error("fatal:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -175,4 +175,12 @@ GOOGLE_GENERATIVE_AI_API_KEY="<your-google-generative-ai-api-key>"
|
||||
MISTRAL_API_KEY="<your-mistral-api-key>"
|
||||
|
||||
# Perplexity API key - required only if you use Perplexity as an AI provider
|
||||
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
|
||||
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
|
||||
|
||||
|
||||
##############################
|
||||
### CLI Sync config ###
|
||||
##############################
|
||||
|
||||
# Shared secret for CLI sync JWT signing (HS256) — must match the broker's CLI_SYNC_SECRET
|
||||
CLI_SYNC_SECRET="<your-cli-sync-secret>"
|
||||
@@ -10,7 +10,11 @@ RUN corepack enable && corepack prepare pnpm@10.25.0 --activate
|
||||
# pnpm workspace needs full context to resolve workspace:* + catalog:
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# --ignore-scripts skips sherif postinstall linting (exits 1 on warnings)
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts && \
|
||||
node node_modules/esbuild/install.js && \
|
||||
node node_modules/sharp/install/check.js || npm run --prefix node_modules/sharp build 2>/dev/null; \
|
||||
true
|
||||
|
||||
# Build — SKIP_ENV_VALIDATION lets missing runtime vars pass (validated at startup instead)
|
||||
ENV NODE_ENV=production
|
||||
@@ -25,9 +29,9 @@ ENV NEXT_PUBLIC_URL=$NEXT_PUBLIC_URL
|
||||
ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME
|
||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
|
||||
# TURBOPACK=0 forces webpack for production build — Payload CMS's
|
||||
# richtext-lexical CSS imports fail under Turbopack.
|
||||
ENV TURBOPACK=0
|
||||
# Node ESM loader that stubs .css imports during route collection.
|
||||
# Payload CMS deps import .css files that Node can't handle outside webpack.
|
||||
ENV NODE_OPTIONS="--import /app/apps/web/css-stub-loader.mjs"
|
||||
RUN npx turbo run build --filter=web...
|
||||
|
||||
# Stage 2: runtime — standalone output only
|
||||
|
||||
10
apps/web/css-stub-loader.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
// Node.js ESM loader that stubs non-JS asset imports during Next.js page data collection.
|
||||
// Payload CMS and its deps import .css/.scss/.svg files that Node.js can't handle.
|
||||
const STUB_EXTENSIONS = ['.css', '.scss', '.sass', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot'];
|
||||
|
||||
export function resolve(specifier, context, nextResolve) {
|
||||
if (STUB_EXTENSIONS.some(ext => specifier.endsWith(ext))) {
|
||||
return { url: 'data:text/javascript,export default ""', shortCircuit: true };
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
10
apps/web/css-stub-register.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
import { register } from "node:module";
|
||||
register("data:text/javascript," + encodeURIComponent(`
|
||||
const STUB_EXT = ['.css', '.scss', '.sass', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.eot'];
|
||||
export function resolve(specifier, context, nextResolve) {
|
||||
if (STUB_EXT.some(ext => specifier.endsWith(ext))) {
|
||||
return { url: 'data:text/javascript,export default ""', shortCircuit: true };
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
`));
|
||||
@@ -72,6 +72,7 @@ const securityHeaders = [
|
||||
},
|
||||
];
|
||||
|
||||
// build: 1776069543
|
||||
const config: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
@@ -88,7 +89,10 @@ const config: NextConfig = {
|
||||
"@payloadcms/db-postgres",
|
||||
"@payloadcms/db-sqlite",
|
||||
"@payloadcms/richtext-lexical",
|
||||
"@payloadcms/next",
|
||||
"@payloadcms/ui",
|
||||
"sharp",
|
||||
"libsodium-wrappers",
|
||||
],
|
||||
turbopack: {
|
||||
rules: {
|
||||
@@ -99,6 +103,24 @@ const config: NextConfig = {
|
||||
},
|
||||
},
|
||||
|
||||
// Webpack SVG loader (used when TURBOPACK=0 for production builds).
|
||||
// Exclude app/ dir SVGs (icon.svg, opengraph-image) — Next.js metadata
|
||||
// loader handles those. Only process package SVGs (flags, logos).
|
||||
webpack(config) {
|
||||
const existingSvgRule = config.module.rules.find(
|
||||
(rule: { test?: RegExp }) => rule.test?.test?.(".svg"),
|
||||
);
|
||||
if (existingSvgRule) {
|
||||
existingSvgRule.exclude = /packages\/ui\/.*\.svg$/;
|
||||
}
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
include: /packages\/ui\//,
|
||||
use: ["@svgr/webpack"],
|
||||
});
|
||||
return config;
|
||||
},
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
@@ -108,7 +130,7 @@ const config: NextConfig = {
|
||||
},
|
||||
|
||||
/** Enables hot reloading for local packages without a build step */
|
||||
transpilePackages: INTERNAL_PACKAGES,
|
||||
transpilePackages: [...INTERNAL_PACKAGES, "react-image-crop"],
|
||||
experimental: {
|
||||
optimizePackageImports: INTERNAL_PACKAGES,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build --no-turbopack",
|
||||
"build": "NODE_OPTIONS='--import ./css-stub-register.mjs' next build --webpack",
|
||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||
"dev": "next dev",
|
||||
"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 |
@@ -153,14 +153,14 @@ export default function AboutPage() {
|
||||
GitHub
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.linkedin.com/in/alejandrogutierrezmourente/"
|
||||
href="https://www.linkedin.com/in/alejandro-mourente/"
|
||||
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
LinkedIn
|
||||
</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)]"
|
||||
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 { Surfaces } from "~/modules/marketing/home/surfaces";
|
||||
import { Pricing } from "~/modules/marketing/home/pricing";
|
||||
import { LaptopToLaptop } from "~/modules/marketing/home/laptop-to-laptop";
|
||||
import { HeroWithMesh } from "~/modules/marketing/home/hero-with-mesh";
|
||||
import { Features } from "~/modules/marketing/home/features";
|
||||
import { MeetsYou } from "~/modules/marketing/home/meets-you";
|
||||
import { BeyondTerminal } from "~/modules/marketing/home/beyond-terminal";
|
||||
import { DemoDashboard } from "~/modules/marketing/home/demo-dashboard";
|
||||
import { WhereMeshFits } from "~/modules/marketing/home/where-mesh-fits";
|
||||
import { WhatIsClaudemesh } from "~/modules/marketing/home/what-is-claudemesh";
|
||||
import { Timeline } from "~/modules/marketing/home/timeline";
|
||||
import { Pricing } from "~/modules/marketing/home/pricing";
|
||||
import { FAQ } from "~/modules/marketing/home/faq";
|
||||
import { CallToAction } from "~/modules/marketing/home/cta";
|
||||
import { MeshStats } from "~/modules/marketing/home/mesh-stats";
|
||||
import { LatestNewsToaster } from "~/modules/marketing/home/toaster";
|
||||
|
||||
// Revalidate the page every 60s so the mesh-stats counter stays fresh
|
||||
// without hammering the DB. The /api/public/stats endpoint has its own
|
||||
// 60s in-memory cache too.
|
||||
export const revalidate = 60;
|
||||
|
||||
const HomePage = () => {
|
||||
@@ -23,15 +17,12 @@ const HomePage = () => {
|
||||
className="bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<Hero />
|
||||
<Surfaces />
|
||||
<Pricing />
|
||||
<LaptopToLaptop />
|
||||
<HeroWithMesh />
|
||||
<Features />
|
||||
<MeetsYou />
|
||||
<WhereMeshFits />
|
||||
<WhatIsClaudemesh />
|
||||
<DemoDashboard />
|
||||
<BeyondTerminal />
|
||||
<Timeline />
|
||||
<Pricing />
|
||||
<FAQ />
|
||||
<CallToAction />
|
||||
<MeshStats />
|
||||
|
||||
876
apps/web/src/app/[locale]/cli-auth/cli-auth-flow.tsx
Normal file
@@ -0,0 +1,876 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
import { getMyMeshesResponseSchema } from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Mesh {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
myRole: "admin" | "member";
|
||||
isOwner: boolean;
|
||||
memberCount: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
code: string | null;
|
||||
port: string | null;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const slugify = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
|
||||
const ease = [0.22, 0.61, 0.36, 1] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animated mesh node background
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MeshBackdrop() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{/* Radial glow */}
|
||||
<div
|
||||
className="absolute left-1/2 top-0 h-[600px] w-[900px] -translate-x-1/2 opacity-[0.06]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(ellipse at 50% 0%, var(--cm-clay) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
{/* Floating mesh nodes */}
|
||||
{[
|
||||
{ x: "12%", y: "18%", delay: 0, size: 3 },
|
||||
{ x: "85%", y: "14%", delay: 1.2, size: 2 },
|
||||
{ x: "72%", y: "55%", delay: 0.6, size: 4 },
|
||||
{ x: "8%", y: "65%", delay: 2.0, size: 2 },
|
||||
{ x: "45%", y: "80%", delay: 0.3, size: 3 },
|
||||
{ x: "92%", y: "78%", delay: 1.8, size: 2 },
|
||||
].map((node, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute rounded-full bg-[var(--cm-clay)]"
|
||||
style={{
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
width: node.size,
|
||||
height: node.size,
|
||||
}}
|
||||
animate={{
|
||||
opacity: [0.15, 0.4, 0.15],
|
||||
scale: [1, 1.5, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
ease: "easeInOut",
|
||||
repeat: Infinity,
|
||||
delay: node.delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Connecting lines (SVG) */}
|
||||
<svg className="absolute inset-0 h-full w-full opacity-[0.04]">
|
||||
<line
|
||||
x1="12%"
|
||||
y1="18%"
|
||||
x2="45%"
|
||||
y2="80%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="85%"
|
||||
y1="14%"
|
||||
x2="72%"
|
||||
y2="55%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="72%"
|
||||
y1="55%"
|
||||
x2="92%"
|
||||
y2="78%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<line
|
||||
x1="8%"
|
||||
y1="65%"
|
||||
x2="45%"
|
||||
y2="80%"
|
||||
stroke="var(--cm-clay)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Terminal-style status indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusPulse({ status }: { status: "waiting" | "syncing" | "done" | "error" }) {
|
||||
const colors = {
|
||||
waiting: "bg-[var(--cm-clay)]",
|
||||
syncing: "bg-amber-400",
|
||||
done: "bg-emerald-400",
|
||||
error: "bg-red-400",
|
||||
};
|
||||
return (
|
||||
<span className="relative inline-flex h-2 w-2">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${colors[status]}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative inline-flex h-2 w-2 rounded-full ${colors[status]}`}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CliAuthFlow({ code, port, userId, userEmail }: Props) {
|
||||
const [meshes, setMeshes] = useState<Mesh[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [redirected, setRedirected] = useState(false);
|
||||
|
||||
// Create-mesh form state
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newSlug, setNewSlug] = useState("");
|
||||
const [slugDirty, setSlugDirty] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto-slug from name
|
||||
useEffect(() => {
|
||||
if (!slugDirty && newName) {
|
||||
setNewSlug(slugify(newName));
|
||||
}
|
||||
}, [newName, slugDirty]);
|
||||
|
||||
// Fetch user meshes
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const { data } = await handle(api.my.meshes.$get, {
|
||||
schema: getMyMeshesResponseSchema,
|
||||
})({
|
||||
query: { page: "1", perPage: "50", sort: JSON.stringify([]) },
|
||||
});
|
||||
setMeshes(data);
|
||||
setSelected(new Set(data.map((m) => m.id)));
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error ? e.message : "Failed to load your meshes.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Auto-focus name input when no meshes
|
||||
useEffect(() => {
|
||||
if (!loading && meshes.length === 0 && nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
}
|
||||
}, [loading, meshes.length]);
|
||||
|
||||
const toggleMesh = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const status = token
|
||||
? redirected
|
||||
? "done"
|
||||
: "done"
|
||||
: syncing || creating
|
||||
? "syncing"
|
||||
: error
|
||||
? "error"
|
||||
: "waiting";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create mesh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim() || !newSlug.trim()) return;
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const createRes = await fetch("/api/my/meshes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: newName.trim(),
|
||||
slug: newSlug.trim(),
|
||||
visibility: "private",
|
||||
transport: "managed",
|
||||
}),
|
||||
});
|
||||
const res = (await createRes.json()) as
|
||||
| { id: string; slug: string }
|
||||
| { error: string };
|
||||
if (!createRes.ok || "error" in res) {
|
||||
setCreateError("error" in res ? res.error : "Failed to create mesh.");
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
await doSync(
|
||||
[{ id: res.id, slug: res.slug, role: "admin" as const }],
|
||||
"create",
|
||||
{ name: newName.trim(), slug: newSlug.trim() },
|
||||
);
|
||||
} catch (e) {
|
||||
setCreateError(e instanceof Error ? e.message : "Failed to create mesh.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const doSync = async (
|
||||
meshList: Array<{ id: string; slug: string; role: string }>,
|
||||
action: "sync" | "create" = "sync",
|
||||
newMesh?: { name: string; slug: string },
|
||||
) => {
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/cli-sync-token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ meshes: meshList, action, newMesh }),
|
||||
});
|
||||
const data = (await res.json()) as { token?: string; error?: string };
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Failed to generate token.");
|
||||
setSyncing(false);
|
||||
return;
|
||||
}
|
||||
const jwt = data.token as string;
|
||||
setToken(jwt);
|
||||
if (port) {
|
||||
setRedirected(true);
|
||||
window.location.href = `http://localhost:${port}/callback?token=${encodeURIComponent(jwt)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to generate sync token.");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = () => {
|
||||
const selectedMeshes = meshes
|
||||
.filter((m) => selected.has(m.id))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
slug: m.slug,
|
||||
role: m.isOwner ? "admin" : m.myRole,
|
||||
}));
|
||||
if (selectedMeshes.length === 0) {
|
||||
setError("Select at least one mesh to sync.");
|
||||
return;
|
||||
}
|
||||
doSync(selectedMeshes, "sync");
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!token) return;
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<header className="relative z-20 border-b border-[var(--cm-border)] px-6 py-5 md:px-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="claudemesh home"
|
||||
className="group flex w-fit items-center gap-2.5"
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="text-[var(--cm-clay)] transition-transform duration-300 group-hover:rotate-180"
|
||||
>
|
||||
<circle cx="12" cy="4" r="2" fill="currentColor" />
|
||||
<circle cx="4" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="20" cy="12" r="2" fill="currentColor" />
|
||||
<circle cx="12" cy="20" r="2" fill="currentColor" />
|
||||
<path
|
||||
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
opacity="0.45"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
className="text-[17px] font-medium tracking-tight"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
claudemesh
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<StatusPulse status={status} />
|
||||
<span>
|
||||
{status === "waiting" && "awaiting sync"}
|
||||
{status === "syncing" && "generating token..."}
|
||||
{status === "done" && "synced"}
|
||||
{status === "error" && "error"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 mx-auto w-full max-w-2xl px-6 py-16 md:px-12 md:py-24">
|
||||
<MeshBackdrop />
|
||||
|
||||
{/* Section tag */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease }}
|
||||
className="mb-5 flex items-center gap-2 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="inline-block h-1 w-1 rounded-full bg-[var(--cm-clay)]" />
|
||||
— cli sync
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease, delay: 0.08 }}
|
||||
className="text-[clamp(2rem,4vw,2.75rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Sync with{" "}
|
||||
<span className="italic text-[var(--cm-clay)]">claudemesh CLI</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease, delay: 0.16 }}
|
||||
className="mt-4 text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Link your terminal session to your account and choose which meshes to
|
||||
sync.
|
||||
</motion.p>
|
||||
|
||||
{/* Pairing code */}
|
||||
<AnimatePresence>
|
||||
{code && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.5, ease, delay: 0.24 }}
|
||||
className="mt-10 overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20"
|
||||
>
|
||||
{/* Terminal-style header bar */}
|
||||
<div className="flex items-center gap-2 border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-4 py-2.5">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-[var(--cm-fg-tertiary)]/30" />
|
||||
</div>
|
||||
<span
|
||||
className="ml-2 text-[10px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
pairing verification
|
||||
</span>
|
||||
</div>
|
||||
{/* Code display */}
|
||||
<div className="bg-[var(--cm-bg-elevated)] px-5 py-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
className="text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
code:
|
||||
</span>
|
||||
<motion.span
|
||||
className="text-4xl font-bold tracking-[0.2em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
>
|
||||
{code.split("").map((char, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.5 + i * 0.1, ease }}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
))}
|
||||
</motion.span>
|
||||
</div>
|
||||
<p
|
||||
className="mt-3 text-[13px] leading-relaxed text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Confirm this matches the code shown in your terminal.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
<AnimatePresence>
|
||||
{loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="mt-10 space-y-3"
|
||||
>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 animate-pulse rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)]"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Error */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="mt-6 flex items-start gap-3 rounded-[var(--cm-radius-md)] border border-red-500/20 bg-red-500/[0.06] p-4"
|
||||
>
|
||||
<span className="mt-0.5 text-red-400">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-sm text-red-400">{error}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Token result */}
|
||||
<AnimatePresence>
|
||||
{token && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease }}
|
||||
className="mt-10"
|
||||
>
|
||||
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-emerald-500/20">
|
||||
{/* Success header */}
|
||||
<div className="flex items-center gap-2 border-b border-emerald-500/10 bg-emerald-500/[0.06] px-4 py-3">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-emerald-400"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
<span
|
||||
className="text-sm font-medium text-emerald-400"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{redirected ? "Redirecting to CLI..." : "Sync token generated"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Token body */}
|
||||
<div className="bg-[var(--cm-bg-elevated)] p-5">
|
||||
<p
|
||||
className="mb-3 text-[13px] text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
{redirected
|
||||
? "If your terminal didn\u2019t pick up the token, copy it manually:"
|
||||
: "Paste this token in your terminal when prompted:"}
|
||||
</p>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-text overflow-hidden text-ellipsis whitespace-nowrap rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3 py-2.5 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
onClick={(e) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(e.currentTarget);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}}
|
||||
>
|
||||
{token}
|
||||
</div>
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-4 py-2.5 text-sm font-medium text-[var(--cm-fg-secondary)] transition-all duration-200 hover:border-[var(--cm-clay)]/40 hover:text-[var(--cm-fg)]"
|
||||
>
|
||||
{copied ? (
|
||||
<span className="text-emerald-400">Copied</span>
|
||||
) : (
|
||||
"Copy"
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Mesh list */}
|
||||
{!loading && !token && meshes.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-10"
|
||||
>
|
||||
<h2
|
||||
className="mb-4 text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Your meshes
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{meshes.map((m, i) => (
|
||||
<motion.label
|
||||
key={m.id}
|
||||
initial={{ opacity: 0, x: -12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.4, ease, delay: 0.35 + i * 0.06 }}
|
||||
className={`group flex cursor-pointer items-center gap-4 rounded-[var(--cm-radius-md)] border p-4 transition-all duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)]/30 bg-[var(--cm-clay)]/[0.04]"
|
||||
: "border-[var(--cm-border)] hover:border-[var(--cm-clay)]/20 hover:bg-[var(--cm-bg-elevated)]"
|
||||
}`}
|
||||
>
|
||||
{/* Custom checkbox */}
|
||||
<div
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-all duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)] bg-[var(--cm-clay)]"
|
||||
: "border-[var(--cm-fg-tertiary)]/40 group-hover:border-[var(--cm-fg-tertiary)]"
|
||||
}`}
|
||||
>
|
||||
{selected.has(m.id) && (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(m.id)}
|
||||
onChange={() => toggleMesh(m.id)}
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-[var(--cm-fg)]">
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.slug}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--cm-fg-tertiary)]">
|
||||
{m.memberCount}{" "}
|
||||
{m.memberCount === 1 ? "member" : "members"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] uppercase tracking-wider transition-colors duration-200 ${
|
||||
selected.has(m.id)
|
||||
? "border-[var(--cm-clay)]/30 text-[var(--cm-clay)]"
|
||||
: "border-[var(--cm-border)] text-[var(--cm-fg-tertiary)]"
|
||||
}`}
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
{m.isOwner ? "owner" : m.myRole}
|
||||
</span>
|
||||
</motion.label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: 0.5 }}
|
||||
className="mt-8 flex items-center gap-4"
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleSync}
|
||||
disabled={syncing || selected.size === 0}
|
||||
className="group relative inline-flex items-center gap-2.5 overflow-hidden rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-7 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<motion.span
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="inline-block"
|
||||
>
|
||||
⟳
|
||||
</motion.span>
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sync to CLI
|
||||
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
→
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
<span className="text-xs text-[var(--cm-fg-tertiary)]">
|
||||
{selected.size} of {meshes.length} selected
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* No meshes — create form */}
|
||||
{!loading && !token && meshes.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease, delay: 0.3 }}
|
||||
className="mt-10"
|
||||
>
|
||||
<div className="overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/20">
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--cm-clay)]/10 bg-[var(--cm-clay)]/[0.06] px-5 py-4">
|
||||
<h2
|
||||
className="text-lg font-medium text-[var(--cm-fg)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Create your first mesh
|
||||
</h2>
|
||||
<p
|
||||
className="mt-1 text-[13px] leading-relaxed text-[var(--cm-fg-secondary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
A mesh is the space where your Claude Code sessions talk to each
|
||||
other.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="space-y-5 bg-[var(--cm-bg-elevated)] p-5">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="mesh-name"
|
||||
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
id="mesh-name"
|
||||
type="text"
|
||||
placeholder="Platform team"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="mesh-slug"
|
||||
className="mb-1.5 block text-sm font-medium text-[var(--cm-fg)]"
|
||||
>
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
id="mesh-slug"
|
||||
type="text"
|
||||
placeholder="platform-team"
|
||||
value={newSlug}
|
||||
onChange={(e) => {
|
||||
setSlugDirty(true);
|
||||
setNewSlug(e.target.value);
|
||||
}}
|
||||
className="w-full rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-3.5 py-2.5 text-sm text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)]/50 transition-colors duration-200 focus:border-[var(--cm-clay)]/50 focus:outline-none focus:ring-1 focus:ring-[var(--cm-clay)]/20"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
/>
|
||||
<p
|
||||
className="mt-1.5 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
lowercase · digits · hyphens
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{createError && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="text-sm text-red-400"
|
||||
>
|
||||
{createError}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !newName.trim() || !newSlug.trim()}
|
||||
className="group inline-flex w-full items-center justify-center gap-2.5 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-6 py-3.5 text-[15px] font-medium text-white transition-all duration-300 hover:bg-[var(--cm-clay-hover)] disabled:opacity-40"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<motion.span
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="inline-block"
|
||||
>
|
||||
⟳
|
||||
</motion.span>
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Create & sync to CLI
|
||||
<span className="transition-transform duration-300 group-hover:translate-x-0.5">
|
||||
→
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Footer security note */}
|
||||
<AnimatePresence>
|
||||
{!token && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="mt-16 flex items-start gap-3 text-[13px] leading-[1.7] text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mt-0.5 shrink-0 text-[var(--cm-fg-tertiary)]/60"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<span>
|
||||
The sync token is valid for 15 minutes and can only be used once.
|
||||
Your ed25519 keys stay on your machine — the broker only sees
|
||||
ciphertext.
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
147
apps/web/src/app/[locale]/cli-auth/cli-auth-login.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
interface Props {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export function CliAuthLogin({ code }: Props) {
|
||||
const redirectTo = `/cli-auth?code=${encodeURIComponent(code)}`;
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSocial = async (provider: "google" | "github") => {
|
||||
setLoading(provider);
|
||||
setError("");
|
||||
try {
|
||||
await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: redirectTo,
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Sign-in failed");
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading("email");
|
||||
setError("");
|
||||
try {
|
||||
if (mode === "register") {
|
||||
await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
name: name || email.split("@")[0] || "User",
|
||||
callbackURL: redirectTo,
|
||||
});
|
||||
} else {
|
||||
await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
callbackURL: redirectTo,
|
||||
});
|
||||
}
|
||||
window.location.href = redirectTo;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed");
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const btnBase = "w-full flex items-center justify-center gap-3 rounded-lg px-4 py-3 text-[15px] font-medium transition-all";
|
||||
const btnOutline = `${btnBase} border border-[var(--cm-border,#333)] text-[var(--cm-fg,#fafafa)] hover:bg-[var(--cm-bg-elevated,#1a1a1a)]`;
|
||||
const btnPrimary = `${btnBase} bg-[var(--cm-clay,#b07a56)] text-[var(--cm-fg,#fafafa)] hover:opacity-90`;
|
||||
const inputBase = "w-full rounded-lg border border-[var(--cm-border,#333)] bg-[var(--cm-bg,#0a0a0a)] px-4 py-3 text-[15px] text-[var(--cm-fg,#fafafa)] placeholder:text-[var(--cm-fg-muted,#666)] focus:outline-none focus:ring-2 focus:ring-[var(--cm-clay,#b07a56)]/50 focus:border-[var(--cm-clay,#b07a56)]";
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[400px] space-y-6 p-8">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto w-14 h-14 rounded-2xl flex items-center justify-center" style={{ background: "var(--cm-clay, #b07a56)" }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="4" r="2" fill="#fff" />
|
||||
<circle cx="4" cy="12" r="2" fill="#fff" />
|
||||
<circle cx="20" cy="12" r="2" fill="#fff" />
|
||||
<circle cx="12" cy="20" r="2" fill="#fff" />
|
||||
<path d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20" stroke="#fff" strokeWidth="1.2" opacity="0.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-[22px] font-bold tracking-tight">
|
||||
Connect to claudemesh CLI
|
||||
</h1>
|
||||
<p className="text-[14px]" style={{ color: "var(--cm-fg-muted, #888)" }}>
|
||||
{mode === "login" ? "Sign in" : "Create an account"} to connect your terminal session.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social buttons */}
|
||||
<div className="space-y-2.5">
|
||||
<button onClick={() => handleSocial("google")} disabled={!!loading} className={btnOutline}>
|
||||
{loading === "google" ? (
|
||||
<span className="animate-spin">⟳</span>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
|
||||
)}
|
||||
Continue with Google
|
||||
</button>
|
||||
<button onClick={() => handleSocial("github")} disabled={!!loading} className={btnOutline}>
|
||||
{loading === "github" ? (
|
||||
<span className="animate-spin">⟳</span>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2.2c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6a4.7 4.7 0 011.3-3.3c-.2-.3-.6-1.6.1-3.3 0 0 1-.3 3.3 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 3 .1 3.3a4.7 4.7 0 011.3 3.3c0 4.7-2.8 5.7-5.5 6 .4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3"/></svg>
|
||||
)}
|
||||
Continue with GitHub
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 h-px" style={{ background: "var(--cm-border, #333)" }} />
|
||||
<span className="text-[12px] uppercase tracking-wider" style={{ color: "var(--cm-fg-muted, #666)" }}>or</span>
|
||||
<div className="flex-1 h-px" style={{ background: "var(--cm-border, #333)" }} />
|
||||
</div>
|
||||
|
||||
{/* Email form */}
|
||||
<form onSubmit={handleEmailSubmit} className="space-y-3">
|
||||
{mode === "register" && (
|
||||
<input type="text" placeholder="Name" value={name} onChange={e => setName(e.target.value)} className={inputBase} />
|
||||
)}
|
||||
<input type="email" placeholder="Email" value={email} onChange={e => setEmail(e.target.value)} required className={inputBase} />
|
||||
<input type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} className={inputBase} />
|
||||
|
||||
{error && <p className="text-[13px] text-red-400">{error}</p>}
|
||||
|
||||
<button type="submit" disabled={!!loading} className={btnPrimary}>
|
||||
{loading === "email" ? "..." : mode === "login" ? "Sign in" : "Create account"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Toggle mode */}
|
||||
<p className="text-center text-[13px]" style={{ color: "var(--cm-fg-muted, #888)" }}>
|
||||
{mode === "login" ? (
|
||||
<>Don't have an account?{" "}<button onClick={() => { setMode("register"); setError(""); }} className="underline hover:text-[var(--cm-fg)]">Register</button></>
|
||||
) : (
|
||||
<>Already have an account?{" "}<button onClick={() => { setMode("login"); setError(""); }} className="underline hover:text-[var(--cm-fg)]">Sign in</button></>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Device code */}
|
||||
<div className="pt-2 text-center">
|
||||
<div className="inline-block rounded-lg px-5 py-2.5 font-mono text-lg tracking-[0.25em]" style={{ background: "var(--cm-bg-elevated, #1a1a1a)", border: "1px solid var(--cm-border, #333)" }}>
|
||||
{code}
|
||||
</div>
|
||||
<p className="mt-2 text-[12px]" style={{ color: "var(--cm-fg-muted, #666)" }}>
|
||||
Confirm this code matches your terminal
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/web/src/app/[locale]/cli-auth/device-code-approval.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
interface Props {
|
||||
code: string;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
export function DeviceCodeApproval({ code, userName }: Props) {
|
||||
const [status, setStatus] = useState<"approving" | "done" | "error">("approving");
|
||||
const [error, setError] = useState("");
|
||||
const attempted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (attempted.current) return;
|
||||
attempted.current = true;
|
||||
|
||||
// Auto-approve on mount — user is already authenticated
|
||||
fetch("/api/auth/cli/device-code/approve-by-user-code", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ user_code: code }),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.ok) {
|
||||
setStatus("done");
|
||||
} else {
|
||||
const body = await res.json().catch(() => ({ error: "Unknown error" }));
|
||||
setError((body as { error?: string }).error ?? `Error ${res.status}`);
|
||||
setStatus("error");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e instanceof Error ? e.message : "Network error");
|
||||
setStatus("error");
|
||||
});
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md text-center space-y-6 p-8">
|
||||
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center text-3xl"
|
||||
style={{ background: "var(--cm-accent, #f97316)" }}>
|
||||
{status === "done" ? "✓" : status === "error" ? "!" : "⟳"}
|
||||
</div>
|
||||
|
||||
{status === "approving" && (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold">Connecting your terminal…</h1>
|
||||
<p className="text-sm" style={{ color: "var(--cm-fg-muted, #888)" }}>
|
||||
Signing in as {userName}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "done" && (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold">Connected!</h1>
|
||||
<p style={{ color: "var(--cm-fg-muted, #888)" }}>
|
||||
Signed in as <strong>{userName}</strong>
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: "var(--cm-fg-muted, #888)" }}>
|
||||
You can close this tab and return to your terminal.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold">Connection failed</h1>
|
||||
<p style={{ color: "#ef4444" }}>
|
||||
{error || "Something went wrong."}
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: "var(--cm-fg-muted, #888)" }}>
|
||||
Run <code className="px-1.5 py-0.5 rounded" style={{ background: "var(--cm-bg-muted, #1a1a1a)" }}>claudemesh login</code> again in your terminal.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="pt-4">
|
||||
<div className="rounded-lg p-3 font-mono text-sm tracking-wider"
|
||||
style={{ background: "var(--cm-bg-muted, #1a1a1a)", color: "var(--cm-fg-muted, #888)" }}>
|
||||
Device code: {code}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
apps/web/src/app/[locale]/cli-auth/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { getSession } from "~/lib/auth/server";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
|
||||
import { CliAuthFlow } from "./cli-auth-flow";
|
||||
import { DeviceCodeApproval } from "./device-code-approval";
|
||||
import { CliAuthLogin } from "./cli-auth-login";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Connect CLI",
|
||||
description: "Sign in to connect your claudemesh CLI.",
|
||||
});
|
||||
|
||||
export default async function CliAuthPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ code?: string; port?: string }>;
|
||||
}) {
|
||||
const { user } = await getSession();
|
||||
const { code, port } = await searchParams;
|
||||
|
||||
// Device-code flow: code contains "-" (e.g. "ABCD-EFGH"), no port
|
||||
const isDeviceCode = code && code.includes("-") && !port;
|
||||
|
||||
if (isDeviceCode) {
|
||||
if (!user) {
|
||||
// NOT logged in → show inline auth form with device code context
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--cm-bg,#0a0a0a)] text-[var(--cm-fg,#fafafa)] antialiased flex items-center justify-center">
|
||||
<CliAuthLogin code={code} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Logged in → auto-approve
|
||||
return (
|
||||
<main className="min-h-screen bg-[var(--cm-bg,#0a0a0a)] text-[var(--cm-fg,#fafafa)] antialiased flex items-center justify-center">
|
||||
<DeviceCodeApproval
|
||||
code={code}
|
||||
userName={user.name ?? user.email}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy callback flow (port-based)
|
||||
if (!user) {
|
||||
const { redirect } = await import("next/navigation");
|
||||
const qs = new URLSearchParams();
|
||||
if (code) qs.set("code", code);
|
||||
if (port) qs.set("port", port);
|
||||
const returnTo = `/cli-auth${qs.size ? `?${qs}` : ""}`;
|
||||
return redirect(`/auth/login?redirectTo=${encodeURIComponent(returnTo)}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className="min-h-screen bg-[var(--cm-bg,#0a0a0a)] text-[var(--cm-fg,#fafafa)] antialiased"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
<CliAuthFlow
|
||||
code={code ?? null}
|
||||
port={port ?? null}
|
||||
userId={user.id}
|
||||
userEmail={user.email}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
DashboardHeaderTitle,
|
||||
} from "~/modules/common/layout/dashboard/header";
|
||||
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
|
||||
import { PeerGraphPanel } from "~/modules/mesh/peer-graph-panel";
|
||||
import { ResourcePanel } from "~/modules/mesh/resource-panel";
|
||||
import { StateTimelinePanel } from "~/modules/mesh/state-timeline-panel";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Live mesh",
|
||||
@@ -63,7 +66,14 @@ export default async function LiveMeshPage({
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
|
||||
<LiveStreamPanel meshId={id} />
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<PeerGraphPanel meshId={id} />
|
||||
<LiveStreamPanel meshId={id} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<StateTimelinePanel meshId={id} />
|
||||
<ResourcePanel meshId={id} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 { getMetadata } from "~/lib/metadata";
|
||||
import { InstallToggle } from "~/modules/join/install-toggle";
|
||||
import { InviteCard } from "~/modules/join/invite-card";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "Join a mesh",
|
||||
@@ -112,42 +113,29 @@ export default async function JoinPage({
|
||||
</Link>
|
||||
</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 ? (
|
||||
<>
|
||||
<div
|
||||
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
— invitation
|
||||
</div>
|
||||
<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>
|
||||
<InviteCard
|
||||
meshName={invite.meshName}
|
||||
inviterName={invite.inviterName}
|
||||
role={invite.role}
|
||||
memberCount={invite.memberCount}
|
||||
expiresAt={new Date(invite.expiresAt)}
|
||||
/>
|
||||
|
||||
<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} />
|
||||
</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)" }}
|
||||
>
|
||||
By joining, you'll be known as a peer with an ed25519
|
||||
@@ -163,24 +151,27 @@ export default async function JoinPage({
|
||||
</div>
|
||||
|
||||
<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)" }}
|
||||
>
|
||||
expires {new Date(invite.expiresAt).toLocaleDateString()} ·{" "}
|
||||
{invite.maxUses - invite.usedCount} of {invite.maxUses} uses
|
||||
remaining
|
||||
</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
|
||||
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)" }}
|
||||
>
|
||||
— invitation unavailable
|
||||
</div>
|
||||
<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)" }}
|
||||
>
|
||||
{ERROR_COPY[invite.reason].title}
|
||||
@@ -210,7 +201,7 @@ export default async function JoinPage({
|
||||
← claudemesh.com
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@turbostarter/auth/server";
|
||||
import { deviceCodes } from "../../new/route";
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ code: string }> },
|
||||
) {
|
||||
const { code } = await params;
|
||||
|
||||
// Verify the user is authenticated via Better Auth session
|
||||
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 });
|
||||
}
|
||||
|
||||
const entry = deviceCodes.get(code);
|
||||
if (!entry) {
|
||||
return NextResponse.json({ error: "Code not found or expired" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expires_at) {
|
||||
deviceCodes.delete(code);
|
||||
return NextResponse.json({ error: "Code expired" }, { status: 410 });
|
||||
}
|
||||
|
||||
if (entry.status !== "pending") {
|
||||
return NextResponse.json({ error: "Code already used" }, { status: 409 });
|
||||
}
|
||||
|
||||
// Sign a CLI session JWT (same pattern as cli-sync-token)
|
||||
const secret = process.env.CLI_SYNC_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json({ error: "Server not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Create a simple session token for CLI use
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
sub: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
type: "cli-session",
|
||||
jti: crypto.randomUUID(),
|
||||
iat: now,
|
||||
exp: now + 30 * 24 * 60 * 60, // 30 days
|
||||
};
|
||||
|
||||
// Sign JWT (inline HS256 — same as cli-sync-token route)
|
||||
const encoder = new TextEncoder();
|
||||
const headerB64 = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }))
|
||||
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
const payloadB64 = btoa(JSON.stringify(payload))
|
||||
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
|
||||
);
|
||||
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(`${headerB64}.${payloadB64}`));
|
||||
const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig)))
|
||||
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
const token = `${headerB64}.${payloadB64}.${sigB64}`;
|
||||
|
||||
// Mark device code as approved
|
||||
entry.status = "approved";
|
||||
entry.session_token = token;
|
||||
entry.user = {
|
||||
id: session.user.id,
|
||||
display_name: session.user.name ?? session.user.email ?? "User",
|
||||
email: session.user.email ?? "",
|
||||
};
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
15
apps/web/src/app/api/auth/cli/device-code/[code]/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const BROKER_URL = (process.env.BROKER_HTTP_URL || "https://ic.claudemesh.com").replace(/\/$/, "");
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ code: string }> },
|
||||
) {
|
||||
const { code } = await params;
|
||||
|
||||
const brokerRes = await fetch(`${BROKER_URL}/cli/device-code/${code}`);
|
||||
const brokerBody = await brokerRes.json().catch(() => ({ status: "expired" }));
|
||||
|
||||
return NextResponse.json(brokerBody as Record<string, unknown>, { status: brokerRes.status });
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@turbostarter/auth/server";
|
||||
|
||||
const BROKER_URL = (process.env.BROKER_HTTP_URL || "https://ic.claudemesh.com").replace(/\/$/, "");
|
||||
|
||||
export async function POST(request: Request) {
|
||||
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 });
|
||||
}
|
||||
|
||||
let body: { user_code?: string };
|
||||
try {
|
||||
body = (await request.json()) as typeof body;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.user_code) {
|
||||
return NextResponse.json({ error: "user_code required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Proxy approve to the broker
|
||||
const brokerRes = await fetch(`${BROKER_URL}/cli/device-code/${body.user_code}/approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
user_id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
}),
|
||||
});
|
||||
|
||||
const brokerBody = await brokerRes.json().catch(() => ({ error: "Broker error" }));
|
||||
|
||||
return NextResponse.json(brokerBody as Record<string, unknown>, { status: brokerRes.status });
|
||||
}
|
||||
19
apps/web/src/app/api/auth/cli/device-code/new/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const BROKER_URL = (process.env.BROKER_HTTP_URL || "https://ic.claudemesh.com").replace(/\/$/, "");
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.text();
|
||||
|
||||
const brokerRes = await fetch(`${BROKER_URL}/cli/device-code`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Forwarded-For": request.headers.get("x-forwarded-for") ?? "",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const brokerBody = await brokerRes.json().catch(() => ({ error: "Broker error" }));
|
||||
return NextResponse.json(brokerBody as Record<string, unknown>, { status: brokerRes.status });
|
||||
}
|
||||
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
|
||||
* Node for the user — it checks for a compatible Node + npm and
|
||||
* 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.
|
||||
* Tracks each fetch server-side (PostHog server event + console log).
|
||||
* curl doesn't execute JS, so client-side analytics can't track this.
|
||||
*/
|
||||
|
||||
import { headers } from "next/headers";
|
||||
|
||||
// In-memory counter (resets on deploy — good enough for a signal).
|
||||
// For persistent tracking, write to DB or use PostHog server SDK.
|
||||
let installFetches = 0;
|
||||
|
||||
const SCRIPT = `#!/usr/bin/env bash
|
||||
# claudemesh-cli installer
|
||||
# Source: https://claudemesh.com/install
|
||||
@@ -88,7 +89,41 @@ say "Need an invite? Visit \${BOLD}https://claudemesh.com\${RESET}"
|
||||
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, {
|
||||
status: 200,
|
||||
headers: {
|
||||
|
||||
@@ -69,6 +69,7 @@ const pathsConfig = {
|
||||
},
|
||||
},
|
||||
marketing: {
|
||||
gettingStarted: "/getting-started",
|
||||
pricing: "/pricing",
|
||||
contact: "/contact",
|
||||
blog: {
|
||||
@@ -85,6 +86,7 @@ const pathsConfig = {
|
||||
updatePassword: `${AUTH_PREFIX}/password/update`,
|
||||
error: `${AUTH_PREFIX}/error`,
|
||||
},
|
||||
cliAuth: "/cli-auth",
|
||||
dashboard: {
|
||||
user: {
|
||||
index: DASHBOARD_PREFIX,
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const LAUNCH_CMD = (token: string) => `claudemesh launch --name YourName --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) => {
|
||||
const [hasCli, setHasCli] = useState<"unknown" | "yes" | "no">("unknown");
|
||||
@@ -60,7 +61,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
}
|
||||
|
||||
if (hasCli === "yes") {
|
||||
const cmd = JOIN_CMD(token);
|
||||
const cmd = LAUNCH_CMD(token);
|
||||
return (
|
||||
<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">
|
||||
@@ -68,7 +69,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
run this in your terminal
|
||||
join + launch in one step
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
@@ -96,7 +97,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const joinCmd = JOIN_CMD(token);
|
||||
const launchCmd = LAUNCH_CMD(token);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ol className="space-y-3">
|
||||
@@ -106,7 +107,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span>
|
||||
install + init
|
||||
install the CLI
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
@@ -127,8 +128,7 @@ export const InstallToggle = ({ token }: Props) => {
|
||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Generates your ed25519 keypair locally and wires claudemesh into
|
||||
your Claude Code config. You own the keys.
|
||||
Requires Node.js 20+.
|
||||
</p>
|
||||
</li>
|
||||
<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)" }}
|
||||
>
|
||||
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">2</span>
|
||||
join the mesh
|
||||
join + launch
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
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)" }}
|
||||
>
|
||||
{joinCmd}
|
||||
{launchCmd}
|
||||
</code>
|
||||
<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)]"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{copiedKey === "join" ? "Copied ✓" : "Copy"}
|
||||
</button>
|
||||
</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
|
||||
className="text-sm text-[var(--cm-fg-secondary)]"
|
||||
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Your Claude Code session will announce itself to the mesh. Other
|
||||
peers see you appear as a green dot in their dashboard.
|
||||
Joins the mesh and launches Claude Code in one step.
|
||||
</p>
|
||||
</li>
|
||||
</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)" }}
|
||||
>
|
||||
Anthropic built Claude Code per developer. The next unlock is
|
||||
between developers. Build the layer with us.
|
||||
between developers. Hosted on claudemesh.com or self-hosted in
|
||||
your VPC — same CLI, same features, same encryption.
|
||||
</p>
|
||||
</Reveal>
|
||||
<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)]"
|
||||
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||
>
|
||||
Real conversation between peers. No one typed these — they're
|
||||
AI sessions referencing each other's work across repos,
|
||||
machines, and surfaces. Hover any message to see what the broker
|
||||
sees.
|
||||
Real conversation between peers. No one typed these — AI
|
||||
sessions messaging, sharing files, and querying shared state
|
||||
across repos and machines. Hover any message to see what the
|
||||
broker sees: ciphertext only.
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||