Compare commits
243 Commits
cli-v0.1.3
...
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 | ||
|
|
ab08be04a5 | ||
|
|
ee585a8370 | ||
|
|
1f078bf0c8 | ||
|
|
2372032a68 | ||
|
|
a70c5fd124 | ||
|
|
5c62d287cf | ||
|
|
9ae378c2e3 | ||
|
|
7381738f0b | ||
|
|
8c6b0c0e07 | ||
|
|
ec9626503c | ||
|
|
820ec085b2 | ||
|
|
9e6f6d7bc9 | ||
|
|
7194e7d28e | ||
|
|
0b4e389f2b | ||
|
|
7a5f786e0c | ||
|
|
10e5fdcfd1 | ||
|
|
cc6e56aef9 | ||
|
|
1aaa483d60 | ||
|
|
99d9d19079 | ||
|
|
888078876a | ||
|
|
02b1e5695f | ||
|
|
663f800b4b | ||
|
|
2557235c68 | ||
|
|
a987e9e27b | ||
|
|
ff86db615f | ||
|
|
4aa61b40e2 | ||
|
|
4afe365c00 | ||
|
|
92bb276a3e | ||
|
|
af8f8ed1f9 | ||
|
|
c8682dd700 | ||
|
|
004602a83c | ||
|
|
2a2aac3622 | ||
|
|
e0659b0b6f | ||
|
|
4c057be069 | ||
|
|
aaab7feea6 | ||
|
|
af13125424 | ||
|
|
4c52ee236c | ||
|
|
7d51f101d7 | ||
|
|
d8bafe3144 | ||
|
|
2be08ab85f | ||
|
|
d3e60d4d82 | ||
|
|
9cefe863e3 | ||
|
|
78c80cc43c | ||
|
|
59ce33f943 | ||
|
|
2cdcdccbc9 | ||
|
|
9653171b78 | ||
|
|
d14bdf6b5a | ||
|
|
f1af8c0a79 | ||
|
|
96cae38196 | ||
|
|
a14b6c28dd | ||
|
|
479d6a454a | ||
|
|
c5bf1c303f | ||
|
|
c0cb19c53a | ||
|
|
b758fe07ff | ||
|
|
8de952d91b | ||
|
|
03ca9f10d3 | ||
|
|
8bd8d1ff76 | ||
|
|
57a6af5013 | ||
|
|
067ef10b70 | ||
|
|
6b062ab239 | ||
|
|
5c4cb2cf84 | ||
|
|
8fa2bb5cd2 | ||
|
|
253e0ac43c | ||
|
|
8fca7fb21a | ||
|
|
8c7a6a05c3 | ||
|
|
8e906daf6f | ||
|
|
de684c44bb | ||
|
|
66b9696b2d | ||
|
|
09c5d759fa | ||
|
|
a1c6c6dc6a | ||
|
|
00b5ba8190 | ||
|
|
ccff802163 | ||
|
|
231618c595 | ||
|
|
f698aaeac7 |
232
.artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md
Normal file
232
.artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Anthropic Vision: Meshes & Invitations
|
||||||
|
|
||||||
|
**Status:** in progress · partial implementation 2026-04-10
|
||||||
|
**Owner:** agutierrez
|
||||||
|
**Scope:** `apps/web`, `packages/api`, `packages/db`, `apps/broker` (future), `apps/cli` (future)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guiding principles
|
||||||
|
|
||||||
|
1. **Identity is opaque, display is free-form.** Humans pick any name; the system uses random IDs.
|
||||||
|
2. **Secrets never appear in URLs.** Links are capabilities, not credentials.
|
||||||
|
3. **Defaults are obvious; advanced options are discoverable but hidden.**
|
||||||
|
4. **Self-service wherever possible; admins don't become gatekeepers.**
|
||||||
|
5. **Every visible action is also an auditable event.**
|
||||||
|
|
||||||
|
These mirror how Anthropic builds its own org/workspace/project model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Meshes
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Global uniqueness on `mesh.slug` creates name collisions at scale. Two users picking "platform" or "test" fight for the slug. At 50k users this is the default state.
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
**Drop the slug as an identity concept.** `mesh.id` (opaque, already random) is the canonical identifier everywhere (URLs, invites, broker lookups). `mesh.name` is a free-form display label, non-unique. `mesh.slug` is kept as a non-unique cosmetic string derived from the name at creation time, embedded in invite payloads for debugging.
|
||||||
|
|
||||||
|
### What this enables
|
||||||
|
- Two users can both name their mesh "platform-team" with zero friction
|
||||||
|
- URLs stay stable (`/meshes/{id}`) even if the user renames the mesh
|
||||||
|
- No "slug taken" error state exists in the product anymore
|
||||||
|
|
||||||
|
### Tradeoff explicitly accepted
|
||||||
|
Users lose the ability to type `claudemesh join platform-team` — but they never did, because the CLI takes signed invite tokens, not slugs. This capability was phantom.
|
||||||
|
|
||||||
|
### Implementation — DONE in this spec
|
||||||
|
- [x] Drop `UNIQUE` constraint on `mesh.slug` (migration `0017_mesh-slug-non-unique.sql`)
|
||||||
|
- [x] Remove `slug` field from `createMyMeshInputSchema`
|
||||||
|
- [x] Remove slug field from `CreateMeshForm`
|
||||||
|
- [x] Server-side `toSlug(name)` derives slug from name automatically
|
||||||
|
- [x] Schema comment documents the non-canonical role of `slug`
|
||||||
|
|
||||||
|
### Future (optional, not in v0.1.x)
|
||||||
|
- **Vanity slugs as a Pro feature:** one globally-unique handle per *account* (not per mesh), exposed as `claudemesh.com/@acme/...`. Sold as part of an org tier. This is where slug uniqueness actually pays for itself — against usernames, not against meshes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Invitations
|
||||||
|
|
||||||
|
### Problems with the current invite system
|
||||||
|
|
||||||
|
| # | Problem | Severity |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `mesh_root_key` is embedded in the invite URL as base64url JSON | 🔴 **Security** |
|
||||||
|
| 2 | Invite URLs are ~400 chars of opaque base64url | 🟡 UX |
|
||||||
|
| 3 | No invite-by-email; only shareable link | 🟡 UX |
|
||||||
|
| 4 | Required form fields (role, maxUses, expiresInDays) for every invite | 🟡 UX |
|
||||||
|
| 5 | Landing page does not clearly preview role/consent | 🟡 UX |
|
||||||
|
| 6 | No audit trail for invites received-but-never-clicked | 🟢 Polish |
|
||||||
|
| 7 | `ic://` link scheme is vestigial, nothing registers the handler | 🟢 Polish |
|
||||||
|
|
||||||
|
### Severity 🔴 — the root key leak
|
||||||
|
|
||||||
|
Current canonical invite bytes:
|
||||||
|
```
|
||||||
|
v | mesh_id | mesh_slug | broker_url | expires_at | mesh_root_key | role | owner_pubkey
|
||||||
|
```
|
||||||
|
|
||||||
|
`mesh_root_key` is a 32-byte shared secret used by all channel and broadcast encryption in the mesh. Once it lives in a URL:
|
||||||
|
- Slack/Telegram/Discord link previews fetch and cache the URL → root key is in those caches
|
||||||
|
- Browser history, sync, analytics pixels, error logs → root key persists anywhere URLs persist
|
||||||
|
- A screenshot of the invite link is a compromise
|
||||||
|
- Revoking the invite does **not** rotate the key, so exposure is permanent
|
||||||
|
|
||||||
|
**Anthropic would never do this.** The fix is a protocol change: the invite grants the *right* to receive the key, it is not the key itself.
|
||||||
|
|
||||||
|
### The v2 invite protocol (spec only in this doc — NOT implemented this session)
|
||||||
|
|
||||||
|
**Design goals**
|
||||||
|
1. No secret material in any user-visible string (URL, QR, paste buffer)
|
||||||
|
2. Invite URLs are short (<30 chars): `claudemesh.com/i/abc12345`
|
||||||
|
3. Existing v1 invites continue to work during a deprecation window
|
||||||
|
4. Revocation is clean and immediate
|
||||||
|
5. One recipient = one root-key-delivery capability
|
||||||
|
|
||||||
|
**Flow**
|
||||||
|
```
|
||||||
|
Admin creates invite (v2):
|
||||||
|
server generates short_code (base62, 8 chars, unique)
|
||||||
|
server stores in DB: {id, mesh_id, code, role, max_uses, expires_at, signed_capability}
|
||||||
|
signed_capability = ed25519_sign(canonical_v2_bytes, mesh.owner_secret_key)
|
||||||
|
canonical_v2_bytes = v=2 | mesh_id | invite_id | expires_at | role | owner_pubkey
|
||||||
|
NOTE: no root_key, no broker_url
|
||||||
|
returns: claudemesh.com/i/{code}
|
||||||
|
|
||||||
|
Recipient clicks the link:
|
||||||
|
web: GET /api/public/invites/code/{code}
|
||||||
|
returns {mesh_name, inviter_name, role, expires_at, member_count}
|
||||||
|
no secrets, no signature leaked
|
||||||
|
web: shows consent landing: "You are joining ACME as a Member"
|
||||||
|
recipient authenticates (sign up / log in) OR runs CLI
|
||||||
|
|
||||||
|
Recipient claims the invite:
|
||||||
|
CLI: generates session ed25519 keypair (ephemeral)
|
||||||
|
CLI: connects to broker ws://ic.claudemesh.com/ws
|
||||||
|
CLI: sends { type: "claim_invite", code, recipient_pubkey }
|
||||||
|
broker: looks up invite by code
|
||||||
|
broker: verifies signed_capability against mesh.owner_pubkey
|
||||||
|
broker: checks expires_at, max_uses vs used_count, revoked_at
|
||||||
|
broker: increments used_count, creates mesh.member row
|
||||||
|
broker: seals mesh.root_key with crypto_box_seal to recipient_pubkey
|
||||||
|
broker: returns { sealed_root_key, mesh_id, member_id }
|
||||||
|
CLI: unseals with its secret key → has root_key
|
||||||
|
CLI: starts normal mesh traffic
|
||||||
|
|
||||||
|
Revocation:
|
||||||
|
admin sets invite.revoked_at = now()
|
||||||
|
any future claim fails at broker with invite_revoked
|
||||||
|
root_key is NOT rotated — past members keep access
|
||||||
|
(for "kick a member" semantics, use a separate member revocation, which DOES rotate the key)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties**
|
||||||
|
- URL contains only `{code}` (8 chars base62)
|
||||||
|
- `signed_capability` lives server-side; leaks of the URL never expose the root key
|
||||||
|
- Screenshot of invite URL is harmless
|
||||||
|
- Link preview bots see nothing sensitive
|
||||||
|
- Broker DB is the source of truth for revocation
|
||||||
|
|
||||||
|
**Migration strategy (v1 → v2)**
|
||||||
|
- Add `invite.code`, `invite.v2_capability` columns (nullable for existing rows)
|
||||||
|
- `createMyInvite` generates BOTH v1 token (legacy) and v2 code
|
||||||
|
- Web invite UI displays the short URL by default, long URL as "Legacy format" disclosure
|
||||||
|
- Broker accepts both formats until v0.2.0
|
||||||
|
- Announce deprecation window; at v0.2.0 the long-format endpoints 410 Gone
|
||||||
|
|
||||||
|
**Status update 2026-04-10 — v2 is now being implemented in parallel**
|
||||||
|
|
||||||
|
The scope that was deferred at the top of the session is actively landing in a coordinated multi-agent push:
|
||||||
|
- Broker: new `/api/public/invites/:code/claim` endpoint, `crypto_box_seal` against recipient x25519 pubkey, signed capability verification, single-use accounting.
|
||||||
|
- DB: `mesh.invite.version` int, `mesh.invite.capability_v2` text nullable, `mesh.invite.claimed_by_pubkey` text nullable. New table `mesh.pending_invite` for email invites.
|
||||||
|
- CLI / web claim client: generates a fresh x25519 keypair (separate from the ed25519 identity), POSTs the pubkey, unseals the returned `sealed_root_key`, then verifies `canonical_v2` against `owner_pubkey`.
|
||||||
|
- Email invites (parallel track): Postmark delivery wired on top of `pending_invite`; the email body carries the same `claudemesh.com/i/{code}` short URL.
|
||||||
|
|
||||||
|
v1 invites continue to work throughout v0.1.x. v1 endpoints return `410 Gone` at v0.2.0.
|
||||||
|
|
||||||
|
Docs updated in the same session: `SPEC.md` §14b, `docs/protocol.md` (v2 invites subsection), `docs/roadmap.md` (in progress).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Severity 🟡 — implemented this session
|
||||||
|
|
||||||
|
#### Short invite codes (URL shortening, backward-compatible)
|
||||||
|
|
||||||
|
Additive: invites now get both a long token AND a short opaque code. The web app prefers the short URL.
|
||||||
|
|
||||||
|
**DB:** new nullable `invite.code` column, unique. New migration `0018_invite-short-code.sql`.
|
||||||
|
|
||||||
|
**API:** `createMyInvite` generates `code` (base62, 8 chars, collision-retry). Returns `shortUrl` alongside `inviteLink` / `joinUrl`.
|
||||||
|
|
||||||
|
**Web:** new server route `/i/[code]/page.tsx` that resolves the code server-side and redirects to the canonical `/join/[token]` page. Invite generator UI shows the short URL as the primary "Copy link" target.
|
||||||
|
|
||||||
|
**Backward compat:** existing invites without a `code` keep working via their long token. No broker/CLI changes.
|
||||||
|
|
||||||
|
**This is NOT the v2 protocol.** It only fixes the URL-length problem. The root key is still embedded in the long token that the short code resolves to. The short code is a URL shortener, not a capability boundary. Document this clearly so nobody confuses the two.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Collapsed advanced fields
|
||||||
|
|
||||||
|
The invite form asks for `role`, `max uses`, `expires in days` upfront. 90% of users only ever create `{ role: member, max_uses: 1, expires_in_days: 7 }`.
|
||||||
|
|
||||||
|
Change: defaults are pre-filled; the three fields are hidden behind an "Advanced" disclosure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Severity 🟡 — deferred
|
||||||
|
|
||||||
|
#### Invite by email
|
||||||
|
|
||||||
|
- Requires an `invitation_email` table or equivalent pending-invites state
|
||||||
|
- Requires wire-up to email delivery (already have Postmark via turbostarter)
|
||||||
|
- Out of scope this session; fits naturally on top of v2 invite protocol
|
||||||
|
|
||||||
|
#### Consent landing redesign
|
||||||
|
|
||||||
|
- The `/join/[token]` page should show: mesh name, inviter, role being granted, member count, expiry, explicit "Join as Member of ACME" button
|
||||||
|
- Needs a design pass
|
||||||
|
- Deferred
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Severity 🟢 — deferred
|
||||||
|
|
||||||
|
- Remove `ic://` scheme — it's dead, nothing handles it, safe to delete in v0.1.x cleanup
|
||||||
|
- Received-but-not-clicked audit — falls out of email invites for free
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary table
|
||||||
|
|
||||||
|
| Change | Status | File(s) |
|
||||||
|
|---|---|---|
|
||||||
|
| Drop global slug uniqueness | ✅ done | `packages/db/src/schema/mesh.ts`, migration `0017` |
|
||||||
|
| Remove slug from create-mesh form | ✅ done | `apps/web/src/modules/mesh/create-mesh-form.tsx` |
|
||||||
|
| Server-derived slug from name | ✅ done | `packages/api/src/modules/mesh/mutations.ts` |
|
||||||
|
| Short invite codes (URL shortener) | ✅ done | `packages/db` migration `0018`, api, web `/i/[code]` |
|
||||||
|
| Collapse invite advanced fields | ✅ done | `apps/web/src/modules/mesh/invite-generator.tsx` |
|
||||||
|
| v2 invite protocol (root key out of URL) | 🚧 in progress | broker `/api/public/invites/:code/claim`, `mesh.invite.version` + `capability_v2` + `claimed_by_pubkey`, CLI/web claim client |
|
||||||
|
| Invite by email | 🚧 in progress | `mesh.pending_invite` table, Postmark delivery |
|
||||||
|
| Consent landing redesign | 📝 spec only | (future PR) |
|
||||||
|
| Remove `ic://` scheme | 📝 spec only | (cleanup PR) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-goals (for clarity)
|
||||||
|
|
||||||
|
- Not adding per-user mesh namespaces (`alice/platform`) — opaque IDs are enough
|
||||||
|
- Not adding vanity slugs at v0.1.x — can come as a Pro tier later
|
||||||
|
- Not changing the broker wire protocol this session
|
||||||
|
- Not rewriting the CLI join flow this session
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-implementation checklist
|
||||||
|
|
||||||
|
- [x] Web builds without type errors on changed files
|
||||||
|
- [x] Migrations run on production DB (`0017` applied; `0018` after review)
|
||||||
|
- [x] No broker protocol change (backward compat verified)
|
||||||
|
- [x] Existing long-token invites continue to resolve
|
||||||
|
- [x] New invites expose `shortUrl` in the API response
|
||||||
@@ -16,3 +16,6 @@ URL="http://localhost:3000"
|
|||||||
|
|
||||||
# Default locale of the apps, can be overridden separately in each app.
|
# Default locale of the apps, can be overridden separately in each app.
|
||||||
DEFAULT_LOCALE="en"
|
DEFAULT_LOCALE="en"
|
||||||
|
|
||||||
|
# Shared secret for CLI sync JWT signing (HS256) — must match between broker and web app
|
||||||
|
CLI_SYNC_SECRET="<your-cli-sync-secret>"
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -45,6 +45,9 @@ yarn-error.log*
|
|||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
# secrets
|
||||||
|
.cli_sync_secret
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
@@ -67,3 +70,10 @@ dist/
|
|||||||
|
|
||||||
# Auto Claude data directory
|
# Auto Claude data directory
|
||||||
.auto-claude/
|
.auto-claude/
|
||||||
|
|
||||||
|
# Payload CMS
|
||||||
|
apps/web/payload.db
|
||||||
|
apps/web/public/media/*
|
||||||
|
!apps/web/public/media/.gitkeep
|
||||||
|
.env.local
|
||||||
|
apps/cli-v2/
|
||||||
|
|||||||
30
CLAUDE.md
Normal file
30
CLAUDE.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# claudemesh
|
||||||
|
|
||||||
|
Peer mesh for Claude Code sessions. Broker + CLI + MCP server.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- `apps/broker/` — WebSocket broker (Bun + Drizzle + PostgreSQL), deployed at `wss://ic.claudemesh.com/ws`
|
||||||
|
- `apps/cli/` — `claudemesh-cli` npm package (CLI + MCP server)
|
||||||
|
- `apps/web/` — Marketing site + dashboard at claudemesh.com
|
||||||
|
- `docs/` — Protocol spec, quickstart, FAQ, roadmap
|
||||||
|
|
||||||
|
## Key docs
|
||||||
|
|
||||||
|
- `SPEC.md` — What claudemesh is, protocol, crypto, wire format
|
||||||
|
- `docs/protocol.md` — Wire protocol reference
|
||||||
|
- `docs/roadmap.md` — Public roadmap (shipped + planned)
|
||||||
|
- `docs/vision-20260407.md` — Internal feature brainstorm with 19 ideas across 3 tiers, effort estimates, and build order
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
- **Broker:** `git push gitea-vps main` triggers Coolify auto-deploy. Manual: `curl -s -X GET "http://100.122.34.28:8000/api/v1/deploy?uuid=mcn8m74tbxfxbplmyb40b2ia" -H "Authorization: Bearer 3|K2vkSJzdUA69rj22CKZc5z0YB6pkY43GLEonti3UzcnqVJj6WhrqqYTAng6DzMUi"`
|
||||||
|
- **CLI:** `cd apps/cli && pnpm publish --access public --no-git-checks`
|
||||||
|
- **Web:** Vercel auto-deploy on push to GitHub
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
- Monorepo: pnpm workspaces + Turborepo
|
||||||
|
- Broker dev: `cd apps/broker && bun --hot src/index.ts`
|
||||||
|
- CLI build: `cd apps/cli && pnpm build` (Bun bundler)
|
||||||
|
- CLI link for local testing: `cd apps/cli && npm link`
|
||||||
@@ -37,7 +37,7 @@ COPY --from=deps --chown=bun:bun /deploy /app
|
|||||||
|
|
||||||
EXPOSE 7900
|
EXPOSE 7900
|
||||||
|
|
||||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
|
||||||
CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
# Non-root user (oven/bun image ships with 'bun' uid 1000)
|
# Non-root user (oven/bun image ships with 'bun' uid 1000)
|
||||||
|
|||||||
@@ -15,10 +15,14 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@turbostarter/prettier-config",
|
"prettier": "@turbostarter/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@qdrant/js-client-rest": "1.17.0",
|
||||||
"@turbostarter/db": "workspace:*",
|
"@turbostarter/db": "workspace:*",
|
||||||
"@turbostarter/shared": "workspace:*",
|
"@turbostarter/shared": "workspace:*",
|
||||||
"drizzle-orm": "0.44.7",
|
"drizzle-orm": "0.44.7",
|
||||||
|
"grammy": "^1.35.0",
|
||||||
"libsodium-wrappers": "0.7.15",
|
"libsodium-wrappers": "0.7.15",
|
||||||
|
"minio": "8.0.7",
|
||||||
|
"neo4j-driver": "6.0.1",
|
||||||
"ws": "8.20.0",
|
"ws": "8.20.0",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
215
apps/broker/src/audit.ts
Normal file
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
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
133
apps/broker/src/cli-sync.ts
Normal file
133
apps/broker/src/cli-sync.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* POST /cli-sync handler.
|
||||||
|
*
|
||||||
|
* Accepts a sync JWT from the dashboard, creates or finds member rows
|
||||||
|
* for each mesh in the token, and returns mesh details + member IDs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import { db } from "./db";
|
||||||
|
import { verifySyncToken, type SyncTokenPayload } from "./jwt";
|
||||||
|
|
||||||
|
// Import schema tables
|
||||||
|
import {
|
||||||
|
mesh as meshTable,
|
||||||
|
meshMember as memberTable,
|
||||||
|
} from "@turbostarter/db/schema/mesh";
|
||||||
|
import { generateId } from "@turbostarter/shared/utils";
|
||||||
|
|
||||||
|
export interface CliSyncRequest {
|
||||||
|
sync_token: string;
|
||||||
|
peer_pubkey: string; // ed25519 hex (64 chars)
|
||||||
|
display_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliSyncResponse {
|
||||||
|
ok: true;
|
||||||
|
account_id: string;
|
||||||
|
meshes: Array<{
|
||||||
|
mesh_id: string;
|
||||||
|
slug: string;
|
||||||
|
broker_url: string;
|
||||||
|
member_id: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliSyncError {
|
||||||
|
ok: false;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCliSync(
|
||||||
|
body: CliSyncRequest,
|
||||||
|
): Promise<CliSyncResponse | CliSyncError> {
|
||||||
|
// 1. Validate inputs
|
||||||
|
if (!body.sync_token || !body.peer_pubkey || !body.display_name) {
|
||||||
|
return { ok: false, error: "sync_token, peer_pubkey, display_name required" };
|
||||||
|
}
|
||||||
|
if (!/^[0-9a-f]{64}$/i.test(body.peer_pubkey)) {
|
||||||
|
return { ok: false, error: "peer_pubkey must be 64 hex chars (32 bytes)" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verify JWT
|
||||||
|
const tokenResult = await verifySyncToken(body.sync_token);
|
||||||
|
if (!tokenResult.ok) {
|
||||||
|
return { ok: false, error: `sync token invalid: ${tokenResult.error}` };
|
||||||
|
}
|
||||||
|
const payload = tokenResult.payload;
|
||||||
|
|
||||||
|
// 3. For each mesh in the token, create or find a member row
|
||||||
|
const resultMeshes: CliSyncResponse["meshes"] = [];
|
||||||
|
|
||||||
|
for (const tokenMesh of payload.meshes) {
|
||||||
|
// Verify mesh exists and is not archived
|
||||||
|
const [m] = await db
|
||||||
|
.select({ id: meshTable.id, slug: meshTable.slug })
|
||||||
|
.from(meshTable)
|
||||||
|
.where(and(eq(meshTable.id, tokenMesh.id), isNull(meshTable.archivedAt)));
|
||||||
|
|
||||||
|
if (!m) {
|
||||||
|
// Skip meshes that don't exist (could have been deleted)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this pubkey is already a member of this mesh
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: memberTable.id, role: memberTable.role })
|
||||||
|
.from(memberTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(memberTable.meshId, tokenMesh.id),
|
||||||
|
eq(memberTable.peerPubkey, body.peer_pubkey),
|
||||||
|
isNull(memberTable.revokedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let memberId: string;
|
||||||
|
let role: "admin" | "member";
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Already a member — update dashboard link + display name
|
||||||
|
memberId = existing.id;
|
||||||
|
role = existing.role;
|
||||||
|
await db
|
||||||
|
.update(memberTable)
|
||||||
|
.set({
|
||||||
|
dashboardUserId: payload.sub,
|
||||||
|
displayName: body.display_name,
|
||||||
|
})
|
||||||
|
.where(eq(memberTable.id, existing.id));
|
||||||
|
} else {
|
||||||
|
// Create new member row
|
||||||
|
memberId = generateId();
|
||||||
|
role = tokenMesh.role;
|
||||||
|
await db.insert(memberTable).values({
|
||||||
|
id: memberId,
|
||||||
|
meshId: tokenMesh.id,
|
||||||
|
peerPubkey: body.peer_pubkey,
|
||||||
|
displayName: body.display_name,
|
||||||
|
role: tokenMesh.role,
|
||||||
|
dashboardUserId: payload.sub,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMeshes.push({
|
||||||
|
mesh_id: tokenMesh.id,
|
||||||
|
slug: m.slug,
|
||||||
|
broker_url: process.env.BROKER_PUBLIC_URL ?? "wss://ic.claudemesh.com/ws",
|
||||||
|
member_id: memberId,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultMeshes.length === 0) {
|
||||||
|
return { ok: false, error: "no valid meshes found in sync token" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
account_id: payload.sub,
|
||||||
|
meshes: resultMeshes,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,7 +7,10 @@
|
|||||||
* current member of the claimed mesh.
|
* current member of the claimed mesh.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||||
import sodium from "libsodium-wrappers";
|
import sodium from "libsodium-wrappers";
|
||||||
|
import { db } from "./db";
|
||||||
|
import { invite as inviteTable, mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
||||||
|
|
||||||
let ready = false;
|
let ready = false;
|
||||||
async function ensureSodium(): Promise<typeof sodium> {
|
async function ensureSodium(): Promise<typeof sodium> {
|
||||||
@@ -69,6 +72,70 @@ export async function verifyEd25519(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical v2 invite bytes — signed by the mesh owner's ed25519 secret key.
|
||||||
|
* NOTE: deliberately does NOT include the root_key or broker_url; the v2
|
||||||
|
* protocol moves the root_key out of the URL entirely. Format is locked:
|
||||||
|
* `v=2|mesh_id|invite_id|expires_at|role|owner_pubkey` (no trailing newline).
|
||||||
|
*/
|
||||||
|
export function canonicalInviteV2(p: {
|
||||||
|
mesh_id: string;
|
||||||
|
invite_id: string;
|
||||||
|
expires_at: number; // unix seconds
|
||||||
|
role: "admin" | "member";
|
||||||
|
owner_pubkey: string; // hex
|
||||||
|
}): string {
|
||||||
|
return `v=2|${p.mesh_id}|${p.invite_id}|${p.expires_at}|${p.role}|${p.owner_pubkey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an ed25519 signature over the v2 canonical invite bytes against
|
||||||
|
* the mesh owner's public key. Returns true on valid signature.
|
||||||
|
*/
|
||||||
|
export async function verifyInviteV2(params: {
|
||||||
|
canonical: string;
|
||||||
|
signatureHex: string;
|
||||||
|
ownerPubkeyHex: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
return verifyEd25519(
|
||||||
|
params.canonical,
|
||||||
|
params.signatureHex,
|
||||||
|
params.ownerPubkeyHex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seal the mesh root_key to a recipient-provided x25519 public key using
|
||||||
|
* libsodium's sealed box (crypto_box_seal). Only the holder of the matching
|
||||||
|
* x25519 secret key can unseal.
|
||||||
|
*
|
||||||
|
* rootKeyBase64url is the mesh.root_key column value (base64url of 32 bytes).
|
||||||
|
* recipientX25519PubkeyBase64url is the 32-byte x25519 pubkey the recipient
|
||||||
|
* provided in its claim request. We do NOT convert an ed25519 pubkey here —
|
||||||
|
* the recipient generates a dedicated x25519 keypair and sends us the pubkey.
|
||||||
|
*
|
||||||
|
* Returns base64url of the sealed ciphertext.
|
||||||
|
*/
|
||||||
|
export async function sealRootKeyToRecipient(params: {
|
||||||
|
rootKeyBase64url: string;
|
||||||
|
recipientX25519PubkeyBase64url: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const rootKeyBytes = s.from_base64(
|
||||||
|
params.rootKeyBase64url,
|
||||||
|
s.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
);
|
||||||
|
const recipientPk = s.from_base64(
|
||||||
|
params.recipientX25519PubkeyBase64url,
|
||||||
|
s.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
);
|
||||||
|
if (recipientPk.length !== 32) {
|
||||||
|
throw new Error("recipient_x25519_pubkey must decode to 32 bytes");
|
||||||
|
}
|
||||||
|
const sealed = s.crypto_box_seal(rootKeyBytes, recipientPk);
|
||||||
|
return s.to_base64(sealed, s.base64_variants.URLSAFE_NO_PADDING);
|
||||||
|
}
|
||||||
|
|
||||||
export const HELLO_SKEW_MS = 60_000;
|
export const HELLO_SKEW_MS = 60_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,3 +185,185 @@ export async function verifyHelloSignature(args: {
|
|||||||
return { ok: false, reason: "malformed" };
|
return { ok: false, reason: "malformed" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// v2 invite claim core — exported for the HTTP handler in index.ts AND for
|
||||||
|
// tests that need to exercise the logic without spinning up the broker server.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// capabilityV2 column is stored as JSON:
|
||||||
|
// { "canonical": "v=2|mesh_id|invite_id|expires_at|role|owner_pubkey",
|
||||||
|
// "signature": "<hex ed25519 detached signature>" }
|
||||||
|
// The broker recomputes the canonical bytes from the invite row and verifies
|
||||||
|
// the signature against mesh.ownerPubkey. v1 rows (version === 1 OR
|
||||||
|
// capabilityV2 === null) skip verification — the legacy path still works
|
||||||
|
// during the deprecation window.
|
||||||
|
|
||||||
|
export type InviteClaimV2Result =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
status: 200;
|
||||||
|
body: {
|
||||||
|
sealed_root_key: string;
|
||||||
|
mesh_id: string;
|
||||||
|
member_id: string;
|
||||||
|
owner_pubkey: string;
|
||||||
|
canonical_v2: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| { ok: false; status: 400 | 404 | 410; body: { error: string } };
|
||||||
|
|
||||||
|
export async function claimInviteV2Core(params: {
|
||||||
|
code: string;
|
||||||
|
recipientX25519PubkeyBase64url: string;
|
||||||
|
displayName?: string;
|
||||||
|
now?: number;
|
||||||
|
}): Promise<InviteClaimV2Result> {
|
||||||
|
const now = params.now ?? Date.now();
|
||||||
|
const recipientPk = params.recipientX25519PubkeyBase64url;
|
||||||
|
|
||||||
|
if (!recipientPk || typeof recipientPk !== "string" || recipientPk.length < 32) {
|
||||||
|
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Look up the invite by opaque code.
|
||||||
|
const [inv] = await db
|
||||||
|
.select()
|
||||||
|
.from(inviteTable)
|
||||||
|
.where(eq(inviteTable.code, params.code))
|
||||||
|
.limit(1);
|
||||||
|
if (!inv) return { ok: false, status: 404, body: { error: "not_found" } };
|
||||||
|
|
||||||
|
// 2. Lifecycle checks: revoked → expired → exhausted.
|
||||||
|
if (inv.revokedAt) {
|
||||||
|
return { ok: false, status: 410, body: { error: "revoked" } };
|
||||||
|
}
|
||||||
|
if (inv.expiresAt.getTime() < now) {
|
||||||
|
return { ok: false, status: 410, body: { error: "expired" } };
|
||||||
|
}
|
||||||
|
if (inv.usedCount >= inv.maxUses) {
|
||||||
|
return { ok: false, status: 410, body: { error: "exhausted" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Load the mesh for owner_pubkey + root_key.
|
||||||
|
const [m] = await db
|
||||||
|
.select({
|
||||||
|
id: mesh.id,
|
||||||
|
ownerPubkey: mesh.ownerPubkey,
|
||||||
|
rootKey: mesh.rootKey,
|
||||||
|
})
|
||||||
|
.from(mesh)
|
||||||
|
.where(and(eq(mesh.id, inv.meshId), isNull(mesh.archivedAt)))
|
||||||
|
.limit(1);
|
||||||
|
if (!m) return { ok: false, status: 404, body: { error: "not_found" } };
|
||||||
|
if (!m.ownerPubkey || !m.rootKey) {
|
||||||
|
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Compute canonical_v2 from the row (used in the response either way).
|
||||||
|
const expiresAtUnix = Math.floor(inv.expiresAt.getTime() / 1000);
|
||||||
|
const canonical = canonicalInviteV2({
|
||||||
|
mesh_id: inv.meshId,
|
||||||
|
invite_id: inv.id,
|
||||||
|
expires_at: expiresAtUnix,
|
||||||
|
role: inv.role as "admin" | "member",
|
||||||
|
owner_pubkey: m.ownerPubkey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inv.version === 2 && inv.capabilityV2) {
|
||||||
|
let storedCanonical: string | undefined;
|
||||||
|
let signatureHex: string | undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(inv.capabilityV2) as {
|
||||||
|
canonical?: string;
|
||||||
|
signature?: string;
|
||||||
|
};
|
||||||
|
storedCanonical = parsed.canonical;
|
||||||
|
signatureHex = parsed.signature;
|
||||||
|
} catch {
|
||||||
|
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||||
|
}
|
||||||
|
if (!storedCanonical || !signatureHex) {
|
||||||
|
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||||
|
}
|
||||||
|
// Broker-recomputed canonical must match the signed bytes exactly.
|
||||||
|
if (storedCanonical !== canonical) {
|
||||||
|
return { ok: false, status: 400, body: { error: "bad_signature" } };
|
||||||
|
}
|
||||||
|
const sigOk = await verifyInviteV2({
|
||||||
|
canonical: storedCanonical,
|
||||||
|
signatureHex,
|
||||||
|
ownerPubkeyHex: m.ownerPubkey,
|
||||||
|
});
|
||||||
|
if (!sigOk) {
|
||||||
|
return { ok: false, status: 400, body: { error: "bad_signature" } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// v1 rows: skip signature verification (legacy path during migration).
|
||||||
|
|
||||||
|
// 5. Atomic consume: increment used_count iff still under max_uses.
|
||||||
|
const [claimed] = await db
|
||||||
|
.update(inviteTable)
|
||||||
|
.set({
|
||||||
|
usedCount: sql`${inviteTable.usedCount} + 1`,
|
||||||
|
claimedByPubkey: recipientPk,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(inviteTable.id, inv.id),
|
||||||
|
lt(inviteTable.usedCount, inv.maxUses),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: inviteTable.id });
|
||||||
|
if (!claimed) {
|
||||||
|
return { ok: false, status: 410, body: { error: "exhausted" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Create a member row for the claimant.
|
||||||
|
const preset = (inv.preset as {
|
||||||
|
displayName?: string;
|
||||||
|
roleTag?: string;
|
||||||
|
groups?: Array<{ name: string; role?: string }>;
|
||||||
|
messageMode?: string;
|
||||||
|
} | null) ?? {};
|
||||||
|
const displayName =
|
||||||
|
preset.displayName ?? params.displayName ?? `member-${recipientPk.slice(0, 8)}`;
|
||||||
|
const [row] = await db
|
||||||
|
.insert(meshMember)
|
||||||
|
.values({
|
||||||
|
meshId: inv.meshId,
|
||||||
|
peerPubkey: recipientPk,
|
||||||
|
displayName,
|
||||||
|
role: inv.role,
|
||||||
|
roleTag: preset.roleTag ?? null,
|
||||||
|
defaultGroups: preset.groups ?? [],
|
||||||
|
messageMode: preset.messageMode ?? "push",
|
||||||
|
})
|
||||||
|
.returning({ id: meshMember.id });
|
||||||
|
if (!row) {
|
||||||
|
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Seal the mesh root_key to the recipient's x25519 pubkey.
|
||||||
|
let sealed: string;
|
||||||
|
try {
|
||||||
|
sealed = await sealRootKeyToRecipient({
|
||||||
|
rootKeyBase64url: m.rootKey,
|
||||||
|
recipientX25519PubkeyBase64url: recipientPk,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
sealed_root_key: sealed,
|
||||||
|
mesh_id: inv.meshId,
|
||||||
|
member_id: row.id,
|
||||||
|
owner_pubkey: m.ownerPubkey,
|
||||||
|
canonical_v2: canonical,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ const envSchema = z.object({
|
|||||||
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
|
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
|
||||||
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
|
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
|
||||||
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30),
|
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30),
|
||||||
|
MINIO_ENDPOINT: z.string().default("minio:9000"),
|
||||||
|
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
|
||||||
|
MINIO_SECRET_KEY: z.string().default("changeme"),
|
||||||
|
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
|
||||||
|
QDRANT_URL: z.string().default("http://qdrant:6333"),
|
||||||
|
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
|
||||||
|
NEO4J_USER: z.string().default("neo4j"),
|
||||||
|
NEO4J_PASSWORD: z.string().default("changeme"),
|
||||||
|
RUNNER_URL: z.string().default("http://runner:7901"),
|
||||||
|
CLAUDEMESH_SERVICES_DIR: z.string().default("/var/claudemesh/services"),
|
||||||
|
BROKER_ENCRYPTION_KEY: z.string().default(""), // 64 hex chars (32 bytes). Auto-generated if empty.
|
||||||
|
CLI_SYNC_SECRET: z.string().default(""), // HS256 shared secret for dashboard→broker sync JWTs. Required for /cli-sync.
|
||||||
|
MAX_SERVICES_PER_MESH: z.coerce.number().int().positive().default(20),
|
||||||
|
MAX_SERVICE_ZIP_BYTES: z.coerce.number().int().positive().default(50 * 1024 * 1024),
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(["development", "production", "test"])
|
.enum(["development", "production", "test"])
|
||||||
.default("development"),
|
.default("development"),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
146
apps/broker/src/jwt.ts
Normal file
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
284
apps/broker/src/member-api.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Member profile REST API handlers.
|
||||||
|
*
|
||||||
|
* PATCH /mesh/:meshId/member/:memberId — update member profile
|
||||||
|
* GET /mesh/:meshId/members — list all members with online status
|
||||||
|
* PATCH /mesh/:meshId/settings — update mesh settings (selfEditable)
|
||||||
|
*
|
||||||
|
* These are standalone handler functions. Route wiring happens in index.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||||
|
import { db } from "./db";
|
||||||
|
import {
|
||||||
|
mesh as meshTable,
|
||||||
|
meshMember as memberTable,
|
||||||
|
presence as presenceTable,
|
||||||
|
} from "@turbostarter/db/schema/mesh";
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface MemberProfileUpdate {
|
||||||
|
displayName?: string;
|
||||||
|
roleTag?: string;
|
||||||
|
groups?: Array<{ name: string; role?: string }>;
|
||||||
|
messageMode?: "push" | "inbox" | "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberPermissionUpdate {
|
||||||
|
permission?: "admin" | "member"; // only admins can change this
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MemberUpdateRequest = MemberProfileUpdate & MemberPermissionUpdate;
|
||||||
|
|
||||||
|
interface SelfEditablePolicy {
|
||||||
|
displayName: boolean;
|
||||||
|
roleTag: boolean;
|
||||||
|
groups: boolean;
|
||||||
|
messageMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a member's profile fields.
|
||||||
|
*
|
||||||
|
* Authorization:
|
||||||
|
* - If caller is the target member: check mesh.selfEditable for each field
|
||||||
|
* - If caller is a mesh admin: allow all fields
|
||||||
|
* - permission field: admin-only always
|
||||||
|
*
|
||||||
|
* Returns: { ok: true, member: {...} } or { ok: false, error: string }
|
||||||
|
*/
|
||||||
|
export async function updateMemberProfile(
|
||||||
|
meshId: string,
|
||||||
|
memberId: string,
|
||||||
|
callerMemberId: string, // from auth header or WS connection
|
||||||
|
updates: MemberUpdateRequest,
|
||||||
|
): Promise<
|
||||||
|
| { ok: true; member: Record<string, unknown>; changes: MemberProfileUpdate }
|
||||||
|
| { ok: false; error: string }
|
||||||
|
> {
|
||||||
|
// 1. Load mesh for selfEditable policy
|
||||||
|
const [m] = await db
|
||||||
|
.select({ id: meshTable.id, selfEditable: meshTable.selfEditable })
|
||||||
|
.from(meshTable)
|
||||||
|
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
||||||
|
|
||||||
|
if (!m) return { ok: false, error: "mesh not found" };
|
||||||
|
|
||||||
|
// 2. Load caller's member row to check permission
|
||||||
|
const [caller] = await db
|
||||||
|
.select({ id: memberTable.id, role: memberTable.role })
|
||||||
|
.from(memberTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(memberTable.id, callerMemberId),
|
||||||
|
eq(memberTable.meshId, meshId),
|
||||||
|
isNull(memberTable.revokedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!caller) return { ok: false, error: "caller not a member of this mesh" };
|
||||||
|
|
||||||
|
const isAdmin = caller.role === "admin";
|
||||||
|
const isSelf = callerMemberId === memberId;
|
||||||
|
|
||||||
|
if (!isAdmin && !isSelf) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "not authorized — only admins or self can edit",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check self-edit permissions for non-admin self-edits
|
||||||
|
const policy: SelfEditablePolicy =
|
||||||
|
(m.selfEditable as SelfEditablePolicy) ?? {
|
||||||
|
displayName: true,
|
||||||
|
roleTag: true,
|
||||||
|
groups: true,
|
||||||
|
messageMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejected: string[] = [];
|
||||||
|
if (!isAdmin && isSelf) {
|
||||||
|
if (updates.displayName !== undefined && !policy.displayName)
|
||||||
|
rejected.push("displayName");
|
||||||
|
if (updates.roleTag !== undefined && !policy.roleTag)
|
||||||
|
rejected.push("roleTag");
|
||||||
|
if (updates.groups !== undefined && !policy.groups)
|
||||||
|
rejected.push("groups");
|
||||||
|
if (updates.messageMode !== undefined && !policy.messageMode)
|
||||||
|
rejected.push("messageMode");
|
||||||
|
if (updates.permission !== undefined) rejected.push("permission");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `admin-managed fields: ${rejected.join(", ")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build update set
|
||||||
|
const set: Record<string, unknown> = {};
|
||||||
|
const changes: MemberProfileUpdate = {};
|
||||||
|
|
||||||
|
if (updates.displayName !== undefined) {
|
||||||
|
set.displayName = updates.displayName;
|
||||||
|
changes.displayName = updates.displayName;
|
||||||
|
}
|
||||||
|
if (updates.roleTag !== undefined) {
|
||||||
|
set.roleTag = updates.roleTag;
|
||||||
|
changes.roleTag = updates.roleTag;
|
||||||
|
}
|
||||||
|
if (updates.groups !== undefined) {
|
||||||
|
set.defaultGroups = updates.groups;
|
||||||
|
changes.groups = updates.groups;
|
||||||
|
}
|
||||||
|
if (updates.messageMode !== undefined) {
|
||||||
|
set.messageMode = updates.messageMode;
|
||||||
|
changes.messageMode = updates.messageMode;
|
||||||
|
}
|
||||||
|
if (updates.permission !== undefined && isAdmin) {
|
||||||
|
set.role = updates.permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(set).length === 0) {
|
||||||
|
return { ok: false, error: "no fields to update" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Update member row
|
||||||
|
await db.update(memberTable).set(set).where(eq(memberTable.id, memberId));
|
||||||
|
|
||||||
|
// 6. Read back the updated member
|
||||||
|
const [updated] = await db
|
||||||
|
.select()
|
||||||
|
.from(memberTable)
|
||||||
|
.where(eq(memberTable.id, memberId));
|
||||||
|
|
||||||
|
if (!updated) return { ok: false, error: "member not found after update" };
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
member: {
|
||||||
|
id: updated.id,
|
||||||
|
displayName: updated.displayName,
|
||||||
|
roleTag: updated.roleTag,
|
||||||
|
groups: updated.defaultGroups,
|
||||||
|
messageMode: updated.messageMode,
|
||||||
|
permission: updated.role,
|
||||||
|
dashboardUserId: updated.dashboardUserId,
|
||||||
|
joinedAt: updated.joinedAt,
|
||||||
|
lastSeenAt: updated.lastSeenAt,
|
||||||
|
},
|
||||||
|
changes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all members of a mesh with online status.
|
||||||
|
*/
|
||||||
|
export async function listMeshMembers(
|
||||||
|
meshId: string,
|
||||||
|
): Promise<
|
||||||
|
| { ok: true; members: Array<Record<string, unknown>> }
|
||||||
|
| { ok: false; error: string }
|
||||||
|
> {
|
||||||
|
// Verify mesh exists
|
||||||
|
const [m] = await db
|
||||||
|
.select({ id: meshTable.id })
|
||||||
|
.from(meshTable)
|
||||||
|
.where(and(eq(meshTable.id, meshId), isNull(meshTable.archivedAt)));
|
||||||
|
|
||||||
|
if (!m) return { ok: false, error: "mesh not found" };
|
||||||
|
|
||||||
|
// Get all non-revoked members
|
||||||
|
const members = await db
|
||||||
|
.select()
|
||||||
|
.from(memberTable)
|
||||||
|
.where(
|
||||||
|
and(eq(memberTable.meshId, meshId), isNull(memberTable.revokedAt)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Early return for empty member list (avoids invalid SQL IN clause)
|
||||||
|
if (members.length === 0) {
|
||||||
|
return { ok: true, members: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get active presences for online status
|
||||||
|
const activePresences = await db
|
||||||
|
.select({
|
||||||
|
memberId: presenceTable.memberId,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(presenceTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNull(presenceTable.disconnectedAt),
|
||||||
|
sql`${presenceTable.memberId} IN (${sql.join(
|
||||||
|
members.map((m) => sql`${m.id}`),
|
||||||
|
sql`, `,
|
||||||
|
)})`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(presenceTable.memberId);
|
||||||
|
|
||||||
|
const onlineMap = new Map(
|
||||||
|
activePresences.map((p) => [p.memberId, p.count]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
members: members.map((member) => ({
|
||||||
|
id: member.id,
|
||||||
|
displayName: member.displayName,
|
||||||
|
roleTag: member.roleTag,
|
||||||
|
groups: member.defaultGroups,
|
||||||
|
messageMode: member.messageMode,
|
||||||
|
permission: member.role,
|
||||||
|
dashboardUserId: member.dashboardUserId,
|
||||||
|
joinedAt: member.joinedAt?.toISOString(),
|
||||||
|
lastSeenAt: member.lastSeenAt?.toISOString(),
|
||||||
|
online: onlineMap.has(member.id),
|
||||||
|
sessionCount: onlineMap.get(member.id) ?? 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update mesh settings (currently: selfEditable policy).
|
||||||
|
* Admin-only.
|
||||||
|
*/
|
||||||
|
export async function updateMeshSettings(
|
||||||
|
meshId: string,
|
||||||
|
callerMemberId: string,
|
||||||
|
settings: { selfEditable?: SelfEditablePolicy },
|
||||||
|
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
|
// Check caller is admin
|
||||||
|
const [caller] = await db
|
||||||
|
.select({ role: memberTable.role })
|
||||||
|
.from(memberTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(memberTable.id, callerMemberId),
|
||||||
|
eq(memberTable.meshId, meshId),
|
||||||
|
isNull(memberTable.revokedAt),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!caller || caller.role !== "admin") {
|
||||||
|
return { ok: false, error: "admin access required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const set: Record<string, unknown> = {};
|
||||||
|
if (settings.selfEditable) set.selfEditable = settings.selfEditable;
|
||||||
|
|
||||||
|
if (Object.keys(set).length === 0) {
|
||||||
|
return { ok: false, error: "no settings to update" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(meshTable).set(set).where(eq(meshTable.id, meshId));
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
28
apps/broker/src/minio.ts
Normal file
28
apps/broker/src/minio.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* MinIO client for file storage.
|
||||||
|
*
|
||||||
|
* Each mesh gets its own bucket (mesh-{meshId}). Files are stored under
|
||||||
|
* a key path that encodes persistence and origin:
|
||||||
|
* - persistent: shared/{fileId}/{originalName}
|
||||||
|
* - ephemeral: ephemeral/{YYYY-MM-DD}/{fileId}/{originalName}
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Client } from "minio";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
export const minioClient = new Client({
|
||||||
|
endPoint: env.MINIO_ENDPOINT.split(":")[0]!,
|
||||||
|
port: parseInt(env.MINIO_ENDPOINT.split(":")[1] || "9000"),
|
||||||
|
useSSL: env.MINIO_USE_SSL,
|
||||||
|
accessKey: env.MINIO_ACCESS_KEY,
|
||||||
|
secretKey: env.MINIO_SECRET_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function ensureBucket(name: string): Promise<void> {
|
||||||
|
const exists = await minioClient.bucketExists(name);
|
||||||
|
if (!exists) await minioClient.makeBucket(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function meshBucketName(meshId: string): string {
|
||||||
|
return `mesh-${meshId.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
|
||||||
|
}
|
||||||
22
apps/broker/src/neo4j-client.ts
Normal file
22
apps/broker/src/neo4j-client.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import neo4j from "neo4j-driver";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
export const neo4jDriver = neo4j.driver(
|
||||||
|
env.NEO4J_URL,
|
||||||
|
neo4j.auth.basic(env.NEO4J_USER, env.NEO4J_PASSWORD),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function meshDbName(meshId: string): string {
|
||||||
|
return `mesh_${meshId.toLowerCase().replace(/[^a-z0-9]/g, "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDatabase(name: string): Promise<void> {
|
||||||
|
const session = neo4jDriver.session({ database: "system" });
|
||||||
|
try {
|
||||||
|
await session.run(`CREATE DATABASE $name IF NOT EXISTS`, { name });
|
||||||
|
} catch {
|
||||||
|
/* may not support multi-db in community edition — fall back to default */
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/broker/src/qdrant.ts
Normal file
24
apps/broker/src/qdrant.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
export const qdrant = new QdrantClient({ url: env.QDRANT_URL });
|
||||||
|
|
||||||
|
export function meshCollectionName(
|
||||||
|
meshId: string,
|
||||||
|
collection: string,
|
||||||
|
): string {
|
||||||
|
return `mesh_${meshId}_${collection}`.toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureCollection(
|
||||||
|
name: string,
|
||||||
|
vectorSize = 1536,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await qdrant.getCollection(name);
|
||||||
|
} catch {
|
||||||
|
await qdrant.createCollection(name, {
|
||||||
|
vectors: { size: vectorSize, distance: "Cosine" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
788
apps/broker/src/service-manager.ts
Normal file
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
1711
apps/broker/src/telegram-bridge.ts
Normal file
File diff suppressed because it is too large
Load Diff
148
apps/broker/src/telegram-token.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}`;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
97
apps/broker/src/webhooks.ts
Normal file
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
268
apps/broker/tests/invite-v2.test.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* v2 invite protocol — broker claim endpoint.
|
||||||
|
*
|
||||||
|
* Covers the sealed-root-key delivery flow added in
|
||||||
|
* .artifacts/specs/2026-04-10-anthropic-vision-meshes-invites.md :
|
||||||
|
*
|
||||||
|
* - happy path: signed v2 invite claim returns a sealed root_key the
|
||||||
|
* recipient can unseal back to the mesh.rootKey column value
|
||||||
|
* - tampered signature → 400 bad_signature
|
||||||
|
* - expired invite → 410 expired
|
||||||
|
* - revoked invite → 410 revoked
|
||||||
|
* - exhausted invite (usedCount === maxUses) → 410 exhausted
|
||||||
|
* - round-trip: recipient-side crypto_box_seal_open recovers the real key
|
||||||
|
*
|
||||||
|
* Tests talk directly to claimInviteV2Core() to avoid spinning up the
|
||||||
|
* full broker HTTP server. The handler delegates to this function with
|
||||||
|
* zero extra logic, so coverage is equivalent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
|
import { db } from "../src/db";
|
||||||
|
import { invite, mesh } from "@turbostarter/db/schema/mesh";
|
||||||
|
import { canonicalInviteV2 } from "../src/crypto";
|
||||||
|
import { claimInviteV2Core } from "../src/index";
|
||||||
|
import {
|
||||||
|
cleanupAllTestMeshes,
|
||||||
|
setupTestMesh,
|
||||||
|
type TestMesh,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupAllTestMeshes();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await sodium.ready;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a random base64url root_key on an existing test mesh. The helpers
|
||||||
|
* don't set one by default, so v2 tests prime it per-mesh here.
|
||||||
|
*/
|
||||||
|
async function primeRootKey(meshId: string): Promise<Uint8Array> {
|
||||||
|
const key = sodium.randombytes_buf(32);
|
||||||
|
const b64 = sodium.to_base64(key, sodium.base64_variants.URLSAFE_NO_PADDING);
|
||||||
|
await db.update(mesh).set({ rootKey: b64 }).where(eq(mesh.id, meshId));
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a signed v2 invite row. Returns the opaque short code + the
|
||||||
|
* recipient x25519 keypair the test will use to unseal.
|
||||||
|
*/
|
||||||
|
async function insertV2Invite(
|
||||||
|
m: TestMesh,
|
||||||
|
opts: {
|
||||||
|
code: string;
|
||||||
|
expiresInSec?: number;
|
||||||
|
maxUses?: number;
|
||||||
|
role?: "admin" | "member";
|
||||||
|
tamper?: boolean; // corrupt the signature
|
||||||
|
revoked?: boolean;
|
||||||
|
used?: number;
|
||||||
|
},
|
||||||
|
): Promise<{ inviteId: string; canonical: string }> {
|
||||||
|
const expiresInSec = opts.expiresInSec ?? 3600;
|
||||||
|
const expiresAt = new Date(Date.now() + expiresInSec * 1000);
|
||||||
|
const maxUses = opts.maxUses ?? 1;
|
||||||
|
const role = opts.role ?? "member";
|
||||||
|
|
||||||
|
// Insert first with a placeholder capability so we have the invite id.
|
||||||
|
const [row] = await db
|
||||||
|
.insert(invite)
|
||||||
|
.values({
|
||||||
|
meshId: m.meshId,
|
||||||
|
token: `v2-test-token-${opts.code}`,
|
||||||
|
code: opts.code,
|
||||||
|
maxUses,
|
||||||
|
usedCount: opts.used ?? 0,
|
||||||
|
role,
|
||||||
|
expiresAt,
|
||||||
|
createdBy: "test-user-integration",
|
||||||
|
version: 2,
|
||||||
|
revokedAt: opts.revoked ? new Date() : null,
|
||||||
|
})
|
||||||
|
.returning({ id: invite.id });
|
||||||
|
if (!row) throw new Error("v2 invite insert failed");
|
||||||
|
|
||||||
|
// Now compute canonical_v2 using the real invite id and sign with the
|
||||||
|
// mesh owner's ed25519 secret key.
|
||||||
|
const expiresAtUnix = Math.floor(expiresAt.getTime() / 1000);
|
||||||
|
const canonical = canonicalInviteV2({
|
||||||
|
mesh_id: m.meshId,
|
||||||
|
invite_id: row.id,
|
||||||
|
expires_at: expiresAtUnix,
|
||||||
|
role,
|
||||||
|
owner_pubkey: m.ownerPubkey,
|
||||||
|
});
|
||||||
|
let signatureHex = sodium.to_hex(
|
||||||
|
sodium.crypto_sign_detached(
|
||||||
|
sodium.from_string(canonical),
|
||||||
|
sodium.from_hex(m.ownerSecretKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (opts.tamper) {
|
||||||
|
// Flip a single hex nibble — keeps length valid, invalidates signature.
|
||||||
|
const first = signatureHex[0] === "0" ? "1" : "0";
|
||||||
|
signatureHex = first + signatureHex.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const capability = JSON.stringify({
|
||||||
|
canonical,
|
||||||
|
signature: signatureHex,
|
||||||
|
});
|
||||||
|
await db
|
||||||
|
.update(invite)
|
||||||
|
.set({ capabilityV2: capability })
|
||||||
|
.where(eq(invite.id, row.id));
|
||||||
|
return { inviteId: row.id, canonical };
|
||||||
|
}
|
||||||
|
|
||||||
|
function genRecipientX25519(): { pk: string; sk: Uint8Array } {
|
||||||
|
const kp = sodium.crypto_box_keypair();
|
||||||
|
return {
|
||||||
|
pk: sodium.to_base64(kp.publicKey, sodium.base64_variants.URLSAFE_NO_PADDING),
|
||||||
|
sk: kp.privateKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("claimInviteV2Core — v2 invite claim", () => {
|
||||||
|
let m: TestMesh;
|
||||||
|
afterEach(async () => m && (await m.cleanup()));
|
||||||
|
|
||||||
|
test("happy path: signed v2 invite returns sealed root_key and member row", async () => {
|
||||||
|
m = await setupTestMesh("v2-ok");
|
||||||
|
const rootKeyBytes = await primeRootKey(m.meshId);
|
||||||
|
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
const { inviteId, canonical } = await insertV2Invite(m, { code });
|
||||||
|
const recipient = genRecipientX25519();
|
||||||
|
|
||||||
|
const result = await claimInviteV2Core({
|
||||||
|
code,
|
||||||
|
recipientX25519PubkeyBase64url: recipient.pk,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(result.body.mesh_id).toBe(m.meshId);
|
||||||
|
expect(result.body.owner_pubkey).toBe(m.ownerPubkey);
|
||||||
|
expect(result.body.canonical_v2).toBe(canonical);
|
||||||
|
expect(result.body.member_id).toBeTruthy();
|
||||||
|
|
||||||
|
// Recipient unseals the sealed_root_key using its x25519 secret key.
|
||||||
|
const sealed = sodium.from_base64(
|
||||||
|
result.body.sealed_root_key,
|
||||||
|
sodium.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
);
|
||||||
|
const recipientPkBytes = sodium.from_base64(
|
||||||
|
recipient.pk,
|
||||||
|
sodium.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
);
|
||||||
|
const opened = sodium.crypto_box_seal_open(
|
||||||
|
sealed,
|
||||||
|
recipientPkBytes,
|
||||||
|
recipient.sk,
|
||||||
|
);
|
||||||
|
expect(opened).toBeInstanceOf(Uint8Array);
|
||||||
|
expect(opened.length).toBe(32);
|
||||||
|
expect(Array.from(opened)).toEqual(Array.from(rootKeyBytes));
|
||||||
|
|
||||||
|
// usedCount incremented and claimedByPubkey recorded.
|
||||||
|
const [updated] = await db
|
||||||
|
.select({
|
||||||
|
usedCount: invite.usedCount,
|
||||||
|
claimedByPubkey: invite.claimedByPubkey,
|
||||||
|
})
|
||||||
|
.from(invite)
|
||||||
|
.where(eq(invite.id, inviteId));
|
||||||
|
expect(updated?.usedCount).toBe(1);
|
||||||
|
expect(updated?.claimedByPubkey).toBe(recipient.pk);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tampered signature → 400 bad_signature", async () => {
|
||||||
|
m = await setupTestMesh("v2-tampered");
|
||||||
|
await primeRootKey(m.meshId);
|
||||||
|
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
await insertV2Invite(m, { code, tamper: true });
|
||||||
|
const recipient = genRecipientX25519();
|
||||||
|
|
||||||
|
const result = await claimInviteV2Core({
|
||||||
|
code,
|
||||||
|
recipientX25519PubkeyBase64url: recipient.pk,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.status).toBe(400);
|
||||||
|
expect(result.body.error).toBe("bad_signature");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired invite → 410 expired", async () => {
|
||||||
|
m = await setupTestMesh("v2-expired");
|
||||||
|
await primeRootKey(m.meshId);
|
||||||
|
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
await insertV2Invite(m, { code, expiresInSec: -60 });
|
||||||
|
const recipient = genRecipientX25519();
|
||||||
|
|
||||||
|
const result = await claimInviteV2Core({
|
||||||
|
code,
|
||||||
|
recipientX25519PubkeyBase64url: recipient.pk,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.status).toBe(410);
|
||||||
|
expect(result.body.error).toBe("expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("revoked invite → 410 revoked", async () => {
|
||||||
|
m = await setupTestMesh("v2-revoked");
|
||||||
|
await primeRootKey(m.meshId);
|
||||||
|
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
await insertV2Invite(m, { code, revoked: true });
|
||||||
|
const recipient = genRecipientX25519();
|
||||||
|
|
||||||
|
const result = await claimInviteV2Core({
|
||||||
|
code,
|
||||||
|
recipientX25519PubkeyBase64url: recipient.pk,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.status).toBe(410);
|
||||||
|
expect(result.body.error).toBe("revoked");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exhausted invite (usedCount >= maxUses) → 410 exhausted", async () => {
|
||||||
|
m = await setupTestMesh("v2-exhausted");
|
||||||
|
await primeRootKey(m.meshId);
|
||||||
|
const code = `c${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
await insertV2Invite(m, { code, maxUses: 1, used: 1 });
|
||||||
|
const recipient = genRecipientX25519();
|
||||||
|
|
||||||
|
const result = await claimInviteV2Core({
|
||||||
|
code,
|
||||||
|
recipientX25519PubkeyBase64url: recipient.pk,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.status).toBe(410);
|
||||||
|
expect(result.body.error).toBe("exhausted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown code → 404 not_found", async () => {
|
||||||
|
m = await setupTestMesh("v2-404");
|
||||||
|
await primeRootKey(m.meshId);
|
||||||
|
const recipient = genRecipientX25519();
|
||||||
|
|
||||||
|
const result = await claimInviteV2Core({
|
||||||
|
code: "nonexistent",
|
||||||
|
recipientX25519PubkeyBase64url: recipient.pk,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.status).toBe(404);
|
||||||
|
expect(result.body.error).toBe("not_found");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "0.1.3",
|
"version": "0.10.6",
|
||||||
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "1.27.1",
|
"@modelcontextprotocol/sdk": "1.27.1",
|
||||||
|
"citty": "0.2.2",
|
||||||
"libsodium-wrappers": "0.7.15",
|
"libsodium-wrappers": "0.7.15",
|
||||||
"ws": "8.20.0",
|
"ws": "8.20.0",
|
||||||
"zod": "4.1.13"
|
"zod": "4.1.13"
|
||||||
|
|||||||
42
apps/cli/src/__tests__/crypto-roundtrip.test.ts
Normal file
42
apps/cli/src/__tests__/crypto-roundtrip.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { encryptDirect, decryptDirect } from "../crypto/envelope";
|
||||||
|
import { generateKeypair } from "../crypto/keypair";
|
||||||
|
|
||||||
|
describe("crypto roundtrip", () => {
|
||||||
|
it("Alice encrypts for Bob, Bob decrypts successfully", async () => {
|
||||||
|
const alice = await generateKeypair();
|
||||||
|
const bob = await generateKeypair();
|
||||||
|
|
||||||
|
const plaintext = "hello world";
|
||||||
|
const envelope = await encryptDirect(plaintext, bob.publicKey, alice.secretKey);
|
||||||
|
|
||||||
|
const decrypted = await decryptDirect(envelope, alice.publicKey, bob.secretKey);
|
||||||
|
expect(decrypted).toBe(plaintext);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Carol cannot decrypt a message encrypted for Bob", async () => {
|
||||||
|
const alice = await generateKeypair();
|
||||||
|
const bob = await generateKeypair();
|
||||||
|
const carol = await generateKeypair();
|
||||||
|
|
||||||
|
const envelope = await encryptDirect("hello world", bob.publicKey, alice.secretKey);
|
||||||
|
|
||||||
|
const decrypted = await decryptDirect(envelope, alice.publicKey, carol.secretKey);
|
||||||
|
expect(decrypted).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tampered ciphertext returns null on decrypt", async () => {
|
||||||
|
const alice = await generateKeypair();
|
||||||
|
const bob = await generateKeypair();
|
||||||
|
|
||||||
|
const envelope = await encryptDirect("hello world", bob.publicKey, alice.secretKey);
|
||||||
|
|
||||||
|
// Flip a byte in the ciphertext
|
||||||
|
const raw = Buffer.from(envelope.ciphertext, "base64");
|
||||||
|
raw[0] = raw[0]! ^ 0xff;
|
||||||
|
const tampered = { nonce: envelope.nonce, ciphertext: raw.toString("base64") };
|
||||||
|
|
||||||
|
const decrypted = await decryptDirect(tampered, alice.publicKey, bob.secretKey);
|
||||||
|
expect(decrypted).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
apps/cli/src/__tests__/invite-parse.test.ts
Normal file
67
apps/cli/src/__tests__/invite-parse.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
parseInviteLink,
|
||||||
|
buildSignedInvite,
|
||||||
|
extractInviteToken,
|
||||||
|
} from "../invite/parse";
|
||||||
|
import { generateKeypair } from "../crypto/keypair";
|
||||||
|
|
||||||
|
describe("invite parse", () => {
|
||||||
|
it("round-trips a signed invite through encode and parse", async () => {
|
||||||
|
const owner = await generateKeypair();
|
||||||
|
const expiresAt = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
|
||||||
|
|
||||||
|
const { link, payload } = await buildSignedInvite({
|
||||||
|
v: 1,
|
||||||
|
mesh_id: "mesh-abc-123",
|
||||||
|
mesh_slug: "test-mesh",
|
||||||
|
broker_url: "wss://broker.example.com",
|
||||||
|
expires_at: expiresAt,
|
||||||
|
mesh_root_key: "deadbeefcafebabe",
|
||||||
|
role: "member",
|
||||||
|
owner_pubkey: owner.publicKey,
|
||||||
|
owner_secret_key: owner.secretKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = await parseInviteLink(link);
|
||||||
|
expect(parsed.payload.mesh_id).toBe("mesh-abc-123");
|
||||||
|
expect(parsed.payload.mesh_slug).toBe("test-mesh");
|
||||||
|
expect(parsed.payload.broker_url).toBe("wss://broker.example.com");
|
||||||
|
expect(parsed.payload.expires_at).toBe(expiresAt);
|
||||||
|
expect(parsed.payload.role).toBe("member");
|
||||||
|
expect(parsed.payload.owner_pubkey).toBe(owner.publicKey);
|
||||||
|
expect(parsed.payload.signature).toBe(payload.signature);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an expired invite", async () => {
|
||||||
|
const owner = await generateKeypair();
|
||||||
|
const expiredAt = Math.floor(Date.now() / 1000) - 60; // 1 minute ago
|
||||||
|
|
||||||
|
const { link } = await buildSignedInvite({
|
||||||
|
v: 1,
|
||||||
|
mesh_id: "mesh-expired",
|
||||||
|
mesh_slug: "expired-mesh",
|
||||||
|
broker_url: "wss://broker.example.com",
|
||||||
|
expires_at: expiredAt,
|
||||||
|
mesh_root_key: "deadbeef",
|
||||||
|
role: "member",
|
||||||
|
owner_pubkey: owner.publicKey,
|
||||||
|
owner_secret_key: owner.secretKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(parseInviteLink(link)).rejects.toThrow("invite expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed base64 in invite URL", async () => {
|
||||||
|
// Empty payload after ic://join/ should throw.
|
||||||
|
expect(() => extractInviteToken("ic://join/")).toThrow("invite link has no payload");
|
||||||
|
|
||||||
|
// Short garbage that doesn't match any format should throw.
|
||||||
|
expect(() => extractInviteToken("!!!not-valid!!!")).toThrow("invalid invite format");
|
||||||
|
|
||||||
|
// A sufficiently long but garbage base64url token that decodes to
|
||||||
|
// invalid JSON should throw at the JSON parse stage.
|
||||||
|
const garbage = "A".repeat(30); // valid base64url chars, decodes to binary
|
||||||
|
await expect(parseInviteLink(`ic://join/${garbage}`)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
90
apps/cli/src/auth/callback-listener.ts
Normal file
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
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
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
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
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
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
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
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
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
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
58
apps/cli/src/commands/info.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh info` — show mesh overview: slug, broker URL, peer count, state count.
|
||||||
|
*
|
||||||
|
* Useful for AI agents to orient themselves in a mesh via bash.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { withMesh } from "./connect";
|
||||||
|
import { loadConfig } from "../state/config";
|
||||||
|
|
||||||
|
export interface InfoFlags {
|
||||||
|
mesh?: string;
|
||||||
|
json?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runInfo(flags: InfoFlags): Promise<void> {
|
||||||
|
const useColor =
|
||||||
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||||
|
const [brokerInfo, peers, state] = await Promise.all([
|
||||||
|
client.meshInfo(),
|
||||||
|
client.listPeers(),
|
||||||
|
client.listState(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
slug: mesh.slug,
|
||||||
|
meshId: mesh.meshId,
|
||||||
|
memberId: mesh.memberId,
|
||||||
|
brokerUrl: mesh.brokerUrl,
|
||||||
|
displayName: config.displayName ?? null,
|
||||||
|
peerCount: peers.length,
|
||||||
|
stateCount: state.length,
|
||||||
|
...(brokerInfo ?? {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (flags.json) {
|
||||||
|
console.log(JSON.stringify(output, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(bold(mesh.slug) + dim(` · ${mesh.brokerUrl}`));
|
||||||
|
console.log(dim(` mesh: ${mesh.meshId}`));
|
||||||
|
console.log(dim(` member: ${mesh.memberId}`));
|
||||||
|
console.log(` peers: ${peers.length} connected`);
|
||||||
|
console.log(` state: ${state.length} keys`);
|
||||||
|
if (brokerInfo && typeof brokerInfo === "object") {
|
||||||
|
for (const [k, v] of Object.entries(brokerInfo)) {
|
||||||
|
if (["slug", "meshId", "brokerUrl"].includes(k)) continue;
|
||||||
|
console.log(dim(` ${k}: ${JSON.stringify(v)}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
chmodSync,
|
chmodSync,
|
||||||
|
copyFileSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
@@ -28,6 +29,7 @@ import { homedir, platform } from "node:os";
|
|||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { loadConfig } from "../state/config";
|
||||||
|
|
||||||
const MCP_NAME = "claudemesh";
|
const MCP_NAME = "claudemesh";
|
||||||
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
|
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
|
||||||
@@ -65,7 +67,65 @@ function readClaudeConfig(): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeClaudeConfig(obj: Record<string, unknown>): void {
|
/**
|
||||||
|
* Create a timestamped backup of ~/.claude.json before any write.
|
||||||
|
*/
|
||||||
|
function backupClaudeConfig(): void {
|
||||||
|
if (!existsSync(CLAUDE_CONFIG)) return;
|
||||||
|
const backupDir = join(dirname(CLAUDE_CONFIG), ".claude", "backups");
|
||||||
|
mkdirSync(backupDir, { recursive: true });
|
||||||
|
const ts = Date.now();
|
||||||
|
const dest = join(backupDir, `.claude.json.pre-claudemesh.${ts}`);
|
||||||
|
copyFileSync(CLAUDE_CONFIG, dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
|
||||||
|
* patches ONLY the `claudemesh` MCP entry. Never touches other keys.
|
||||||
|
* Returns the action taken ("added" | "updated" | "unchanged").
|
||||||
|
*/
|
||||||
|
function patchMcpServer(entry: McpEntry): "added" | "updated" | "unchanged" {
|
||||||
|
backupClaudeConfig();
|
||||||
|
const cfg = readClaudeConfig();
|
||||||
|
const servers =
|
||||||
|
((cfg.mcpServers as Record<string, McpEntry>) ?? {});
|
||||||
|
if (!cfg.mcpServers) cfg.mcpServers = servers;
|
||||||
|
|
||||||
|
const existing = servers[MCP_NAME];
|
||||||
|
let action: "added" | "updated" | "unchanged";
|
||||||
|
if (!existing) {
|
||||||
|
servers[MCP_NAME] = entry;
|
||||||
|
action = "added";
|
||||||
|
} else if (entriesEqual(existing, entry)) {
|
||||||
|
return "unchanged";
|
||||||
|
} else {
|
||||||
|
servers[MCP_NAME] = entry;
|
||||||
|
action = "updated";
|
||||||
|
}
|
||||||
|
|
||||||
|
flushClaudeConfig(cfg);
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
|
||||||
|
* removes ONLY the `claudemesh` MCP entry. Never touches other keys.
|
||||||
|
* Returns true if an entry was removed.
|
||||||
|
*/
|
||||||
|
function removeMcpServer(): boolean {
|
||||||
|
if (!existsSync(CLAUDE_CONFIG)) return false;
|
||||||
|
backupClaudeConfig();
|
||||||
|
const cfg = readClaudeConfig();
|
||||||
|
const servers = cfg.mcpServers as Record<string, McpEntry> | undefined;
|
||||||
|
if (!servers || !(MCP_NAME in servers)) return false;
|
||||||
|
delete servers[MCP_NAME];
|
||||||
|
cfg.mcpServers = servers;
|
||||||
|
flushClaudeConfig(cfg);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Low-level write — callers must backup + merge first. */
|
||||||
|
function flushClaudeConfig(obj: Record<string, unknown>): void {
|
||||||
mkdirSync(dirname(CLAUDE_CONFIG), { recursive: true });
|
mkdirSync(dirname(CLAUDE_CONFIG), { recursive: true });
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
CLAUDE_CONFIG,
|
CLAUDE_CONFIG,
|
||||||
@@ -79,6 +139,7 @@ function writeClaudeConfig(obj: Record<string, unknown>): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Check `bun` is on PATH — OS-agnostic, node:child_process. */
|
/** Check `bun` is on PATH — OS-agnostic, node:child_process. */
|
||||||
function bunAvailable(): boolean {
|
function bunAvailable(): boolean {
|
||||||
const res =
|
const res =
|
||||||
@@ -152,6 +213,92 @@ function writeClaudeSettings(obj: Record<string, unknown>): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All claudemesh MCP tool names, prefixed for allowedTools.
|
||||||
|
* These let Claude Code use claudemesh tools without --dangerously-skip-permissions.
|
||||||
|
*/
|
||||||
|
const CLAUDEMESH_TOOLS = [
|
||||||
|
"mcp__claudemesh__cancel_scheduled",
|
||||||
|
"mcp__claudemesh__check_messages",
|
||||||
|
"mcp__claudemesh__claim_task",
|
||||||
|
"mcp__claudemesh__complete_task",
|
||||||
|
"mcp__claudemesh__create_stream",
|
||||||
|
"mcp__claudemesh__create_task",
|
||||||
|
"mcp__claudemesh__delete_file",
|
||||||
|
"mcp__claudemesh__file_status",
|
||||||
|
"mcp__claudemesh__forget",
|
||||||
|
"mcp__claudemesh__get_context",
|
||||||
|
"mcp__claudemesh__get_file",
|
||||||
|
"mcp__claudemesh__get_state",
|
||||||
|
"mcp__claudemesh__grant_file_access",
|
||||||
|
"mcp__claudemesh__graph_execute",
|
||||||
|
"mcp__claudemesh__graph_query",
|
||||||
|
"mcp__claudemesh__join_group",
|
||||||
|
"mcp__claudemesh__leave_group",
|
||||||
|
"mcp__claudemesh__list_collections",
|
||||||
|
"mcp__claudemesh__list_contexts",
|
||||||
|
"mcp__claudemesh__list_files",
|
||||||
|
"mcp__claudemesh__list_peers",
|
||||||
|
"mcp__claudemesh__list_scheduled",
|
||||||
|
"mcp__claudemesh__list_state",
|
||||||
|
"mcp__claudemesh__list_streams",
|
||||||
|
"mcp__claudemesh__list_tasks",
|
||||||
|
"mcp__claudemesh__mesh_execute",
|
||||||
|
"mcp__claudemesh__mesh_info",
|
||||||
|
"mcp__claudemesh__mesh_query",
|
||||||
|
"mcp__claudemesh__mesh_schema",
|
||||||
|
"mcp__claudemesh__message_status",
|
||||||
|
"mcp__claudemesh__ping_mesh",
|
||||||
|
"mcp__claudemesh__publish",
|
||||||
|
"mcp__claudemesh__recall",
|
||||||
|
"mcp__claudemesh__remember",
|
||||||
|
"mcp__claudemesh__schedule_reminder",
|
||||||
|
"mcp__claudemesh__send_message",
|
||||||
|
"mcp__claudemesh__set_state",
|
||||||
|
"mcp__claudemesh__set_status",
|
||||||
|
"mcp__claudemesh__set_summary",
|
||||||
|
"mcp__claudemesh__share_context",
|
||||||
|
"mcp__claudemesh__share_file",
|
||||||
|
"mcp__claudemesh__subscribe",
|
||||||
|
"mcp__claudemesh__vector_delete",
|
||||||
|
"mcp__claudemesh__vector_search",
|
||||||
|
"mcp__claudemesh__vector_store",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-approve all claudemesh MCP tools in allowedTools.
|
||||||
|
* Merges into any existing list — never overwrites other entries.
|
||||||
|
* Returns which tools were added vs already present.
|
||||||
|
*/
|
||||||
|
function installAllowedTools(): { added: string[]; unchanged: number } {
|
||||||
|
const settings = readClaudeSettings();
|
||||||
|
const existing = new Set<string>((settings.allowedTools as string[] | undefined) ?? []);
|
||||||
|
const toAdd = CLAUDEMESH_TOOLS.filter((t) => !existing.has(t));
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
settings.allowedTools = [...Array.from(existing), ...toAdd];
|
||||||
|
writeClaudeSettings(settings);
|
||||||
|
}
|
||||||
|
return { added: toAdd, unchanged: CLAUDEMESH_TOOLS.length - toAdd.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove claudemesh tools from allowedTools.
|
||||||
|
* Leaves all other entries intact. Returns count removed.
|
||||||
|
*/
|
||||||
|
function uninstallAllowedTools(): number {
|
||||||
|
if (!existsSync(CLAUDE_SETTINGS)) return 0;
|
||||||
|
const settings = readClaudeSettings();
|
||||||
|
const existing = (settings.allowedTools as string[] | undefined) ?? [];
|
||||||
|
const toolSet = new Set(CLAUDEMESH_TOOLS);
|
||||||
|
const kept = existing.filter((t) => !toolSet.has(t));
|
||||||
|
const removed = existing.length - kept.length;
|
||||||
|
if (removed > 0) {
|
||||||
|
settings.allowedTools = kept;
|
||||||
|
writeClaudeSettings(settings);
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
|
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
|
||||||
* idempotent on the command string. Returns counts for reporting.
|
* idempotent on the command string. Returns counts for reporting.
|
||||||
@@ -231,24 +378,8 @@ export function runInstall(args: string[] = []): void {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = readClaudeConfig();
|
|
||||||
const servers =
|
|
||||||
((cfg.mcpServers ??= {}) as Record<string, McpEntry>) ?? {};
|
|
||||||
const desired = buildMcpEntry(entry);
|
const desired = buildMcpEntry(entry);
|
||||||
const existing = servers[MCP_NAME];
|
const action = patchMcpServer(desired);
|
||||||
let action: "added" | "updated" | "unchanged";
|
|
||||||
if (!existing) {
|
|
||||||
servers[MCP_NAME] = desired;
|
|
||||||
action = "added";
|
|
||||||
} else if (entriesEqual(existing, desired)) {
|
|
||||||
action = "unchanged";
|
|
||||||
} else {
|
|
||||||
servers[MCP_NAME] = desired;
|
|
||||||
action = "updated";
|
|
||||||
}
|
|
||||||
cfg.mcpServers = servers;
|
|
||||||
|
|
||||||
writeClaudeConfig(cfg);
|
|
||||||
|
|
||||||
// Read-back verification.
|
// Read-back verification.
|
||||||
const verify = readClaudeConfig();
|
const verify = readClaudeConfig();
|
||||||
@@ -277,6 +408,26 @@ export function runInstall(args: string[] = []): void {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// allowedTools — pre-approve claudemesh MCP tools so peers don't need
|
||||||
|
// --dangerously-skip-permissions just to call mesh tools.
|
||||||
|
try {
|
||||||
|
const { added, unchanged } = installAllowedTools();
|
||||||
|
if (added.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`✓ allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`,
|
||||||
|
);
|
||||||
|
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
|
||||||
|
console.log(dim(` Your existing allowedTools entries were preserved.`));
|
||||||
|
} else {
|
||||||
|
console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
|
||||||
|
}
|
||||||
|
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
|
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
|
||||||
if (!skipHooks) {
|
if (!skipHooks) {
|
||||||
try {
|
try {
|
||||||
@@ -301,12 +452,35 @@ export function runInstall(args: string[] = []): void {
|
|||||||
console.log(dim("· Hooks skipped (--no-hooks)"));
|
console.log(dim("· Hooks skipped (--no-hooks)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user has any meshes joined — nudge them if not.
|
||||||
|
let hasMeshes = false;
|
||||||
|
try {
|
||||||
|
const meshConfig = loadConfig();
|
||||||
|
hasMeshes = meshConfig.meshes.length > 0;
|
||||||
|
} catch {
|
||||||
|
// Config missing or corrupt — treat as no meshes.
|
||||||
|
}
|
||||||
|
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
|
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
|
||||||
console.log("");
|
|
||||||
console.log(
|
if (!hasMeshes) {
|
||||||
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
console.log("");
|
||||||
);
|
console.log(yellow("No meshes joined.") + " To connect with peers:");
|
||||||
|
console.log(
|
||||||
|
` ${bold("claudemesh join <invite-url>")}` +
|
||||||
|
dim(" — join an existing mesh"),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("");
|
||||||
|
console.log(
|
||||||
|
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log(
|
console.log(
|
||||||
yellow("⚠ For real-time push messages from peers, launch with:"),
|
yellow("⚠ For real-time push messages from peers, launch with:"),
|
||||||
@@ -324,22 +498,25 @@ export function runUninstall(): void {
|
|||||||
console.log("claudemesh uninstall");
|
console.log("claudemesh uninstall");
|
||||||
console.log("--------------------");
|
console.log("--------------------");
|
||||||
|
|
||||||
// MCP entry
|
// MCP entry — only removes claudemesh, never touches other servers.
|
||||||
if (existsSync(CLAUDE_CONFIG)) {
|
if (removeMcpServer()) {
|
||||||
const cfg = readClaudeConfig();
|
console.log(`✓ MCP server "${MCP_NAME}" removed`);
|
||||||
const servers = cfg.mcpServers as
|
|
||||||
| Record<string, McpEntry>
|
|
||||||
| undefined;
|
|
||||||
if (servers && MCP_NAME in servers) {
|
|
||||||
delete servers[MCP_NAME];
|
|
||||||
cfg.mcpServers = servers;
|
|
||||||
writeClaudeConfig(cfg);
|
|
||||||
console.log(`✓ MCP server "${MCP_NAME}" removed`);
|
|
||||||
} else {
|
|
||||||
console.log(`· MCP server "${MCP_NAME}" not present`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.log(`· no ${CLAUDE_CONFIG} — MCP entry skipped`);
|
console.log(`· MCP server "${MCP_NAME}" not present`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowedTools
|
||||||
|
try {
|
||||||
|
const removed = uninstallAllowedTools();
|
||||||
|
if (removed > 0) {
|
||||||
|
console.log(`✓ allowedTools: ${removed} claudemesh tools removed`);
|
||||||
|
} else {
|
||||||
|
console.log("· No claudemesh allowedTools to remove");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
|
|||||||
@@ -1,32 +1,123 @@
|
|||||||
/**
|
/**
|
||||||
* `claudemesh join <invite-link>` — full join flow.
|
* `claudemesh join <invite-link-or-code>` — full join flow.
|
||||||
*
|
*
|
||||||
* 1. Parse + validate the ic://join/... link
|
* Accepts either:
|
||||||
* 2. Generate a fresh ed25519 keypair (libsodium)
|
* - v2 short invite: `claudemesh.com/i/<code>` or bare `<code>`
|
||||||
* 3. POST /join to the broker → get member_id
|
* → POSTs to /api/public/invites/:code/claim, unseals root_key,
|
||||||
* 4. Persist the mesh + keypair to ~/.claudemesh/config.json (0600)
|
* persists mesh + fresh ed25519 identity.
|
||||||
* 5. Print success
|
* - v1 legacy invite: `ic://join/<token>` or `https://.../join/<token>`
|
||||||
|
* → parses signed payload, calls broker /join, persists.
|
||||||
*
|
*
|
||||||
* Signature verification + invite-token one-time-use land in Step 18.
|
* v1 continues to work throughout v0.1.x. v1 endpoints 410 Gone at v0.2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parseInviteLink } from "../invite/parse";
|
import { parseInviteLink } from "../invite/parse";
|
||||||
import { enrollWithBroker } from "../invite/enroll";
|
import { enrollWithBroker } from "../invite/enroll";
|
||||||
import { generateKeypair } from "../crypto/keypair";
|
import { generateKeypair } from "../crypto/keypair";
|
||||||
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
||||||
import { hostname } from "node:os";
|
import { claimInviteV2, parseV2InviteInput } from "../lib/invite-v2";
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
|
import { writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { homedir, hostname } from "node:os";
|
||||||
|
import { env } from "../env";
|
||||||
|
|
||||||
|
/** Derive the web app base URL from the broker URL, unless explicitly overridden. */
|
||||||
|
function deriveAppBaseUrl(): string {
|
||||||
|
const override = process.env.CLAUDEMESH_APP_URL;
|
||||||
|
if (override) return override.replace(/\/$/, "");
|
||||||
|
// Broker is `wss://ic.claudemesh.com/ws` → app is `https://claudemesh.com`.
|
||||||
|
// For self-hosted: honour the broker host's parent domain as best-effort.
|
||||||
|
try {
|
||||||
|
const u = new URL(env.CLAUDEMESH_BROKER_URL);
|
||||||
|
const host = u.host.replace(/^ic\./, "");
|
||||||
|
const scheme = u.protocol === "wss:" ? "https:" : "http:";
|
||||||
|
return `${scheme}//${host}`;
|
||||||
|
} catch {
|
||||||
|
return "https://claudemesh.com";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runJoinV2(code: string): Promise<void> {
|
||||||
|
const appBaseUrl = deriveAppBaseUrl();
|
||||||
|
console.log(`Claiming invite ${code} via ${appBaseUrl}…`);
|
||||||
|
|
||||||
|
let claim;
|
||||||
|
try {
|
||||||
|
claim = await claimInviteV2({ appBaseUrl, code });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a fresh ed25519 identity for this peer. The v2 claim
|
||||||
|
// endpoint creates the member row keyed on the x25519 pubkey we sent;
|
||||||
|
// the ed25519 keypair is what the `hello` handshake and future
|
||||||
|
// envelope signing will use. Stored locally only.
|
||||||
|
const keypair = await generateKeypair();
|
||||||
|
const displayName = `${hostname()}-${process.pid}`;
|
||||||
|
|
||||||
|
// Encode the unsealed 32-byte root key as URL-safe base64url (no pad)
|
||||||
|
// to match the format used everywhere else (broker stores it the
|
||||||
|
// same way in mesh.rootKey).
|
||||||
|
await sodium.ready;
|
||||||
|
const rootKeyB64 = sodium.to_base64(
|
||||||
|
claim.rootKey,
|
||||||
|
sodium.base64_variants.URLSAFE_NO_PADDING,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist. We don't have a mesh_slug in the v2 response — the server
|
||||||
|
// derives slug from name and slug is no longer globally unique. Use a
|
||||||
|
// stable short derivative of the mesh id so `list` / `launch --mesh`
|
||||||
|
// still have something to match on.
|
||||||
|
const fallbackSlug = `mesh-${claim.meshId.slice(0, 8)}`;
|
||||||
|
const config = loadConfig();
|
||||||
|
config.meshes = config.meshes.filter((m) => m.meshId !== claim.meshId);
|
||||||
|
config.meshes.push({
|
||||||
|
meshId: claim.meshId,
|
||||||
|
memberId: claim.memberId,
|
||||||
|
slug: fallbackSlug,
|
||||||
|
name: fallbackSlug,
|
||||||
|
pubkey: keypair.publicKey,
|
||||||
|
secretKey: keypair.secretKey,
|
||||||
|
brokerUrl: env.CLAUDEMESH_BROKER_URL,
|
||||||
|
joinedAt: new Date().toISOString(),
|
||||||
|
rootKey: rootKeyB64,
|
||||||
|
inviteVersion: 2,
|
||||||
|
});
|
||||||
|
saveConfig(config);
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log(`✓ Joined mesh ${claim.meshId} via v2 invite`);
|
||||||
|
console.log(` member id: ${claim.memberId}`);
|
||||||
|
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}…`);
|
||||||
|
console.log(` broker: ${env.CLAUDEMESH_BROKER_URL}`);
|
||||||
|
console.log(` config: ${getConfigPath()}`);
|
||||||
|
console.log("");
|
||||||
|
console.log("Restart Claude Code to pick up the new mesh.");
|
||||||
|
}
|
||||||
|
|
||||||
export async function runJoin(args: string[]): Promise<void> {
|
export async function runJoin(args: string[]): Promise<void> {
|
||||||
const link = args[0];
|
const link = args[0];
|
||||||
if (!link) {
|
if (!link) {
|
||||||
console.error("Usage: claudemesh join <invite-url-or-token>");
|
console.error("Usage: claudemesh join <invite-url-or-code>");
|
||||||
console.error("");
|
console.error("");
|
||||||
console.error(
|
console.error("Examples:");
|
||||||
"Example: claudemesh join https://claudemesh.com/join/eyJ2IjoxLC4uLn0",
|
console.error(" claudemesh join https://claudemesh.com/i/abc12345");
|
||||||
);
|
console.error(" claudemesh join abc12345");
|
||||||
|
console.error(" claudemesh join ic://join/eyJ2IjoxLC4uLn0 (v1 legacy)");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try v2 first — short code / `/i/<code>` URL.
|
||||||
|
const v2Code = parseV2InviteInput(link);
|
||||||
|
if (v2Code) {
|
||||||
|
await runJoinV2(v2Code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Parse + verify signature client-side.
|
// 1. Parse + verify signature client-side.
|
||||||
let invite;
|
let invite;
|
||||||
try {
|
try {
|
||||||
@@ -78,6 +169,16 @@ export async function runJoin(args: string[]): Promise<void> {
|
|||||||
});
|
});
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
|
|
||||||
|
// 4b. Store invite token for per-session re-enrollment (launch --name).
|
||||||
|
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||||
|
const inviteFile = join(configDir, `invite-${payload.mesh_slug}.txt`);
|
||||||
|
try {
|
||||||
|
mkdirSync(dirname(inviteFile), { recursive: true });
|
||||||
|
writeFileSync(inviteFile, link, "utf-8");
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — launch will fall back to shared identity.
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Report.
|
// 5. Report.
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -1,94 +1,817 @@
|
|||||||
/**
|
/**
|
||||||
* `claudemesh launch` — spawn `claude` with the dev-channel flag so the
|
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
||||||
* claudemesh MCP server's `notifications/claude/channel` pushes get
|
|
||||||
* injected as system reminders mid-turn.
|
|
||||||
*
|
*
|
||||||
* Equivalent to:
|
* Flags are defined in index.ts (citty command) — that is the source of
|
||||||
* claude --dangerously-load-development-channels server:claudemesh [extra args]
|
* truth. This file receives already-parsed flags and rawArgs.
|
||||||
*
|
*
|
||||||
* Any additional args (e.g. --model opus, --resume, -c) are passed
|
* Flow:
|
||||||
* through verbatim. Use --quiet to skip the informational banner.
|
* 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 { 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 { loadConfig, getConfigPath } from "../state/config";
|
||||||
|
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
|
||||||
|
import { startCallbackListener, openBrowser, generatePairingCode } from "../auth";
|
||||||
|
import { BrokerClient } from "../ws/client";
|
||||||
|
|
||||||
function printBanner(): void {
|
// Flags as parsed by citty (index.ts is the source of truth for definitions).
|
||||||
|
export interface LaunchFlags {
|
||||||
|
name?: string;
|
||||||
|
role?: string;
|
||||||
|
groups?: string;
|
||||||
|
join?: string;
|
||||||
|
mesh?: string;
|
||||||
|
"message-mode"?: string;
|
||||||
|
"system-prompt"?: string;
|
||||||
|
resume?: string;
|
||||||
|
continue?: boolean;
|
||||||
|
yes?: boolean;
|
||||||
|
quiet?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Interactive mesh picker ---
|
||||||
|
|
||||||
|
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
|
||||||
|
if (meshes.length === 1) return meshes[0]!;
|
||||||
|
|
||||||
|
console.log("\n Select mesh:");
|
||||||
|
meshes.forEach((m, i) => {
|
||||||
|
console.log(` ${i + 1}) ${m.slug}`);
|
||||||
|
});
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(" Choice [1]: ", (answer) => {
|
||||||
|
rl.close();
|
||||||
|
const idx = parseInt(answer || "1", 10) - 1;
|
||||||
|
if (idx >= 0 && idx < meshes.length) {
|
||||||
|
resolve(meshes[idx]!);
|
||||||
|
} else {
|
||||||
|
console.error(" Invalid choice, using first mesh.");
|
||||||
|
resolve(meshes[0]!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group string parser ---
|
||||||
|
|
||||||
|
/** Parse "frontend:lead,reviewers:member,all" → GroupEntry[] */
|
||||||
|
function parseGroupsString(raw: string): GroupEntry[] {
|
||||||
|
return raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((token) => {
|
||||||
|
const idx = token.indexOf(":");
|
||||||
|
if (idx === -1) return { name: token };
|
||||||
|
return { name: token.slice(0, idx), role: token.slice(idx + 1) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Interactive role/groups prompts ---
|
||||||
|
|
||||||
|
function askLine(prompt: string): Promise<string> {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(prompt, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(answer.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Permission confirmation ---
|
||||||
|
|
||||||
|
async function confirmPermissions(): Promise<void> {
|
||||||
|
const useColor =
|
||||||
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||||
|
|
||||||
|
console.log(yellow(bold(" Autonomous mode")));
|
||||||
|
console.log("");
|
||||||
|
console.log(" Claude will run with --dangerously-skip-permissions, bypassing");
|
||||||
|
console.log(" ALL permission prompts — not just claudemesh tools.");
|
||||||
|
console.log(" Peers exchange text only — no file access, no tool calls.");
|
||||||
|
console.log("");
|
||||||
|
console.log(dim(" Without -y: only claudemesh tools are pre-approved (via allowedTools)."));
|
||||||
|
console.log(dim(" Use -y for autonomous agents. Omit it for shared/multi-person meshes."));
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
rl.question(` ${bold("Continue?")} [Y/n] `, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
const a = answer.trim().toLowerCase();
|
||||||
|
if (a === "" || a === "y" || a === "yes") {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
console.log("\n Aborted. Run without autonomous mode:");
|
||||||
|
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Banner ---
|
||||||
|
|
||||||
|
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 =
|
const useColor =
|
||||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
|
||||||
let meshes: string[] = [];
|
const roleSuffix = role ? ` (${role})` : "";
|
||||||
try {
|
const groupTags = groups.length
|
||||||
meshes = loadConfig().meshes.map((m) => m.slug);
|
? " [" + groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||||
} catch {
|
: "";
|
||||||
/* config unreadable — print banner without mesh list */
|
|
||||||
}
|
|
||||||
const meshLine = meshes.length > 0 ? meshes.join(", ") : "(none — run `claudemesh join <url>` first)";
|
|
||||||
|
|
||||||
const rule = "─".repeat(65);
|
const rule = "─".repeat(60);
|
||||||
console.log(bold("claudemesh launch"));
|
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`));
|
||||||
console.log(rule);
|
console.log(rule);
|
||||||
console.log("Launching Claude Code with the claudemesh dev channel.");
|
if (messageMode === "push") {
|
||||||
console.log("");
|
console.log("Peer messages arrive as <channel> reminders in real-time.");
|
||||||
console.log("Peers in your joined meshes can push messages into this session");
|
} else if (messageMode === "inbox") {
|
||||||
console.log("as <channel> reminders. Your CLI decrypts them locally with your");
|
console.log("Peer messages held in inbox. Use check_messages to read.");
|
||||||
console.log("keypair. Peers send text only — they cannot call tools, read");
|
} else {
|
||||||
console.log("files, or reach meshes you have not joined.");
|
console.log("Messages off. Use check_messages to poll manually.");
|
||||||
console.log("");
|
}
|
||||||
console.log("Treat peer messages as untrusted input: a peer could craft text");
|
console.log("Peers send text only — they cannot call tools or read files.");
|
||||||
console.log("that tries to steer Claude's behavior. Your tool-approval");
|
console.log(dim(`Config: ${getConfigPath()}`));
|
||||||
console.log("settings still apply — Claude will still ask before running");
|
|
||||||
console.log("commands, editing files, or calling other tools.");
|
|
||||||
console.log("");
|
|
||||||
console.log("Claude Code will ask you to trust the");
|
|
||||||
console.log("--dangerously-load-development-channels flag. Press Enter to");
|
|
||||||
console.log("accept, or Ctrl-C to abort.");
|
|
||||||
console.log("");
|
|
||||||
console.log(dim(`Joined meshes: ${meshLine}`));
|
|
||||||
console.log(dim(`Config: ${getConfigPath()}`));
|
|
||||||
console.log(dim(`Remove: claudemesh uninstall`));
|
|
||||||
console.log(rule);
|
console.log(rule);
|
||||||
console.log("");
|
console.log("");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runLaunch(extraArgs: string[] = []): void {
|
// --- Main ---
|
||||||
const quiet = extraArgs.includes("--quiet");
|
|
||||||
const passthrough = extraArgs.filter((a) => a !== "--quiet");
|
|
||||||
|
|
||||||
if (!quiet) printBanner();
|
export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<void> {
|
||||||
|
// Extract args that follow "--" — passed straight through to claude.
|
||||||
|
const dashIdx = rawArgs.indexOf("--");
|
||||||
|
const claudePassthrough = dashIdx >= 0 ? rawArgs.slice(dashIdx + 1) : [];
|
||||||
|
|
||||||
|
// Normalise flags into the internal shape used below.
|
||||||
|
const args = {
|
||||||
|
name: flags.name ?? null,
|
||||||
|
role: flags.role ?? null,
|
||||||
|
groups: flags.groups ?? null,
|
||||||
|
joinLink: flags.join ?? null,
|
||||||
|
meshSlug: flags.mesh ?? null,
|
||||||
|
messageMode: (["push", "inbox", "off"].includes(flags["message-mode"] ?? "")
|
||||||
|
? flags["message-mode"] as "push" | "inbox" | "off"
|
||||||
|
: null),
|
||||||
|
systemPrompt: flags["system-prompt"] ?? null,
|
||||||
|
resume: flags.resume ?? null,
|
||||||
|
continueSession: flags.continue ?? false,
|
||||||
|
quiet: flags.quiet ?? false,
|
||||||
|
skipPermConfirm: flags.yes ?? false,
|
||||||
|
claudeArgs: claudePassthrough,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. If --join, run join flow first.
|
||||||
|
if (args.joinLink) {
|
||||||
|
console.log("Joining mesh...");
|
||||||
|
const invite = await parseInviteLink(args.joinLink);
|
||||||
|
const keypair = await generateKeypair();
|
||||||
|
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
||||||
|
const enroll = await enrollWithBroker({
|
||||||
|
brokerWsUrl: invite.payload.broker_url,
|
||||||
|
inviteToken: invite.token,
|
||||||
|
invitePayload: invite.payload,
|
||||||
|
peerPubkey: keypair.publicKey,
|
||||||
|
displayName,
|
||||||
|
});
|
||||||
|
const config = loadConfig();
|
||||||
|
config.meshes = config.meshes.filter(
|
||||||
|
(m) => m.slug !== invite.payload.mesh_slug,
|
||||||
|
);
|
||||||
|
config.meshes.push({
|
||||||
|
meshId: invite.payload.mesh_id,
|
||||||
|
memberId: enroll.memberId,
|
||||||
|
slug: invite.payload.mesh_slug,
|
||||||
|
name: invite.payload.mesh_slug,
|
||||||
|
pubkey: keypair.publicKey,
|
||||||
|
secretKey: keypair.secretKey,
|
||||||
|
brokerUrl: invite.payload.broker_url,
|
||||||
|
joinedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
const { saveConfig } = await import("../state/config");
|
||||||
|
saveConfig(config);
|
||||||
|
console.log(
|
||||||
|
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Load config, pick mesh.
|
||||||
|
const config = loadConfig();
|
||||||
|
let justSynced = false;
|
||||||
|
|
||||||
|
if (config.meshes.length === 0 && !args.joinLink) {
|
||||||
|
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||||
|
|
||||||
|
const code = generatePairingCode();
|
||||||
|
const listener = await startCallbackListener();
|
||||||
|
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
|
||||||
|
|
||||||
|
console.log(`\n ${bold("Welcome to claudemesh!")} No meshes found.`);
|
||||||
|
console.log(` Opening browser to sign in...\n`);
|
||||||
|
|
||||||
|
const opened = await openBrowser(url);
|
||||||
|
if (!opened) {
|
||||||
|
console.log(` Couldn't open browser automatically.`);
|
||||||
|
}
|
||||||
|
console.log(` ${dim(`Visit: ${url}`)}`);
|
||||||
|
console.log(` ${dim(`Or join with invite: claudemesh launch --join <url>`)}\n`);
|
||||||
|
|
||||||
|
// Race: localhost callback vs manual paste vs timeout
|
||||||
|
const manualPromise = new Promise<string>((resolve) => {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
rl.question(" Paste sync token (or wait for browser): ", (answer) => {
|
||||||
|
rl.close();
|
||||||
|
if (answer.trim()) resolve(answer.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<null>((resolve) => {
|
||||||
|
setTimeout(() => resolve(null), 15 * 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncToken = await Promise.race([
|
||||||
|
listener.token,
|
||||||
|
manualPromise,
|
||||||
|
timeoutPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
listener.close();
|
||||||
|
|
||||||
|
if (!syncToken) {
|
||||||
|
console.error("\n Timed out waiting for sign-in.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate keypair and sync with broker
|
||||||
|
const { generateKeypair } = await import("../crypto/keypair");
|
||||||
|
const keypair = await generateKeypair();
|
||||||
|
const displayNameForSync = args.name ?? `${hostname()}-${process.pid}`;
|
||||||
|
|
||||||
|
const { syncWithBroker } = await import("../auth/sync-with-broker");
|
||||||
|
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
|
||||||
|
|
||||||
|
// Write all meshes to config
|
||||||
|
const { saveConfig } = await import("../state/config");
|
||||||
|
for (const m of result.meshes) {
|
||||||
|
config.meshes.push({
|
||||||
|
meshId: m.mesh_id,
|
||||||
|
memberId: m.member_id,
|
||||||
|
slug: m.slug,
|
||||||
|
name: m.slug,
|
||||||
|
pubkey: keypair.publicKey,
|
||||||
|
secretKey: keypair.secretKey,
|
||||||
|
brokerUrl: m.broker_url,
|
||||||
|
joinedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
config.accountId = result.account_id;
|
||||||
|
saveConfig(config);
|
||||||
|
justSynced = true;
|
||||||
|
|
||||||
|
console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.meshes.length === 0) {
|
||||||
|
console.error("No meshes joined. Run `claudemesh join <url>` or use --join <url>.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
if (!found) {
|
||||||
|
console.error(
|
||||||
|
`Mesh "${args.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 {
|
||||||
|
// Multiple meshes — wizard will handle selection
|
||||||
|
mesh = null as unknown as JoinedMesh; // set by wizard below
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Session identity + role/groups via TUI wizard.
|
||||||
|
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
||||||
|
|
||||||
|
let role: string | null = args.role;
|
||||||
|
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
|
||||||
|
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
|
||||||
|
|
||||||
|
if (!args.quiet && !justSynced) {
|
||||||
|
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)
|
||||||
|
const tmpBase = tmpdir();
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(tmpBase)) {
|
||||||
|
if (!entry.startsWith("claudemesh-")) continue;
|
||||||
|
const full = join(tmpBase, entry);
|
||||||
|
const age = Date.now() - statSync(full).mtimeMs;
|
||||||
|
if (age > 3600_000) rmSync(full, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
|
||||||
|
// Clean up stale mesh MCP entries from crashed sessions
|
||||||
|
try {
|
||||||
|
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||||
|
if (existsSync(claudeConfigPath)) {
|
||||||
|
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||||
|
const mcpServers = claudeConfig.mcpServers ?? {};
|
||||||
|
let cleaned = 0;
|
||||||
|
for (const key of Object.keys(mcpServers)) {
|
||||||
|
if (!key.startsWith("mesh:")) continue;
|
||||||
|
const meta = mcpServers[key]?._meshSession;
|
||||||
|
if (!meta?.pid) continue;
|
||||||
|
// Check if the PID is still alive
|
||||||
|
try {
|
||||||
|
process.kill(meta.pid, 0); // signal 0 = check existence
|
||||||
|
} catch {
|
||||||
|
// PID is dead — remove stale entry
|
||||||
|
delete mcpServers[key];
|
||||||
|
cleaned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleaned > 0) {
|
||||||
|
claudeConfig.mcpServers = mcpServers;
|
||||||
|
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
|
||||||
|
// --- Fetch deployed services for native MCP entries ---
|
||||||
|
let serviceCatalog: Array<{
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
tools: Array<{ name: string; description: string; inputSchema: object }>;
|
||||||
|
deployed_by: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tmpClient = new BrokerClient(mesh, { displayName });
|
||||||
|
await tmpClient.connect();
|
||||||
|
// Wait briefly for hello_ack with service catalog
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
serviceCatalog = tmpClient.serviceCatalog;
|
||||||
|
tmpClient.close();
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — launch without native service entries
|
||||||
|
if (!args.quiet) {
|
||||||
|
console.log(" (Could not fetch service catalog — mesh services won't be natively available)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Write session config to tmpdir (isolates mesh selection).
|
||||||
|
const tmpDir = mkdtempSync(join(tmpdir(), "claudemesh-"));
|
||||||
|
const sessionConfig: Config = {
|
||||||
|
version: 1,
|
||||||
|
meshes: [mesh],
|
||||||
|
displayName,
|
||||||
|
...(role ? { role } : {}),
|
||||||
|
...(parsedGroups.length > 0 ? { groups: parsedGroups } : {}),
|
||||||
|
messageMode,
|
||||||
|
};
|
||||||
|
writeFileSync(
|
||||||
|
join(tmpDir, "config.json"),
|
||||||
|
JSON.stringify(sessionConfig, null, 2) + "\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Print summary banner (wizard already handled all interactive config).
|
||||||
|
if (!args.quiet) {
|
||||||
|
printBanner(displayName, mesh.slug, role, parsedGroups, messageMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Install native MCP entries for deployed mesh services ---
|
||||||
|
const meshMcpEntries: Array<{ key: string; entry: unknown }> = [];
|
||||||
|
|
||||||
|
if (serviceCatalog.length > 0) {
|
||||||
|
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||||
|
|
||||||
|
// Read-modify-write: only touch mesh:* entries in mcpServers
|
||||||
|
let claudeConfig: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||||
|
} catch {
|
||||||
|
claudeConfig = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcpServers = (claudeConfig.mcpServers ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Session-scoped key: mesh:<service>:<sessionId>
|
||||||
|
const sessionTag = `${process.pid}`;
|
||||||
|
|
||||||
|
for (const svc of serviceCatalog) {
|
||||||
|
if (svc.status !== "running") continue;
|
||||||
|
const entryKey = `mesh:${svc.name}:${sessionTag}`;
|
||||||
|
const entry = {
|
||||||
|
command: "claudemesh",
|
||||||
|
args: ["mcp", "--service", svc.name],
|
||||||
|
env: {
|
||||||
|
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||||
|
},
|
||||||
|
_meshSession: {
|
||||||
|
pid: process.pid,
|
||||||
|
meshSlug: mesh.slug,
|
||||||
|
serviceName: svc.name,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mcpServers[entryKey] = entry;
|
||||||
|
meshMcpEntries.push({ key: entryKey, entry });
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeConfig.mcpServers = mcpServers;
|
||||||
|
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||||
|
|
||||||
|
if (!args.quiet && meshMcpEntries.length > 0) {
|
||||||
|
console.log(` ${meshMcpEntries.length} mesh service(s) registered as native MCPs:`);
|
||||||
|
for (const { key } of meshMcpEntries) {
|
||||||
|
const svcName = key.split(":")[1];
|
||||||
|
const svc = serviceCatalog.find(s => s.name === svcName);
|
||||||
|
console.log(` ${svcName} (${svc?.tools.length ?? 0} tools)`);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Spawn claude with ephemeral config + dev channel + auto-permissions.
|
||||||
|
// Strip any user-supplied --dangerously flags to avoid duplicates.
|
||||||
|
const filtered: string[] = [];
|
||||||
|
for (let i = 0; i < args.claudeArgs.length; i++) {
|
||||||
|
if (args.claudeArgs[i] === "--dangerously-load-development-channels"
|
||||||
|
|| args.claudeArgs[i] === "--dangerously-skip-permissions") {
|
||||||
|
if (args.claudeArgs[i] === "--dangerously-load-development-channels") i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filtered.push(args.claudeArgs[i]!);
|
||||||
|
}
|
||||||
|
// --dangerously-skip-permissions is only added when the user explicitly
|
||||||
|
// passes -y / --yes. Without it, claudemesh tools still work because
|
||||||
|
// `claudemesh install` pre-approves them via allowedTools in settings.json.
|
||||||
|
// This keeps permissions tight for multi-person meshes.
|
||||||
|
// Session identity: --resume reuses existing session, otherwise generate new.
|
||||||
|
// When resuming, Claude Code reuses the session ID so the mesh peer identity persists.
|
||||||
|
const isResume = args.resume !== null || args.continueSession;
|
||||||
|
const claudeSessionId = isResume ? undefined : randomUUID();
|
||||||
|
|
||||||
const claudeArgs = [
|
const claudeArgs = [
|
||||||
"--dangerously-load-development-channels",
|
"--dangerously-load-development-channels",
|
||||||
"server:claudemesh",
|
"server:claudemesh",
|
||||||
...passthrough,
|
...(claudeSessionId ? ["--session-id", claudeSessionId] : []),
|
||||||
|
...(args.resume ? ["--resume", args.resume] : []),
|
||||||
|
...(args.continueSession ? ["--continue"] : []),
|
||||||
|
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
|
||||||
|
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
|
||||||
|
...filtered,
|
||||||
];
|
];
|
||||||
// Windows: npm global binaries are .cmd shims. Node's spawn without
|
|
||||||
// shell:true does not resolve PATHEXT, so we need shell:true on win32
|
// Resolve the full path to `claude` — when launched from a non-interactive
|
||||||
// to find claude.cmd. POSIX stays shell-less to avoid quoting surprises.
|
// shell (e.g. nvm node shebang), ~/.local/bin may not be in PATH.
|
||||||
const isWindows = process.platform === "win32";
|
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",
|
stdio: "inherit",
|
||||||
shell: isWindows,
|
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 } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on("error", (err: NodeJS.ErrnoException) => {
|
// 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") {
|
if (err.code === "ENOENT") {
|
||||||
console.error(
|
console.error("✗ `claude` not found on PATH. Install Claude Code first.");
|
||||||
"✗ `claude` not found on PATH. Install Claude Code first: https://claude.com/claude-code",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
console.error(`✗ failed to launch claude: ${err.message}`);
|
console.error(`✗ failed to launch claude: ${err.message}`);
|
||||||
}
|
}
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
}
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
if (result.signal) {
|
||||||
if (signal) {
|
// Re-raise the same signal so the parent dies the same way the child did.
|
||||||
process.kill(process.pid, signal);
|
process.kill(process.pid, result.signal);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
process.exit(code ?? 0);
|
|
||||||
});
|
process.exit(result.status ?? 0);
|
||||||
}
|
}
|
||||||
|
|||||||
63
apps/cli/src/commands/memory.ts
Normal file
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
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
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
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
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
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
88
apps/cli/src/commands/sync.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh sync` — re-sync meshes from dashboard account.
|
||||||
|
*
|
||||||
|
* Opens browser for OAuth, receives sync token, calls broker /cli-sync,
|
||||||
|
* merges new meshes into local config.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createInterface } from "node:readline";
|
||||||
|
import { hostname } from "node:os";
|
||||||
|
import { loadConfig, saveConfig } from "../state/config";
|
||||||
|
import { startCallbackListener, openBrowser, generatePairingCode, syncWithBroker } from "../auth";
|
||||||
|
import { generateKeypair } from "../crypto/keypair";
|
||||||
|
|
||||||
|
export async function runSync(args: { force?: boolean }): Promise<void> {
|
||||||
|
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
const code = generatePairingCode();
|
||||||
|
const listener = await startCallbackListener();
|
||||||
|
const url = `https://claudemesh.com/cli-auth?port=${listener.port}&code=${code}&action=sync`;
|
||||||
|
|
||||||
|
console.log(`Opening browser to sync meshes...`);
|
||||||
|
console.log(dim(`Visit: ${url}`));
|
||||||
|
await openBrowser(url);
|
||||||
|
|
||||||
|
// Race: localhost callback vs manual paste vs timeout
|
||||||
|
const manualPromise = new Promise<string>((resolve) => {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
rl.question("Paste sync token (or wait for browser): ", (answer) => {
|
||||||
|
rl.close();
|
||||||
|
if (answer.trim()) resolve(answer.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<null>((resolve) => {
|
||||||
|
setTimeout(() => resolve(null), 15 * 60_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncToken = await Promise.race([
|
||||||
|
listener.token,
|
||||||
|
manualPromise,
|
||||||
|
timeoutPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
listener.close();
|
||||||
|
|
||||||
|
if (!syncToken) {
|
||||||
|
console.error("Timed out waiting for sign-in.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing keypair from first mesh, or generate new
|
||||||
|
const keypair = config.meshes.length > 0
|
||||||
|
? { publicKey: config.meshes[0]!.pubkey, secretKey: config.meshes[0]!.secretKey }
|
||||||
|
: await generateKeypair();
|
||||||
|
|
||||||
|
const displayName = config.displayName ?? `${hostname()}-${process.pid}`;
|
||||||
|
|
||||||
|
const result = await syncWithBroker(syncToken, keypair.publicKey, displayName);
|
||||||
|
|
||||||
|
// Merge: add new meshes, skip duplicates
|
||||||
|
let added = 0;
|
||||||
|
for (const m of result.meshes) {
|
||||||
|
if (config.meshes.some(existing => existing.meshId === m.mesh_id)) continue;
|
||||||
|
config.meshes.push({
|
||||||
|
meshId: m.mesh_id,
|
||||||
|
memberId: m.member_id,
|
||||||
|
slug: m.slug,
|
||||||
|
name: m.slug,
|
||||||
|
pubkey: keypair.publicKey,
|
||||||
|
secretKey: keypair.secretKey,
|
||||||
|
brokerUrl: m.broker_url,
|
||||||
|
joinedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
config.accountId = result.account_id;
|
||||||
|
saveConfig(config);
|
||||||
|
|
||||||
|
if (added > 0) {
|
||||||
|
console.log(green(`✓ Added ${added} new mesh(es)`));
|
||||||
|
} else {
|
||||||
|
console.log(`Already up to date (${config.meshes.length} meshes)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
apps/cli/src/commands/welcome.ts
Normal file
111
apps/cli/src/commands/welcome.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Stateful welcome screen — shown when the user runs `claudemesh`
|
||||||
|
* with no arguments. Detects install state + joined meshes + prints
|
||||||
|
* the next action they should take.
|
||||||
|
*
|
||||||
|
* States, in priority order:
|
||||||
|
* 1. MCP not registered in ~/.claude.json → run install
|
||||||
|
* 2. Config dir exists but no meshes joined → run join
|
||||||
|
* 3. Meshes joined, all reachable → run launch
|
||||||
|
* 4. Meshes joined, broker unreachable → run status / doctor
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { loadConfig } from "../state/config";
|
||||||
|
import { VERSION } from "../version";
|
||||||
|
|
||||||
|
type State = "no-install" | "no-meshes" | "ready" | "broken-config";
|
||||||
|
|
||||||
|
function detectState(): State {
|
||||||
|
// 1. MCP registered?
|
||||||
|
const claudeConfig = join(homedir(), ".claude.json");
|
||||||
|
let mcpRegistered = false;
|
||||||
|
if (existsSync(claudeConfig)) {
|
||||||
|
try {
|
||||||
|
const cfg = JSON.parse(readFileSync(claudeConfig, "utf-8")) as {
|
||||||
|
mcpServers?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
mcpRegistered = Boolean(cfg.mcpServers?.["claudemesh"]);
|
||||||
|
} catch {
|
||||||
|
/* treat parse errors as not-registered */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mcpRegistered) return "no-install";
|
||||||
|
|
||||||
|
// 2. Config parseable + has meshes?
|
||||||
|
try {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
return cfg.meshes.length === 0 ? "no-meshes" : "ready";
|
||||||
|
} catch {
|
||||||
|
return "broken-config";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runWelcome(): void {
|
||||||
|
const useColor =
|
||||||
|
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||||
|
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||||
|
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||||
|
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||||
|
const yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||||
|
|
||||||
|
console.log(bold(`claudemesh v${VERSION}`) + dim(" — peer mesh for Claude Code"));
|
||||||
|
console.log("─".repeat(60));
|
||||||
|
|
||||||
|
const state = detectState();
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case "no-install":
|
||||||
|
console.log("Welcome. Let's get you set up.");
|
||||||
|
console.log("");
|
||||||
|
console.log(bold("Step 1:") + " register the MCP server + status hooks");
|
||||||
|
console.log(` ${green("$")} claudemesh install`);
|
||||||
|
console.log("");
|
||||||
|
console.log(dim("Step 2 (after restart): claudemesh join <invite-url>"));
|
||||||
|
console.log(dim("Step 3: claudemesh launch"));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "no-meshes":
|
||||||
|
console.log(green("✓") + " MCP registered. Now join a mesh.");
|
||||||
|
console.log("");
|
||||||
|
console.log(bold("Step 2:") + " join a mesh");
|
||||||
|
console.log(` ${green("$")} claudemesh join https://claudemesh.com/join/<token>`);
|
||||||
|
console.log("");
|
||||||
|
console.log(
|
||||||
|
dim(" Don't have an invite? Create one at ") +
|
||||||
|
bold("https://claudemesh.com") +
|
||||||
|
dim(" or ask a mesh owner."),
|
||||||
|
);
|
||||||
|
console.log("");
|
||||||
|
console.log(dim("Step 3 (after joining): claudemesh launch"));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ready": {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const meshNames = cfg.meshes.map((m) => m.slug).join(", ");
|
||||||
|
console.log(green("✓") + " MCP registered.");
|
||||||
|
console.log(green("✓") + ` ${cfg.meshes.length} mesh(es) joined: ${meshNames}`);
|
||||||
|
console.log("");
|
||||||
|
console.log(bold("You're ready.") + " Launch Claude Code with real-time peer messages:");
|
||||||
|
console.log(` ${green("$")} claudemesh launch`);
|
||||||
|
console.log("");
|
||||||
|
console.log(dim(" (Plain `claude` works too — messages pull-only via check_messages.)"));
|
||||||
|
console.log("");
|
||||||
|
console.log(dim("Health check: claudemesh status"));
|
||||||
|
console.log(dim("Diagnostics: claudemesh doctor"));
|
||||||
|
console.log(dim("All commands: claudemesh --help"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "broken-config":
|
||||||
|
console.log(yellow("⚠") + " Your ~/.claudemesh/config.json is unreadable.");
|
||||||
|
console.log("");
|
||||||
|
console.log("Run diagnostics to see what's wrong:");
|
||||||
|
console.log(` ${green("$")} claudemesh doctor`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
90
apps/cli/src/crypto/file-crypto.ts
Normal file
90
apps/cli/src/crypto/file-crypto.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* File encryption for claudemesh E2E file sharing.
|
||||||
|
*
|
||||||
|
* Symmetric: crypto_secretbox_easy with random Kf (32-byte key).
|
||||||
|
* Key wrapping: crypto_box_seal to recipient's X25519 pub (converted from ed25519).
|
||||||
|
* Key opening: crypto_box_seal_open with own X25519 keypair.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ensureSodium } from "./keypair";
|
||||||
|
|
||||||
|
export interface EncryptedFile {
|
||||||
|
ciphertext: Uint8Array; // secretbox ciphertext (includes MAC)
|
||||||
|
nonce: string; // base64 24-byte nonce
|
||||||
|
key: Uint8Array; // 32-byte symmetric Kf (keep in memory only)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt file bytes with a fresh random symmetric key.
|
||||||
|
* Returns ciphertext, nonce (base64), and the plaintext Kf.
|
||||||
|
*/
|
||||||
|
export async function encryptFile(plaintext: Uint8Array): Promise<EncryptedFile> {
|
||||||
|
const sodium = await ensureSodium();
|
||||||
|
const key = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES);
|
||||||
|
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||||
|
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
|
||||||
|
return {
|
||||||
|
ciphertext,
|
||||||
|
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
||||||
|
key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt file bytes with the symmetric key Kf.
|
||||||
|
* Returns null if decryption fails.
|
||||||
|
*/
|
||||||
|
export async function decryptFile(
|
||||||
|
ciphertext: Uint8Array,
|
||||||
|
nonceB64: string,
|
||||||
|
key: Uint8Array,
|
||||||
|
): Promise<Uint8Array | null> {
|
||||||
|
const sodium = await ensureSodium();
|
||||||
|
try {
|
||||||
|
const nonce = sodium.from_base64(nonceB64, sodium.base64_variants.ORIGINAL);
|
||||||
|
return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seal Kf for a recipient using crypto_box_seal (ephemeral sender key).
|
||||||
|
* recipientPubkeyHex: ed25519 pubkey of recipient (64 hex chars).
|
||||||
|
* Returns base64 sealed box.
|
||||||
|
*/
|
||||||
|
export async function sealKeyForPeer(
|
||||||
|
kf: Uint8Array,
|
||||||
|
recipientPubkeyHex: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const sodium = await ensureSodium();
|
||||||
|
const recipientCurve = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||||
|
sodium.from_hex(recipientPubkeyHex),
|
||||||
|
);
|
||||||
|
const sealed = sodium.crypto_box_seal(kf, recipientCurve);
|
||||||
|
return sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a sealed key blob using own ed25519 keypair (converted to X25519).
|
||||||
|
* Returns the 32-byte Kf or null if decryption fails.
|
||||||
|
*/
|
||||||
|
export async function openSealedKey(
|
||||||
|
sealedB64: string,
|
||||||
|
myPubkeyHex: string,
|
||||||
|
mySecretKeyHex: string,
|
||||||
|
): Promise<Uint8Array | null> {
|
||||||
|
const sodium = await ensureSodium();
|
||||||
|
try {
|
||||||
|
const myCurvePub = sodium.crypto_sign_ed25519_pk_to_curve25519(
|
||||||
|
sodium.from_hex(myPubkeyHex),
|
||||||
|
);
|
||||||
|
const myCurveSec = sodium.crypto_sign_ed25519_sk_to_curve25519(
|
||||||
|
sodium.from_hex(mySecretKeyHex),
|
||||||
|
);
|
||||||
|
const sealed = sodium.from_base64(sealedB64, sodium.base64_variants.ORIGINAL);
|
||||||
|
return sodium.crypto_box_seal_open(sealed, myCurvePub, myCurveSec);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,23 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI environment config.
|
* CLI environment config.
|
||||||
*
|
*
|
||||||
* Read once at startup. Overridable via env vars so users can point
|
* Read once at startup. Overridable via env vars so users can point
|
||||||
* at a self-hosted broker or a staging instance without rebuilding.
|
* at a self-hosted broker or a staging instance without rebuilding.
|
||||||
*/
|
*/
|
||||||
const envSchema = z.object({
|
|
||||||
CLAUDEMESH_BROKER_URL: z.string().default("wss://ic.claudemesh.com/ws"),
|
|
||||||
CLAUDEMESH_CONFIG_DIR: z.string().optional(),
|
|
||||||
CLAUDEMESH_DEBUG: z.coerce.boolean().default(false),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CliEnv = z.infer<typeof envSchema>;
|
export interface CliEnv {
|
||||||
|
CLAUDEMESH_BROKER_URL: string;
|
||||||
|
CLAUDEMESH_CONFIG_DIR: string | undefined;
|
||||||
|
CLAUDEMESH_DEBUG: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function loadEnv(): CliEnv {
|
export function loadEnv(): CliEnv {
|
||||||
const parsed = envSchema.safeParse(process.env);
|
return {
|
||||||
if (!parsed.success) {
|
CLAUDEMESH_BROKER_URL:
|
||||||
console.error("[claudemesh] invalid environment:");
|
process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||||
console.error(z.treeifyError(parsed.error));
|
CLAUDEMESH_CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || undefined,
|
||||||
process.exit(1);
|
CLAUDEMESH_DEBUG: process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true",
|
||||||
}
|
};
|
||||||
return parsed.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const env = loadEnv();
|
export const env = loadEnv();
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* claudemesh-cli entry point.
|
* claudemesh-cli entry point.
|
||||||
*
|
*
|
||||||
|
* Uses citty to define commands and flags. --help is generated from
|
||||||
|
* the command definitions — the flag list here IS the documentation.
|
||||||
|
*
|
||||||
* Dispatches between two modes:
|
* Dispatches between two modes:
|
||||||
* - `claudemesh mcp` → MCP server (stdio transport)
|
* - `claudemesh mcp` → MCP server (stdio transport)
|
||||||
* - `claudemesh <subcommand>` → CLI subcommand
|
* - `claudemesh <subcommand>` → CLI subcommand
|
||||||
*
|
|
||||||
* Claude Code invokes the `mcp` mode via stdio. Humans use all others.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { defineCommand, runMain } from "citty";
|
||||||
import { startMcpServer } from "./mcp/server";
|
import { startMcpServer } from "./mcp/server";
|
||||||
import { runInstall, runUninstall } from "./commands/install";
|
import { runInstall, runUninstall } from "./commands/install";
|
||||||
import { runJoin } from "./commands/join";
|
import { runJoin } from "./commands/join";
|
||||||
@@ -18,93 +20,338 @@ import { runHook } from "./commands/hook";
|
|||||||
import { runLaunch } from "./commands/launch";
|
import { runLaunch } from "./commands/launch";
|
||||||
import { runStatus } from "./commands/status";
|
import { runStatus } from "./commands/status";
|
||||||
import { runDoctor } from "./commands/doctor";
|
import { runDoctor } from "./commands/doctor";
|
||||||
|
import { runWelcome } from "./commands/welcome";
|
||||||
|
import { runPeers } from "./commands/peers";
|
||||||
|
import { runSend } from "./commands/send";
|
||||||
|
import { runInbox } from "./commands/inbox";
|
||||||
|
import { runStateGet, runStateSet, runStateList } from "./commands/state";
|
||||||
|
import { runRemember, runRecall } from "./commands/memory";
|
||||||
|
import { runInfo } from "./commands/info";
|
||||||
|
import { runRemind } from "./commands/remind";
|
||||||
|
import { runCreate } from "./commands/create";
|
||||||
|
import { runSync } from "./commands/sync";
|
||||||
|
import { runProfile, type ProfileFlags } from "./commands/profile";
|
||||||
|
import { connectTelegram } from "./commands/connect-telegram";
|
||||||
|
import { disconnectTelegram } from "./commands/disconnect-telegram";
|
||||||
import { VERSION } from "./version";
|
import { VERSION } from "./version";
|
||||||
|
|
||||||
const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
|
const launch = defineCommand({
|
||||||
|
meta: {
|
||||||
|
name: "launch",
|
||||||
|
description: "Spawn a Claude Code session with mesh connectivity and MCP tools",
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
description: "Display name visible to other peers",
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
type: "string",
|
||||||
|
description: "Free-form role tag: `dev`, `lead`, `analyst`, etc",
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
type: "string",
|
||||||
|
description: 'Groups to join as `group:role,...` — e.g. `"eng/frontend:lead,qa:member"`',
|
||||||
|
},
|
||||||
|
mesh: {
|
||||||
|
type: "string",
|
||||||
|
description: "Mesh slug (interactive picker if omitted and >1 joined)",
|
||||||
|
},
|
||||||
|
join: {
|
||||||
|
type: "string",
|
||||||
|
description: "Join a mesh via invite URL before launching",
|
||||||
|
},
|
||||||
|
"message-mode": {
|
||||||
|
type: "string",
|
||||||
|
description: '`"push"` (default) | `"inbox"` | `"off"` — how peer messages arrive',
|
||||||
|
},
|
||||||
|
"system-prompt": {
|
||||||
|
type: "string",
|
||||||
|
description: "Custom system prompt for this Claude session",
|
||||||
|
},
|
||||||
|
yes: {
|
||||||
|
type: "boolean",
|
||||||
|
alias: "y",
|
||||||
|
description: "Skip the --dangerously-skip-permissions confirmation",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
resume: {
|
||||||
|
type: "string",
|
||||||
|
alias: "r",
|
||||||
|
description: "Resume a previous Claude Code session by ID, or pass `true` for interactive picker",
|
||||||
|
},
|
||||||
|
continue: {
|
||||||
|
type: "boolean",
|
||||||
|
alias: "c",
|
||||||
|
description: "Continue the most recent conversation in this directory",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
quiet: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Suppress banner and interactive prompts",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run({ args, rawArgs }) {
|
||||||
|
// Forward to the existing launch runner, preserving -- passthrough to claude.
|
||||||
|
return runLaunch(args, rawArgs);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
Usage:
|
const install = defineCommand({
|
||||||
claudemesh <command> [args]
|
meta: {
|
||||||
|
name: "install",
|
||||||
|
description: "Register MCP server and status hooks with Claude Code",
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
"no-hooks": {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Register MCP server only, skip hooks",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run({ rawArgs }) {
|
||||||
|
runInstall(rawArgs);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
Commands:
|
const join = defineCommand({
|
||||||
install Register MCP + Stop/UserPromptSubmit status hooks
|
meta: {
|
||||||
(add --no-hooks for bare MCP registration)
|
name: "join",
|
||||||
uninstall Remove MCP server + hooks
|
description: "Join a mesh via invite URL or token",
|
||||||
launch [args] Launch Claude Code with real-time push messages enabled
|
},
|
||||||
(add --quiet to skip the info banner; passes through
|
args: {
|
||||||
extra flags, e.g. --model, --resume)
|
url: {
|
||||||
join <url> Join a mesh via https://claudemesh.com/join/... URL
|
type: "positional",
|
||||||
list Show all joined meshes
|
description: "Invite URL (`https://claudemesh.com/join/...`) or token",
|
||||||
leave <slug> Leave a joined mesh
|
required: true,
|
||||||
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)
|
run({ args }) {
|
||||||
mcp Start MCP server (stdio) — invoked by Claude Code
|
return runJoin([args.url]);
|
||||||
--help, -h Show this help
|
},
|
||||||
--version, -v Show the CLI version
|
});
|
||||||
|
|
||||||
Environment:
|
const leave = defineCommand({
|
||||||
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws)
|
meta: {
|
||||||
CLAUDEMESH_CONFIG_DIR Override config directory (default: ~/.claudemesh/)
|
name: "leave",
|
||||||
CLAUDEMESH_DEBUG=1 Verbose logging
|
description: "Leave a joined mesh and remove its local keypair",
|
||||||
`;
|
},
|
||||||
|
args: {
|
||||||
|
slug: {
|
||||||
|
type: "positional",
|
||||||
|
description: "Mesh slug to leave (see `claudemesh list`)",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run({ args }) {
|
||||||
|
runLeave([args.slug]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const cmd = process.argv[2];
|
const main = defineCommand({
|
||||||
const args = process.argv.slice(3);
|
meta: {
|
||||||
|
name: "claudemesh",
|
||||||
|
version: VERSION,
|
||||||
|
description: "Peer mesh for Claude Code sessions",
|
||||||
|
},
|
||||||
|
subCommands: {
|
||||||
|
launch,
|
||||||
|
create: defineCommand({
|
||||||
|
meta: { name: "create", description: "Create a new mesh from a template" },
|
||||||
|
args: {
|
||||||
|
template: { type: "string", description: "Template name: `dev-team`, `research`, `ops-incident`, `simulation`, `personal`" },
|
||||||
|
"list-templates": { type: "boolean", description: "List available templates and exit", default: false },
|
||||||
|
},
|
||||||
|
run({ args }) { runCreate(args); },
|
||||||
|
}),
|
||||||
|
install,
|
||||||
|
uninstall: defineCommand({
|
||||||
|
meta: { name: "uninstall", description: "Remove MCP server and hooks from Claude Code config" },
|
||||||
|
run() { runUninstall(); },
|
||||||
|
}),
|
||||||
|
join,
|
||||||
|
list: defineCommand({
|
||||||
|
meta: { name: "list", description: "Show joined meshes, slugs, and local identities" },
|
||||||
|
run() { runList(); },
|
||||||
|
}),
|
||||||
|
leave,
|
||||||
|
peers: defineCommand({
|
||||||
|
meta: { name: "peers", description: "List online peers with status, summary, and groups" },
|
||||||
|
args: {
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
},
|
||||||
|
async run({ args }) { await runPeers(args); },
|
||||||
|
}),
|
||||||
|
send: defineCommand({
|
||||||
|
meta: { name: "send", description: "Send a message to a peer, group, or all peers" },
|
||||||
|
args: {
|
||||||
|
to: { type: "positional", description: "Recipient: display name, `@group`, `*` (broadcast), or pubkey hex", required: true },
|
||||||
|
message: { type: "positional", description: "Message text", required: true },
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
priority: { type: "string", description: '`"now"` | `"next"` (default) | `"low"`' },
|
||||||
|
},
|
||||||
|
async run({ args }) { await runSend(args, args.to, args.message); },
|
||||||
|
}),
|
||||||
|
inbox: defineCommand({
|
||||||
|
meta: { name: "inbox", description: "Drain pending inbound messages" },
|
||||||
|
args: {
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
wait: { type: "string", description: "Seconds to wait for broker delivery (default: `1`)" },
|
||||||
|
},
|
||||||
|
async run({ args }) {
|
||||||
|
await runInbox({ ...args, wait: args.wait ? parseInt(args.wait, 10) : undefined });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
state: defineCommand({
|
||||||
|
meta: { name: "state", description: "Get, set, or list shared key-value state in the mesh" },
|
||||||
|
args: {
|
||||||
|
action: { type: "positional", description: "`get <key>` | `set <key> <value>` | `list`", required: true },
|
||||||
|
key: { type: "positional", description: "State key (required for `get` and `set`)" },
|
||||||
|
value: { type: "positional", description: "Value to store (required for `set`)" },
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
},
|
||||||
|
async run({ args }) {
|
||||||
|
if (args.action === "list") {
|
||||||
|
await runStateList(args);
|
||||||
|
} else if (args.action === "get") {
|
||||||
|
if (!args.key) { console.error("Usage: claudemesh state get <key>"); process.exit(1); }
|
||||||
|
await runStateGet(args, args.key);
|
||||||
|
} else if (args.action === "set") {
|
||||||
|
if (!args.key || !args.value) { console.error("Usage: claudemesh state set <key> <value>"); process.exit(1); }
|
||||||
|
await runStateSet(args, args.key, args.value);
|
||||||
|
} else {
|
||||||
|
console.error(`Unknown action "${args.action}". Use: get, set, list`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
info: defineCommand({
|
||||||
|
meta: { name: "info", description: "Show mesh overview: slug, broker, peer count, state keys" },
|
||||||
|
args: {
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
},
|
||||||
|
async run({ args }) { await runInfo(args); },
|
||||||
|
}),
|
||||||
|
remember: defineCommand({
|
||||||
|
meta: { name: "remember", description: "Store a persistent memory visible to all peers" },
|
||||||
|
args: {
|
||||||
|
content: { type: "positional", description: "Text to store", required: true },
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
tags: { type: "string", description: "Comma-separated tags, e.g. `task,context`" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
},
|
||||||
|
async run({ args }) { await runRemember(args, args.content); },
|
||||||
|
}),
|
||||||
|
recall: defineCommand({
|
||||||
|
meta: { name: "recall", description: "Search mesh memories by keyword or phrase" },
|
||||||
|
args: {
|
||||||
|
query: { type: "positional", description: "Full-text search query", required: true },
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
},
|
||||||
|
async run({ args }) { await runRecall(args, args.query); },
|
||||||
|
}),
|
||||||
|
remind: defineCommand({
|
||||||
|
meta: { name: "remind", description: "Schedule a delayed message. Also: `remind list`, `remind cancel <id>`" },
|
||||||
|
args: {
|
||||||
|
message: { type: "positional", description: "Message text — or `list` / `cancel <id>` to manage reminders", required: false },
|
||||||
|
extra: { type: "positional", description: "Reminder ID for `cancel`", required: false },
|
||||||
|
in: { type: "string", description: 'Deliver after duration: `"2h"`, `"30m"`, `"90s"`' },
|
||||||
|
at: { type: "string", description: 'Deliver at time: `"15:00"` or ISO timestamp' },
|
||||||
|
cron: { type: "string", description: 'Recurring cron expression: `"0 */2 * * *"` (every 2h), `"30 9 * * 1-5"` (9:30 weekdays)' },
|
||||||
|
to: { type: "string", description: "Recipient (default: self). Name, `@group`, `*`, or pubkey" },
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
},
|
||||||
|
async run({ args, rawArgs }) {
|
||||||
|
// Collect positional args from rawArgs (before any flags)
|
||||||
|
const positionals = rawArgs.filter((a) => !a.startsWith("-"));
|
||||||
|
await runRemind(args, positionals);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sync: defineCommand({
|
||||||
|
meta: { name: "sync", description: "Sync meshes from your dashboard account" },
|
||||||
|
args: {
|
||||||
|
force: { type: "boolean", description: "Re-link account even if already linked", default: false },
|
||||||
|
},
|
||||||
|
async run({ args }) { await runSync(args); },
|
||||||
|
}),
|
||||||
|
profile: defineCommand({
|
||||||
|
meta: { name: "profile", description: "View or edit your member profile" },
|
||||||
|
args: {
|
||||||
|
mesh: { type: "string", description: "Mesh slug (auto-selected if only one joined)" },
|
||||||
|
"role-tag": { type: "string", description: "Set role tag (e.g. 'backend-dev', 'lead')" },
|
||||||
|
groups: { type: "string", description: "Set groups as 'group:role,...' (e.g. 'eng:lead,review')" },
|
||||||
|
"message-mode": { type: "string", description: "'push' | 'inbox' | 'off'" },
|
||||||
|
name: { type: "string", description: "Set display name" },
|
||||||
|
member: { type: "string", description: "Edit another member (admin only)" },
|
||||||
|
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||||
|
},
|
||||||
|
async run({ args }) { await runProfile(args as ProfileFlags); },
|
||||||
|
}),
|
||||||
|
status: defineCommand({
|
||||||
|
meta: { name: "status", description: "Check broker connectivity for each joined mesh" },
|
||||||
|
async run() { await runStatus(); },
|
||||||
|
}),
|
||||||
|
doctor: defineCommand({
|
||||||
|
meta: { name: "doctor", description: "Diagnose install, config, keypairs, and PATH issues" },
|
||||||
|
async run() { await runDoctor(); },
|
||||||
|
}),
|
||||||
|
mcp: defineCommand({
|
||||||
|
meta: { name: "mcp", description: "Start MCP server on stdio (called by Claude Code, not users)" },
|
||||||
|
async run() { await startMcpServer(); },
|
||||||
|
}),
|
||||||
|
"seed-test-mesh": defineCommand({
|
||||||
|
meta: { name: "seed-test-mesh", description: "Dev: inject a mesh into local config, skip invite flow" },
|
||||||
|
run({ rawArgs }) { runSeedTestMesh(rawArgs); },
|
||||||
|
}),
|
||||||
|
hook: defineCommand({
|
||||||
|
meta: { name: "hook", description: "Internal: handle Claude Code hook events" },
|
||||||
|
async run({ rawArgs }) { await runHook(rawArgs); },
|
||||||
|
}),
|
||||||
|
connect: defineCommand({
|
||||||
|
meta: { name: "connect", description: "Connect an integration (e.g. telegram)" },
|
||||||
|
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
|
||||||
|
async run({ args }) {
|
||||||
|
if (args.target === "telegram") await connectTelegram(process.argv.slice(process.argv.indexOf("telegram") + 1));
|
||||||
|
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
disconnect: defineCommand({
|
||||||
|
meta: { name: "disconnect", description: "Disconnect an integration (e.g. telegram)" },
|
||||||
|
args: { target: { type: "positional", description: "Integration target (telegram)", required: true } },
|
||||||
|
async run({ args }) {
|
||||||
|
if (args.target === "telegram") await disconnectTelegram();
|
||||||
|
else { console.error(`Unknown target: ${args.target}`); process.exit(1); }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
await runWelcome();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
// Friction reducer: if the user types `claudemesh --resume xxx` or any other
|
||||||
switch (cmd) {
|
// flag-first invocation, route it through `launch`. This keeps `claudemesh`
|
||||||
case "mcp":
|
// bare (welcome screen), `claudemesh <known-sub>` (dispatch normally), and
|
||||||
await startMcpServer();
|
// every flag-only form as implicit `launch`.
|
||||||
return;
|
const KNOWN_SUBCOMMANDS = new Set(Object.keys(main.subCommands ?? {}));
|
||||||
case "install":
|
// Flags citty handles on the root command — must not be rewritten to `launch`.
|
||||||
runInstall(args);
|
const ROOT_PASSTHROUGH_FLAGS = new Set(["--help", "-h", "--version", "-v"]);
|
||||||
return;
|
|
||||||
case "uninstall":
|
const argv = process.argv.slice(2);
|
||||||
runUninstall();
|
const first = argv[0];
|
||||||
return;
|
if (first && !ROOT_PASSTHROUGH_FLAGS.has(first) && !KNOWN_SUBCOMMANDS.has(first)) {
|
||||||
case "hook":
|
// Starts with a flag, or an unknown bareword → treat as launch args.
|
||||||
await runHook(args);
|
// (Unknown barewords that look like typos would otherwise hit citty's
|
||||||
return;
|
// "unknown command" path; forwarding to launch lets claude surface the
|
||||||
case "launch":
|
// error if it's a real claude flag, and launch's own parser rejects junk.)
|
||||||
runLaunch(args);
|
process.argv.splice(2, 0, "launch");
|
||||||
return;
|
|
||||||
case "join":
|
|
||||||
await runJoin(args);
|
|
||||||
return;
|
|
||||||
case "list":
|
|
||||||
runList();
|
|
||||||
return;
|
|
||||||
case "leave":
|
|
||||||
runLeave(args);
|
|
||||||
return;
|
|
||||||
case "status":
|
|
||||||
await runStatus();
|
|
||||||
return;
|
|
||||||
case "doctor":
|
|
||||||
await runDoctor();
|
|
||||||
return;
|
|
||||||
case "seed-test-mesh":
|
|
||||||
runSeedTestMesh(args);
|
|
||||||
return;
|
|
||||||
case "--version":
|
|
||||||
case "-v":
|
|
||||||
case "version":
|
|
||||||
console.log(VERSION);
|
|
||||||
return;
|
|
||||||
case "--help":
|
|
||||||
case "-h":
|
|
||||||
case "help":
|
|
||||||
case undefined:
|
|
||||||
console.log(HELP);
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
console.error(`Unknown command: ${cmd}`);
|
|
||||||
console.error("Run `claudemesh --help` for usage.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((e) => {
|
runMain(main);
|
||||||
console.error(`claudemesh: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,22 +5,19 @@
|
|||||||
* verification and one-time-use invite-token tracking land in Step 18.
|
* verification and one-time-use invite-token tracking land in Step 18.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ensureSodium } from "../crypto/keypair";
|
import { ensureSodium } from "../crypto/keypair";
|
||||||
|
|
||||||
const invitePayloadSchema = z.object({
|
export interface InvitePayload {
|
||||||
v: z.literal(1),
|
v: 1;
|
||||||
mesh_id: z.string().min(1),
|
mesh_id: string;
|
||||||
mesh_slug: z.string().min(1),
|
mesh_slug: string;
|
||||||
broker_url: z.string().min(1),
|
broker_url: string;
|
||||||
expires_at: z.number().int().positive(),
|
expires_at: number;
|
||||||
mesh_root_key: z.string().min(1),
|
mesh_root_key: string;
|
||||||
role: z.enum(["admin", "member"]),
|
role: "admin" | "member";
|
||||||
owner_pubkey: z.string().regex(/^[0-9a-f]{64}$/i),
|
owner_pubkey: string;
|
||||||
signature: z.string().regex(/^[0-9a-f]{128}$/i),
|
signature: string;
|
||||||
});
|
}
|
||||||
|
|
||||||
export type InvitePayload = z.infer<typeof invitePayloadSchema>;
|
|
||||||
|
|
||||||
export interface ParsedInvite {
|
export interface ParsedInvite {
|
||||||
payload: InvitePayload;
|
payload: InvitePayload;
|
||||||
@@ -28,6 +25,21 @@ export interface ParsedInvite {
|
|||||||
token: string; // base64url(JSON) — DB lookup key (everything after ic://join/)
|
token: string; // base64url(JSON) — DB lookup key (everything after ic://join/)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validatePayload(obj: unknown): InvitePayload {
|
||||||
|
if (!obj || typeof obj !== "object") throw new Error("invite payload is not an object");
|
||||||
|
const o = obj as Record<string, unknown>;
|
||||||
|
if (o.v !== 1) throw new Error("invite payload: v must be 1");
|
||||||
|
if (typeof o.mesh_id !== "string" || !o.mesh_id) throw new Error("invite payload: mesh_id required");
|
||||||
|
if (typeof o.mesh_slug !== "string" || !o.mesh_slug) throw new Error("invite payload: mesh_slug required");
|
||||||
|
if (typeof o.broker_url !== "string" || !o.broker_url) throw new Error("invite payload: broker_url required");
|
||||||
|
if (typeof o.expires_at !== "number" || o.expires_at <= 0) throw new Error("invite payload: expires_at must be a positive number");
|
||||||
|
if (typeof o.mesh_root_key !== "string" || !o.mesh_root_key) throw new Error("invite payload: mesh_root_key required");
|
||||||
|
if (o.role !== "admin" && o.role !== "member") throw new Error("invite payload: role must be admin or member");
|
||||||
|
if (typeof o.owner_pubkey !== "string" || !/^[0-9a-f]{64}$/i.test(o.owner_pubkey)) throw new Error("invite payload: owner_pubkey must be 64 hex chars");
|
||||||
|
if (typeof o.signature !== "string" || !/^[0-9a-f]{128}$/i.test(o.signature)) throw new Error("invite payload: signature must be 128 hex chars");
|
||||||
|
return o as unknown as InvitePayload;
|
||||||
|
}
|
||||||
|
|
||||||
/** Canonical invite bytes — must match broker's canonicalInvite(). */
|
/** Canonical invite bytes — must match broker's canonicalInvite(). */
|
||||||
export function canonicalInvite(p: {
|
export function canonicalInvite(p: {
|
||||||
v: number;
|
v: number;
|
||||||
@@ -96,41 +108,34 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = invitePayloadSchema.safeParse(obj);
|
const payload = validatePayload(obj);
|
||||||
if (!parsed.success) {
|
|
||||||
throw new Error(
|
|
||||||
`invite link shape invalid: ${parsed.error.issues.map((i) => i.path.join(".") + ": " + i.message).join("; ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expiry check (unix seconds).
|
// Expiry check (unix seconds).
|
||||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
if (parsed.data.expires_at < nowSeconds) {
|
if (payload.expires_at < nowSeconds) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`invite expired: expires_at=${parsed.data.expires_at}, now=${nowSeconds}`,
|
`invite expired: expires_at=${payload.expires_at}, now=${nowSeconds}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the ed25519 signature against the embedded owner_pubkey.
|
// Verify the ed25519 signature against the embedded owner_pubkey.
|
||||||
// Client-side verification gives immediate feedback on tampered
|
|
||||||
// links; broker re-verifies authoritatively on /join.
|
|
||||||
const s = await ensureSodium();
|
const s = await ensureSodium();
|
||||||
const canonical = canonicalInvite({
|
const canonical = canonicalInvite({
|
||||||
v: parsed.data.v,
|
v: payload.v,
|
||||||
mesh_id: parsed.data.mesh_id,
|
mesh_id: payload.mesh_id,
|
||||||
mesh_slug: parsed.data.mesh_slug,
|
mesh_slug: payload.mesh_slug,
|
||||||
broker_url: parsed.data.broker_url,
|
broker_url: payload.broker_url,
|
||||||
expires_at: parsed.data.expires_at,
|
expires_at: payload.expires_at,
|
||||||
mesh_root_key: parsed.data.mesh_root_key,
|
mesh_root_key: payload.mesh_root_key,
|
||||||
role: parsed.data.role,
|
role: payload.role,
|
||||||
owner_pubkey: parsed.data.owner_pubkey,
|
owner_pubkey: payload.owner_pubkey,
|
||||||
});
|
});
|
||||||
const sigOk = (() => {
|
const sigOk = (() => {
|
||||||
try {
|
try {
|
||||||
return s.crypto_sign_verify_detached(
|
return s.crypto_sign_verify_detached(
|
||||||
s.from_hex(parsed.data.signature),
|
s.from_hex(payload.signature),
|
||||||
s.from_string(canonical),
|
s.from_string(canonical),
|
||||||
s.from_hex(parsed.data.owner_pubkey),
|
s.from_hex(payload.owner_pubkey),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -140,7 +145,7 @@ export async function parseInviteLink(link: string): Promise<ParsedInvite> {
|
|||||||
throw new Error("invite signature invalid (link tampered?)");
|
throw new Error("invite signature invalid (link tampered?)");
|
||||||
}
|
}
|
||||||
|
|
||||||
return { payload: parsed.data, raw: link, token: encoded };
|
return { payload, raw: link, token: encoded };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,8 +160,6 @@ export function encodeInviteLink(payload: InvitePayload): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign and assemble an invite payload → ic://join/... link.
|
* Sign and assemble an invite payload → ic://join/... link.
|
||||||
* The canonical bytes (everything except signature) are signed with
|
|
||||||
* the mesh owner's ed25519 secret key.
|
|
||||||
*/
|
*/
|
||||||
export async function buildSignedInvite(args: {
|
export async function buildSignedInvite(args: {
|
||||||
v: 1;
|
v: 1;
|
||||||
|
|||||||
217
apps/cli/src/lib/invite-v2.ts
Normal file
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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -12,13 +12,16 @@ export const TOOLS: Tool[] = [
|
|||||||
{
|
{
|
||||||
name: "send_message",
|
name: "send_message",
|
||||||
description:
|
description:
|
||||||
"Send a message to a peer in one of your joined meshes. `to` is a peer display name, hex pubkey, or `#channel`. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
|
"Send a message to a peer in one of your joined meshes. `to` can be a peer display name (resolved via list_peers), hex pubkey, @group, `#channel`, or `*` for broadcast. `priority` controls delivery: `now` bypasses busy gates, `next` waits for idle (default), `low` is pull-only.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
to: {
|
to: {
|
||||||
type: "string",
|
oneOf: [
|
||||||
description: "Peer name, pubkey, or #channel",
|
{ type: "string", description: "Peer name, pubkey, @group" },
|
||||||
|
{ type: "array", items: { type: "string" }, description: "Multiple targets" },
|
||||||
|
],
|
||||||
|
description: "Single target or array of targets",
|
||||||
},
|
},
|
||||||
message: { type: "string", description: "Message text" },
|
message: { type: "string", description: "Message text" },
|
||||||
priority: {
|
priority: {
|
||||||
@@ -44,6 +47,21 @@ export const TOOLS: Tool[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "message_status",
|
||||||
|
description:
|
||||||
|
"Check the delivery status of a sent message. Shows whether each recipient received it.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: "string",
|
||||||
|
description: "Message ID (returned by send_message)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "check_messages",
|
name: "check_messages",
|
||||||
description:
|
description:
|
||||||
@@ -78,4 +96,925 @@ export const TOOLS: Tool[] = [
|
|||||||
required: ["status"],
|
required: ["status"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "set_visible",
|
||||||
|
description:
|
||||||
|
"Control your visibility in the mesh. When hidden, you won't appear in list_peers and won't receive broadcasts — but direct messages still reach you.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
visible: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "true to be visible (default), false to hide",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["visible"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "set_profile",
|
||||||
|
description:
|
||||||
|
"Set your public profile — what other peers see about you. Avatar (emoji), title, bio, and capabilities list.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
avatar: {
|
||||||
|
type: "string",
|
||||||
|
description: "Emoji or URL for your avatar",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: "string",
|
||||||
|
description: "Short role label (e.g. 'Frontend Lead', 'DevOps')",
|
||||||
|
},
|
||||||
|
bio: {
|
||||||
|
type: "string",
|
||||||
|
description: "One-liner about yourself",
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "What you can help with",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "join_group",
|
||||||
|
description:
|
||||||
|
"Join a group with an optional role. Other peers see your group membership in list_peers.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Group name (without @)" },
|
||||||
|
role: {
|
||||||
|
type: "string",
|
||||||
|
description: "Your role in the group (e.g. lead, member, observer)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leave_group",
|
||||||
|
description: "Leave a group.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Group name (without @)" },
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- State tools ---
|
||||||
|
{
|
||||||
|
name: "set_state",
|
||||||
|
description:
|
||||||
|
"Set a shared state value visible to all peers in the mesh. Pushes a change notification.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
key: { type: "string" },
|
||||||
|
value: { description: "Any JSON value" },
|
||||||
|
},
|
||||||
|
required: ["key", "value"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_state",
|
||||||
|
description: "Read a shared state value.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
key: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["key"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_state",
|
||||||
|
description: "List all shared state keys and values in the mesh.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Memory tools ---
|
||||||
|
{
|
||||||
|
name: "remember",
|
||||||
|
description:
|
||||||
|
"Store persistent knowledge in the mesh's shared memory. Survives across sessions.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
content: {
|
||||||
|
type: "string",
|
||||||
|
description: "The knowledge to remember",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Optional categorization tags",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recall",
|
||||||
|
description: "Search the mesh's shared memory by relevance.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Search query" },
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forget",
|
||||||
|
description: "Remove a memory from the mesh's shared knowledge.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "Memory ID to forget" },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- File tools ---
|
||||||
|
{
|
||||||
|
name: "share_file",
|
||||||
|
description:
|
||||||
|
"Share a persistent file with the mesh. All current and future peers can access it. If `to` is specified, the file is E2E encrypted and only accessible to that peer (and you).",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: { type: "string", description: "Local file path to share" },
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
description: "Display name (defaults to filename)",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Tags for categorization",
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
type: "string",
|
||||||
|
description: "Peer display name or pubkey hex — if set, file is E2E encrypted for this peer only",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["path"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_file",
|
||||||
|
description: "Download a shared file to a local path.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "File ID" },
|
||||||
|
save_to: {
|
||||||
|
type: "string",
|
||||||
|
description: "Local path to save the file",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["id", "save_to"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_files",
|
||||||
|
description: "List files shared in the mesh.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Search by name or tags" },
|
||||||
|
from: { type: "string", description: "Filter by uploader name" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file_status",
|
||||||
|
description: "Check who has accessed a shared file.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "File ID" },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete_file",
|
||||||
|
description: "Remove a shared file from the mesh.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "File ID" },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "grant_file_access",
|
||||||
|
description: "Grant a peer access to an E2E encrypted file you shared. You must be the owner.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
fileId: { type: "string", description: "File ID" },
|
||||||
|
to: { type: "string", description: "Peer display name or pubkey hex to grant access to" },
|
||||||
|
},
|
||||||
|
required: ["fileId", "to"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Vector tools ---
|
||||||
|
{
|
||||||
|
name: "vector_store",
|
||||||
|
description:
|
||||||
|
"Store an embedding in a per-mesh Qdrant collection. Auto-creates the collection on first use.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
collection: { type: "string", description: "Collection name" },
|
||||||
|
text: { type: "string", description: "Text to embed and store" },
|
||||||
|
metadata: {
|
||||||
|
type: "object",
|
||||||
|
description: "Optional metadata to attach",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["collection", "text"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "vector_search",
|
||||||
|
description: "Semantic search over stored embeddings in a collection.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
collection: { type: "string", description: "Collection name" },
|
||||||
|
query: { type: "string", description: "Search query text" },
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Max results (default: 10)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["collection", "query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "vector_delete",
|
||||||
|
description: "Remove an embedding from a collection.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
collection: { type: "string", description: "Collection name" },
|
||||||
|
id: { type: "string", description: "Embedding ID to delete" },
|
||||||
|
},
|
||||||
|
required: ["collection", "id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_collections",
|
||||||
|
description: "List vector collections in this mesh.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Graph tools ---
|
||||||
|
{
|
||||||
|
name: "graph_query",
|
||||||
|
description:
|
||||||
|
"Run a read-only Cypher query on the per-mesh Neo4j database.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
cypher: { type: "string", description: "Cypher MATCH query" },
|
||||||
|
},
|
||||||
|
required: ["cypher"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "graph_execute",
|
||||||
|
description:
|
||||||
|
"Run a write Cypher query (CREATE, MERGE, DELETE) on the per-mesh Neo4j database.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
cypher: { type: "string", description: "Cypher write query" },
|
||||||
|
},
|
||||||
|
required: ["cypher"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Mesh Database tools ---
|
||||||
|
{
|
||||||
|
name: "mesh_query",
|
||||||
|
description:
|
||||||
|
"Run a SELECT query on the per-mesh shared database.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
sql: { type: "string", description: "SQL SELECT query" },
|
||||||
|
},
|
||||||
|
required: ["sql"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_execute",
|
||||||
|
description:
|
||||||
|
"Run DDL/DML on the per-mesh database (CREATE TABLE, INSERT, UPDATE, DELETE).",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
sql: { type: "string", description: "SQL statement" },
|
||||||
|
},
|
||||||
|
required: ["sql"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_schema",
|
||||||
|
description:
|
||||||
|
"List tables and columns in the per-mesh shared database.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Stream tools ---
|
||||||
|
{
|
||||||
|
name: "create_stream",
|
||||||
|
description:
|
||||||
|
"Create a real-time data stream in the mesh.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Stream name" },
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "publish",
|
||||||
|
description:
|
||||||
|
"Push data to a stream. Subscribers receive it in real-time.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
stream: { type: "string", description: "Stream name" },
|
||||||
|
data: { description: "Any JSON data to publish" },
|
||||||
|
},
|
||||||
|
required: ["stream", "data"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subscribe",
|
||||||
|
description:
|
||||||
|
"Subscribe to a stream. Data pushes arrive as channel notifications.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
stream: { type: "string", description: "Stream name" },
|
||||||
|
},
|
||||||
|
required: ["stream"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_streams",
|
||||||
|
description:
|
||||||
|
"List active streams in the mesh.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Context tools ---
|
||||||
|
{
|
||||||
|
name: "share_context",
|
||||||
|
description:
|
||||||
|
"Share your session understanding with the mesh. Call after exploring a codebase area.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
summary: {
|
||||||
|
type: "string",
|
||||||
|
description: "Summary of what you explored/learned",
|
||||||
|
},
|
||||||
|
files_read: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "File paths you read",
|
||||||
|
},
|
||||||
|
key_findings: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Key findings or insights",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Tags for categorization",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["summary"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_context",
|
||||||
|
description:
|
||||||
|
"Find context from peers who explored an area. Check before re-reading files another peer already analyzed.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description: "Search query (file path, topic, etc.)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_contexts",
|
||||||
|
description: "See what all peers currently know about the codebase.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Task tools ---
|
||||||
|
{
|
||||||
|
name: "create_task",
|
||||||
|
description: "Create a work item for the mesh.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: { type: "string", description: "Task title" },
|
||||||
|
assignee: {
|
||||||
|
type: "string",
|
||||||
|
description: "Peer name to assign (optional)",
|
||||||
|
},
|
||||||
|
priority: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["low", "normal", "high", "urgent"],
|
||||||
|
description: "Priority level (default: normal)",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Tags for categorization",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["title"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "claim_task",
|
||||||
|
description: "Claim an unclaimed task to take ownership.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "Task ID" },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complete_task",
|
||||||
|
description: "Mark a task as done with an optional result summary.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "Task ID" },
|
||||||
|
result: {
|
||||||
|
type: "string",
|
||||||
|
description: "Summary of what was done",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_tasks",
|
||||||
|
description: "List tasks filtered by status and/or assignee.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["open", "claimed", "completed"],
|
||||||
|
description: "Filter by status",
|
||||||
|
},
|
||||||
|
assignee: {
|
||||||
|
type: "string",
|
||||||
|
description: "Filter by assignee name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Scheduled messages ---
|
||||||
|
{
|
||||||
|
name: "schedule_reminder",
|
||||||
|
description:
|
||||||
|
"Schedule a one-shot or recurring message. Without `to`, it fires back to yourself (a self-reminder). With `to`, it delivers to a peer, @group, or * broadcast. For one-shot, provide `deliver_at` or `in_seconds`. For recurring, provide `cron` (standard 5-field expression). The broker persists schedules to the database — they survive restarts. Receivers see `subtype: reminder` in the push envelope.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
message: { type: "string", description: "Message or reminder text" },
|
||||||
|
deliver_at: { type: "number", description: "Unix timestamp (ms) when to deliver (one-shot)" },
|
||||||
|
in_seconds: { type: "number", description: "Alternative to deliver_at: fire after N seconds (one-shot)" },
|
||||||
|
cron: { type: "string", description: "Cron expression for recurring reminders (e.g. '0 */2 * * *' for every 2 hours, '30 9 * * 1-5' for 9:30 weekdays)" },
|
||||||
|
to: {
|
||||||
|
type: "string",
|
||||||
|
description: "Recipient: display name, pubkey hex, @group, or * (omit for self-reminder)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["message"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_scheduled",
|
||||||
|
description: "List all your pending scheduled messages: id, recipient, preview, and delivery time.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cancel_scheduled",
|
||||||
|
description: "Cancel a pending scheduled message before it fires.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string", description: "Scheduled message ID" },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Mesh info ---
|
||||||
|
{
|
||||||
|
name: "mesh_info",
|
||||||
|
description:
|
||||||
|
"Get a complete overview of the mesh: peers, groups, state, memory, files, tasks, streams, tables. Call on session start for full situational awareness.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Stats ---
|
||||||
|
{
|
||||||
|
name: "mesh_stats",
|
||||||
|
description:
|
||||||
|
"View resource usage stats for all peers: messages sent/received, tool calls, uptime, errors.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- MCP Proxy ---
|
||||||
|
{
|
||||||
|
name: "mesh_mcp_register",
|
||||||
|
description:
|
||||||
|
"Register an MCP server with the mesh. Other peers can invoke its tools through the mesh without restarting their sessions. Provide the server name, description, and full tool definitions.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
server_name: { type: "string", description: "Unique name for the MCP server (e.g. 'github', 'jira')" },
|
||||||
|
description: { type: "string", description: "What this MCP server does" },
|
||||||
|
tools: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
description: { type: "string" },
|
||||||
|
inputSchema: { type: "object", description: "JSON Schema for tool arguments" },
|
||||||
|
},
|
||||||
|
required: ["name", "description", "inputSchema"],
|
||||||
|
},
|
||||||
|
description: "Tool definitions to expose",
|
||||||
|
},
|
||||||
|
persistent: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "If true, registration survives peer disconnect. Other peers see it as 'offline' until you reconnect. Default: false",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["server_name", "description", "tools"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_mcp_list",
|
||||||
|
description:
|
||||||
|
"List MCP servers available in the mesh with their tools. Shows which peer hosts each server.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_tool_call",
|
||||||
|
description:
|
||||||
|
"Call a tool on a mesh-registered MCP server. Route: you -> broker -> hosting peer -> execute -> result back. Timeout: 30s.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
server_name: { type: "string", description: "Name of the MCP server" },
|
||||||
|
tool_name: { type: "string", description: "Name of the tool to call" },
|
||||||
|
args: { type: "object", description: "Tool arguments (JSON object)" },
|
||||||
|
},
|
||||||
|
required: ["server_name", "tool_name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_mcp_remove",
|
||||||
|
description:
|
||||||
|
"Unregister an MCP server you previously registered with the mesh.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
server_name: { type: "string", description: "Name of the MCP server to remove" },
|
||||||
|
},
|
||||||
|
required: ["server_name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
// --- Simulation clock tools ---
|
||||||
|
{
|
||||||
|
name: "mesh_set_clock",
|
||||||
|
description:
|
||||||
|
"Set the simulation clock speed. x1 = real-time, x10 = 10x faster, x100 = 100x. Peers receive heartbeat ticks at the simulated rate.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
speed: {
|
||||||
|
type: "number",
|
||||||
|
description: "Speed multiplier (1-100). x1 = tick every 60s, x10 = tick every 6s, x100 = tick every 600ms.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["speed"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_pause_clock",
|
||||||
|
description:
|
||||||
|
"Pause the simulation clock. Ticks stop until resumed.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_resume_clock",
|
||||||
|
description:
|
||||||
|
"Resume a paused simulation clock.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_clock",
|
||||||
|
description:
|
||||||
|
"Get current simulation clock status: speed, tick count, simulated time.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Skills ---
|
||||||
|
{
|
||||||
|
name: "share_skill",
|
||||||
|
description:
|
||||||
|
"Publish a reusable skill to the mesh. Other peers can discover and load it as a slash command. If a skill with the same name exists, it is updated. Skills are automatically exposed as MCP prompts and skill:// resources for native Claude Code integration.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Unique skill name (e.g. 'code-review', 'deploy-checklist'). Becomes the slash command name." },
|
||||||
|
description: { type: "string", description: "Short description of what the skill does" },
|
||||||
|
instructions: { type: "string", description: "Full instructions/prompt markdown. Can include frontmatter (---) block." },
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Tags for discoverability",
|
||||||
|
},
|
||||||
|
when_to_use: { type: "string", description: "Detailed description of when Claude should auto-invoke this skill" },
|
||||||
|
allowed_tools: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
description: "Tool names this skill is allowed to use (e.g. ['Bash', 'Read', 'Edit'])",
|
||||||
|
},
|
||||||
|
model: { type: "string", description: "Model override (e.g. 'sonnet', 'opus', 'haiku')" },
|
||||||
|
context: { type: "string", enum: ["inline", "fork"], description: "Execution context: 'inline' (default) or 'fork' (sub-agent)" },
|
||||||
|
agent: { type: "string", description: "Agent type when forked (e.g. 'general-purpose')" },
|
||||||
|
user_invocable: { type: "boolean", description: "Whether users can invoke via /skill-name (default: true)" },
|
||||||
|
argument_hint: { type: "string", description: "Hint text for arguments (e.g. '<file-path>')" },
|
||||||
|
},
|
||||||
|
required: ["name", "description", "instructions"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get_skill",
|
||||||
|
description:
|
||||||
|
"Load a skill's full instructions by name. Use to acquire capabilities shared by other peers.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Skill name to load" },
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_skills",
|
||||||
|
description:
|
||||||
|
"Browse available skills in the mesh. Optionally filter by keyword across name, description, and tags.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "Search keyword (optional)" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove_skill",
|
||||||
|
description:
|
||||||
|
"Remove a skill you published from the mesh.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Skill name to remove" },
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Diagnostics ---
|
||||||
|
{
|
||||||
|
name: "ping_mesh",
|
||||||
|
description:
|
||||||
|
"Send test messages through the full pipeline and measure round-trip timing per priority. Diagnoses push delivery issues.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
priorities: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string", enum: ["now", "next", "low"] },
|
||||||
|
description: "Priorities to test (default: [\"now\", \"next\"])",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Peer file sharing ---
|
||||||
|
{
|
||||||
|
name: "read_peer_file",
|
||||||
|
description:
|
||||||
|
"Read a file from another peer's project. Specify the peer (by name) and the file path relative to their working directory. The peer must be online and sharing files. Max file size: 1MB.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
peer: { type: "string", description: "Peer display name or pubkey" },
|
||||||
|
path: { type: "string", description: "File path relative to peer's working directory" },
|
||||||
|
},
|
||||||
|
required: ["peer", "path"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_peer_files",
|
||||||
|
description:
|
||||||
|
"List files in a peer's shared directory. Returns a tree of file names (not contents). The peer must be online and sharing files.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
peer: { type: "string", description: "Peer display name or pubkey" },
|
||||||
|
path: { type: "string", description: "Directory path relative to peer's cwd (default: root)" },
|
||||||
|
pattern: { type: "string", description: "Glob-like filter pattern (e.g. '*.ts', 'src/*')" },
|
||||||
|
},
|
||||||
|
required: ["peer"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Webhooks ---
|
||||||
|
{
|
||||||
|
name: "create_webhook",
|
||||||
|
description:
|
||||||
|
"Create an inbound webhook. Returns a URL that external services (GitHub, CI/CD, monitoring) can POST to — the payload becomes a mesh message to all peers.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
description: "Webhook name (e.g. 'github-ci', 'datadog-alerts')",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_webhooks",
|
||||||
|
description: "List active webhooks for this mesh.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete_webhook",
|
||||||
|
description: "Deactivate a webhook.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", description: "Webhook name to deactivate" },
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Service deployment tools ---
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "mesh_mcp_deploy",
|
||||||
|
description: "Deploy an MCP server to the mesh from a zip file or git repo. Runs on the broker VPS, persists across peer sessions. Default scope: private (only you).",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
server_name: { type: "string", description: "Unique name for the server in this mesh" },
|
||||||
|
file_id: { type: "string", description: "File ID of uploaded zip (from share_file)" },
|
||||||
|
git_url: { type: "string", description: "Git repo URL" },
|
||||||
|
git_branch: { type: "string", description: "Branch to clone (default: main)" },
|
||||||
|
npx_package: { type: "string", description: "npm package name to run via npx (e.g. @upstash/context7-mcp)" },
|
||||||
|
env: { type: "object", description: "Environment variables. Use $vault:<key> for vault secrets." },
|
||||||
|
runtime: { type: "string", enum: ["node", "python", "bun"], description: "Runtime (auto-detected if omitted)" },
|
||||||
|
memory_mb: { type: "number", description: "Memory limit in MB (default: 256)" },
|
||||||
|
network_allow: { type: "array", items: { type: "string" }, description: "Allowed outbound hosts (default: none)" },
|
||||||
|
scope: { description: "Visibility: 'peer' (default), 'mesh', or {group/groups/role/peers}" },
|
||||||
|
},
|
||||||
|
required: ["server_name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_mcp_undeploy",
|
||||||
|
description: "Stop and remove a managed MCP server from the mesh.",
|
||||||
|
inputSchema: { type: "object", properties: { server_name: { type: "string" } }, required: ["server_name"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_mcp_update",
|
||||||
|
description: "Pull latest code and restart a git-sourced MCP server.",
|
||||||
|
inputSchema: { type: "object", properties: { server_name: { type: "string" } }, required: ["server_name"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_mcp_logs",
|
||||||
|
description: "View recent logs from a managed MCP server.",
|
||||||
|
inputSchema: { type: "object", properties: { server_name: { type: "string" }, lines: { type: "number", description: "Lines (default: 50, max: 1000)" } }, required: ["server_name"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_mcp_scope",
|
||||||
|
description: "Get or set the visibility scope of a deployed MCP server.",
|
||||||
|
inputSchema: { type: "object", properties: { server_name: { type: "string" }, scope: { description: "New scope to set. Omit to read current." } }, required: ["server_name"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_mcp_schema",
|
||||||
|
description: "Inspect tool schemas for a deployed MCP server.",
|
||||||
|
inputSchema: { type: "object", properties: { server_name: { type: "string" }, tool_name: { type: "string", description: "Specific tool (omit for all)" } }, required: ["server_name"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_mcp_catalog",
|
||||||
|
description: "List all deployed services in the mesh with status, scope, and tool count.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Skill deployment tools ---
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "mesh_skill_deploy",
|
||||||
|
description: "Deploy a multi-file skill bundle from a zip or git repo.",
|
||||||
|
inputSchema: { type: "object", properties: { file_id: { type: "string" }, git_url: { type: "string" }, git_branch: { type: "string" } } },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Vault tools ---
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "vault_set",
|
||||||
|
description: "Store an encrypted credential in your vault. Reference in mesh_mcp_deploy with $vault:<key>.",
|
||||||
|
inputSchema: { type: "object", properties: { key: { type: "string" }, value: { type: "string", description: "Secret value or local file path (for type=file)" }, type: { type: "string", enum: ["env", "file"] }, mount_path: { type: "string" }, description: { type: "string" } }, required: ["key", "value"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "vault_list",
|
||||||
|
description: "List your vault entries (keys and metadata only, no secret values).",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "vault_delete",
|
||||||
|
description: "Remove a credential from your vault.",
|
||||||
|
inputSchema: { type: "object", properties: { key: { type: "string" } }, required: ["key"] },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- URL Watch tools ---
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "mesh_watch",
|
||||||
|
description: "Watch a URL for changes. The broker polls it at the given interval and notifies you when the response changes. Works with any URL — websites (hash mode), JSON APIs (json mode), or status codes (status mode).",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
url: { type: "string", description: "URL to watch" },
|
||||||
|
mode: { type: "string", enum: ["hash", "json", "status"], description: "Detection mode: hash (SHA-256 of body), json (extract jsonpath value), status (HTTP status code). Default: hash" },
|
||||||
|
extract: { type: "string", description: "For json mode: dot path to extract (e.g. 'status' or 'data.deployments[0].status')" },
|
||||||
|
interval: { type: "number", description: "Poll interval in seconds (min: 5, default: 30)" },
|
||||||
|
notify_on: { type: "string", description: "When to notify: 'change' (default), 'match:<value>', 'not_match:<value>'" },
|
||||||
|
headers: { type: "object", description: "Optional HTTP headers (e.g. for auth)" },
|
||||||
|
label: { type: "string", description: "Human-readable label for this watch" },
|
||||||
|
},
|
||||||
|
required: ["url"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_unwatch",
|
||||||
|
description: "Stop watching a URL.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { watch_id: { type: "string" } },
|
||||||
|
required: ["watch_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mesh_watches",
|
||||||
|
description: "List your active URL watches.",
|
||||||
|
inputSchema: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export type Priority = "now" | "next" | "low";
|
|||||||
export type PeerStatus = "idle" | "working" | "dnd";
|
export type PeerStatus = "idle" | "working" | "dnd";
|
||||||
|
|
||||||
export interface SendMessageArgs {
|
export interface SendMessageArgs {
|
||||||
to: string; // peer name, pubkey, or #channel
|
to: string | string[]; // peer name, pubkey, @group, or array of targets
|
||||||
message: string;
|
message: string;
|
||||||
priority?: Priority;
|
priority?: Priority;
|
||||||
}
|
}
|
||||||
@@ -22,3 +22,60 @@ export interface SetSummaryArgs {
|
|||||||
export interface SetStatusArgs {
|
export interface SetStatusArgs {
|
||||||
status: PeerStatus;
|
status: PeerStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Service deployment types ---
|
||||||
|
|
||||||
|
export type ServiceScope =
|
||||||
|
| "peer"
|
||||||
|
| "mesh"
|
||||||
|
| { peers: string[] }
|
||||||
|
| { group: string }
|
||||||
|
| { groups: string[] }
|
||||||
|
| { role: string };
|
||||||
|
|
||||||
|
export interface ServiceInfo {
|
||||||
|
name: string;
|
||||||
|
type: "mcp" | "skill";
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
tool_count: number;
|
||||||
|
deployed_by: string;
|
||||||
|
scope: ServiceScope;
|
||||||
|
source_type: string;
|
||||||
|
runtime?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceToolSchema {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VaultEntry {
|
||||||
|
key: string;
|
||||||
|
entry_type: "env" | "file";
|
||||||
|
mount_path?: string;
|
||||||
|
description?: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeshMcpDeployArgs {
|
||||||
|
server_name: string;
|
||||||
|
file_id?: string;
|
||||||
|
git_url?: string;
|
||||||
|
git_branch?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
runtime?: "node" | "python" | "bun";
|
||||||
|
memory_mb?: number;
|
||||||
|
network_allow?: string[];
|
||||||
|
scope?: ServiceScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VaultSetArgs {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
type?: "env" | "file";
|
||||||
|
mount_path?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,38 +15,57 @@ import {
|
|||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { z } from "zod";
|
|
||||||
import { env } from "../env";
|
import { env } from "../env";
|
||||||
|
|
||||||
const joinedMeshSchema = z.object({
|
export interface JoinedMesh {
|
||||||
meshId: z.string(),
|
meshId: string;
|
||||||
memberId: z.string(),
|
memberId: string;
|
||||||
slug: z.string(),
|
slug: string;
|
||||||
name: z.string(),
|
name: string;
|
||||||
pubkey: z.string(), // ed25519 hex (32 bytes = 64 chars)
|
pubkey: string; // ed25519 hex (32 bytes = 64 chars)
|
||||||
secretKey: z.string(), // ed25519 hex (64 bytes = 128 chars)
|
secretKey: string; // ed25519 hex (64 bytes = 128 chars)
|
||||||
brokerUrl: z.string(),
|
brokerUrl: string;
|
||||||
joinedAt: z.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;
|
||||||
|
}
|
||||||
|
|
||||||
const configSchema = z.object({
|
export interface GroupEntry {
|
||||||
version: z.literal(1).default(1),
|
name: string;
|
||||||
meshes: z.array(joinedMeshSchema).default([]),
|
role?: string;
|
||||||
});
|
}
|
||||||
|
|
||||||
export type JoinedMesh = z.infer<typeof joinedMeshSchema>;
|
export interface Config {
|
||||||
export type Config = z.infer<typeof configSchema>;
|
version: 1;
|
||||||
|
meshes: JoinedMesh[];
|
||||||
|
displayName?: string; // per-session override, written by `claudemesh launch --name`
|
||||||
|
role?: string; // per-session role tag (display + hello)
|
||||||
|
groups?: GroupEntry[];
|
||||||
|
messageMode?: "push" | "inbox" | "off";
|
||||||
|
accountId?: string; // linked dashboard user ID (from CLI sync flow)
|
||||||
|
}
|
||||||
|
|
||||||
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
const CONFIG_DIR = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||||
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
||||||
|
|
||||||
export function loadConfig(): Config {
|
export function loadConfig(): Config {
|
||||||
if (!existsSync(CONFIG_PATH)) {
|
if (!existsSync(CONFIG_PATH)) {
|
||||||
return configSchema.parse({ version: 1, meshes: [] });
|
return { version: 1, meshes: [] };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
||||||
return configSchema.parse(JSON.parse(raw));
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || !Array.isArray(parsed.meshes)) {
|
||||||
|
return { version: 1, meshes: [] };
|
||||||
|
}
|
||||||
|
return { version: 1, meshes: parsed.meshes, displayName: parsed.displayName, role: parsed.role, groups: parsed.groups, messageMode: parsed.messageMode, accountId: parsed.accountId };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
`Failed to load ${CONFIG_PATH}: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
|||||||
17
apps/cli/src/templates/dev-team.json
Normal file
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
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
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
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
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
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."
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -11,15 +11,21 @@ import type { Config, JoinedMesh } from "../state/config";
|
|||||||
import { env } from "../env";
|
import { env } from "../env";
|
||||||
|
|
||||||
const clients = new Map<string, BrokerClient>();
|
const clients = new Map<string, BrokerClient>();
|
||||||
|
let configDisplayName: string | undefined;
|
||||||
|
let configGroups: Config["groups"] = [];
|
||||||
|
|
||||||
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
|
||||||
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
||||||
const existing = clients.get(mesh.meshId);
|
const existing = clients.get(mesh.meshId);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
const client = new BrokerClient(mesh, { debug: env.CLAUDEMESH_DEBUG });
|
const client = new BrokerClient(mesh, { debug: env.CLAUDEMESH_DEBUG, displayName: configDisplayName });
|
||||||
clients.set(mesh.meshId, client);
|
clients.set(mesh.meshId, client);
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
// Auto-join groups declared at launch time (--groups flag or config).
|
||||||
|
for (const g of configGroups ?? []) {
|
||||||
|
try { await client.joinGroup(g.name, g.role); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Connect failed → client is in "reconnecting" state, leave it
|
// Connect failed → client is in "reconnecting" state, leave it
|
||||||
// wired so tool calls can surface the status.
|
// wired so tool calls can surface the status.
|
||||||
@@ -29,6 +35,8 @@ export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
|
|||||||
|
|
||||||
/** Start clients for every joined mesh. Called once on MCP server start. */
|
/** Start clients for every joined mesh. Called once on MCP server start. */
|
||||||
export async function startClients(config: Config): Promise<void> {
|
export async function startClients(config: Config): Promise<void> {
|
||||||
|
configDisplayName = config.displayName;
|
||||||
|
configGroups = config.groups ?? [];
|
||||||
await Promise.allSettled(config.meshes.map(ensureClient));
|
await Promise.allSettled(config.meshes.map(ensureClient));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
apps/cli/vitest.config.ts
Normal file
7
apps/cli/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/__tests__/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
38
apps/runner/Dockerfile
Normal file
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
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
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
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
759
apps/telegram/src/index.ts
Normal file
@@ -0,0 +1,759 @@
|
|||||||
|
/**
|
||||||
|
* Claudemesh ↔ Telegram Bridge
|
||||||
|
*
|
||||||
|
* Joins the mesh as a peer named "telegram-bridge", relays messages
|
||||||
|
* between a Telegram chat and mesh peers.
|
||||||
|
*
|
||||||
|
* Telegram → Mesh:
|
||||||
|
* "@Mou fix the bug" → send_message(to: "Mou", message: "fix the bug")
|
||||||
|
* "/peers" → list_peers → reply with online list
|
||||||
|
* "/broadcast hello" → send_message(to: "*", message: "hello")
|
||||||
|
* "any text" → send_message(to: "*", message: text) (broadcast)
|
||||||
|
*
|
||||||
|
* Mesh → Telegram:
|
||||||
|
* Any push message addressed to this peer → forward to Telegram chat
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Bot, InputFile } from "grammy";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
|
||||||
|
// --- Config ---
|
||||||
|
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
|
||||||
|
const ALLOWED_CHAT_IDS = (process.env.TELEGRAM_CHAT_IDS ?? "").split(",").filter(Boolean).map(Number);
|
||||||
|
const CONFIG_DIR = process.env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||||
|
const DISPLAY_NAME = process.env.BRIDGE_NAME ?? "telegram-bridge";
|
||||||
|
|
||||||
|
if (!BOT_TOKEN) {
|
||||||
|
console.error("TELEGRAM_BOT_TOKEN required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Load mesh config ---
|
||||||
|
interface JoinedMesh {
|
||||||
|
meshId: string;
|
||||||
|
memberId: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
pubkey: string;
|
||||||
|
secretKey: string;
|
||||||
|
brokerUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMeshConfig(): JoinedMesh[] {
|
||||||
|
// Support env-based config for Docker/VPS deployment
|
||||||
|
if (process.env.MESH_ID && process.env.MESH_MEMBER_ID && process.env.MESH_PUBKEY && process.env.MESH_SECRET_KEY) {
|
||||||
|
return [{
|
||||||
|
meshId: process.env.MESH_ID,
|
||||||
|
memberId: process.env.MESH_MEMBER_ID,
|
||||||
|
slug: process.env.MESH_SLUG ?? "mesh",
|
||||||
|
name: process.env.MESH_NAME ?? "mesh",
|
||||||
|
pubkey: process.env.MESH_PUBKEY,
|
||||||
|
secretKey: process.env.MESH_SECRET_KEY,
|
||||||
|
brokerUrl: process.env.MESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
// Fall back to config file
|
||||||
|
const path = join(CONFIG_DIR, "config.json");
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
console.error(`No config at ${path} — set MESH_ID/MESH_MEMBER_ID/MESH_PUBKEY/MESH_SECRET_KEY env vars or run 'claudemesh join' first`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const config = JSON.parse(readFileSync(path, "utf-8"));
|
||||||
|
return config.meshes ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Crypto ---
|
||||||
|
let sodiumReady = false;
|
||||||
|
|
||||||
|
async function ensureSodium() {
|
||||||
|
if (!sodiumReady) {
|
||||||
|
await sodium.ready;
|
||||||
|
sodiumReady = true;
|
||||||
|
}
|
||||||
|
return sodium;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateSessionKeypair() {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const kp = s.crypto_sign_keypair();
|
||||||
|
return {
|
||||||
|
publicKey: s.to_hex(kp.publicKey),
|
||||||
|
secretKey: s.to_hex(kp.privateKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signHello(meshId: string, memberId: string, pubkey: string, secretKeyHex: string) {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const canonical = `${meshId}|${memberId}|${pubkey}|${timestamp}`;
|
||||||
|
const sig = s.crypto_sign_detached(s.from_string(canonical), s.from_hex(secretKeyHex));
|
||||||
|
return { timestamp, signature: s.to_hex(sig) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt a direct message envelope using crypto_box (X25519). */
|
||||||
|
async function decryptDirect(
|
||||||
|
nonce: string,
|
||||||
|
ciphertext: string,
|
||||||
|
senderPubkeyHex: string,
|
||||||
|
recipientSecretKeyHex: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
try {
|
||||||
|
const senderPub = s.crypto_sign_ed25519_pk_to_curve25519(s.from_hex(senderPubkeyHex));
|
||||||
|
const recipientSec = s.crypto_sign_ed25519_sk_to_curve25519(s.from_hex(recipientSecretKeyHex));
|
||||||
|
const nonceBytes = s.from_base64(nonce, s.base64_variants.ORIGINAL);
|
||||||
|
const ciphertextBytes = s.from_base64(ciphertext, s.base64_variants.ORIGINAL);
|
||||||
|
const plain = s.crypto_box_open_easy(ciphertextBytes, nonceBytes, senderPub, recipientSec);
|
||||||
|
return s.to_string(plain);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mesh WS Client (simplified) ---
|
||||||
|
interface PeerInfo {
|
||||||
|
displayName: string;
|
||||||
|
pubkey: string;
|
||||||
|
status: string;
|
||||||
|
summary?: string;
|
||||||
|
cwd?: string;
|
||||||
|
groups?: string[];
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MeshBridge {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private mesh: JoinedMesh;
|
||||||
|
private sessionPubkey: string | null = null;
|
||||||
|
private sessionSecretKey: string | null = null;
|
||||||
|
private connected = false;
|
||||||
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
|
private reconnectAttempt = 0;
|
||||||
|
private onMessage: (from: string, text: string, priority: string) => void;
|
||||||
|
private resolvers = new Map<string, { resolve: (v: any) => void; timer: NodeJS.Timeout }>();
|
||||||
|
/** Map pubkey → {name, avatar}, populated from list_peers */
|
||||||
|
private peerInfo = new Map<string, { name: string; avatar?: string }>();
|
||||||
|
|
||||||
|
constructor(mesh: JoinedMesh, onMessage: (from: string, text: string, priority: string) => void) {
|
||||||
|
this.mesh = mesh;
|
||||||
|
this.onMessage = onMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
const sessionKP = await generateSessionKeypair();
|
||||||
|
this.sessionPubkey = sessionKP.publicKey;
|
||||||
|
this.sessionSecretKey = sessionKP.secretKey;
|
||||||
|
return this._connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _connect(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(this.mesh.brokerUrl);
|
||||||
|
this.ws = ws;
|
||||||
|
|
||||||
|
ws.on("open", async () => {
|
||||||
|
try {
|
||||||
|
const { timestamp, signature } = await signHello(
|
||||||
|
this.mesh.meshId, this.mesh.memberId,
|
||||||
|
this.mesh.pubkey, this.mesh.secretKey,
|
||||||
|
);
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
meshId: this.mesh.meshId,
|
||||||
|
memberId: this.mesh.memberId,
|
||||||
|
pubkey: this.mesh.pubkey,
|
||||||
|
sessionPubkey: this.sessionPubkey,
|
||||||
|
displayName: DISPLAY_NAME,
|
||||||
|
sessionId: `telegram-${process.pid}-${Date.now()}`,
|
||||||
|
pid: process.pid,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
hostname: require("os").hostname(),
|
||||||
|
peerType: "bridge",
|
||||||
|
channel: "telegram",
|
||||||
|
timestamp,
|
||||||
|
signature,
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const helloTimeout = setTimeout(() => {
|
||||||
|
ws.close();
|
||||||
|
reject(new Error("hello_ack timeout"));
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
ws.on("message", async (raw) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(raw.toString());
|
||||||
|
if (msg.type !== "hello_ack" && msg.type !== "ack") {
|
||||||
|
console.log(`[mesh] recv: ${msg.type}${msg.subtype ? '/' + msg.subtype : ''}${msg.event ? '/' + msg.event : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "hello_ack") {
|
||||||
|
clearTimeout(helloTimeout);
|
||||||
|
this.connected = true;
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
console.log(`[mesh] connected to ${this.mesh.slug} as ${DISPLAY_NAME}`);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push messages from peers
|
||||||
|
if (msg.type === "push") {
|
||||||
|
let text: string | null = null;
|
||||||
|
const senderPubkey = msg.senderPubkey ?? msg.senderSessionPubkey;
|
||||||
|
|
||||||
|
// System messages (no encryption)
|
||||||
|
if (msg.subtype === "system") {
|
||||||
|
const event = msg.event ?? "";
|
||||||
|
const data = msg.eventData ?? {};
|
||||||
|
if (event === "peer_joined") text = `[joined] ${data.displayName ?? "peer"}`;
|
||||||
|
else if (event === "peer_left") text = `[left] ${data.displayName ?? "peer"}`;
|
||||||
|
else if (event === "peer_returned") text = `[returned] ${data.name ?? "peer"}`;
|
||||||
|
else text = msg.plaintext ?? `[${event}]`;
|
||||||
|
}
|
||||||
|
// Encrypted direct message
|
||||||
|
else if (senderPubkey && msg.nonce && msg.ciphertext) {
|
||||||
|
// Try session key first, then mesh member key
|
||||||
|
text = await decryptDirect(msg.nonce, msg.ciphertext, senderPubkey, this.sessionSecretKey!)
|
||||||
|
?? await decryptDirect(msg.nonce, msg.ciphertext, senderPubkey, this.mesh.secretKey);
|
||||||
|
if (!text) text = "[could not decrypt]";
|
||||||
|
}
|
||||||
|
// Plaintext fallback (broadcasts, legacy)
|
||||||
|
else if (msg.plaintext) {
|
||||||
|
text = msg.plaintext;
|
||||||
|
}
|
||||||
|
// Base64 ciphertext without nonce (legacy broadcast)
|
||||||
|
else if (msg.ciphertext && !msg.nonce) {
|
||||||
|
try { text = Buffer.from(msg.ciphertext, "base64").toString("utf-8"); } catch { text = "[decode error]"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
const info = senderPubkey ? this.peerInfo.get(senderPubkey) : null;
|
||||||
|
const fromName = info?.name ?? (senderPubkey?.slice(0, 12) ?? "system");
|
||||||
|
const avatar = info?.avatar ?? "🤖";
|
||||||
|
console.log(`[mesh] push from ${avatar} ${fromName}: ${text.slice(0, 80)}`);
|
||||||
|
this.onMessage(`${avatar} ${fromName}`, text, msg.priority ?? "next");
|
||||||
|
} else {
|
||||||
|
console.log(`[mesh] push with no text. subtype=${msg.subtype}, hasSender=${!!senderPubkey}, hasNonce=${!!msg.nonce}, hasCipher=${!!msg.ciphertext}, hasPlain=${!!msg.plaintext}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve pending requests
|
||||||
|
const reqId = msg._reqId;
|
||||||
|
if (reqId && this.resolvers.has(reqId)) {
|
||||||
|
const r = this.resolvers.get(reqId)!;
|
||||||
|
clearTimeout(r.timer);
|
||||||
|
this.resolvers.delete(reqId);
|
||||||
|
r.resolve(msg);
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
this.connected = false;
|
||||||
|
this.ws = null;
|
||||||
|
if (this.reconnectTimer) return;
|
||||||
|
const delays = [1000, 2000, 4000, 8000, 16000, 30000];
|
||||||
|
const delay = delays[Math.min(this.reconnectAttempt, delays.length - 1)]!;
|
||||||
|
this.reconnectAttempt++;
|
||||||
|
console.log(`[mesh] reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
this._connect().catch(e => console.error("[mesh] reconnect failed:", e));
|
||||||
|
}, delay);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", (err) => {
|
||||||
|
console.error("[mesh] ws error:", err.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeReqId(): string {
|
||||||
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
private request(msg: Record<string, unknown>, timeout = 10_000): Promise<any> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reqId = this.makeReqId();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.resolvers.delete(reqId);
|
||||||
|
resolve(null);
|
||||||
|
}, timeout);
|
||||||
|
this.resolvers.set(reqId, { resolve, timer });
|
||||||
|
this.ws?.send(JSON.stringify({ ...msg, _reqId: reqId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(to: string, message: string, priority: string = "next"): Promise<boolean> {
|
||||||
|
if (!this.ws || !this.connected) return false;
|
||||||
|
|
||||||
|
// For direct targets (pubkeys), use crypto_box encryption.
|
||||||
|
// For broadcasts/groups, use base64-encoded plaintext (legacy format).
|
||||||
|
let nonce = "";
|
||||||
|
let ciphertext = "";
|
||||||
|
const isDirect = /^[0-9a-f]{64}$/.test(to);
|
||||||
|
if (isDirect) {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const recipientPub = s.crypto_sign_ed25519_pk_to_curve25519(s.from_hex(to));
|
||||||
|
const senderSec = s.crypto_sign_ed25519_sk_to_curve25519(s.from_hex(this.sessionSecretKey!));
|
||||||
|
const nonceBytes = s.randombytes_buf(s.crypto_box_NONCEBYTES);
|
||||||
|
const ciphertextBytes = s.crypto_box_easy(s.from_string(message), nonceBytes, recipientPub, senderSec);
|
||||||
|
nonce = s.to_base64(nonceBytes, s.base64_variants.ORIGINAL);
|
||||||
|
ciphertext = s.to_base64(ciphertextBytes, s.base64_variants.ORIGINAL);
|
||||||
|
} else {
|
||||||
|
// Broadcast/group: base64 plaintext (CLI decodes this when no nonce present)
|
||||||
|
ciphertext = Buffer.from(message, "utf-8").toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.makeReqId();
|
||||||
|
console.log(`[mesh] sending to ${to.slice(0, 16)}…, encrypted=${isDirect}`);
|
||||||
|
this.ws.send(JSON.stringify({
|
||||||
|
type: "send",
|
||||||
|
id,
|
||||||
|
targetSpec: to,
|
||||||
|
priority,
|
||||||
|
nonce,
|
||||||
|
ciphertext,
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find all peers matching a display name. */
|
||||||
|
async findPeersByName(name: string): Promise<PeerInfo[]> {
|
||||||
|
const peers = await this.listPeers();
|
||||||
|
return peers.filter(p => p.displayName.toLowerCase() === name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload a file to the mesh via broker HTTP. Returns file ID. */
|
||||||
|
async uploadFile(data: Buffer, fileName: string, tags?: string[]): Promise<string | null> {
|
||||||
|
const brokerHttp = this.mesh.brokerUrl.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${brokerHttp}/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"X-Mesh-Id": this.mesh.meshId,
|
||||||
|
"X-Member-Id": this.mesh.memberId,
|
||||||
|
"X-File-Name": fileName,
|
||||||
|
"X-Tags": JSON.stringify(tags ?? ["telegram"]),
|
||||||
|
"X-Persistent": "true",
|
||||||
|
},
|
||||||
|
body: data,
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
const body = await res.json() as { ok?: boolean; fileId?: string; error?: string };
|
||||||
|
if (!res.ok || !body.fileId) return null;
|
||||||
|
return body.fileId;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[mesh] upload failed:", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a download URL for a mesh file. */
|
||||||
|
async getFileUrl(fileId: string): Promise<{ url: string; name: string } | null> {
|
||||||
|
const resp = await this.request({ type: "get_file", fileId });
|
||||||
|
if (!resp?.url) return null;
|
||||||
|
return { url: resp.url, name: resp.name ?? "file" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPeers(): Promise<PeerInfo[]> {
|
||||||
|
const resp = await this.request({ type: "list_peers" });
|
||||||
|
if (!resp?.peers) return [];
|
||||||
|
return resp.peers.map((p: any) => {
|
||||||
|
const name = p.displayName ?? p.pubkey?.slice(0, 12) ?? "?";
|
||||||
|
const avatar = p.profile?.avatar;
|
||||||
|
// Cache pubkey → info for push message attribution
|
||||||
|
const info = { name, avatar };
|
||||||
|
if (p.pubkey) this.peerInfo.set(p.pubkey, info);
|
||||||
|
if (p.sessionPubkey) this.peerInfo.set(p.sessionPubkey, info);
|
||||||
|
return {
|
||||||
|
displayName: name,
|
||||||
|
pubkey: p.pubkey ?? "",
|
||||||
|
status: p.status ?? "unknown",
|
||||||
|
summary: p.summary,
|
||||||
|
cwd: p.cwd,
|
||||||
|
groups: p.groups?.map((g: any) => g.name) ?? [],
|
||||||
|
avatar: avatar,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh peer name cache. Called periodically. */
|
||||||
|
async refreshPeerNames(): Promise<void> {
|
||||||
|
await this.listPeers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSummary(summary: string): Promise<void> {
|
||||||
|
this.ws?.send(JSON.stringify({ type: "set_summary", summary }));
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||||
|
this.ws?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Resolve display name from peers ---
|
||||||
|
async function resolveTarget(bridge: MeshBridge, name: string): Promise<string> {
|
||||||
|
// If it starts with @, it's a group
|
||||||
|
if (name.startsWith("@")) return name;
|
||||||
|
// If *, broadcast
|
||||||
|
if (name === "*") return "*";
|
||||||
|
// Otherwise resolve as display name — the broker handles this via targetSpec
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Telegram Bot ---
|
||||||
|
async function main() {
|
||||||
|
const meshes = loadMeshConfig();
|
||||||
|
if (meshes.length === 0) {
|
||||||
|
console.error("No meshes joined — run 'claudemesh join' first");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bot = new Bot(BOT_TOKEN);
|
||||||
|
const bridges: MeshBridge[] = [];
|
||||||
|
|
||||||
|
// One bridge per mesh
|
||||||
|
for (const mesh of meshes) {
|
||||||
|
const bridge = new MeshBridge(mesh, (from, text, priority) => {
|
||||||
|
// Forward mesh messages to all allowed Telegram chats
|
||||||
|
const prefix = `[${mesh.slug}] ${from}`;
|
||||||
|
const formatted = `💬 *${prefix}*\n${text}`;
|
||||||
|
for (const chatId of ALLOWED_CHAT_IDS) {
|
||||||
|
bot.api.sendMessage(chatId, formatted, { parse_mode: "Markdown" }).catch(e => {
|
||||||
|
console.error(`[tg] failed to send to ${chatId}:`, e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bridge.connect();
|
||||||
|
await bridge.setSummary("Telegram bridge — relays messages between Telegram and mesh peers");
|
||||||
|
await bridge.refreshPeerNames();
|
||||||
|
bridges.push(bridge);
|
||||||
|
// Refresh peer names every 30s for display name resolution on pushes
|
||||||
|
setInterval(() => bridge.refreshPeerNames().catch(() => {}), 30_000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[mesh] failed to connect to ${mesh.slug}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bridges.length === 0) {
|
||||||
|
console.error("Failed to connect to any mesh");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultBridge = bridges[0]!;
|
||||||
|
|
||||||
|
// --- Bot commands ---
|
||||||
|
|
||||||
|
bot.command("peers", async (ctx) => {
|
||||||
|
if (!isAllowed(ctx.chat.id)) return;
|
||||||
|
const peers = await defaultBridge.listPeers();
|
||||||
|
if (peers.length === 0) {
|
||||||
|
await ctx.reply("No peers online.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = peers.map(p => {
|
||||||
|
const status = p.status === "idle" ? "🟢" : p.status === "working" ? "🟡" : "🔴";
|
||||||
|
const summary = p.summary ? ` — _${p.summary}_` : "";
|
||||||
|
return `${status} *${p.displayName}*${summary}`;
|
||||||
|
});
|
||||||
|
await ctx.reply(lines.join("\n"), { parse_mode: "Markdown" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pending messages waiting for peer selection (chatId → {message, matches})
|
||||||
|
const pendingDMs = new Map<number, { message: string; matches: PeerInfo[]; selected: Set<number> }>();
|
||||||
|
|
||||||
|
bot.command("dm", async (ctx) => {
|
||||||
|
if (!isAllowed(ctx.chat.id)) return;
|
||||||
|
const text = ctx.match;
|
||||||
|
if (!text) {
|
||||||
|
await ctx.reply("Usage: /dm <peer-name> <message>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const spaceIdx = text.indexOf(" ");
|
||||||
|
if (spaceIdx === -1) {
|
||||||
|
await ctx.reply("Usage: /dm <peer-name> <message>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = text.slice(0, spaceIdx);
|
||||||
|
const message = text.slice(spaceIdx + 1);
|
||||||
|
|
||||||
|
// Find matching peers
|
||||||
|
const matches = await defaultBridge.findPeersByName(target);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
await ctx.reply(`❌ No peer named "${target}" found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (matches.length === 1) {
|
||||||
|
// Single match — send directly
|
||||||
|
const ok = await defaultBridge.sendMessage(matches[0]!.pubkey, `[via Telegram] ${message}`, "now");
|
||||||
|
await ctx.reply(ok ? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}` : "❌ Not connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Multiple matches — show picker with individual + all option
|
||||||
|
pendingDMs.set(ctx.chat.id, { message, matches, selected: new Set() });
|
||||||
|
const buttons = matches.map((p, i) => {
|
||||||
|
const dir = p.cwd?.split("/").pop() ?? "?";
|
||||||
|
const avatar = p.avatar ?? "🤖";
|
||||||
|
return [{ text: `${avatar} ${p.displayName} (${dir})`, callback_data: `dm:${i}` }];
|
||||||
|
});
|
||||||
|
buttons.push([{ text: "📨 Send to ALL", callback_data: "dm:all" }]);
|
||||||
|
await ctx.reply(`Multiple "${target}" peers online. Pick one or all:`, {
|
||||||
|
reply_markup: { inline_keyboard: buttons },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command("broadcast", async (ctx) => {
|
||||||
|
if (!isAllowed(ctx.chat.id)) return;
|
||||||
|
const message = ctx.match;
|
||||||
|
if (!message) {
|
||||||
|
await ctx.reply("Usage: /broadcast <message>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await defaultBridge.sendMessage("*", `[via Telegram] ${message}`, "now");
|
||||||
|
await ctx.reply(ok ? "✅ Broadcast sent" : "❌ Not connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command("group", async (ctx) => {
|
||||||
|
if (!isAllowed(ctx.chat.id)) return;
|
||||||
|
const text = ctx.match;
|
||||||
|
if (!text) {
|
||||||
|
await ctx.reply("Usage: /group <@group-name> <message>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const spaceIdx = text.indexOf(" ");
|
||||||
|
if (spaceIdx === -1) {
|
||||||
|
await ctx.reply("Usage: /group <@group-name> <message>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = text.slice(0, spaceIdx);
|
||||||
|
const message = text.slice(spaceIdx + 1);
|
||||||
|
const ok = await defaultBridge.sendMessage(target, `[via Telegram] ${message}`, "now");
|
||||||
|
await ctx.reply(ok ? `✅ Sent to ${target}` : "❌ Not connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command("status", async (ctx) => {
|
||||||
|
if (!isAllowed(ctx.chat.id)) return;
|
||||||
|
const meshStatus = bridges.map(b =>
|
||||||
|
`${b.isConnected() ? "🟢" : "🔴"} Connected`
|
||||||
|
).join("\n");
|
||||||
|
await ctx.reply(`*Claudemesh Telegram Bridge*\n${meshStatus}`, { parse_mode: "Markdown" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- File: get a mesh file by ID ---
|
||||||
|
bot.command("file", async (ctx) => {
|
||||||
|
if (!isAllowed(ctx.chat.id)) return;
|
||||||
|
const fileId = ctx.match?.trim();
|
||||||
|
if (!fileId) {
|
||||||
|
await ctx.reply("Usage: /file <file-id>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = await defaultBridge.getFileUrl(fileId);
|
||||||
|
if (!file) {
|
||||||
|
await ctx.reply(`❌ File ${fileId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(file.url, { signal: AbortSignal.timeout(30_000) });
|
||||||
|
if (!resp.ok) { await ctx.reply(`❌ Download failed (${resp.status})`); return; }
|
||||||
|
const buf = Buffer.from(await resp.arrayBuffer());
|
||||||
|
await ctx.replyWithDocument(new InputFile(buf, file.name));
|
||||||
|
} catch (e) {
|
||||||
|
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bot.command("start", async (ctx) => {
|
||||||
|
if (!isAllowed(ctx.chat.id)) {
|
||||||
|
await ctx.reply("⛔ Not authorized. Add your chat ID to TELEGRAM_CHAT_IDS.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ctx.reply(
|
||||||
|
"🔗 *Claudemesh Telegram Bridge*\n\n" +
|
||||||
|
"Commands:\n" +
|
||||||
|
"• /peers — List online peers\n" +
|
||||||
|
"• /dm <name> <msg> — DM a specific peer\n" +
|
||||||
|
"• /broadcast <msg> — Message all peers\n" +
|
||||||
|
"• /group @name <msg> — Message a group\n" +
|
||||||
|
"• /file <id> — Download a mesh file\n" +
|
||||||
|
"• /status — Bridge connection status\n\n" +
|
||||||
|
"Send a photo/document to share it with the mesh.\n" +
|
||||||
|
"Or just type a message to broadcast it.",
|
||||||
|
{ parse_mode: "Markdown" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle inline keyboard callbacks for peer selection
|
||||||
|
bot.on("callback_query:data", async (ctx) => {
|
||||||
|
const data = ctx.callbackQuery.data;
|
||||||
|
const chatId = ctx.chat?.id;
|
||||||
|
if (!chatId || !data.startsWith("dm:")) {
|
||||||
|
await ctx.answerCallbackQuery();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = pendingDMs.get(chatId);
|
||||||
|
if (!pending) {
|
||||||
|
await ctx.answerCallbackQuery({ text: "Session expired. Send /dm again." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data === "dm:all") {
|
||||||
|
// Send to all matches
|
||||||
|
let sent = 0;
|
||||||
|
for (const p of pending.matches) {
|
||||||
|
const ok = await defaultBridge.sendMessage(p.pubkey, `[via Telegram] ${pending.message}`, "now");
|
||||||
|
if (ok) sent++;
|
||||||
|
}
|
||||||
|
pendingDMs.delete(chatId);
|
||||||
|
await ctx.answerCallbackQuery({ text: `Sent to ${sent} peers` });
|
||||||
|
await ctx.editMessageText(`✅ Sent to all ${sent} ${pending.matches[0]?.displayName ?? "?"} peers`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single selection: dm:0, dm:1, etc.
|
||||||
|
const idx = parseInt(data.slice(3));
|
||||||
|
const peer = pending.matches[idx];
|
||||||
|
if (!peer) {
|
||||||
|
await ctx.answerCallbackQuery({ text: "Invalid selection" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await defaultBridge.sendMessage(peer.pubkey, `[via Telegram] ${pending.message}`, "now");
|
||||||
|
pendingDMs.delete(chatId);
|
||||||
|
const dir = peer.cwd?.split("/").pop() ?? "?";
|
||||||
|
await ctx.answerCallbackQuery({ text: ok ? "Sent!" : "Failed" });
|
||||||
|
await ctx.editMessageText(ok ? `✅ → ${peer.avatar ?? "🤖"} ${peer.displayName} (${dir})` : "❌ Not connected");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle photos from Telegram → share to mesh
|
||||||
|
bot.on("message:photo", async (ctx) => {
|
||||||
|
if (!isAllowed(ctx.chat.id)) return;
|
||||||
|
const photo = ctx.message.photo.at(-1); // highest resolution
|
||||||
|
if (!photo) return;
|
||||||
|
try {
|
||||||
|
const file = await ctx.api.getFile(photo.file_id);
|
||||||
|
const url = `https://api.telegram.org/file/bot${BOT_TOKEN}/${file.file_path}`;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
const buf = Buffer.from(await resp.arrayBuffer());
|
||||||
|
const name = `telegram-photo-${Date.now()}.jpg`;
|
||||||
|
const fileId = await defaultBridge.uploadFile(buf, name, ["telegram", "photo"]);
|
||||||
|
if (fileId) {
|
||||||
|
const caption = ctx.message.caption ? ` — "${ctx.message.caption}"` : "";
|
||||||
|
await defaultBridge.sendMessage("*", `[via Telegram] 📷 Photo shared${caption} (file: ${fileId})`, "next");
|
||||||
|
await ctx.reply(`✅ Photo shared to mesh (${fileId})`);
|
||||||
|
} else {
|
||||||
|
await ctx.reply("❌ Upload failed");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle documents from Telegram → share to mesh
|
||||||
|
bot.on("message:document", async (ctx) => {
|
||||||
|
if (!isAllowed(ctx.chat.id)) return;
|
||||||
|
const doc = ctx.message.document;
|
||||||
|
if (!doc) return;
|
||||||
|
try {
|
||||||
|
const file = await ctx.api.getFile(doc.file_id);
|
||||||
|
const url = `https://api.telegram.org/file/bot${BOT_TOKEN}/${file.file_path}`;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
const buf = Buffer.from(await resp.arrayBuffer());
|
||||||
|
const name = doc.file_name ?? `telegram-file-${Date.now()}`;
|
||||||
|
const fileId = await defaultBridge.uploadFile(buf, name, ["telegram", "document"]);
|
||||||
|
if (fileId) {
|
||||||
|
const caption = ctx.message.caption ? ` — "${ctx.message.caption}"` : "";
|
||||||
|
await defaultBridge.sendMessage("*", `[via Telegram] 📎 File shared: ${name}${caption} (file: ${fileId})`, "next");
|
||||||
|
await ctx.reply(`✅ File shared to mesh: ${name} (${fileId})`);
|
||||||
|
} else {
|
||||||
|
await ctx.reply("❌ Upload failed");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await ctx.reply(`❌ ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default: any text without a command → broadcast
|
||||||
|
bot.on("message:text", async (ctx) => {
|
||||||
|
if (!isAllowed(ctx.chat.id)) return;
|
||||||
|
const text = ctx.message.text;
|
||||||
|
if (text.startsWith("/")) return; // Skip unknown commands
|
||||||
|
|
||||||
|
// Check for @mention pattern: "@PeerName message"
|
||||||
|
const mentionMatch = text.match(/^@(\S+)\s+([\s\S]+)$/);
|
||||||
|
if (mentionMatch) {
|
||||||
|
const target = mentionMatch[1]!;
|
||||||
|
const message = mentionMatch[2]!;
|
||||||
|
const matches = await defaultBridge.findPeersByName(target);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
await ctx.reply(`❌ No peer named "${target}"`);
|
||||||
|
} else if (matches.length === 1) {
|
||||||
|
const ok = await defaultBridge.sendMessage(matches[0]!.pubkey, `[via Telegram] ${message}`, "now");
|
||||||
|
await ctx.reply(ok ? `✅ → ${matches[0]!.avatar ?? "🤖"} ${matches[0]!.displayName}` : "❌ Not connected");
|
||||||
|
} else {
|
||||||
|
pendingDMs.set(ctx.chat.id, { message, matches, selected: new Set() });
|
||||||
|
const buttons = matches.map((p, i) => {
|
||||||
|
const dir = p.cwd?.split("/").pop() ?? "?";
|
||||||
|
return [{ text: `${p.avatar ?? "🤖"} ${p.displayName} (${dir})`, callback_data: `dm:${i}` }];
|
||||||
|
});
|
||||||
|
buttons.push([{ text: "📨 Send to ALL", callback_data: "dm:all" }]);
|
||||||
|
await ctx.reply(`Multiple "${target}" peers. Pick one or all:`, {
|
||||||
|
reply_markup: { inline_keyboard: buttons },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No mention → broadcast
|
||||||
|
const ok = await defaultBridge.sendMessage("*", `[via Telegram] ${text}`, "next");
|
||||||
|
if (!ok) await ctx.reply("❌ Not connected to mesh");
|
||||||
|
});
|
||||||
|
|
||||||
|
function isAllowed(chatId: number): boolean {
|
||||||
|
// If no chat IDs configured, allow all (dev mode)
|
||||||
|
if (ALLOWED_CHAT_IDS.length === 0) return true;
|
||||||
|
return ALLOWED_CHAT_IDS.includes(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start bot
|
||||||
|
console.log("[tg] starting bot...");
|
||||||
|
bot.start({
|
||||||
|
onStart: () => console.log("[tg] bot running"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
console.log("[shutdown] closing...");
|
||||||
|
bot.stop();
|
||||||
|
bridges.forEach(b => b.close());
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
console.log("[shutdown] closing...");
|
||||||
|
bot.stop();
|
||||||
|
bridges.forEach(b => b.close());
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
console.error("fatal:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -175,4 +175,12 @@ GOOGLE_GENERATIVE_AI_API_KEY="<your-google-generative-ai-api-key>"
|
|||||||
MISTRAL_API_KEY="<your-mistral-api-key>"
|
MISTRAL_API_KEY="<your-mistral-api-key>"
|
||||||
|
|
||||||
# Perplexity API key - required only if you use Perplexity as an AI provider
|
# Perplexity API key - required only if you use Perplexity as an AI provider
|
||||||
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
|
PERPLEXITY_API_KEY="<your-perplexity-api-key>"
|
||||||
|
|
||||||
|
|
||||||
|
##############################
|
||||||
|
### CLI Sync config ###
|
||||||
|
##############################
|
||||||
|
|
||||||
|
# Shared secret for CLI sync JWT signing (HS256) — must match the broker's CLI_SYNC_SECRET
|
||||||
|
CLI_SYNC_SECRET="<your-cli-sync-secret>"
|
||||||
@@ -10,7 +10,11 @@ RUN corepack enable && corepack prepare pnpm@10.25.0 --activate
|
|||||||
# pnpm workspace needs full context to resolve workspace:* + catalog:
|
# pnpm workspace needs full context to resolve workspace:* + catalog:
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
# --ignore-scripts skips sherif postinstall linting (exits 1 on warnings)
|
||||||
|
RUN pnpm install --frozen-lockfile --ignore-scripts && \
|
||||||
|
node node_modules/esbuild/install.js && \
|
||||||
|
node node_modules/sharp/install/check.js || npm run --prefix node_modules/sharp build 2>/dev/null; \
|
||||||
|
true
|
||||||
|
|
||||||
# Build — SKIP_ENV_VALIDATION lets missing runtime vars pass (validated at startup instead)
|
# Build — SKIP_ENV_VALIDATION lets missing runtime vars pass (validated at startup instead)
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -25,6 +29,9 @@ ENV NEXT_PUBLIC_URL=$NEXT_PUBLIC_URL
|
|||||||
ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME
|
ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME
|
||||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||||
|
|
||||||
|
# Node ESM loader that stubs .css imports during route collection.
|
||||||
|
# Payload CMS deps import .css files that Node can't handle outside webpack.
|
||||||
|
ENV NODE_OPTIONS="--import /app/apps/web/css-stub-loader.mjs"
|
||||||
RUN npx turbo run build --filter=web...
|
RUN npx turbo run build --filter=web...
|
||||||
|
|
||||||
# Stage 2: runtime — standalone output only
|
# Stage 2: runtime — standalone output only
|
||||||
|
|||||||
10
apps/web/css-stub-loader.mjs
Normal file
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
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);
|
||||||
|
}
|
||||||
|
`));
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const { withPayload } = require("@payloadcms/next/withPayload");
|
||||||
|
|
||||||
import env from "./env.config";
|
import env from "./env.config";
|
||||||
|
|
||||||
const INTERNAL_PACKAGES = [
|
const INTERNAL_PACKAGES = [
|
||||||
@@ -69,6 +72,7 @@ const securityHeaders = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// build: 1776069543
|
||||||
const config: NextConfig = {
|
const config: NextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
@@ -80,6 +84,15 @@ const config: NextConfig = {
|
|||||||
serverExternalPackages: [
|
serverExternalPackages: [
|
||||||
"better-sqlite3",
|
"better-sqlite3",
|
||||||
"@mapbox/node-pre-gyp",
|
"@mapbox/node-pre-gyp",
|
||||||
|
"esbuild",
|
||||||
|
"payload",
|
||||||
|
"@payloadcms/db-postgres",
|
||||||
|
"@payloadcms/db-sqlite",
|
||||||
|
"@payloadcms/richtext-lexical",
|
||||||
|
"@payloadcms/next",
|
||||||
|
"@payloadcms/ui",
|
||||||
|
"sharp",
|
||||||
|
"libsodium-wrappers",
|
||||||
],
|
],
|
||||||
turbopack: {
|
turbopack: {
|
||||||
rules: {
|
rules: {
|
||||||
@@ -90,6 +103,24 @@ const config: NextConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Webpack SVG loader (used when TURBOPACK=0 for production builds).
|
||||||
|
// Exclude app/ dir SVGs (icon.svg, opengraph-image) — Next.js metadata
|
||||||
|
// loader handles those. Only process package SVGs (flags, logos).
|
||||||
|
webpack(config) {
|
||||||
|
const existingSvgRule = config.module.rules.find(
|
||||||
|
(rule: { test?: RegExp }) => rule.test?.test?.(".svg"),
|
||||||
|
);
|
||||||
|
if (existingSvgRule) {
|
||||||
|
existingSvgRule.exclude = /packages\/ui\/.*\.svg$/;
|
||||||
|
}
|
||||||
|
config.module.rules.push({
|
||||||
|
test: /\.svg$/,
|
||||||
|
include: /packages\/ui\//,
|
||||||
|
use: ["@svgr/webpack"],
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
@@ -99,7 +130,7 @@ const config: NextConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** Enables hot reloading for local packages without a build step */
|
/** Enables hot reloading for local packages without a build step */
|
||||||
transpilePackages: INTERNAL_PACKAGES,
|
transpilePackages: [...INTERNAL_PACKAGES, "react-image-crop"],
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: INTERNAL_PACKAGES,
|
optimizePackageImports: INTERNAL_PACKAGES,
|
||||||
},
|
},
|
||||||
@@ -124,4 +155,4 @@ const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
|||||||
enabled: env.ANALYZE,
|
enabled: env.ANALYZE,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default withBundleAnalyzer(config);
|
export default withPayload(withBundleAnalyzer(config));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "NODE_OPTIONS='--import ./css-stub-register.mjs' next build --webpack",
|
||||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
@@ -18,8 +18,12 @@
|
|||||||
"@anaralabs/lector": "3.7.3",
|
"@anaralabs/lector": "3.7.3",
|
||||||
"@formatjs/intl-localematcher": "0.6.2",
|
"@formatjs/intl-localematcher": "0.6.2",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@next/bundle-analyzer": "16.0.10",
|
"@next/bundle-analyzer": "16.2.2",
|
||||||
"@number-flow/react": "0.5.10",
|
"@number-flow/react": "0.5.10",
|
||||||
|
"@payloadcms/db-postgres": "3.81.0",
|
||||||
|
"@payloadcms/db-sqlite": "^3.81.0",
|
||||||
|
"@payloadcms/next": "^3.81.0",
|
||||||
|
"@payloadcms/richtext-lexical": "^3.81.0",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-query-devtools": "catalog:",
|
"@tanstack/react-query-devtools": "catalog:",
|
||||||
"@tanstack/react-table": "catalog:",
|
"@tanstack/react-table": "catalog:",
|
||||||
@@ -40,10 +44,11 @@
|
|||||||
"marked": "16.4.1",
|
"marked": "16.4.1",
|
||||||
"motion": "12.23.24",
|
"motion": "12.23.24",
|
||||||
"negotiator": "1.0.0",
|
"negotiator": "1.0.0",
|
||||||
"next": "16.0.10",
|
"next": "16.2.2",
|
||||||
"next-i18n-router": "5.5.5",
|
"next-i18n-router": "5.5.5",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nuqs": "2.7.2",
|
"nuqs": "2.7.2",
|
||||||
|
"payload": "^3.81.0",
|
||||||
"pdfjs-dist": "5.4.530",
|
"pdfjs-dist": "5.4.530",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"react": "catalog:react19",
|
"react": "catalog:react19",
|
||||||
@@ -57,6 +62,7 @@
|
|||||||
"rehype-raw": "7.0.0",
|
"rehype-raw": "7.0.0",
|
||||||
"remark-gfm": "4.0.1",
|
"remark-gfm": "4.0.1",
|
||||||
"remark-math": "6.0.0",
|
"remark-math": "6.0.0",
|
||||||
|
"sharp": "0.34.5",
|
||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
"zustand": "5.0.8"
|
"zustand": "5.0.8"
|
||||||
|
|||||||
212
apps/web/payload.config.ts
Normal file
212
apps/web/payload.config.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { buildConfig } from "payload";
|
||||||
|
import { postgresAdapter } from "@payloadcms/db-postgres";
|
||||||
|
import { sqliteAdapter } from "@payloadcms/db-sqlite";
|
||||||
|
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const filename = fileURLToPath(import.meta.url);
|
||||||
|
const dirname = path.dirname(filename);
|
||||||
|
|
||||||
|
// Use Postgres in production (DATABASE_URL), SQLite locally
|
||||||
|
const usePostgres = !!process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
export default buildConfig({
|
||||||
|
secret: process.env.PAYLOAD_SECRET || "claudemesh-dev-secret-change-in-production",
|
||||||
|
|
||||||
|
routes: {
|
||||||
|
admin: "/payload",
|
||||||
|
},
|
||||||
|
|
||||||
|
admin: {
|
||||||
|
user: "users",
|
||||||
|
meta: {
|
||||||
|
titleSuffix: "— claudemesh",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
editor: lexicalEditor(),
|
||||||
|
|
||||||
|
db: usePostgres
|
||||||
|
? postgresAdapter({
|
||||||
|
pool: { connectionString: process.env.DATABASE_URL! },
|
||||||
|
schemaName: "payload",
|
||||||
|
})
|
||||||
|
: sqliteAdapter({
|
||||||
|
client: {
|
||||||
|
url: process.env.PAYLOAD_DATABASE_URI || `file:${path.resolve(dirname, "payload.db")}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
sharp,
|
||||||
|
|
||||||
|
collections: [
|
||||||
|
// --- Users (admin panel) ---
|
||||||
|
{
|
||||||
|
slug: "users",
|
||||||
|
auth: true,
|
||||||
|
admin: { useAsTitle: "email" },
|
||||||
|
fields: [
|
||||||
|
{ name: "name", type: "text" },
|
||||||
|
{ name: "role", type: "select", options: ["admin", "editor"], defaultValue: "editor" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Media ---
|
||||||
|
{
|
||||||
|
slug: "media",
|
||||||
|
upload: {
|
||||||
|
staticDir: path.resolve(dirname, "public/media"),
|
||||||
|
mimeTypes: ["image/*"],
|
||||||
|
},
|
||||||
|
admin: { useAsTitle: "alt" },
|
||||||
|
fields: [
|
||||||
|
{ name: "alt", type: "text", required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Authors ---
|
||||||
|
{
|
||||||
|
slug: "authors",
|
||||||
|
admin: { useAsTitle: "name" },
|
||||||
|
fields: [
|
||||||
|
{ name: "name", type: "text", required: true },
|
||||||
|
{ name: "slug", type: "text", required: true, unique: true },
|
||||||
|
{ name: "bio", type: "textarea" },
|
||||||
|
{ name: "role", type: "text" },
|
||||||
|
{
|
||||||
|
name: "avatar",
|
||||||
|
type: "upload",
|
||||||
|
relationTo: "media",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "links",
|
||||||
|
type: "group",
|
||||||
|
fields: [
|
||||||
|
{ name: "github", type: "text" },
|
||||||
|
{ name: "twitter", type: "text" },
|
||||||
|
{ name: "website", type: "text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Categories ---
|
||||||
|
{
|
||||||
|
slug: "categories",
|
||||||
|
admin: { useAsTitle: "name" },
|
||||||
|
fields: [
|
||||||
|
{ name: "name", type: "text", required: true },
|
||||||
|
{ name: "slug", type: "text", required: true, unique: true },
|
||||||
|
{ name: "description", type: "textarea" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Blog Posts ---
|
||||||
|
{
|
||||||
|
slug: "posts",
|
||||||
|
admin: {
|
||||||
|
useAsTitle: "title",
|
||||||
|
defaultColumns: ["title", "status", "publishedAt", "author"],
|
||||||
|
},
|
||||||
|
versions: { drafts: true },
|
||||||
|
fields: [
|
||||||
|
{ name: "title", type: "text", required: true },
|
||||||
|
{
|
||||||
|
name: "slug",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
unique: true,
|
||||||
|
admin: {
|
||||||
|
position: "sidebar",
|
||||||
|
description: "URL-friendly identifier. Auto-generated from title if left blank.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "excerpt",
|
||||||
|
type: "textarea",
|
||||||
|
admin: { description: "1-2 sentence summary for cards and meta descriptions." },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "content",
|
||||||
|
type: "richText",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "coverImage",
|
||||||
|
type: "upload",
|
||||||
|
relationTo: "media",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "author",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "authors",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "categories",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "categories",
|
||||||
|
hasMany: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "publishedAt",
|
||||||
|
type: "date",
|
||||||
|
admin: { position: "sidebar", date: { pickerAppearance: "dayOnly" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "Draft", value: "draft" },
|
||||||
|
{ label: "Published", value: "published" },
|
||||||
|
],
|
||||||
|
defaultValue: "draft",
|
||||||
|
admin: { position: "sidebar" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "seo",
|
||||||
|
type: "group",
|
||||||
|
fields: [
|
||||||
|
{ name: "metaTitle", type: "text" },
|
||||||
|
{ name: "metaDescription", type: "textarea" },
|
||||||
|
{ name: "ogImage", type: "upload", relationTo: "media" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Changelog ---
|
||||||
|
{
|
||||||
|
slug: "changelog",
|
||||||
|
admin: {
|
||||||
|
useAsTitle: "version",
|
||||||
|
defaultColumns: ["version", "date", "type"],
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{ name: "version", type: "text", required: true },
|
||||||
|
{ name: "date", type: "date", required: true },
|
||||||
|
{
|
||||||
|
name: "type",
|
||||||
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ label: "Feature", value: "feat" },
|
||||||
|
{ label: "Fix", value: "fix" },
|
||||||
|
{ label: "Docs", value: "docs" },
|
||||||
|
{ label: "Breaking", value: "breaking" },
|
||||||
|
],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{ name: "summary", type: "text", required: true },
|
||||||
|
{ name: "body", type: "richText" },
|
||||||
|
{ name: "npmUrl", type: "text" },
|
||||||
|
{ name: "githubUrl", type: "text" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
typescript: {
|
||||||
|
outputFile: path.resolve(dirname, "src/payload-types.ts"),
|
||||||
|
},
|
||||||
|
});
|
||||||
BIN
apps/web/public/logo-wordmark.png
Normal file
BIN
apps/web/public/logo-wordmark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/web/public/logo.png
Normal file
BIN
apps/web/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
0
apps/web/public/media/.gitkeep
Normal file
0
apps/web/public/media/.gitkeep
Normal file
BIN
apps/web/public/media/blog-hero-mesh.png
Normal file
BIN
apps/web/public/media/blog-hero-mesh.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
53
apps/web/public/media/blog-hero-mesh.svg
Normal file
53
apps/web/public/media/blog-hero-mesh.svg
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||||
|
<rect width="1200" height="630" fill="#141413"/>
|
||||||
|
|
||||||
|
<!-- mesh connections -->
|
||||||
|
<g stroke="#d97757" stroke-width="1" opacity="0.3">
|
||||||
|
<line x1="180" y1="160" x2="420" y2="280"/>
|
||||||
|
<line x1="420" y1="280" x2="700" y2="200"/>
|
||||||
|
<line x1="700" y1="200" x2="950" y2="320"/>
|
||||||
|
<line x1="180" y1="160" x2="300" y2="400"/>
|
||||||
|
<line x1="300" y1="400" x2="550" y2="450"/>
|
||||||
|
<line x1="550" y1="450" x2="700" y2="200"/>
|
||||||
|
<line x1="550" y1="450" x2="950" y2="320"/>
|
||||||
|
<line x1="420" y1="280" x2="300" y2="400"/>
|
||||||
|
<line x1="700" y1="200" x2="850" y2="480"/>
|
||||||
|
<line x1="950" y1="320" x2="850" y2="480"/>
|
||||||
|
<line x1="300" y1="400" x2="150" y2="520"/>
|
||||||
|
<line x1="550" y1="450" x2="850" y2="480"/>
|
||||||
|
<line x1="1050" y1="150" x2="950" y2="320"/>
|
||||||
|
<line x1="100" y1="350" x2="180" y2="160"/>
|
||||||
|
<line x1="100" y1="350" x2="300" y2="400"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- encrypted data flow (dashed) -->
|
||||||
|
<g stroke="#d97757" stroke-width="1.5" stroke-dasharray="6 8" opacity="0.15">
|
||||||
|
<line x1="180" y1="160" x2="950" y2="320"/>
|
||||||
|
<line x1="300" y1="400" x2="700" y2="200"/>
|
||||||
|
<line x1="100" y1="350" x2="550" y2="450"/>
|
||||||
|
<line x1="420" y1="280" x2="850" y2="480"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- nodes -->
|
||||||
|
<g fill="#d97757">
|
||||||
|
<circle cx="180" cy="160" r="5"/>
|
||||||
|
<circle cx="420" cy="280" r="5"/>
|
||||||
|
<circle cx="700" cy="200" r="5"/>
|
||||||
|
<circle cx="950" cy="320" r="5"/>
|
||||||
|
<circle cx="300" cy="400" r="5"/>
|
||||||
|
<circle cx="550" cy="450" r="5"/>
|
||||||
|
<circle cx="850" cy="480" r="4"/>
|
||||||
|
<circle cx="1050" cy="150" r="3.5"/>
|
||||||
|
<circle cx="100" cy="350" r="3.5"/>
|
||||||
|
<circle cx="150" cy="520" r="3"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- node halos -->
|
||||||
|
<g fill="none" stroke="#d97757" stroke-width="0.5" opacity="0.2">
|
||||||
|
<circle cx="180" cy="160" r="16"/>
|
||||||
|
<circle cx="420" cy="280" r="14"/>
|
||||||
|
<circle cx="700" cy="200" r="18"/>
|
||||||
|
<circle cx="950" cy="320" r="15"/>
|
||||||
|
<circle cx="550" cy="450" r="12"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
14
apps/web/src/app/(payload)/layout.tsx
Normal file
14
apps/web/src/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import "@payloadcms/next/css";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "CMS — claudemesh",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PayloadLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
apps/web/src/app/(payload)/payload/[[...segments]]/page.tsx
Normal file
16
apps/web/src/app/(payload)/payload/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck — Payload generates these types at build time
|
||||||
|
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||||
|
import { importMap } from "../importMap";
|
||||||
|
import config from "@payload-config";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Args = { params: Promise<{ segments: string[] }> };
|
||||||
|
|
||||||
|
export const generateMetadata = ({ params }: Args) =>
|
||||||
|
generatePageMetadata({ config, params });
|
||||||
|
|
||||||
|
export default function Page({ params }: Args) {
|
||||||
|
return <RootPage config={config} params={params} importMap={importMap} />;
|
||||||
|
}
|
||||||
51
apps/web/src/app/(payload)/payload/importMap.js
Normal file
51
apps/web/src/app/(payload)/payload/importMap.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
|
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
|
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
|
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
|
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||||
|
|
||||||
|
export const importMap = {
|
||||||
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
|
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
|
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
|
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
|
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||||
|
}
|
||||||
173
apps/web/src/app/[locale]/(marketing)/about/page.tsx
Normal file
173
apps/web/src/app/[locale]/(marketing)/about/page.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Reveal, SectionIcon } from "~/modules/marketing/home/_reveal";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "About — claudemesh",
|
||||||
|
description:
|
||||||
|
"claudemesh is built by Alejandro A. Gutiérrez Mourente — fighter pilot, AI business architect, solo builder.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl px-6 py-24 md:py-32">
|
||||||
|
<Reveal className="mb-6">
|
||||||
|
<SectionIcon glyph="leaf" />
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={1}>
|
||||||
|
<h1
|
||||||
|
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
About
|
||||||
|
</h1>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={2}>
|
||||||
|
<div
|
||||||
|
className="mt-10 space-y-6 text-[15px] leading-[1.8] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
claudemesh is built by{" "}
|
||||||
|
<span className="font-medium text-[var(--cm-fg)]">
|
||||||
|
Alejandro A. Gutiérrez Mourente
|
||||||
|
</span>{" "}
|
||||||
|
— a fighter pilot who builds production AI systems.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A decade flying F-18s and serving as Operational Safety Officer
|
||||||
|
in the Spanish Air Force taught one thing: systems either work
|
||||||
|
under pressure or they fail people. That standard followed into
|
||||||
|
software.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Before claudemesh, that meant shipping a document intelligence
|
||||||
|
platform that replaced a manual process worth €5M/year (four
|
||||||
|
extraction engines, contract generation, production-grade), AI
|
||||||
|
backoffice modules for a multi-tenant enterprise platform, and
|
||||||
|
end-to-end ERP integrations across automotive, aviation, fintech,
|
||||||
|
legal, and defense — each designed, built, and presented to
|
||||||
|
leadership by one person.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-[var(--cm-fg)]">
|
||||||
|
claudemesh exists because Claude Code sessions are isolated. You
|
||||||
|
close the terminal and the context dies. Your teammate re-solves
|
||||||
|
the same bug. The insight never travels.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The fix: a peer mesh. End-to-end encrypted, delivered mid-turn,
|
||||||
|
broker-never-decrypts. The{" "}
|
||||||
|
<Link
|
||||||
|
href="https://github.com/alezmad/claudemesh-cli"
|
||||||
|
className="text-[var(--cm-clay)] hover:underline"
|
||||||
|
>
|
||||||
|
CLI is MIT-licensed
|
||||||
|
</Link>
|
||||||
|
. The{" "}
|
||||||
|
<Link
|
||||||
|
href="https://github.com/alezmad/claudemesh-cli/blob/main/PROTOCOL.md"
|
||||||
|
className="text-[var(--cm-clay)] hover:underline"
|
||||||
|
>
|
||||||
|
wire protocol is documented
|
||||||
|
</Link>
|
||||||
|
. The{" "}
|
||||||
|
<Link
|
||||||
|
href="https://github.com/alezmad/claudemesh-cli/blob/main/THREAT_MODEL.md"
|
||||||
|
className="text-[var(--cm-clay)] hover:underline"
|
||||||
|
>
|
||||||
|
threat model is public
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The same safety thinking that goes into clearing a formation
|
||||||
|
through weather goes into deciding what untrusted text should and
|
||||||
|
should not reach your AI agent. The stakes are lower. The method
|
||||||
|
is the same: understand the failure modes first, then build the
|
||||||
|
system that handles them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={3}>
|
||||||
|
<div className="mt-12 border-t border-[var(--cm-border)] pt-8">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-[18px] font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Background
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="space-y-3 text-[13px] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||||
|
<span>
|
||||||
|
Fighter pilot · Spanish Air Force (Ejército del Aire) · F-18
|
||||||
|
Hornet · Operational Safety Officer (QASO)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||||
|
<span>
|
||||||
|
AI Business Architect · document intelligence, ERP
|
||||||
|
integration, multi-tenant enterprise platforms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||||
|
<span>
|
||||||
|
Full-stack solo builder · TypeScript, Python, LLM
|
||||||
|
orchestration, domain-driven design
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||||
|
<span>
|
||||||
|
Regulated industries · automotive, aviation, fintech, legal,
|
||||||
|
defense
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="mt-1 block h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||||
|
<span>Las Palmas, Canarias, Spain</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<Reveal delay={4}>
|
||||||
|
<div className="mt-10 flex flex-wrap gap-4">
|
||||||
|
<Link
|
||||||
|
href="https://github.com/alezmad"
|
||||||
|
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="https://www.linkedin.com/in/alejandro-mourente/"
|
||||||
|
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
LinkedIn
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="mailto:alex@mourente.ai"
|
||||||
|
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-4 py-2 text-[13px] font-medium text-[var(--cm-fg)] transition-colors hover:border-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
Contact
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
apps/web/src/app/[locale]/(marketing)/blog/page.tsx
Normal file
68
apps/web/src/app/[locale]/(marketing)/blog/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Blog — claudemesh",
|
||||||
|
description: "Engineering notes on peer messaging, protocol design, and multi-agent security.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const POSTS = [
|
||||||
|
{
|
||||||
|
slug: "peer-messaging-claude-code",
|
||||||
|
title: "Peer messaging for Claude Code: protocol, security, UX",
|
||||||
|
excerpt:
|
||||||
|
"How claudemesh connects Claude Code sessions over an encrypted mesh, using MCP dev-channels for real-time message injection.",
|
||||||
|
date: "2026-04-06",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BlogIndex() {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl px-6 py-24 md:py-32">
|
||||||
|
<h1
|
||||||
|
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Blog
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="mt-4 text-[15px] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
Engineering notes on protocol design, security, and multi-agent UX.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-12 space-y-10">
|
||||||
|
{POSTS.map((post) => (
|
||||||
|
<article key={post.slug} className="border-b border-[var(--cm-border)] pb-8">
|
||||||
|
<time
|
||||||
|
dateTime={post.date}
|
||||||
|
className="text-[11px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
{new Date(post.date).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
<h2 className="mt-2">
|
||||||
|
<Link
|
||||||
|
href={`/blog/${post.slug}`}
|
||||||
|
className="text-[22px] font-medium leading-tight text-[var(--cm-fg)] transition-colors hover:text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="mt-3 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
{post.excerpt}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Peer messaging for Claude Code: protocol, security, UX — claudemesh",
|
||||||
|
description:
|
||||||
|
"How claudemesh connects Claude Code sessions over an encrypted mesh, using MCP dev-channels for real-time message injection. Wire protocol, threat model, and what's next.",
|
||||||
|
openGraph: {
|
||||||
|
title: "Peer messaging for Claude Code: protocol, security, UX",
|
||||||
|
description: "How claudemesh connects Claude Code sessions over an encrypted mesh.",
|
||||||
|
images: ["/media/blog-hero-mesh.png"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BlogPost() {
|
||||||
|
return (
|
||||||
|
<article className="mx-auto max-w-3xl px-6 py-24 md:py-32">
|
||||||
|
<header className="mb-12">
|
||||||
|
<time
|
||||||
|
dateTime="2026-04-06"
|
||||||
|
className="text-[11px] uppercase tracking-wider text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
April 6, 2026
|
||||||
|
</time>
|
||||||
|
<h1
|
||||||
|
className="mt-3 text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Peer messaging for Claude Code: protocol, security, UX
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="mt-4 text-sm text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
by Alejandro A. Gutiérrez Mourente
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="space-y-5 text-[15px] leading-[1.8] text-[var(--cm-fg-secondary)] [&_h2]:mt-10 [&_h2]:mb-4 [&_h2]:text-[22px] [&_h2]:font-medium [&_h2]:text-[var(--cm-fg)] [&_a]:text-[var(--cm-clay)] [&_a]:hover:underline [&_code]:rounded [&_code]:bg-[var(--cm-gray-800)] [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:text-[13px] [&_code]:text-[var(--cm-fg-secondary)] [&_pre]:overflow-x-auto [&_pre]:rounded-[8px] [&_pre]:border [&_pre]:border-[var(--cm-border)] [&_pre]:bg-[var(--cm-gray-850)] [&_pre]:p-4 [&_pre]:text-[13px] [&_pre]:leading-[1.6] [&_strong]:font-medium [&_strong]:text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Claude Code sessions are islands. You build context over an hour of conversation, close the
|
||||||
|
tab, and that context dies. Two sessions side by side — one refactoring the API, one fixing
|
||||||
|
the frontend — share a filesystem but not a thought. I spent a decade flying F-18s in the
|
||||||
|
Spanish Air Force, where every formation member broadcasts position, fuel, and threat data
|
||||||
|
in real time. Silence kills. I built{" "}
|
||||||
|
<a href="https://github.com/alezmad/claudemesh-cli">claudemesh</a> to give Claude Code
|
||||||
|
sessions the same link: an MCP server that connects them over an encrypted mesh, pushing
|
||||||
|
messages directly into each other's context mid-turn.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The CLI is MIT-licensed, on npm as <code>claudemesh-cli</code>. This post covers the wire
|
||||||
|
protocol, the experimental Claude Code capability behind real-time injection, and the
|
||||||
|
prompt-injection surface that deserves careful attention.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>The protocol</h2>
|
||||||
|
<p>
|
||||||
|
One owner's ed25519 public key defines a mesh. The owner generates signed invite links;
|
||||||
|
each invitee verifies the signature, generates a fresh ed25519 keypair locally, and enrolls
|
||||||
|
with a broker via <code>POST /join</code>. The client then opens a persistent WebSocket
|
||||||
|
(<code>wss://</code> in production) and authenticates with a signed <code>hello</code>{" "}
|
||||||
|
frame:
|
||||||
|
</p>
|
||||||
|
<pre><code>{`{
|
||||||
|
"type": "hello",
|
||||||
|
"meshId": "01HX...",
|
||||||
|
"memberId": "01HX...",
|
||||||
|
"pubkey": "64-hex-chars",
|
||||||
|
"timestamp": 1735689600000,
|
||||||
|
"signature": "128-hex-chars"
|
||||||
|
}`}</code></pre>
|
||||||
|
<p>
|
||||||
|
The signature covers{" "}
|
||||||
|
<code>{"${meshId}|${memberId}|${pubkey}|${timestamp}"}</code>. The broker verifies it
|
||||||
|
against the registered public key and replies <code>hello_ack</code>. The connection is
|
||||||
|
live.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Direct messages use libsodium <code>crypto_box_easy</code> for end-to-end encryption —
|
||||||
|
X25519 keys derived from ed25519 identity pairs via{" "}
|
||||||
|
<code>crypto_sign_ed25519_pk_to_curve25519</code>. The broker routes ciphertext and never
|
||||||
|
sees plaintext. Priority routing: <code>now</code> delivers immediately, <code>next</code>{" "}
|
||||||
|
queues until idle, <code>low</code> waits for an explicit drain. The full specification
|
||||||
|
lives in{" "}
|
||||||
|
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/PROTOCOL.md">PROTOCOL.md</a>{" "}
|
||||||
|
(453 lines).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>Dev channels: the missing piece</h2>
|
||||||
|
<p>
|
||||||
|
An experimental Claude Code capability fixes the polling problem:{" "}
|
||||||
|
<code>notifications/claude/channel</code>. When an MCP server declares{" "}
|
||||||
|
<code>{"{ experimental: { \"claude/channel\": {} } }"}</code> and Claude Code launches
|
||||||
|
with <code>--dangerously-load-development-channels server:<name></code>, the server
|
||||||
|
pushes notifications that arrive as <code>{"<channel source=\"claudemesh\">"}</code> system
|
||||||
|
reminders mid-turn. Claude reacts immediately.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<code>claudemesh launch</code> wraps this into one command. I tested with an echo-channel
|
||||||
|
MCP server emitting a notification every 15 seconds — all three ticks arrived mid-turn and
|
||||||
|
Claude responded inline. Confirmed on Claude Code v2.1.92.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>The prompt-injection question</h2>
|
||||||
|
<p>
|
||||||
|
This section matters most. claudemesh decrypts peer text and injects it into Claude's
|
||||||
|
context. That text is untrusted input. A peer can send instruction overrides, tool-call
|
||||||
|
steering, or confused-deputy attacks invoking other MCP servers through Claude. The same
|
||||||
|
failure-mode analysis that clears a formation through weather applies here: enumerate every
|
||||||
|
way the system breaks, then close each path.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Tool-approval prompts stay intact.</strong> claudemesh never disables Claude Code's
|
||||||
|
permission system. A peer message can ask Claude to run a shell command; Claude still
|
||||||
|
prompts the user.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Messages carry attribution.</strong> Each <code>{"<channel>"}</code> reminder
|
||||||
|
includes <code>from_id</code>, <code>from_name</code>, and <code>mesh_slug</code>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Membership requires a signed invite.</strong> An attacker needs a valid
|
||||||
|
ed25519-signed invite from the mesh owner or a compromised member keypair.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The residual risks are real. If a user blanket-approves tools, a malicious peer message
|
||||||
|
reaches the shell without human review. The causal chain — peer message, Claude decision,
|
||||||
|
tool call — has no persistent audit trail yet.{" "}
|
||||||
|
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/THREAT_MODEL.md">
|
||||||
|
THREAT_MODEL.md
|
||||||
|
</a>{" "}
|
||||||
|
(212 lines) documents all of this. Open questions I want to work through with the Claude
|
||||||
|
Code team.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>What I'd do next</h2>
|
||||||
|
<p>
|
||||||
|
<strong>Shared-key channel crypto.</strong> Channel and broadcast messages are base64
|
||||||
|
plaintext today. The upgrade is a KDF from <code>mesh_root_key</code> plus key rotation.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Causal audit log.</strong> When Claude calls a tool because of a peer message, that
|
||||||
|
link should persist: which message, which tool call, what result.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Sender allowlists.</strong> Per-mesh config: accept messages only from these
|
||||||
|
pubkeys. If a member's key is compromised, others exclude it locally.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Forward secrecy.</strong> <code>crypto_box</code> uses long-lived keys. A leaked
|
||||||
|
key lets an attacker decrypt all past captured ciphertext. A double-ratchet would bound the
|
||||||
|
damage window.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 style={{ fontFamily: "var(--cm-font-serif)" }}>Try it</h2>
|
||||||
|
<pre><code>{`npm install -g claudemesh-cli
|
||||||
|
claudemesh install
|
||||||
|
claudemesh join https://claudemesh.com/join/<token>
|
||||||
|
claudemesh launch`}</code></pre>
|
||||||
|
<p>
|
||||||
|
The code is at{" "}
|
||||||
|
<a href="https://github.com/alezmad/claudemesh-cli">github.com/alezmad/claudemesh-cli</a>.
|
||||||
|
The wire protocol is in{" "}
|
||||||
|
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/PROTOCOL.md">PROTOCOL.md</a>.
|
||||||
|
The threat model is in{" "}
|
||||||
|
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/THREAT_MODEL.md">
|
||||||
|
THREAT_MODEL.md
|
||||||
|
</a>.
|
||||||
|
Contributions welcome — see{" "}
|
||||||
|
<a href="https://github.com/alezmad/claudemesh-cli/blob/main/CONTRIBUTING.md">
|
||||||
|
CONTRIBUTING.md
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you work on Claude Code or the MCP ecosystem and this interests you, I'd like to hear
|
||||||
|
from you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 border-t border-[var(--cm-border)] pt-8">
|
||||||
|
<Link
|
||||||
|
href="/blog"
|
||||||
|
className="text-sm text-[var(--cm-clay)] hover:underline"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
← Back to blog
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/web/src/app/[locale]/(marketing)/changelog/page.tsx
Normal file
55
apps/web/src/app/[locale]/(marketing)/changelog/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
export const metadata = {
|
||||||
|
title: "Changelog — claudemesh",
|
||||||
|
description: "Release history for claudemesh-cli.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENTRIES = [
|
||||||
|
{ version: "0.1.4", date: "2026-04-06", type: "feat", summary: "Stateful welcome screen, PROTOCOL.md, THREAT_MODEL.md, Windows CI matrix" },
|
||||||
|
{ version: "0.1.3", date: "2026-04-05", type: "feat", summary: "claudemesh --version, status, doctor commands" },
|
||||||
|
{ version: "0.1.2", date: "2026-04-05", type: "feat", summary: "claudemesh launch command, transparency banner, decrypt fix, Windows support" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = { feat: "Feature", fix: "Fix", docs: "Docs" };
|
||||||
|
const TYPE_COLORS: Record<string, string> = { feat: "bg-[var(--cm-clay)]", fix: "bg-[var(--cm-cactus)]", docs: "bg-[var(--cm-oat)]" };
|
||||||
|
|
||||||
|
export default function ChangelogPage() {
|
||||||
|
return (
|
||||||
|
<section className="mx-auto max-w-3xl px-6 py-24 md:py-32">
|
||||||
|
<h1
|
||||||
|
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Changelog
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="mt-4 text-[15px] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
Every shipped version of claudemesh-cli.
|
||||||
|
</p>
|
||||||
|
<div className="mt-12 space-y-8">
|
||||||
|
{ENTRIES.map((entry) => (
|
||||||
|
<article key={entry.version} className="border-b border-[var(--cm-border)] pb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`rounded-[4px] px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--cm-bg)] ${TYPE_COLORS[entry.type] || "bg-[var(--cm-fg-tertiary)]"}`}
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
{TYPE_LABELS[entry.type] || entry.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-[18px] font-medium text-[var(--cm-fg)]" style={{ fontFamily: "var(--cm-font-serif)" }}>
|
||||||
|
v{entry.version}
|
||||||
|
</span>
|
||||||
|
<time dateTime={entry.date} className="text-[11px] text-[var(--cm-fg-tertiary)]" style={{ fontFamily: "var(--cm-font-mono)" }}>
|
||||||
|
{new Date(entry.date).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]" style={{ fontFamily: "var(--cm-font-sans)" }}>
|
||||||
|
{entry.summary}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
458
apps/web/src/app/[locale]/(marketing)/getting-started/page.tsx
Normal file
458
apps/web/src/app/[locale]/(marketing)/getting-started/page.tsx
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { getMetadata } from "~/lib/metadata";
|
||||||
|
|
||||||
|
export const generateMetadata = getMetadata({
|
||||||
|
title: "Getting Started",
|
||||||
|
description:
|
||||||
|
"Install the CLI and launch your first peer session in two commands.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const STEP = ({
|
||||||
|
n,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
cmd,
|
||||||
|
note,
|
||||||
|
}: {
|
||||||
|
n: string;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
cmd?: string;
|
||||||
|
note?: string;
|
||||||
|
}) => (
|
||||||
|
<div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-6 md:p-8">
|
||||||
|
<div
|
||||||
|
className="mb-4 flex items-center gap-3 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--cm-clay)]/15 text-[11px] font-medium">
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-[15px] leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{cmd && (
|
||||||
|
<pre
|
||||||
|
className="mt-4 overflow-x-auto rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-4 py-3 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<code>{cmd}</code>
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{note && (
|
||||||
|
<p
|
||||||
|
className="mt-3 text-[12px] leading-[1.6] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
{note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const VERIFY_CHECKS = [
|
||||||
|
"Node.js >= 20 installed",
|
||||||
|
"claude binary on PATH",
|
||||||
|
"~/.claudemesh/config.json parses + chmod 0600",
|
||||||
|
"Mesh keypairs valid",
|
||||||
|
"Broker connectivity",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function GettingStartedPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-6 py-16 md:px-12 md:py-24">
|
||||||
|
<div
|
||||||
|
className="mb-5 text-[11px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
— getting started
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="text-[clamp(2rem,4.5vw,3rem)] font-medium leading-[1.1] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
From zero to meshed in two minutes
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="mt-4 max-w-xl text-lg leading-[1.65] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Install the CLI and launch. Two commands — join is built into launch.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Prerequisites */}
|
||||||
|
<div className="mt-14 mb-10">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Prerequisites
|
||||||
|
</h2>
|
||||||
|
<ul
|
||||||
|
className="space-y-2 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||||
|
<span>
|
||||||
|
<strong className="text-[var(--cm-fg)]">Node.js 20+</strong> —{" "}
|
||||||
|
<Link
|
||||||
|
href="https://nodejs.org"
|
||||||
|
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||||
|
>
|
||||||
|
nodejs.org
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||||
|
<span>
|
||||||
|
<strong className="text-[var(--cm-fg)]">Claude Code 2.0+</strong>{" "}
|
||||||
|
—{" "}
|
||||||
|
<Link
|
||||||
|
href="https://claude.com/claude-code"
|
||||||
|
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||||
|
>
|
||||||
|
claude.com/claude-code
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="mt-[6px] block h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--cm-clay)]" />
|
||||||
|
<span>
|
||||||
|
<strong className="text-[var(--cm-fg)]">An invite link</strong> —
|
||||||
|
from a mesh owner, or{" "}
|
||||||
|
<Link
|
||||||
|
href="/auth/register"
|
||||||
|
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||||
|
>
|
||||||
|
create your own mesh
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<STEP
|
||||||
|
n="1"
|
||||||
|
title="Install the CLI"
|
||||||
|
cmd="npm i -g claudemesh-cli"
|
||||||
|
note="Requires Node.js 20+. Installs the claudemesh CLI globally."
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
One command. If you get a permissions error, see{" "}
|
||||||
|
<Link
|
||||||
|
href="https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||||
|
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||||
|
>
|
||||||
|
npm docs
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</STEP>
|
||||||
|
|
||||||
|
<STEP
|
||||||
|
n="2"
|
||||||
|
title="Launch"
|
||||||
|
cmd='claudemesh launch --name Alice --join https://claudemesh.com/join/eyJ2IjoxLC...'
|
||||||
|
note="--join enrolls you in the mesh (first time only). On subsequent launches, drop the --join flag."
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
This does everything: verifies the invite, generates your ed25519
|
||||||
|
keypair, enrolls with the broker, and spawns Claude Code with
|
||||||
|
real-time peer messaging. Your keys are stored in{" "}
|
||||||
|
<code
|
||||||
|
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
~/.claudemesh/config.json
|
||||||
|
</code>{" "}
|
||||||
|
(chmod 0600) — the broker never sees them.
|
||||||
|
</p>
|
||||||
|
</STEP>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="py-3 text-center text-xs text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
next time, just:
|
||||||
|
<code className="ml-2 rounded bg-[var(--cm-bg-elevated)] px-2 py-1 text-[var(--cm-fg-secondary)]">
|
||||||
|
claudemesh launch --name Alice
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre
|
||||||
|
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-6 py-4 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<code>{`# Full example with all flags
|
||||||
|
claudemesh launch \\
|
||||||
|
--name Alice \\
|
||||||
|
--join https://claudemesh.com/join/eyJ2IjoxLC... \\
|
||||||
|
--role dev \\
|
||||||
|
--groups "frontend:lead,reviewers" \\
|
||||||
|
--message-mode push \\
|
||||||
|
-y # skip permission confirmation`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verify */}
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Verify your setup
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Run the diagnostic check — it walks through every precondition and
|
||||||
|
prints pass/fail with fix hints:
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.7] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<code>{`$ claudemesh doctor
|
||||||
|
claudemesh doctor (v0.8.0)
|
||||||
|
────────────────────────────────────────────────────────────
|
||||||
|
✓ Node.js >= 20 (v22.15.0)
|
||||||
|
✓ claude binary on PATH
|
||||||
|
✓ ~/.claudemesh/config.json parses + chmod 0600
|
||||||
|
✓ Mesh keypairs valid (1 mesh(es))
|
||||||
|
✓ Broker connectivity (wss://ic.claudemesh.com/ws)
|
||||||
|
|
||||||
|
All checks passed.`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite a teammate */}
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Invite a teammate
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="mb-6 text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Mesh owners generate invite links from the{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||||
|
>
|
||||||
|
dashboard
|
||||||
|
</Link>
|
||||||
|
. Each link is a signed ed25519 token with a mesh ID, broker URL,
|
||||||
|
expiry, and role (admin or member). Share via Slack, email, or
|
||||||
|
paste in chat.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-[14px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
The recipient runs{" "}
|
||||||
|
<code
|
||||||
|
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
claudemesh launch --name Name --join <link>
|
||||||
|
</code>{" "}
|
||||||
|
— joins the mesh and launches in one step. No account creation
|
||||||
|
needed. Identity is the ed25519 keypair.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite link formats */}
|
||||||
|
<div className="mt-10">
|
||||||
|
<h3
|
||||||
|
className="mb-3 text-base font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Accepted invite formats
|
||||||
|
</h3>
|
||||||
|
<pre
|
||||||
|
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.9] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<code>{`# Join + launch in one step (recommended)
|
||||||
|
claudemesh launch --name Alice --join https://claudemesh.com/join/eyJ2IjoxLC...
|
||||||
|
|
||||||
|
# Or join separately first
|
||||||
|
claudemesh join https://claudemesh.com/join/eyJ2IjoxLC...
|
||||||
|
claudemesh launch --name Alice
|
||||||
|
|
||||||
|
# All invite formats work with both join and --join:
|
||||||
|
# https://claudemesh.com/join/eyJ2IjoxLC...
|
||||||
|
# https://claudemesh.com/en/join/eyJ2IjoxLC...
|
||||||
|
# ic://join/eyJ2IjoxLC...
|
||||||
|
# eyJ2IjoxLC4uLg (raw token)`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message modes */}
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Message modes
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
mode: "push",
|
||||||
|
desc: "Real-time. Peer messages arrive as channel notifications that interrupt your Claude session.",
|
||||||
|
when: "Default. Best for active collaboration.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "inbox",
|
||||||
|
desc: "Held until you check. You get a notification but messages queue until check_messages.",
|
||||||
|
when: "Deep work. Check when ready.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "off",
|
||||||
|
desc: "No delivery. Tools still work — use check_messages to poll manually.",
|
||||||
|
when: "Solo work on a shared mesh.",
|
||||||
|
},
|
||||||
|
].map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.mode}
|
||||||
|
className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5"
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
className="mb-2 block text-sm font-medium text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
--message-mode {m.mode}
|
||||||
|
</code>
|
||||||
|
<p
|
||||||
|
className="mb-2 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
{m.desc}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-[11px] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
{m.when}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* With vs without launch */}
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claudemesh launch</code> vs plain{" "}
|
||||||
|
<code style={{ fontFamily: "var(--cm-font-mono)" }}>claude</code>
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-px overflow-hidden rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-border)] md:grid-cols-2">
|
||||||
|
<div className="bg-[var(--cm-bg-elevated)] p-6">
|
||||||
|
<div
|
||||||
|
className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-clay)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
claudemesh launch
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
className="space-y-1.5 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
<li>Real-time push messages from peers</li>
|
||||||
|
<li>Native MCP entries for deployed mesh services</li>
|
||||||
|
<li>Per-session ephemeral keypair</li>
|
||||||
|
<li>Display name, groups, and roles</li>
|
||||||
|
<li>Session config isolated in tmpdir</li>
|
||||||
|
<li>MCP_TIMEOUT + output limits tuned for mesh</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[var(--cm-bg-elevated)] p-6">
|
||||||
|
<div
|
||||||
|
className="mb-2 text-[10px] uppercase tracking-[0.22em] text-[var(--cm-fg-tertiary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
plain claude
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
className="space-y-1.5 text-[13px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
<li>All 43 MCP tools still work</li>
|
||||||
|
<li>Messages are pull-only (check_messages)</li>
|
||||||
|
<li>No real-time push delivery</li>
|
||||||
|
<li>Uses member keypair (not ephemeral)</li>
|
||||||
|
<li>No display name or group assignment</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uninstall */}
|
||||||
|
<div className="mt-16">
|
||||||
|
<h2
|
||||||
|
className="mb-4 text-xl font-medium text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Uninstall
|
||||||
|
</h2>
|
||||||
|
<pre
|
||||||
|
className="overflow-x-auto rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] px-6 py-4 text-[13px] leading-[1.9] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
<code>{`claudemesh uninstall # remove MCP server, hooks, and allowedTools
|
||||||
|
npm uninstall -g claudemesh-cli
|
||||||
|
rm -rf ~/.claudemesh # delete config + keypairs (irreversible)`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div className="mt-16 flex flex-col items-start gap-4 border-t border-[var(--cm-border)] pt-10">
|
||||||
|
<p
|
||||||
|
className="text-[15px] leading-[1.6] text-[var(--cm-fg-secondary)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-serif)" }}
|
||||||
|
>
|
||||||
|
Need help? Run{" "}
|
||||||
|
<code
|
||||||
|
className="rounded bg-[var(--cm-bg-elevated)] px-1.5 py-0.5 text-[12px] text-[var(--cm-fg)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-mono)" }}
|
||||||
|
>
|
||||||
|
claudemesh doctor
|
||||||
|
</code>{" "}
|
||||||
|
to diagnose issues, or{" "}
|
||||||
|
<Link
|
||||||
|
href="https://github.com/alezmad/claudemesh-cli/issues"
|
||||||
|
className="underline decoration-[var(--cm-fg-tertiary)] underline-offset-4 hover:text-[var(--cm-fg)]"
|
||||||
|
>
|
||||||
|
open an issue on GitHub
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/auth/register"
|
||||||
|
className="inline-flex items-center gap-2 rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-5 py-3 text-[15px] font-medium text-[var(--cm-fg)] transition-colors duration-300 hover:bg-[var(--cm-clay-hover)]"
|
||||||
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
|
>
|
||||||
|
Create a mesh →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,14 @@
|
|||||||
import { Hero } from "~/modules/marketing/home/hero";
|
import { HeroWithMesh } from "~/modules/marketing/home/hero-with-mesh";
|
||||||
import { Surfaces } from "~/modules/marketing/home/surfaces";
|
|
||||||
import { Pricing } from "~/modules/marketing/home/pricing";
|
|
||||||
import { LaptopToLaptop } from "~/modules/marketing/home/laptop-to-laptop";
|
|
||||||
import { Features } from "~/modules/marketing/home/features";
|
import { Features } from "~/modules/marketing/home/features";
|
||||||
import { MeetsYou } from "~/modules/marketing/home/meets-you";
|
import { WhereMeshFits } from "~/modules/marketing/home/where-mesh-fits";
|
||||||
import { BeyondTerminal } from "~/modules/marketing/home/beyond-terminal";
|
|
||||||
import { DemoDashboard } from "~/modules/marketing/home/demo-dashboard";
|
|
||||||
import { WhatIsClaudemesh } from "~/modules/marketing/home/what-is-claudemesh";
|
import { WhatIsClaudemesh } from "~/modules/marketing/home/what-is-claudemesh";
|
||||||
|
import { Timeline } from "~/modules/marketing/home/timeline";
|
||||||
|
import { Pricing } from "~/modules/marketing/home/pricing";
|
||||||
import { FAQ } from "~/modules/marketing/home/faq";
|
import { FAQ } from "~/modules/marketing/home/faq";
|
||||||
import { CallToAction } from "~/modules/marketing/home/cta";
|
import { CallToAction } from "~/modules/marketing/home/cta";
|
||||||
import { MeshStats } from "~/modules/marketing/home/mesh-stats";
|
import { MeshStats } from "~/modules/marketing/home/mesh-stats";
|
||||||
import { LatestNewsToaster } from "~/modules/marketing/home/toaster";
|
import { LatestNewsToaster } from "~/modules/marketing/home/toaster";
|
||||||
|
|
||||||
// Revalidate the page every 60s so the mesh-stats counter stays fresh
|
|
||||||
// without hammering the DB. The /api/public/stats endpoint has its own
|
|
||||||
// 60s in-memory cache too.
|
|
||||||
export const revalidate = 60;
|
export const revalidate = 60;
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
@@ -23,15 +17,12 @@ const HomePage = () => {
|
|||||||
className="bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
className="bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
||||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||||
>
|
>
|
||||||
<Hero />
|
<HeroWithMesh />
|
||||||
<Surfaces />
|
|
||||||
<Pricing />
|
|
||||||
<LaptopToLaptop />
|
|
||||||
<Features />
|
<Features />
|
||||||
<MeetsYou />
|
<WhereMeshFits />
|
||||||
<WhatIsClaudemesh />
|
<WhatIsClaudemesh />
|
||||||
<DemoDashboard />
|
<Timeline />
|
||||||
<BeyondTerminal />
|
<Pricing />
|
||||||
<FAQ />
|
<FAQ />
|
||||||
<CallToAction />
|
<CallToAction />
|
||||||
<MeshStats />
|
<MeshStats />
|
||||||
|
|||||||
876
apps/web/src/app/[locale]/cli-auth/cli-auth-flow.tsx
Normal file
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
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
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
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,
|
DashboardHeaderTitle,
|
||||||
} from "~/modules/common/layout/dashboard/header";
|
} from "~/modules/common/layout/dashboard/header";
|
||||||
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
|
import { LiveStreamPanel } from "~/modules/mesh/live-stream-panel";
|
||||||
|
import { PeerGraphPanel } from "~/modules/mesh/peer-graph-panel";
|
||||||
|
import { ResourcePanel } from "~/modules/mesh/resource-panel";
|
||||||
|
import { StateTimelinePanel } from "~/modules/mesh/state-timeline-panel";
|
||||||
|
|
||||||
export const generateMetadata = getMetadata({
|
export const generateMetadata = getMetadata({
|
||||||
title: "Live mesh",
|
title: "Live mesh",
|
||||||
@@ -63,7 +66,14 @@ export default async function LiveMeshPage({
|
|||||||
</div>
|
</div>
|
||||||
</DashboardHeader>
|
</DashboardHeader>
|
||||||
|
|
||||||
<LiveStreamPanel meshId={id} />
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<PeerGraphPanel meshId={id} />
|
||||||
|
<LiveStreamPanel meshId={id} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<StateTimelinePanel meshId={id} />
|
||||||
|
<ResourcePanel meshId={id} />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
48
apps/web/src/app/[locale]/i/[code]/page.tsx
Normal file
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}`);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user