Compare commits
220 Commits
v0.1.14
...
b4703a482d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4703a482d | ||
|
|
29f546abcf | ||
|
|
5716a6ce22 | ||
|
|
d37516213a | ||
|
|
5b69de08da | ||
|
|
ccf95ff382 | ||
|
|
43f2728283 | ||
|
|
d33b8fc43b | ||
|
|
ce52fcef2d | ||
|
|
77ee1d0d80 | ||
|
|
2f27a5eef4 | ||
|
|
32851419e6 | ||
|
|
e2b6e53cc1 | ||
|
|
3595fc2c4d | ||
|
|
2825ef7151 | ||
|
|
a9858ef876 | ||
|
|
6836a495a4 | ||
|
|
07720f8f1e | ||
|
|
f4881b21b0 | ||
|
|
4561076904 | ||
|
|
0d53f2ae52 | ||
|
|
b328e78bd3 | ||
|
|
23604a125e | ||
|
|
b680260c8d | ||
|
|
b65a545ece | ||
|
|
d07cff788c | ||
|
|
bb1310167e | ||
|
|
ea4e3b03bb | ||
|
|
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 |
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
|
||||
58
.artifacts/specs/2026-04-15-cli-distribution-pipeline.md
Normal file
58
.artifacts/specs/2026-04-15-cli-distribution-pipeline.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# CLI Distribution Pipeline
|
||||
|
||||
## Status
|
||||
- Shell installer (`/install`): ✅ live, needs polish
|
||||
- Single-binary build script (`scripts/build-binaries.ts`): ✅ written, not wired to CI
|
||||
- GitHub Releases publish: ❌ not set up
|
||||
- Homebrew tap: ❌ not set up
|
||||
- winget manifest: ❌ not set up
|
||||
|
||||
## Shipped this session (alpha.28)
|
||||
- `bun build --compile` script at `apps/cli-v2/scripts/build-binaries.ts` produces
|
||||
`dist/bin/claudemesh-{darwin,linux,windows}-{x64,arm64}` locally.
|
||||
- `/install` updated to use the one-command `claudemesh <invite-url>` flow.
|
||||
- `claudemesh url-handler install` registers the `claudemesh://` scheme on the three OSes.
|
||||
|
||||
## What's missing
|
||||
|
||||
### 1. GitHub Actions to build + publish binaries
|
||||
```yaml
|
||||
# .github/workflows/release-binaries.yml
|
||||
on: { push: { tags: ['v*'] } }
|
||||
jobs:
|
||||
build:
|
||||
strategy: { matrix: { target: [darwin-x64, darwin-arm64, linux-x64, linux-arm64, windows-x64] } }
|
||||
steps:
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- run: cd apps/cli-v2 && bun install --frozen-lockfile
|
||||
- run: cd apps/cli-v2 && bun run scripts/build-binaries.ts
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with: { files: apps/cli-v2/dist/bin/* }
|
||||
```
|
||||
|
||||
### 2. `/install` detects missing Node and downloads a binary
|
||||
Current `/install` requires Node 20+. Next iteration: detect absence, curl the
|
||||
right binary from GitHub Releases, drop it in `~/.claudemesh/bin/`, add to PATH.
|
||||
|
||||
### 3. Homebrew tap (`homebrew-claudemesh`)
|
||||
Separate repo with a formula that points at the GitHub Release artifact.
|
||||
Users: `brew install alezmad/claudemesh/claudemesh`. Auto-updated by the
|
||||
release workflow via `brew bump-formula-pr`.
|
||||
|
||||
### 4. winget manifest
|
||||
YAML in `microsoft/winget-pkgs` repo pointing at the Windows .exe.
|
||||
|
||||
### 5. Auto-update in-CLI
|
||||
Already have `showUpdateNotice`. Upgrade to offer `claudemesh upgrade` that
|
||||
re-runs `/install` OR downloads a new binary in place.
|
||||
|
||||
## Why this matters
|
||||
Current state: users need Node, npm, and patience. Goal state:
|
||||
```
|
||||
curl -fsSL claudemesh.com/install | sh
|
||||
```
|
||||
…and that's it, on any OS, with or without Node.
|
||||
|
||||
## Priority
|
||||
After tier-1 usability (done), this is the next biggest lever for adoption.
|
||||
Estimate: 1-2 days for full pipeline, mostly CI config + release testing.
|
||||
75
.artifacts/specs/2026-04-15-per-peer-capabilities.md
Normal file
75
.artifacts/specs/2026-04-15-per-peer-capabilities.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Per-Peer Capabilities
|
||||
|
||||
## Goal
|
||||
Give mesh members fine-grained control over what peers can do to their
|
||||
session. Today: any mesh peer can send you any message; all messages get
|
||||
pushed as `<channel>` reminders. Users can't say "only @alice can send me
|
||||
messages," "read-only peers," or "@bob can broadcast but not DM."
|
||||
|
||||
## Current state
|
||||
- Mesh-level role: `admin` | `member` (only affects invite issuance)
|
||||
- No per-peer filter — every peer message is delivered
|
||||
- No per-peer read/write split (all peers have the same capabilities)
|
||||
|
||||
## Target capability model
|
||||
|
||||
| Capability | Meaning |
|
||||
|--------------|--------------------------------------------------------|
|
||||
| `read` | Peer appears in your list_peers, can see your summary |
|
||||
| `dm` | Peer can send you direct messages |
|
||||
| `broadcast` | Peer's group broadcasts reach you |
|
||||
| `state-read` | Peer can read shared state keys |
|
||||
| `state-write`| Peer can set shared state keys |
|
||||
| `file-read` | Peer can read files you've shared (already exists) |
|
||||
|
||||
## CLI surface
|
||||
```
|
||||
claudemesh grant @alice dm broadcast # allow direct + broadcast
|
||||
claudemesh grant @bob state-read # read-only
|
||||
claudemesh revoke @alice broadcast
|
||||
claudemesh grants # list current grants per peer
|
||||
claudemesh block @spammer # shorthand for revoke-all
|
||||
```
|
||||
|
||||
## Broker schema
|
||||
New column on `mesh_member`:
|
||||
```sql
|
||||
peer_grants jsonb DEFAULT '{}'::jsonb
|
||||
-- shape: { "<peer_pubkey_hex>": ["dm", "broadcast", ...] }
|
||||
```
|
||||
|
||||
Alternative (cleaner): separate `peer_grant` table keyed on
|
||||
`(member_id, target_pubkey)`.
|
||||
|
||||
## Enforcement point
|
||||
Broker's message router (`apps/broker/src/index.ts` — send flow).
|
||||
Before writing the encrypted message to the recipient's queue, check
|
||||
`recipient.peer_grants[sender_pubkey]` against message kind. Drop
|
||||
silently if disallowed (sender sees delivered, recipient sees nothing —
|
||||
matches Signal/iMessage block semantics).
|
||||
|
||||
## Defaults
|
||||
- Unknown peers: `read + dm` (matches current behavior — additive-safe rollout)
|
||||
- Existing members: grandfathered into `read + dm + broadcast + state-read`
|
||||
via a migration
|
||||
- `claudemesh profile --default-grants read dm` lets users change their own default
|
||||
|
||||
## UI
|
||||
- `claudemesh peers` renders a `[grants: dm,broadcast]` tag per peer
|
||||
- `claudemesh verify` gains a `--with-grants` flag that shows the grant set
|
||||
alongside the safety number (helps the "did I accidentally block them?" check)
|
||||
|
||||
## Crypto implications
|
||||
Grants are server-enforced metadata. Not capability tokens. A malicious
|
||||
broker could forward messages regardless — this is about UX trust (spam /
|
||||
noise control), not protocol security. The spec is clear about this.
|
||||
|
||||
## Migration plan
|
||||
1. Ship broker schema change (jsonb column, nullable, default `{}`).
|
||||
2. Ship `grant/revoke/grants/block` CLI commands against an unused column.
|
||||
3. Enable enforcement in broker behind a per-mesh feature flag.
|
||||
4. Flip on for all meshes.
|
||||
|
||||
## Priority
|
||||
Nice-to-have. The killer feature here is `block` — every mesh gets a bad
|
||||
actor eventually. Ship `block` first even if the full grant system is deferred.
|
||||
@@ -16,3 +16,6 @@ URL="http://localhost:3000"
|
||||
|
||||
# Default locale of the apps, can be overridden separately in each app.
|
||||
DEFAULT_LOCALE="en"
|
||||
|
||||
# Shared secret for CLI sync JWT signing (HS256) — must match between broker and web app
|
||||
CLI_SYNC_SECRET="<your-cli-sync-secret>"
|
||||
|
||||
113
.github/workflows/release-cli.yml
vendored
Normal file
113
.github/workflows/release-cli.yml
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
name: Release CLI binaries
|
||||
|
||||
# Fires on any push of a tag shaped like `cli-v1.2.3` (prerelease `-alpha.N` OK).
|
||||
# Builds self-contained `bun build --compile` binaries for darwin/linux/win
|
||||
# (x64 + arm64) and attaches them to a GitHub Release. The `install.sh`
|
||||
# fallback path curls these when Node isn't available.
|
||||
#
|
||||
# Publishing to npm is still a manual step (pnpm publish from apps/cli-v2) —
|
||||
# this workflow only handles binary distribution.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "cli-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to build (e.g. cli-v1.0.0-alpha.28)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write # to upload release assets
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { target: darwin-x64, bun_target: bun-darwin-x64, runner: macos-latest, ext: "" }
|
||||
- { target: darwin-arm64, bun_target: bun-darwin-arm64, runner: macos-latest, ext: "" }
|
||||
- { target: linux-x64, bun_target: bun-linux-x64, runner: ubuntu-latest, ext: "" }
|
||||
- { target: linux-arm64, bun_target: bun-linux-arm64, runner: ubuntu-latest, ext: "" }
|
||||
- { target: windows-x64, bun_target: bun-windows-x64, runner: windows-latest, ext: ".exe" }
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.2"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Install workspace deps
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Compile binary
|
||||
working-directory: apps/cli-v2
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist/bin
|
||||
bun build --compile --minify \
|
||||
--target=${{ matrix.bun_target }} \
|
||||
src/entrypoints/cli.ts \
|
||||
--outfile dist/bin/claudemesh-${{ matrix.target }}${{ matrix.ext }}
|
||||
|
||||
# Smoke test only on native arch. macos-latest runners are ARM64 (Apple
|
||||
# Silicon); ubuntu-latest is x64. Cross-compiled binaries can't execute
|
||||
# on the build host, so skip them.
|
||||
- name: Smoke test (native only)
|
||||
if: matrix.target == 'darwin-arm64' || matrix.target == 'linux-x64'
|
||||
working-directory: apps/cli-v2
|
||||
run: |
|
||||
./dist/bin/claudemesh-${{ matrix.target }} --version
|
||||
./dist/bin/claudemesh-${{ matrix.target }} --help | head -5
|
||||
|
||||
- name: Upload artefact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claudemesh-${{ matrix.target }}
|
||||
path: apps/cli-v2/dist/bin/claudemesh-${{ matrix.target }}${{ matrix.ext }}
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Stage binaries
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts -type f -exec cp {} release/ \;
|
||||
cd release && sha256sum claudemesh-* > SHA256SUMS
|
||||
|
||||
- name: Publish release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: |
|
||||
release/claudemesh-*
|
||||
release/SHA256SUMS
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
update-homebrew:
|
||||
needs: release
|
||||
runs-on: macos-latest
|
||||
if: github.event_name == 'push' && !contains(github.ref_name, 'alpha')
|
||||
steps:
|
||||
- name: Bump Homebrew tap formula
|
||||
env:
|
||||
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: |
|
||||
brew tap alezmad/claudemesh || true
|
||||
brew bump-formula-pr --no-browse --no-fork \
|
||||
--tag "${{ github.ref_name }}" \
|
||||
--revision "${{ github.sha }}" \
|
||||
alezmad/claudemesh/claudemesh || echo "formula bump skipped (no tap yet)"
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -45,6 +45,9 @@ yarn-error.log*
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# secrets
|
||||
.cli_sync_secret
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -72,3 +75,4 @@ dist/
|
||||
apps/web/payload.db
|
||||
apps/web/public/media/*
|
||||
!apps/web/public/media/.gitkeep
|
||||
.env.local
|
||||
|
||||
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
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
|
||||
CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||
|
||||
# Non-root user (oven/bun image ships with 'bun' uid 1000)
|
||||
|
||||
@@ -15,10 +15,19 @@
|
||||
},
|
||||
"prettier": "@turbostarter/prettier-config",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@qdrant/js-client-rest": "1.17.0",
|
||||
"@react-email/components": "0.3.2",
|
||||
"@react-email/render": "1.3.2",
|
||||
"@turbostarter/db": "workspace:*",
|
||||
"@turbostarter/shared": "workspace:*",
|
||||
"drizzle-orm": "0.44.7",
|
||||
"grammy": "^1.35.0",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"minio": "8.0.7",
|
||||
"neo4j-driver": "6.0.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"ws": "8.20.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
@@ -28,6 +37,8 @@
|
||||
"@turbostarter/tsconfig": "workspace:*",
|
||||
"@turbostarter/vitest-config": "workspace:*",
|
||||
"@types/libsodium-wrappers": "0.7.14",
|
||||
"@types/react": "19.2.0",
|
||||
"@types/react-dom": "19.2.0",
|
||||
"@types/ws": "8.5.13",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "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.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import { db } from "./db";
|
||||
import { invite as inviteTable, mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
||||
|
||||
let ready = false;
|
||||
async function ensureSodium(): Promise<typeof sodium> {
|
||||
@@ -69,6 +72,70 @@ export async function verifyEd25519(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical v2 invite bytes — signed by the mesh owner's ed25519 secret key.
|
||||
* NOTE: deliberately does NOT include the root_key or broker_url; the v2
|
||||
* protocol moves the root_key out of the URL entirely. Format is locked:
|
||||
* `v=2|mesh_id|invite_id|expires_at|role|owner_pubkey` (no trailing newline).
|
||||
*/
|
||||
export function canonicalInviteV2(p: {
|
||||
mesh_id: string;
|
||||
invite_id: string;
|
||||
expires_at: number; // unix seconds
|
||||
role: "admin" | "member";
|
||||
owner_pubkey: string; // hex
|
||||
}): string {
|
||||
return `v=2|${p.mesh_id}|${p.invite_id}|${p.expires_at}|${p.role}|${p.owner_pubkey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an ed25519 signature over the v2 canonical invite bytes against
|
||||
* the mesh owner's public key. Returns true on valid signature.
|
||||
*/
|
||||
export async function verifyInviteV2(params: {
|
||||
canonical: string;
|
||||
signatureHex: string;
|
||||
ownerPubkeyHex: string;
|
||||
}): Promise<boolean> {
|
||||
return verifyEd25519(
|
||||
params.canonical,
|
||||
params.signatureHex,
|
||||
params.ownerPubkeyHex,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal the mesh root_key to a recipient-provided x25519 public key using
|
||||
* libsodium's sealed box (crypto_box_seal). Only the holder of the matching
|
||||
* x25519 secret key can unseal.
|
||||
*
|
||||
* rootKeyBase64url is the mesh.root_key column value (base64url of 32 bytes).
|
||||
* recipientX25519PubkeyBase64url is the 32-byte x25519 pubkey the recipient
|
||||
* provided in its claim request. We do NOT convert an ed25519 pubkey here —
|
||||
* the recipient generates a dedicated x25519 keypair and sends us the pubkey.
|
||||
*
|
||||
* Returns base64url of the sealed ciphertext.
|
||||
*/
|
||||
export async function sealRootKeyToRecipient(params: {
|
||||
rootKeyBase64url: string;
|
||||
recipientX25519PubkeyBase64url: string;
|
||||
}): Promise<string> {
|
||||
const s = await ensureSodium();
|
||||
const rootKeyBytes = s.from_base64(
|
||||
params.rootKeyBase64url,
|
||||
s.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
const recipientPk = s.from_base64(
|
||||
params.recipientX25519PubkeyBase64url,
|
||||
s.base64_variants.URLSAFE_NO_PADDING,
|
||||
);
|
||||
if (recipientPk.length !== 32) {
|
||||
throw new Error("recipient_x25519_pubkey must decode to 32 bytes");
|
||||
}
|
||||
const sealed = s.crypto_box_seal(rootKeyBytes, recipientPk);
|
||||
return s.to_base64(sealed, s.base64_variants.URLSAFE_NO_PADDING);
|
||||
}
|
||||
|
||||
export const HELLO_SKEW_MS = 60_000;
|
||||
|
||||
/**
|
||||
@@ -118,3 +185,185 @@ export async function verifyHelloSignature(args: {
|
||||
return { ok: false, reason: "malformed" };
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// v2 invite claim core — exported for the HTTP handler in index.ts AND for
|
||||
// tests that need to exercise the logic without spinning up the broker server.
|
||||
// ----------------------------------------------------------------------------
|
||||
//
|
||||
// capabilityV2 column is stored as JSON:
|
||||
// { "canonical": "v=2|mesh_id|invite_id|expires_at|role|owner_pubkey",
|
||||
// "signature": "<hex ed25519 detached signature>" }
|
||||
// The broker recomputes the canonical bytes from the invite row and verifies
|
||||
// the signature against mesh.ownerPubkey. v1 rows (version === 1 OR
|
||||
// capabilityV2 === null) skip verification — the legacy path still works
|
||||
// during the deprecation window.
|
||||
|
||||
export type InviteClaimV2Result =
|
||||
| {
|
||||
ok: true;
|
||||
status: 200;
|
||||
body: {
|
||||
sealed_root_key: string;
|
||||
mesh_id: string;
|
||||
member_id: string;
|
||||
owner_pubkey: string;
|
||||
canonical_v2: string;
|
||||
};
|
||||
}
|
||||
| { ok: false; status: 400 | 404 | 410; body: { error: string } };
|
||||
|
||||
export async function claimInviteV2Core(params: {
|
||||
code: string;
|
||||
recipientX25519PubkeyBase64url: string;
|
||||
displayName?: string;
|
||||
now?: number;
|
||||
}): Promise<InviteClaimV2Result> {
|
||||
const now = params.now ?? Date.now();
|
||||
const recipientPk = params.recipientX25519PubkeyBase64url;
|
||||
|
||||
if (!recipientPk || typeof recipientPk !== "string" || recipientPk.length < 32) {
|
||||
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||
}
|
||||
|
||||
// 1. Look up the invite by opaque code.
|
||||
const [inv] = await db
|
||||
.select()
|
||||
.from(inviteTable)
|
||||
.where(eq(inviteTable.code, params.code))
|
||||
.limit(1);
|
||||
if (!inv) return { ok: false, status: 404, body: { error: "not_found" } };
|
||||
|
||||
// 2. Lifecycle checks: revoked → expired → exhausted.
|
||||
if (inv.revokedAt) {
|
||||
return { ok: false, status: 410, body: { error: "revoked" } };
|
||||
}
|
||||
if (inv.expiresAt.getTime() < now) {
|
||||
return { ok: false, status: 410, body: { error: "expired" } };
|
||||
}
|
||||
if (inv.usedCount >= inv.maxUses) {
|
||||
return { ok: false, status: 410, body: { error: "exhausted" } };
|
||||
}
|
||||
|
||||
// 3. Load the mesh for owner_pubkey + root_key.
|
||||
const [m] = await db
|
||||
.select({
|
||||
id: mesh.id,
|
||||
ownerPubkey: mesh.ownerPubkey,
|
||||
rootKey: mesh.rootKey,
|
||||
})
|
||||
.from(mesh)
|
||||
.where(and(eq(mesh.id, inv.meshId), isNull(mesh.archivedAt)))
|
||||
.limit(1);
|
||||
if (!m) return { ok: false, status: 404, body: { error: "not_found" } };
|
||||
if (!m.ownerPubkey || !m.rootKey) {
|
||||
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||
}
|
||||
|
||||
// 4. Compute canonical_v2 from the row (used in the response either way).
|
||||
const expiresAtUnix = Math.floor(inv.expiresAt.getTime() / 1000);
|
||||
const canonical = canonicalInviteV2({
|
||||
mesh_id: inv.meshId,
|
||||
invite_id: inv.id,
|
||||
expires_at: expiresAtUnix,
|
||||
role: inv.role as "admin" | "member",
|
||||
owner_pubkey: m.ownerPubkey,
|
||||
});
|
||||
|
||||
if (inv.version === 2 && inv.capabilityV2) {
|
||||
let storedCanonical: string | undefined;
|
||||
let signatureHex: string | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(inv.capabilityV2) as {
|
||||
canonical?: string;
|
||||
signature?: string;
|
||||
};
|
||||
storedCanonical = parsed.canonical;
|
||||
signatureHex = parsed.signature;
|
||||
} catch {
|
||||
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||
}
|
||||
if (!storedCanonical || !signatureHex) {
|
||||
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||
}
|
||||
// Broker-recomputed canonical must match the signed bytes exactly.
|
||||
if (storedCanonical !== canonical) {
|
||||
return { ok: false, status: 400, body: { error: "bad_signature" } };
|
||||
}
|
||||
const sigOk = await verifyInviteV2({
|
||||
canonical: storedCanonical,
|
||||
signatureHex,
|
||||
ownerPubkeyHex: m.ownerPubkey,
|
||||
});
|
||||
if (!sigOk) {
|
||||
return { ok: false, status: 400, body: { error: "bad_signature" } };
|
||||
}
|
||||
}
|
||||
// v1 rows: skip signature verification (legacy path during migration).
|
||||
|
||||
// 5. Atomic consume: increment used_count iff still under max_uses.
|
||||
const [claimed] = await db
|
||||
.update(inviteTable)
|
||||
.set({
|
||||
usedCount: sql`${inviteTable.usedCount} + 1`,
|
||||
claimedByPubkey: recipientPk,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(inviteTable.id, inv.id),
|
||||
lt(inviteTable.usedCount, inv.maxUses),
|
||||
),
|
||||
)
|
||||
.returning({ id: inviteTable.id });
|
||||
if (!claimed) {
|
||||
return { ok: false, status: 410, body: { error: "exhausted" } };
|
||||
}
|
||||
|
||||
// 6. Create a member row for the claimant.
|
||||
const preset = (inv.preset as {
|
||||
displayName?: string;
|
||||
roleTag?: string;
|
||||
groups?: Array<{ name: string; role?: string }>;
|
||||
messageMode?: string;
|
||||
} | null) ?? {};
|
||||
const displayName =
|
||||
preset.displayName ?? params.displayName ?? `member-${recipientPk.slice(0, 8)}`;
|
||||
const [row] = await db
|
||||
.insert(meshMember)
|
||||
.values({
|
||||
meshId: inv.meshId,
|
||||
peerPubkey: recipientPk,
|
||||
displayName,
|
||||
role: inv.role,
|
||||
roleTag: preset.roleTag ?? null,
|
||||
defaultGroups: preset.groups ?? [],
|
||||
messageMode: preset.messageMode ?? "push",
|
||||
})
|
||||
.returning({ id: meshMember.id });
|
||||
if (!row) {
|
||||
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||
}
|
||||
|
||||
// 7. Seal the mesh root_key to the recipient's x25519 pubkey.
|
||||
let sealed: string;
|
||||
try {
|
||||
sealed = await sealRootKeyToRecipient({
|
||||
rootKeyBase64url: m.rootKey,
|
||||
recipientX25519PubkeyBase64url: recipientPk,
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, status: 400, body: { error: "malformed" } };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
sealed_root_key: sealed,
|
||||
mesh_id: inv.meshId,
|
||||
member_id: row.id,
|
||||
owner_pubkey: m.ownerPubkey,
|
||||
canonical_v2: canonical,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
320
apps/broker/src/emails/mesh-invitation.tsx
Normal file
320
apps/broker/src/emails/mesh-invitation.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Hr,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
interface MeshInvitationProps {
|
||||
meshName: string;
|
||||
inviteUrl: string;
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
appBaseUrl: string;
|
||||
}
|
||||
|
||||
// Brand tokens — mirror of apps/web/src/assets/styles/globals.css (--cm-*).
|
||||
// Inlined here because email clients don't resolve CSS vars.
|
||||
const brand = {
|
||||
bg: "#141413",
|
||||
bgElevated: "#1f1e1d",
|
||||
bgCode: "#0f0e0d",
|
||||
fg: "#faf9f5",
|
||||
fgSecondary: "#c2c0b6",
|
||||
fgTertiary: "#87867f",
|
||||
clay: "#d97757",
|
||||
clayBorder: "rgba(217, 119, 87, 0.35)",
|
||||
border: "rgba(217, 119, 87, 0.2)",
|
||||
serif: 'Georgia, "Times New Roman", serif',
|
||||
mono: '"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace',
|
||||
sans:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
||||
} as const;
|
||||
|
||||
export const MeshInvitation = ({
|
||||
meshName,
|
||||
inviteUrl,
|
||||
token,
|
||||
expiresAt,
|
||||
appBaseUrl,
|
||||
}: MeshInvitationProps) => {
|
||||
const expiresLabel = new Date(expiresAt).toUTCString();
|
||||
const launchCmd = `claudemesh launch --join ${inviteUrl}`;
|
||||
const oneLiner = `npm i -g claudemesh-cli && ${launchCmd}`;
|
||||
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="supported-color-schemes" content="dark" />
|
||||
</Head>
|
||||
<Preview>You've been invited to the {meshName} mesh on claudemesh</Preview>
|
||||
<Body
|
||||
style={{
|
||||
backgroundColor: brand.bg,
|
||||
color: brand.fg,
|
||||
fontFamily: brand.sans,
|
||||
margin: 0,
|
||||
padding: "40px 0",
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
style={{
|
||||
maxWidth: "560px",
|
||||
margin: "0 auto",
|
||||
padding: "0 24px",
|
||||
}}
|
||||
>
|
||||
{/* Header — mesh glyph + wordmark */}
|
||||
<Section style={{ marginBottom: "40px" }}>
|
||||
<table role="presentation" cellPadding={0} cellSpacing={0} border={0}>
|
||||
<tr>
|
||||
<td style={{ verticalAlign: "middle", paddingRight: "10px" }}>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="12" cy="4" r="2" fill={brand.clay} />
|
||||
<circle cx="4" cy="12" r="2" fill={brand.clay} />
|
||||
<circle cx="20" cy="12" r="2" fill={brand.clay} />
|
||||
<circle cx="12" cy="20" r="2" fill={brand.clay} />
|
||||
<path
|
||||
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
|
||||
stroke={brand.clay}
|
||||
strokeWidth="1.2"
|
||||
opacity="0.45"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</td>
|
||||
<td style={{ verticalAlign: "middle" }}>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "17px",
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.01em",
|
||||
color: brand.fg,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
claudemesh
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
{/* Eyebrow */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "11px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.22em",
|
||||
color: brand.clay,
|
||||
margin: "0 0 16px 0",
|
||||
}}
|
||||
>
|
||||
— you're invited
|
||||
</Text>
|
||||
|
||||
{/* Heading */}
|
||||
<Heading
|
||||
as="h1"
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "32px",
|
||||
fontWeight: 500,
|
||||
lineHeight: "1.15",
|
||||
letterSpacing: "-0.01em",
|
||||
color: brand.fg,
|
||||
margin: "0 0 20px 0",
|
||||
}}
|
||||
>
|
||||
Join{" "}
|
||||
<span style={{ fontFamily: brand.mono, color: brand.clay }}>
|
||||
{meshName}
|
||||
</span>{" "}
|
||||
on claudemesh
|
||||
</Heading>
|
||||
|
||||
{/* Body prose */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "16px",
|
||||
lineHeight: "1.65",
|
||||
color: brand.fgSecondary,
|
||||
margin: "0 0 32px 0",
|
||||
}}
|
||||
>
|
||||
claudemesh is a peer mesh for Claude Code sessions — end-to-end
|
||||
encrypted, keys stay on your machine. Open the link below to see
|
||||
the mesh, the inviter, and the command to join.
|
||||
</Text>
|
||||
|
||||
{/* Primary CTA */}
|
||||
<Section style={{ marginBottom: "36px" }}>
|
||||
<Button
|
||||
href={inviteUrl}
|
||||
style={{
|
||||
backgroundColor: brand.clay,
|
||||
color: brand.fg,
|
||||
fontFamily: brand.sans,
|
||||
fontSize: "15px",
|
||||
fontWeight: 500,
|
||||
textDecoration: "none",
|
||||
padding: "14px 28px",
|
||||
borderRadius: "4px",
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
Open invite →
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
{/* Terminal shortcut — for the already-set-up crowd */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "11px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.22em",
|
||||
color: brand.fgTertiary,
|
||||
margin: "0 0 12px 0",
|
||||
}}
|
||||
>
|
||||
— already have the CLI?
|
||||
</Text>
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: brand.bgElevated,
|
||||
border: `1px solid ${brand.clayBorder}`,
|
||||
borderRadius: "6px",
|
||||
padding: "16px 18px",
|
||||
marginBottom: "32px",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "12px",
|
||||
color: brand.fg,
|
||||
margin: 0,
|
||||
wordBreak: "break-all",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
>
|
||||
{launchCmd}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* First-time one-liner */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "11px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.22em",
|
||||
color: brand.fgTertiary,
|
||||
margin: "0 0 12px 0",
|
||||
}}
|
||||
>
|
||||
— first time? one command
|
||||
</Text>
|
||||
<Section
|
||||
style={{
|
||||
backgroundColor: brand.bgElevated,
|
||||
border: `1px solid ${brand.border}`,
|
||||
borderRadius: "6px",
|
||||
padding: "16px 18px",
|
||||
marginBottom: "32px",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "12px",
|
||||
color: brand.fg,
|
||||
margin: 0,
|
||||
lineHeight: "1.6",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{oneLiner}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "12px",
|
||||
color: brand.fgTertiary,
|
||||
margin: "8px 0 0 0",
|
||||
}}
|
||||
>
|
||||
Requires Node.js 20+. Display name defaults to $USER.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `1px solid ${brand.border}`,
|
||||
margin: "28px 0 20px 0",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Footer meta */}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.serif,
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
color: brand.fgTertiary,
|
||||
margin: "0 0 8px 0",
|
||||
}}
|
||||
>
|
||||
Expires{" "}
|
||||
<span style={{ color: brand.fgSecondary }}>{expiresLabel}</span>.
|
||||
If you weren't expecting this, you can ignore it.
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: brand.mono,
|
||||
fontSize: "11px",
|
||||
color: brand.fgTertiary,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={appBaseUrl}
|
||||
style={{ color: brand.fgTertiary, textDecoration: "underline" }}
|
||||
>
|
||||
claudemesh.com
|
||||
</Link>
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
MeshInvitation.PreviewProps = {
|
||||
meshName: "prueba1",
|
||||
inviteUrl: "https://claudemesh.com/i/RUVMYXZQ",
|
||||
token: "eyJ2IjoxLCJtZXNoX2lkIjoiQUtMYUZxR3FKOGZCajN0U3dvVk1PSFYxQmF3UGlYTE8iLCJtZXNoX3NsdWciOiJwcnVlYmExIn0",
|
||||
expiresAt: "2026-04-22T00:51:26.181Z",
|
||||
appBaseUrl: "https://claudemesh.com",
|
||||
} satisfies MeshInvitationProps;
|
||||
|
||||
export default MeshInvitation;
|
||||
@@ -20,6 +20,21 @@ const envSchema = z.object({
|
||||
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
|
||||
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
|
||||
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30),
|
||||
MINIO_ENDPOINT: z.string().default("minio:9000"),
|
||||
MINIO_ACCESS_KEY: z.string().default("claudemesh"),
|
||||
MINIO_SECRET_KEY: z.string().default("changeme"),
|
||||
MINIO_USE_SSL: z.enum(["true", "false", ""]).transform(v => v === "true").default("false"),
|
||||
QDRANT_URL: z.string().default("http://qdrant:6333"),
|
||||
NEO4J_URL: z.string().default("bolt://neo4j:7687"),
|
||||
NEO4J_USER: z.string().default("neo4j"),
|
||||
NEO4J_PASSWORD: z.string().default("changeme"),
|
||||
RUNNER_URL: z.string().default("http://runner:7901"),
|
||||
CLAUDEMESH_SERVICES_DIR: z.string().default("/var/claudemesh/services"),
|
||||
BROKER_ENCRYPTION_KEY: z.string().default(""), // 64 hex chars (32 bytes). Auto-generated if empty.
|
||||
CLI_SYNC_SECRET: z.string().default(""), // HS256 shared secret for dashboard→broker sync JWTs. Required for /cli-sync.
|
||||
MAX_SERVICES_PER_MESH: z.coerce.number().int().positive().default(20),
|
||||
MAX_SERVICE_ZIP_BYTES: z.coerce.number().int().positive().default(50 * 1024 * 1024),
|
||||
ANTHROPIC_API_KEY: z.string().default(""), // Claude API key for Telegram AI bot
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.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();
|
||||
}
|
||||
}
|
||||
112
apps/broker/src/permissions.ts
Normal file
112
apps/broker/src/permissions.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Granular permission checks for mesh operations.
|
||||
*
|
||||
* If a meshPermission row exists for the member, use it.
|
||||
* Otherwise, derive defaults from the member's role.
|
||||
*/
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { meshPermission, meshMember, mesh, DEFAULT_PERMISSIONS } from "@turbostarter/db/schema/mesh";
|
||||
import type { PermissionKey } from "@turbostarter/db/schema/mesh";
|
||||
|
||||
export interface ResolvedPermissions {
|
||||
canInvite: boolean;
|
||||
canDeployMcp: boolean;
|
||||
canManageFiles: boolean;
|
||||
canManageVault: boolean;
|
||||
canManageWatches: boolean;
|
||||
canManageWebhooks: boolean;
|
||||
canWriteState: boolean;
|
||||
canSend: boolean;
|
||||
canUseTools: boolean;
|
||||
canDeleteMesh: boolean;
|
||||
canManagePermissions: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective permissions for a member in a mesh.
|
||||
* Checks for explicit permission row, falls back to role defaults.
|
||||
*/
|
||||
export async function getPermissions(meshId: string, memberId: string): Promise<ResolvedPermissions> {
|
||||
// Get the explicit permission row if it exists
|
||||
const [perm] = await db.select().from(meshPermission)
|
||||
.where(and(eq(meshPermission.meshId, meshId), eq(meshPermission.memberId, memberId)))
|
||||
.limit(1);
|
||||
|
||||
if (perm) {
|
||||
return {
|
||||
canInvite: perm.canInvite,
|
||||
canDeployMcp: perm.canDeployMcp,
|
||||
canManageFiles: perm.canManageFiles,
|
||||
canManageVault: perm.canManageVault,
|
||||
canManageWatches: perm.canManageWatches,
|
||||
canManageWebhooks: perm.canManageWebhooks,
|
||||
canWriteState: perm.canWriteState,
|
||||
canSend: perm.canSend,
|
||||
canUseTools: perm.canUseTools,
|
||||
canDeleteMesh: perm.canDeleteMesh,
|
||||
canManagePermissions: perm.canManagePermissions,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to role-based defaults
|
||||
const [member] = await db.select().from(meshMember)
|
||||
.where(eq(meshMember.id, memberId))
|
||||
.limit(1);
|
||||
|
||||
if (!member) return DEFAULT_PERMISSIONS.member;
|
||||
|
||||
// Check if member is mesh owner
|
||||
const [m] = await db.select().from(mesh)
|
||||
.where(eq(mesh.id, meshId))
|
||||
.limit(1);
|
||||
|
||||
if (m && m.ownerUserId && member.userId === m.ownerUserId) {
|
||||
return DEFAULT_PERMISSIONS.owner;
|
||||
}
|
||||
|
||||
return DEFAULT_PERMISSIONS[member.role] ?? DEFAULT_PERMISSIONS.member;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a single permission for a member.
|
||||
* Returns true if allowed, false if denied.
|
||||
*/
|
||||
export async function checkPermission(
|
||||
meshId: string,
|
||||
memberId: string,
|
||||
permission: PermissionKey,
|
||||
): Promise<boolean> {
|
||||
const perms = await getPermissions(meshId, memberId);
|
||||
return perms[permission];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set explicit permissions for a member (partial update).
|
||||
* Creates the row if it doesn't exist.
|
||||
*/
|
||||
export async function setPermissions(
|
||||
meshId: string,
|
||||
memberId: string,
|
||||
updates: Partial<ResolvedPermissions>,
|
||||
): Promise<void> {
|
||||
const [existing] = await db.select().from(meshPermission)
|
||||
.where(and(eq(meshPermission.meshId, meshId), eq(meshPermission.memberId, memberId)))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await db.update(meshPermission)
|
||||
.set({ ...updates, updatedAt: new Date() })
|
||||
.where(eq(meshPermission.id, existing.id));
|
||||
} else {
|
||||
// Get role defaults first, then overlay updates
|
||||
const defaults = await getPermissions(meshId, memberId);
|
||||
await db.insert(meshPermission).values({
|
||||
meshId,
|
||||
memberId,
|
||||
...defaults,
|
||||
...updates,
|
||||
});
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
424
apps/broker/src/telegram-ai.ts
Normal file
424
apps/broker/src/telegram-ai.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Claude-powered natural language processing for Telegram mesh interactions.
|
||||
*
|
||||
* Uses Claude Haiku 4.5 with tool calling to interpret user intent
|
||||
* and map to mesh operations. Destructive/social actions require
|
||||
* confirmation via Telegram inline buttons.
|
||||
*/
|
||||
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { env } from "./env";
|
||||
import { log } from "./logger";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AiTool {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AiToolCall {
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AiResult {
|
||||
type: "text" | "tool_call" | "error";
|
||||
text?: string;
|
||||
toolCall?: AiToolCall;
|
||||
requiresConfirmation?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tools definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOOLS: AiTool[] = [
|
||||
{
|
||||
name: "send_message",
|
||||
description: "Send a message to a peer in the mesh. Use when the user wants to tell, ask, or communicate something to a specific person or group.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
to: { type: "string", description: "Peer name, @group, or * for broadcast" },
|
||||
message: { type: "string", description: "The message content" },
|
||||
priority: { type: "string", enum: ["now", "next", "low"], description: "Delivery priority (default: next)" },
|
||||
},
|
||||
required: ["to", "message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_peers",
|
||||
description: "List all connected peers in the mesh. Use when user asks who's online, who's available, or what everyone is doing.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_meshes",
|
||||
description: "List all meshes this Telegram chat is connected to. Use when user asks about their meshes, which meshes are available, or wants to see their workspace list.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remember",
|
||||
description: "Store a memory/note in the mesh's shared knowledge. Use when user wants to save information for later.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
content: { type: "string", description: "The content to remember" },
|
||||
tags: { type: "array", items: { type: "string" }, description: "Tags for categorization" },
|
||||
},
|
||||
required: ["content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recall",
|
||||
description: "Search the mesh's shared memory. Use when user asks about something that was previously stored.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query" },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_state",
|
||||
description: "Read a shared state value. Use when user asks about a specific key/variable.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string", description: "State key to read" },
|
||||
},
|
||||
required: ["key"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_state",
|
||||
description: "Write a shared state value. Use when user wants to set/update a key.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: { type: "string", description: "State key" },
|
||||
value: { type: "string", description: "Value to set" },
|
||||
},
|
||||
required: ["key", "value"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_mesh",
|
||||
description: "Create a new mesh. Use when user wants to create a new workspace/mesh.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Mesh name" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "share_mesh",
|
||||
description: "Generate an invite link or send an invite email. Use when user wants to invite someone to the mesh.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
email: { type: "string", description: "Email to invite (optional — if omitted, generates a link)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_services",
|
||||
description: "List all deployed MCP services and skills in the mesh. Use when user asks about available tools, services, MCPs, skills, or capabilities.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_commands",
|
||||
description: "Show available Telegram bot commands. Use when user asks what commands are available, what they can do, or asks for help.",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Actions that need user confirmation before executing
|
||||
const CONFIRM_ACTIONS = new Set([
|
||||
"send_message",
|
||||
"create_mesh",
|
||||
"share_mesh",
|
||||
"set_state",
|
||||
"remember",
|
||||
]);
|
||||
|
||||
const SYSTEM_PROMPT = `You are the claudemesh Telegram assistant. You help users interact with their claudemesh peer network using natural language.
|
||||
|
||||
You have access to tools for mesh operations. When the user's intent maps to a tool, use it. When it's a general question or conversation, respond directly.
|
||||
|
||||
IMPORTANT: Always respond in the same language the user writes in. If they write in Spanish, respond in Spanish. If English, respond in English.
|
||||
|
||||
Key concepts:
|
||||
- A MESH is a workspace/group (like "flexicar", "alexis-mou"). This Telegram chat can be connected to multiple meshes.
|
||||
- A PEER is a person/agent connected to a mesh (like "Nedas", "Mou").
|
||||
- When user says "send to <mesh-name>", they mean BROADCAST to all peers in that mesh. Use send_message with to="*" — the system will route to the correct mesh.
|
||||
- When user says "send to <person-name>", they mean a direct message to that peer.
|
||||
|
||||
Rules:
|
||||
- Be concise — Telegram messages should be short
|
||||
- When sending messages to peers, preserve the user's tone and intent
|
||||
- If the target looks like a mesh name (matches one from context), broadcast to it
|
||||
- Never fabricate peer names — use list_peers to find real names
|
||||
- Default to the first connected mesh if not specified`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI Engine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let client: Anthropic | null = null;
|
||||
|
||||
function getClient(): Anthropic {
|
||||
if (!client) {
|
||||
const apiKey = env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) throw new Error("ANTHROPIC_API_KEY not configured");
|
||||
client = new Anthropic({ apiKey });
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation history (per chat, rolling window)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_HISTORY = 10;
|
||||
const HISTORY_TTL_MS = 30 * 60 * 1000; // 30 min
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
const chatHistory = new Map<number, HistoryEntry[]>();
|
||||
|
||||
// Clean stale histories every 10 min
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [chatId, entries] of chatHistory) {
|
||||
const fresh = entries.filter(e => now - e.ts < HISTORY_TTL_MS);
|
||||
if (fresh.length === 0) chatHistory.delete(chatId);
|
||||
else chatHistory.set(chatId, fresh);
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
function getHistory(chatId: number): HistoryEntry[] {
|
||||
return chatHistory.get(chatId) ?? [];
|
||||
}
|
||||
|
||||
function pushHistory(chatId: number, role: "user" | "assistant", content: string): void {
|
||||
const entries = chatHistory.get(chatId) ?? [];
|
||||
entries.push({ role, content, ts: Date.now() });
|
||||
if (entries.length > MAX_HISTORY * 2) entries.splice(0, entries.length - MAX_HISTORY * 2);
|
||||
chatHistory.set(chatId, entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a tool result in conversation history so the AI knows what happened.
|
||||
*/
|
||||
export function recordToolResult(chatId: number, toolName: string, resultSummary: string): void {
|
||||
pushHistory(chatId, "assistant", `[Tool ${toolName} result]: ${resultSummary}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a natural language message through Claude and return the intent.
|
||||
*/
|
||||
export async function processMessage(
|
||||
chatId: number,
|
||||
userMessage: string,
|
||||
context: { meshSlug?: string; meshSlugs?: string[]; userName?: string; recentPeers?: string[] },
|
||||
): Promise<AiResult> {
|
||||
try {
|
||||
const anthropic = getClient();
|
||||
|
||||
// Record user message in history
|
||||
pushHistory(chatId, "user", userMessage);
|
||||
|
||||
const contextInfo = [
|
||||
context.meshSlugs?.length ? `Connected meshes: ${context.meshSlugs.join(", ")}` : context.meshSlug ? `Current mesh: ${context.meshSlug}` : null,
|
||||
context.userName ? `User's name: ${context.userName}` : null,
|
||||
context.recentPeers?.length ? `Known peers: ${context.recentPeers.join(", ")}` : null,
|
||||
].filter(Boolean).join(". ");
|
||||
|
||||
// Build message history for multi-turn context
|
||||
const history = getHistory(chatId);
|
||||
const messages: Array<{ role: "user" | "assistant"; content: string }> = [];
|
||||
for (const entry of history) {
|
||||
// Alternate roles — Claude API requires user/assistant alternation
|
||||
if (messages.length === 0 || messages[messages.length - 1]!.role !== entry.role) {
|
||||
messages.push({ role: entry.role, content: entry.content });
|
||||
} else {
|
||||
// Same role consecutive — merge into the last message
|
||||
messages[messages.length - 1]!.content += "\n" + entry.content;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure messages start with user and alternate
|
||||
if (messages.length > 0 && messages[0]!.role !== "user") {
|
||||
messages.shift();
|
||||
}
|
||||
|
||||
const response = await anthropic.messages.create({
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
max_tokens: 500,
|
||||
system: SYSTEM_PROMPT + (contextInfo ? `\n\nContext: ${contextInfo}` : ""),
|
||||
tools: TOOLS as Anthropic.Messages.Tool[],
|
||||
messages,
|
||||
});
|
||||
|
||||
// Check for tool use
|
||||
for (const block of response.content) {
|
||||
if (block.type === "tool_use") {
|
||||
pushHistory(chatId, "assistant", `[Using tool: ${block.name}(${JSON.stringify(block.input).slice(0, 100)})]`);
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCall: { name: block.name, input: block.input as Record<string, unknown> },
|
||||
requiresConfirmation: CONFIRM_ACTIONS.has(block.name),
|
||||
};
|
||||
}
|
||||
if (block.type === "text") {
|
||||
pushHistory(chatId, "assistant", block.text);
|
||||
return { type: "text", text: block.text };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "text", text: "I'm not sure how to help with that." };
|
||||
} catch (err) {
|
||||
log.error("telegram-ai", { error: err instanceof Error ? err.message : String(err) });
|
||||
return { type: "error", text: "AI processing failed. Try a /command instead." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a tool call as a human-readable confirmation message for Telegram.
|
||||
*/
|
||||
export function formatConfirmation(toolCall: AiToolCall): string {
|
||||
const { name, input } = toolCall;
|
||||
|
||||
switch (name) {
|
||||
case "send_message":
|
||||
return `📤 <b>Send message to ${escHtml(String(input.to))}:</b>\n\n"${escHtml(String(input.message))}"\n\nPriority: ${input.priority ?? "next"}`;
|
||||
|
||||
case "create_mesh":
|
||||
return `🔧 <b>Create mesh:</b>\n\nName: ${escHtml(String(input.name))}`;
|
||||
|
||||
case "share_mesh":
|
||||
return input.email
|
||||
? `📧 <b>Send invite to:</b>\n\n${escHtml(String(input.email))}`
|
||||
: `🔗 <b>Generate invite link</b>`;
|
||||
|
||||
case "set_state":
|
||||
return `📝 <b>Set state:</b>\n\n<code>${escHtml(String(input.key))}</code> = <code>${escHtml(String(input.value))}</code>`;
|
||||
|
||||
case "remember":
|
||||
return `💾 <b>Remember:</b>\n\n"${escHtml(String(input.content))}"${input.tags ? `\nTags: ${(input.tags as string[]).join(", ")}` : ""}`;
|
||||
|
||||
default:
|
||||
return `⚙️ <b>${escHtml(name)}:</b>\n\n<pre>${escHtml(JSON.stringify(input, null, 2))}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a tool result as a Telegram reply.
|
||||
*/
|
||||
export function formatResult(toolName: string, result: unknown): string {
|
||||
switch (toolName) {
|
||||
case "send_message":
|
||||
return "✅ Message sent.";
|
||||
|
||||
case "list_peers": {
|
||||
const peers = result as Array<{ displayName: string; status: string; summary?: string }>;
|
||||
if (!peers || peers.length === 0) return "No peers online.";
|
||||
return "👥 <b>Online peers:</b>\n\n" + peers.map(p => {
|
||||
const icon = p.status === "idle" ? "🟢" : p.status === "working" ? "🟡" : p.status === "dnd" ? "🔴" : "⚪";
|
||||
return `${icon} <b>${escHtml(p.displayName)}</b>${p.summary ? ` — ${escHtml(p.summary)}` : ""}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
case "list_meshes": {
|
||||
const meshes = result as Array<{ slug: string; peers: number }>;
|
||||
if (!meshes || meshes.length === 0) return "No meshes connected. Use /connect to add one.";
|
||||
return "🔗 <b>Connected meshes:</b>\n\n" + meshes.map(m =>
|
||||
`• <b>${escHtml(m.slug)}</b> — ${m.peers} peer${m.peers !== 1 ? "s" : ""} online`
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
case "recall": {
|
||||
const memories = result as Array<{ content: string; tags: string[] }>;
|
||||
if (!memories || memories.length === 0) return "No memories found.";
|
||||
return "🧠 <b>Memories:</b>\n\n" + memories.map(m =>
|
||||
`• ${escHtml(m.content)}${m.tags.length ? ` <i>[${m.tags.join(", ")}]</i>` : ""}`
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
case "get_state": {
|
||||
const state = result as { key: string; value: unknown } | null;
|
||||
if (!state) return "Key not found.";
|
||||
return `📊 <code>${escHtml(state.key)}</code> = <code>${escHtml(String(state.value))}</code>`;
|
||||
}
|
||||
|
||||
case "remember":
|
||||
return "💾 Remembered.";
|
||||
|
||||
case "set_state":
|
||||
return "📝 State updated.";
|
||||
|
||||
case "create_mesh":
|
||||
return "✅ Mesh created.";
|
||||
|
||||
case "share_mesh":
|
||||
return typeof result === "string" ? `🔗 Invite: ${result}` : "✅ Invite sent.";
|
||||
|
||||
case "list_services": {
|
||||
const services = result as Array<{ name: string; type: string; tools: number; status: string }>;
|
||||
if (!services || services.length === 0) return "No services deployed in this mesh.";
|
||||
return "⚙️ <b>Mesh services:</b>\n\n" + services.map(s =>
|
||||
`• <b>${escHtml(s.name)}</b> (${s.type}) — ${s.tools} tool${s.tools !== 1 ? "s" : ""} [${s.status}]`
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
case "list_commands":
|
||||
return `📋 <b>Available commands:</b>
|
||||
|
||||
/connect — connect to a mesh
|
||||
/disconnect — disconnect from mesh
|
||||
/peers — list online peers
|
||||
/meshes — list connected meshes
|
||||
/dm @Name message — send direct message
|
||||
/broadcast message — send to all peers
|
||||
/status — connection status
|
||||
/help — show help
|
||||
|
||||
Or just type naturally:
|
||||
• "who's online?"
|
||||
• "tell Nedas the API is ready"
|
||||
• "list my meshes"
|
||||
• "what services are available?"`;
|
||||
|
||||
default:
|
||||
return `✅ Done: ${JSON.stringify(result)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export { CONFIRM_ACTIONS };
|
||||
1944
apps/broker/src/telegram-bridge.ts
Normal file
1944
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");
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,9 @@
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
},
|
||||
"types": ["bun-types"]
|
||||
"types": ["bun-types"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
5
apps/cli-v2/.gitignore
vendored
Normal file
5
apps/cli-v2/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
.cache/
|
||||
*.log
|
||||
44
apps/cli-v2/CHANGELOG.md
Normal file
44
apps/cli-v2/CHANGELOG.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0-alpha.0 (2026-04-13)
|
||||
|
||||
### Architecture
|
||||
- Complete folder restructure: `entrypoints/`, `cli/`, `commands/`, `services/` (17 feature-folders with facade pattern), `ui/`, `mcp/`, `constants/`, `types/`, `utils/`, `locales/`, `templates/`
|
||||
- 212 source files, 10,900 lines
|
||||
- ESM-only, Bun bundler, TypeScript strict mode
|
||||
|
||||
### New CLI commands
|
||||
- `claudemesh register` — account creation via browser handoff
|
||||
- `claudemesh login` — device-code OAuth
|
||||
- `claudemesh logout` — revoke session + clear credentials
|
||||
- `claudemesh whoami` — identity check with `--json` support
|
||||
- `claudemesh new <name>` — create mesh from CLI (was dashboard-only)
|
||||
- `claudemesh invite [email]` — generate invite from CLI (was dashboard-only)
|
||||
|
||||
### Ported from v1 (full feature parity)
|
||||
- All 79 MCP tools
|
||||
- All 85 WS message types (broker protocol unchanged)
|
||||
- Welcome wizard, launch flow, install/uninstall
|
||||
- Ed25519 + NaCl crypto (keypairs, crypto_box DMs, file encryption)
|
||||
- Reconnect with exponential backoff
|
||||
- Status priority engine, scheduled messages, URL watch
|
||||
- Doctor checks, Telegram bridge connect wizard
|
||||
|
||||
### Security hardening (25 bugs fixed across 4 reviews)
|
||||
- `execFile` instead of `exec` for browser open (command injection fix)
|
||||
- ReDoS-safe pattern matching in peer file sharing
|
||||
- Atomic config writes via temp file + rename
|
||||
- Auth token stored with `openSync(mode: 0o600)` — no permission race
|
||||
- Decryption oracle collapsed to generic error in `get_file`
|
||||
- Download size limit (100MB) on file retrieval
|
||||
- Path traversal protection with `realpathSync` for symlink escapes
|
||||
- Callback listener double-resolve guard
|
||||
- Push buffer 1MB per-message truncation
|
||||
- `makeReqId` uses `crypto.randomBytes` instead of `Math.random`
|
||||
- Connect guard prevents double-connect race
|
||||
|
||||
### Breaking changes from v0.10.x
|
||||
- Flat command namespace (no `launch` subcommand, no `advanced` prefix)
|
||||
- New config shape (same data, cleaner layout)
|
||||
- New `--json` output format with `schema_version: "1.0"`
|
||||
- New exit codes (see `constants/exit-codes.ts`)
|
||||
90
apps/cli-v2/README.md
Normal file
90
apps/cli-v2/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# claudemesh-cli
|
||||
|
||||
Peer mesh for Claude Code sessions. Connect multiple Claude Code instances into a shared mesh with real-time messaging, shared state, memory, file sharing, and 79 MCP tools.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm i -g claudemesh-cli
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
claudemesh register # create account
|
||||
claudemesh new "my-team" # create a mesh
|
||||
claudemesh invite # generate invite link
|
||||
claudemesh # start a session
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
USAGE
|
||||
claudemesh start a session (creates one if needed)
|
||||
claudemesh <url> join a mesh from an invite link
|
||||
claudemesh new create a new mesh
|
||||
claudemesh invite [email] generate an invite
|
||||
claudemesh list see your meshes
|
||||
claudemesh rename <name> rename the current mesh
|
||||
claudemesh leave [mesh] leave a mesh
|
||||
claudemesh peers see who's online
|
||||
|
||||
claudemesh send <to> <msg> send a message
|
||||
claudemesh inbox drain pending messages
|
||||
claudemesh state ... get, set, or list shared state
|
||||
claudemesh remember <text> store a memory
|
||||
claudemesh recall <query> search memories
|
||||
claudemesh remind ... schedule a reminder
|
||||
claudemesh profile view or edit your profile
|
||||
|
||||
claudemesh doctor diagnose issues
|
||||
claudemesh whoami show current identity
|
||||
claudemesh status check broker connectivity
|
||||
|
||||
claudemesh register create account
|
||||
claudemesh login sign in via browser
|
||||
claudemesh logout sign out
|
||||
|
||||
claudemesh install register MCP server + hooks
|
||||
claudemesh uninstall remove MCP server + hooks
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── entrypoints/ CLI + MCP stdio entry points
|
||||
├── cli/ argv parsing, output formatters, signal handling
|
||||
├── commands/ one verb per file (29 commands)
|
||||
├── services/ 17 feature-folders with facade pattern
|
||||
│ ├── auth/ device-code OAuth, token storage
|
||||
│ ├── broker/ WebSocket client (2200 lines), reconnect, crypto
|
||||
│ ├── crypto/ Ed25519, NaCl crypto_box, AES-GCM file encryption
|
||||
│ ├── config/ ~/.claudemesh/config.json with atomic writes
|
||||
│ ├── mesh/ CRUD, join, resolve target
|
||||
│ ├── invite/ generate, parse, claim (v1 + v2 formats)
|
||||
│ ├── api/ typed HTTP client for claudemesh.com
|
||||
│ ├── health/ 6 diagnostic checks
|
||||
│ └── ... device, clipboard, spawn, telemetry, i18n, logger
|
||||
├── mcp/ MCP server with 79 tools across 21 families
|
||||
├── ui/ TUI: styles, spinner, welcome wizard, launch flow
|
||||
├── constants/ exit codes, paths, URLs, timings
|
||||
├── types/ API, mesh, peer interfaces
|
||||
├── utils/ levenshtein, slug, URL, format, semver, retry
|
||||
├── locales/ English strings (i18n ready)
|
||||
└── templates/ 5 mesh templates
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
bun run dev # hot-reload
|
||||
bun run build # production build
|
||||
bun run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
2
apps/cli-v2/bin/claudemesh
Normal file
2
apps/cli-v2/bin/claudemesh
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import "../dist/entrypoints/cli.js";
|
||||
4
apps/cli-v2/biome.json
Normal file
4
apps/cli-v2/biome.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"extends": ["../../biome.json"]
|
||||
}
|
||||
51
apps/cli-v2/build.ts
Normal file
51
apps/cli-v2/build.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
const MAX_GZIPPED_BYTES = 1.2 * 1024 * 1024; // 1.2 MB
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [
|
||||
"src/entrypoints/cli.ts",
|
||||
"src/entrypoints/mcp.ts",
|
||||
],
|
||||
outdir: "dist/entrypoints",
|
||||
target: "node",
|
||||
format: "esm",
|
||||
splitting: false,
|
||||
sourcemap: "external",
|
||||
external: [
|
||||
"libsodium-wrappers",
|
||||
"ws",
|
||||
"@modelcontextprotocol/sdk",
|
||||
],
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Build failed:");
|
||||
for (const log of result.logs) {
|
||||
console.error(log);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const output of result.outputs) {
|
||||
const raw = statSync(output.path).size;
|
||||
const gz = gzipSync(await Bun.file(output.path).arrayBuffer()).byteLength;
|
||||
const label = output.path.replace(process.cwd() + "/", "");
|
||||
console.log(` ${label} ${(raw / 1024).toFixed(0)} KB (${(gz / 1024).toFixed(0)} KB gzipped)`);
|
||||
|
||||
if (gz > MAX_GZIPPED_BYTES) {
|
||||
console.error(`\n ERROR: ${label} exceeds 1.2 MB gzipped ceiling (${(gz / 1024).toFixed(0)} KB)`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const { chmodSync, readFileSync, writeFileSync } = await import("node:fs");
|
||||
const cliPath = "dist/entrypoints/cli.js";
|
||||
const cliContent = readFileSync(cliPath, "utf-8");
|
||||
if (!cliContent.startsWith("#!")) {
|
||||
writeFileSync(cliPath, "#!/usr/bin/env node\n" + cliContent);
|
||||
}
|
||||
chmodSync(cliPath, 0o755);
|
||||
|
||||
console.log("\nBuild complete.");
|
||||
69
apps/cli-v2/package.json
Normal file
69
apps/cli-v2/package.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "claudemesh-cli-v2",
|
||||
"version": "1.0.0-alpha.30",
|
||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||
"keywords": [
|
||||
"claude-code",
|
||||
"mcp",
|
||||
"model-context-protocol",
|
||||
"claudemesh",
|
||||
"peer-messaging",
|
||||
"multi-agent"
|
||||
],
|
||||
"author": "Alejandro Gutiérrez",
|
||||
"license": "MIT",
|
||||
"homepage": "https://claudemesh.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/alezmad/claudemesh.git",
|
||||
"directory": "apps/cli-v2"
|
||||
},
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"claudemesh": "./dist/entrypoints/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build.ts",
|
||||
"clean": "git clean -xdf .cache .turbo dist node_modules",
|
||||
"dev": "bun --hot src/entrypoints/cli.ts",
|
||||
"start": "bun src/entrypoints/cli.ts",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"prepublishOnly": "bun run build",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@turbostarter/prettier-config",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"citty": "0.2.2",
|
||||
"libsodium-wrappers": "0.7.15",
|
||||
"qrcode-terminal": "0.12.0",
|
||||
"ws": "8.20.0",
|
||||
"zod": "4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@turbostarter/eslint-config": "workspace:*",
|
||||
"@turbostarter/prettier-config": "workspace:*",
|
||||
"@turbostarter/tsconfig": "workspace:*",
|
||||
"@turbostarter/vitest-config": "workspace:*",
|
||||
"@types/libsodium-wrappers": "0.7.14",
|
||||
"@types/qrcode-terminal": "0.12.2",
|
||||
"@types/ws": "8.5.13",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
49
apps/cli-v2/scripts/build-binaries.ts
Normal file
49
apps/cli-v2/scripts/build-binaries.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Cross-platform single-binary compile.
|
||||
*
|
||||
* Run: bun run scripts/build-binaries.ts
|
||||
* Output: dist/bin/claudemesh-{darwin,linux,windows}-{x64,arm64}{.exe}
|
||||
*
|
||||
* Each binary bundles the CLI + Bun runtime, no Node required.
|
||||
* Current caveat: native deps like libsodium-wrappers ship as JS+wasm
|
||||
* so they work. `ws` falls back to its JS polyfill when uws isn't present.
|
||||
*
|
||||
* Intended for CI — GitHub Releases publish → install.sh / Homebrew
|
||||
* pull the right tarball per platform.
|
||||
*/
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const TARGETS: Array<{ name: string; target: string; ext: string }> = [
|
||||
{ name: "darwin-x64", target: "bun-darwin-x64", ext: "" },
|
||||
{ name: "darwin-arm64", target: "bun-darwin-arm64", ext: "" },
|
||||
{ name: "linux-x64", target: "bun-linux-x64", ext: "" },
|
||||
{ name: "linux-arm64", target: "bun-linux-arm64", ext: "" },
|
||||
{ name: "windows-x64", target: "bun-windows-x64", ext: ".exe" },
|
||||
];
|
||||
|
||||
mkdirSync("dist/bin", { recursive: true });
|
||||
|
||||
for (const { name, target, ext } of TARGETS) {
|
||||
const out = `dist/bin/claudemesh-${name}${ext}`;
|
||||
console.log(`→ ${out}`);
|
||||
const res = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"build",
|
||||
"--compile",
|
||||
"--minify",
|
||||
`--target=${target}`,
|
||||
"src/entrypoints/cli.ts",
|
||||
"--outfile",
|
||||
out,
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (res.status !== 0) {
|
||||
console.error(` failed: ${name}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log("\nBinaries built in dist/bin/");
|
||||
30
apps/cli-v2/src/cli/argv.ts
Normal file
30
apps/cli-v2/src/cli/argv.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineCommand, runMain } from "citty";
|
||||
|
||||
export interface ParsedArgs { command: string; positionals: string[]; flags: Record<string, string | boolean | undefined>; }
|
||||
|
||||
export function parseArgv(argv: string[]): ParsedArgs {
|
||||
const args = argv.slice(2);
|
||||
const flags: Record<string, string | boolean | undefined> = {};
|
||||
const positionals: string[] = [];
|
||||
let command = "";
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]!;
|
||||
if (arg.startsWith("--")) {
|
||||
const key = arg.slice(2);
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
|
||||
} else if (arg.startsWith("-") && arg.length === 2) {
|
||||
const key = arg.slice(1);
|
||||
const next = args[i + 1];
|
||||
if (next && !next.startsWith("-")) { flags[key] = next; i++; } else flags[key] = true;
|
||||
} else if (!command) {
|
||||
command = arg;
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
return { command, positionals, flags };
|
||||
}
|
||||
|
||||
export { defineCommand, runMain };
|
||||
7
apps/cli-v2/src/cli/exit.ts
Normal file
7
apps/cli-v2/src/cli/exit.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
const cleanupHooks: Array<() => void> = [];
|
||||
export function onExit(fn: () => void): void { cleanupHooks.push(fn); }
|
||||
export function exit(code: number = EXIT.SUCCESS): never {
|
||||
for (const fn of cleanupHooks) { try { fn(); } catch {} }
|
||||
process.exit(code);
|
||||
}
|
||||
12
apps/cli-v2/src/cli/handlers/error.ts
Normal file
12
apps/cli-v2/src/cli/handlers/error.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { red } from "~/ui/styles.js";
|
||||
export function handleUncaughtError(err: unknown): never {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(red("\n Fatal: " + msg + "\n"));
|
||||
if (process.env.CLAUDEMESH_DEBUG === "1" && err instanceof Error && err.stack) console.error(err.stack);
|
||||
process.exit(EXIT.INTERNAL_ERROR);
|
||||
}
|
||||
export function installErrorHandlers(): void {
|
||||
process.on("uncaughtException", handleUncaughtError);
|
||||
process.on("unhandledRejection", (reason) => handleUncaughtError(reason));
|
||||
}
|
||||
6
apps/cli-v2/src/cli/handlers/signal.ts
Normal file
6
apps/cli-v2/src/cli/handlers/signal.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { SHOW_CURSOR } from "~/ui/styles.js";
|
||||
export function installSignalHandlers(): void {
|
||||
const cleanup = () => { process.stdout.write(SHOW_CURSOR); };
|
||||
process.on("SIGINT", () => { cleanup(); process.exit(1); });
|
||||
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
||||
}
|
||||
6
apps/cli-v2/src/cli/output/list.ts
Normal file
6
apps/cli-v2/src/cli/output/list.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { JoinedMesh } from "~/services/config/facade.js";
|
||||
import { bold, dim } from "~/ui/styles.js";
|
||||
export function renderMeshList(meshes: JoinedMesh[]): string {
|
||||
if (meshes.length === 0) return " No meshes joined.";
|
||||
return meshes.map((m, i) => " " + bold((i + 1) + ")") + " " + m.slug + " " + dim("(" + m.meshId.slice(0, 8) + "\u2026)")).join("\n");
|
||||
}
|
||||
11
apps/cli-v2/src/cli/output/peers.ts
Normal file
11
apps/cli-v2/src/cli/output/peers.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PeerInfo } from "~/services/broker/facade.js";
|
||||
import { bold, dim, green, yellow, red } from "~/ui/styles.js";
|
||||
const S: Record<string, (s: string) => string> = { idle: green, working: yellow, dnd: red };
|
||||
export function renderPeers(peers: PeerInfo[], meshSlug: string): string {
|
||||
if (peers.length === 0) return " No peers online in " + meshSlug + ".";
|
||||
return peers.map(p => {
|
||||
const icon = (S[p.status] ?? dim)("\u25CF");
|
||||
const summary = p.summary ? dim(" \u2014 " + p.summary) : "";
|
||||
return " " + icon + " " + bold(p.displayName) + summary;
|
||||
}).join("\n");
|
||||
}
|
||||
3
apps/cli-v2/src/cli/output/version.ts
Normal file
3
apps/cli-v2/src/cli/output/version.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { VERSION } from "~/constants/urls.js";
|
||||
import { boldOrange } from "~/ui/styles.js";
|
||||
export function renderVersion(): string { return " " + boldOrange("claudemesh") + " v" + VERSION; }
|
||||
11
apps/cli-v2/src/cli/output/whoami.ts
Normal file
11
apps/cli-v2/src/cli/output/whoami.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { WhoAmIResult } from "~/services/auth/facade.js";
|
||||
import { bold, dim } from "~/ui/styles.js";
|
||||
export function renderWhoAmI(result: WhoAmIResult): string {
|
||||
if (!result.signed_in) return " Not signed in.";
|
||||
const lines = [
|
||||
" Signed in as " + bold(result.user!.display_name) + " (" + result.user!.email + ")",
|
||||
" Token source: " + result.token_source + " " + dim("(~/.claudemesh/auth.json)"),
|
||||
];
|
||||
if (result.meshes) lines.push(" Meshes: " + result.meshes.owned + " owned, " + result.meshes.guest + " guest");
|
||||
return lines.join("\n");
|
||||
}
|
||||
7
apps/cli-v2/src/cli/print.ts
Normal file
7
apps/cli-v2/src/cli/print.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||
export function print(msg: string): void { process.stdout.write(msg + "\n"); }
|
||||
export function printErr(msg: string): void { process.stderr.write(msg + "\n"); }
|
||||
export function isQuiet(): boolean { return process.argv.includes("-q") || process.argv.includes("--quiet"); }
|
||||
export function isVerbose(): boolean { return process.argv.includes("-v") || process.argv.includes("--verbose"); }
|
||||
export function isJson(): boolean { return process.argv.includes("--json"); }
|
||||
export function isTty(): boolean { return !!isTTY; }
|
||||
4
apps/cli-v2/src/cli/structured-io.ts
Normal file
4
apps/cli-v2/src/cli/structured-io.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function jsonOutput<T>(data: T): string {
|
||||
return JSON.stringify({ schema_version: "1.0", ...data }, null, 2);
|
||||
}
|
||||
export function writeJson<T>(data: T): void { console.log(jsonOutput(data)); }
|
||||
11
apps/cli-v2/src/cli/update-notice.ts
Normal file
11
apps/cli-v2/src/cli/update-notice.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { checkForUpdate } from "~/services/update/facade.js";
|
||||
import { dim, yellow } from "~/ui/styles.js";
|
||||
export async function showUpdateNotice(currentVersion: string): Promise<void> {
|
||||
try {
|
||||
const info = await checkForUpdate(currentVersion);
|
||||
if (info.updateAvailable) {
|
||||
console.error(yellow(" Update available: " + info.current + " \u2192 " + info.latest));
|
||||
console.error(dim(" Run: npm i -g claudemesh-cli"));
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
147
apps/cli-v2/src/commands/backup.ts
Normal file
147
apps/cli-v2/src/commands/backup.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* `claudemesh backup` — encrypt the local config and save a portable
|
||||
* recovery file. Restore later with `claudemesh restore <file>` on any
|
||||
* machine to recover mesh memberships.
|
||||
*
|
||||
* Crypto:
|
||||
* - Argon2id KDF over a user passphrase → 32-byte key
|
||||
* (via libsodium's crypto_pwhash, INTERACTIVE limits so a weak
|
||||
* passphrase is still workable but brute-force remains expensive)
|
||||
* - XChaCha20-Poly1305 authenticated encryption of the JSON config
|
||||
* - Format: magic "CMB1" · salt (16B) · nonce (24B) · ciphertext
|
||||
*
|
||||
* Output: a single `.claudemesh-backup` file the user can store in
|
||||
* 1Password, email to themselves, etc. Zero server involvement.
|
||||
*
|
||||
* Passphrase hygiene: read twice from TTY, never echoed. Rejects
|
||||
* passphrases shorter than 12 characters.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
import { getConfigPath } from "~/services/config/facade.js";
|
||||
import { ensureSodium } from "~/services/crypto/facade.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const MAGIC = Buffer.from("CMB1", "utf-8");
|
||||
|
||||
function readHidden(prompt: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
process.stdout.write(prompt);
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
||||
// Node readline doesn't mask by default. Turn off echo manually.
|
||||
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
|
||||
const wasRaw = Boolean(stdin.isRaw);
|
||||
if (stdin.isTTY) {
|
||||
stdin.setRawMode(true);
|
||||
}
|
||||
let buf = "";
|
||||
const onData = (chunk: Buffer): void => {
|
||||
const ch = chunk.toString("utf-8");
|
||||
if (ch === "\n" || ch === "\r" || ch === "\u0004") {
|
||||
stdin.removeListener("data", onData);
|
||||
if (stdin.isTTY) stdin.setRawMode(wasRaw);
|
||||
process.stdout.write("\n");
|
||||
rl.close();
|
||||
resolve(buf);
|
||||
return;
|
||||
}
|
||||
if (ch === "\u0003") { // ctrl-c
|
||||
process.exit(130);
|
||||
}
|
||||
if (ch === "\u007f") { // backspace
|
||||
buf = buf.slice(0, -1);
|
||||
return;
|
||||
}
|
||||
buf += ch;
|
||||
};
|
||||
stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
async function deriveKey(pass: string, salt: Buffer, s: Awaited<ReturnType<typeof ensureSodium>>): Promise<Uint8Array> {
|
||||
return s.crypto_pwhash(
|
||||
32,
|
||||
pass,
|
||||
salt,
|
||||
s.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
s.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||
s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runBackup(outPath: string | undefined): Promise<number> {
|
||||
const configPath = getConfigPath();
|
||||
if (!existsSync(configPath)) {
|
||||
console.error(" No config found — nothing to back up. Join a mesh first.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
const plaintext = readFileSync(configPath);
|
||||
|
||||
const pass = await readHidden(" Passphrase (min 12 chars): ");
|
||||
if (pass.length < 12) {
|
||||
console.error(" ✗ Passphrase too short.");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const confirm = await readHidden(" Confirm passphrase: ");
|
||||
if (confirm !== pass) {
|
||||
console.error(" ✗ Passphrases did not match.");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
|
||||
const s = await ensureSodium();
|
||||
const salt = Buffer.from(s.randombytes_buf(16));
|
||||
const nonce = Buffer.from(s.randombytes_buf(24));
|
||||
const key = await deriveKey(pass, salt, s);
|
||||
const ciphertext = Buffer.from(
|
||||
s.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, null, null, nonce, key),
|
||||
);
|
||||
const blob = Buffer.concat([MAGIC, salt, nonce, ciphertext]);
|
||||
|
||||
const file = outPath ?? `claudemesh-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.cmb`;
|
||||
writeFileSync(file, blob, { mode: 0o600 });
|
||||
console.log(`\n ✓ Backup saved: ${file}`);
|
||||
console.log(` Size: ${blob.length} bytes. Guard the passphrase — there is no recovery.\n`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runRestore(inPath: string | undefined): Promise<number> {
|
||||
if (!inPath) {
|
||||
console.error(" Usage: claudemesh restore <backup-file>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
if (!existsSync(inPath)) {
|
||||
console.error(` ✗ File not found: ${inPath}`);
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
const blob = readFileSync(inPath);
|
||||
if (blob.length < 4 + 16 + 24 + 17 || !blob.subarray(0, 4).equals(MAGIC)) {
|
||||
console.error(" ✗ Not a claudemesh backup file (bad magic).");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const salt = blob.subarray(4, 20);
|
||||
const nonce = blob.subarray(20, 44);
|
||||
const ciphertext = blob.subarray(44);
|
||||
|
||||
const pass = await readHidden(" Passphrase: ");
|
||||
const s = await ensureSodium();
|
||||
const key = await deriveKey(pass, Buffer.from(salt), s);
|
||||
let plaintext: Uint8Array;
|
||||
try {
|
||||
plaintext = s.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, null, nonce, key);
|
||||
} catch {
|
||||
console.error(" ✗ Decryption failed — wrong passphrase or tampered file.");
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
const configPath = getConfigPath();
|
||||
if (existsSync(configPath)) {
|
||||
const backupOld = `${configPath}.before-restore.${Date.now()}`;
|
||||
writeFileSync(backupOld, readFileSync(configPath), { mode: 0o600 });
|
||||
console.log(` ↻ Existing config saved to ${backupOld}`);
|
||||
}
|
||||
writeFileSync(configPath, Buffer.from(plaintext), { mode: 0o600 });
|
||||
console.log(`\n ✓ Config restored to ${configPath}`);
|
||||
console.log(" Run `claudemesh list` to verify your meshes.\n");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
122
apps/cli-v2/src/commands/completions.ts
Normal file
122
apps/cli-v2/src/commands/completions.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* `claudemesh completions <shell>` — emit a completion script for bash / zsh / fish.
|
||||
*
|
||||
* Users pipe it into their shell's completion system:
|
||||
* bash: claudemesh completions bash > /etc/bash_completion.d/claudemesh
|
||||
* zsh: claudemesh completions zsh > ~/.zfunc/_claudemesh (add $fpath)
|
||||
* fish: claudemesh completions fish > ~/.config/fish/completions/claudemesh.fish
|
||||
*/
|
||||
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const COMMANDS = [
|
||||
"create", "new", "join", "add", "launch", "connect", "disconnect",
|
||||
"list", "ls", "delete", "rm", "rename", "share", "invite",
|
||||
"peers", "send", "inbox", "state", "info",
|
||||
"remember", "recall", "remind", "profile", "status",
|
||||
"login", "register", "logout", "whoami",
|
||||
"install", "uninstall", "doctor", "sync",
|
||||
"completions", "verify", "url-handler",
|
||||
"help",
|
||||
];
|
||||
|
||||
const FLAGS = [
|
||||
"--help", "-h", "--version", "-V", "--json", "--yes", "-y",
|
||||
"--quiet", "-q", "--mesh", "--name", "--join", "--resume",
|
||||
];
|
||||
|
||||
function bash(): string {
|
||||
return `# claudemesh bash completion
|
||||
_claudemesh_complete() {
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
local commands="${COMMANDS.join(" ")}"
|
||||
local flags="${FLAGS.join(" ")}"
|
||||
|
||||
if [[ \${cword} -eq 1 ]]; then
|
||||
COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "\${cur}" in
|
||||
-*)
|
||||
COMPREPLY=( $(compgen -W "\${flags}" -- "\${cur}") )
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
complete -F _claudemesh_complete claudemesh
|
||||
`;
|
||||
}
|
||||
|
||||
function zsh(): string {
|
||||
return `#compdef claudemesh
|
||||
# claudemesh zsh completion
|
||||
|
||||
_claudemesh() {
|
||||
local -a commands flags
|
||||
commands=(
|
||||
${COMMANDS.map((c) => ` '${c}'`).join("\n")}
|
||||
)
|
||||
flags=(
|
||||
${FLAGS.map((f) => ` '${f}'`).join("\n")}
|
||||
)
|
||||
|
||||
if (( CURRENT == 2 )); then
|
||||
_describe 'command' commands
|
||||
return
|
||||
fi
|
||||
|
||||
case $words[2] in
|
||||
join|add|launch|connect)
|
||||
_arguments '--name[display name]' '--join[invite url]' '-y[non-interactive]' '--mesh[mesh slug]'
|
||||
;;
|
||||
share|invite)
|
||||
_arguments '--mesh[mesh slug]' '--json[machine-readable]'
|
||||
;;
|
||||
*)
|
||||
_values 'flag' $flags
|
||||
;;
|
||||
esac
|
||||
}
|
||||
compdef _claudemesh claudemesh
|
||||
`;
|
||||
}
|
||||
|
||||
function fish(): string {
|
||||
const cmdLines = COMMANDS.map(
|
||||
(c) => `complete -c claudemesh -n '__fish_use_subcommand' -a '${c}'`,
|
||||
).join("\n");
|
||||
return `# claudemesh fish completion
|
||||
${cmdLines}
|
||||
complete -c claudemesh -l help -s h -d 'show help'
|
||||
complete -c claudemesh -l version -s V -d 'show version'
|
||||
complete -c claudemesh -l json -d 'machine-readable output'
|
||||
complete -c claudemesh -l yes -s y -d 'skip confirmations'
|
||||
complete -c claudemesh -l mesh -d 'mesh slug'
|
||||
complete -c claudemesh -l name -d 'display name'
|
||||
complete -c claudemesh -l join -d 'invite url'
|
||||
`;
|
||||
}
|
||||
|
||||
export async function runCompletions(shell: string | undefined): Promise<number> {
|
||||
if (!shell) {
|
||||
console.error("Usage: claudemesh completions <bash|zsh|fish>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
switch (shell.toLowerCase()) {
|
||||
case "bash":
|
||||
process.stdout.write(bash());
|
||||
return EXIT.SUCCESS;
|
||||
case "zsh":
|
||||
process.stdout.write(zsh());
|
||||
return EXIT.SUCCESS;
|
||||
case "fish":
|
||||
process.stdout.write(fish());
|
||||
return EXIT.SUCCESS;
|
||||
default:
|
||||
console.error(`Unsupported shell: ${shell}. Use bash, zsh, or fish.`);
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
}
|
||||
65
apps/cli-v2/src/commands/connect-telegram.ts
Normal file
65
apps/cli-v2/src/commands/connect-telegram.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
|
||||
export async function connectTelegram(args: string[]): Promise<void> {
|
||||
const config = readConfig();
|
||||
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)");
|
||||
}
|
||||
}
|
||||
81
apps/cli-v2/src/commands/connect.ts
Normal file
81
apps/cli-v2/src/commands/connect.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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 { createInterface } from "node:readline";
|
||||
import { BrokerClient } from "~/services/broker/facade.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import type { JoinedMesh } from "~/services/config/facade.js";
|
||||
|
||||
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;
|
||||
/** Connect to all meshes and run fn for each. */
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
|
||||
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]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function withMesh<T>(
|
||||
opts: ConnectOpts,
|
||||
fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const config = readConfig();
|
||||
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 {
|
||||
mesh = await pickMesh(config.meshes);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
128
apps/cli-v2/src/commands/delete-mesh.ts
Normal file
128
apps/cli-v2/src/commands/delete-mesh.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { leave as leaveMesh } from "~/services/mesh/facade.js";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { request } from "~/services/api/facade.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
import { green, red, bold, dim, yellow, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
|
||||
});
|
||||
}
|
||||
|
||||
function getUserId(token: string): string {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split(".")[1]!, "base64url").toString()) as { sub?: string };
|
||||
return payload.sub ?? "";
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
async function isOwner(slug: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await request<{ meshes: Array<{ slug: string; is_owner: boolean }> }>({
|
||||
path: `/cli/meshes?user_id=${userId}`,
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
return res.meshes?.find(m => m.slug === slug)?.is_owner ?? false;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export async function deleteMesh(slug: string, opts: { yes?: boolean } = {}): Promise<number> {
|
||||
const config = readConfig();
|
||||
|
||||
// Mesh picker if no slug given
|
||||
if (!slug) {
|
||||
if (config.meshes.length === 0) {
|
||||
console.error(" No meshes to remove.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
console.log("\n Select mesh to remove:\n");
|
||||
config.meshes.forEach((m, i) => {
|
||||
console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`);
|
||||
});
|
||||
console.log("");
|
||||
const choice = await prompt(" Choice: ");
|
||||
const idx = parseInt(choice, 10) - 1;
|
||||
if (idx < 0 || idx >= config.meshes.length) {
|
||||
console.log(" Cancelled.");
|
||||
return EXIT.USER_CANCELLED;
|
||||
}
|
||||
slug = config.meshes[idx]!.slug;
|
||||
}
|
||||
|
||||
const auth = getStoredToken();
|
||||
const userId = auth ? getUserId(auth.session_token) : "";
|
||||
const ownerCheck = userId ? await isOwner(slug, userId) : false;
|
||||
|
||||
// Ask what to do
|
||||
if (!opts.yes) {
|
||||
console.log(`\n ${bold(slug)}\n`);
|
||||
|
||||
if (ownerCheck) {
|
||||
console.log(` ${bold("1)")} Remove from this device only ${dim("(keep on server)")}`);
|
||||
console.log(` ${bold("2)")} ${red("Delete everywhere")} ${dim("(removes for all members)")}`);
|
||||
console.log(` ${bold("3)")} Cancel`);
|
||||
console.log("");
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
|
||||
if (choice === "3") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
|
||||
|
||||
if (choice === "2") {
|
||||
// Server-side delete — require confirmation
|
||||
console.log(`\n ${red("Warning:")} This will delete ${bold(slug)} for all members.`);
|
||||
const confirm = await prompt(` Type "${slug}" to confirm: `);
|
||||
if (confirm.toLowerCase() !== slug.toLowerCase()) {
|
||||
console.log(" Cancelled.");
|
||||
return EXIT.USER_CANCELLED;
|
||||
}
|
||||
|
||||
try {
|
||||
await request({
|
||||
path: `/cli/mesh/${slug}`,
|
||||
method: "DELETE",
|
||||
body: { user_id: userId },
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
console.log(` ${green(icons.check)} Deleted "${slug}" from server.`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(` ${icons.cross} Server delete failed: ${msg}`);
|
||||
}
|
||||
|
||||
leaveMesh(slug);
|
||||
console.log(` ${green(icons.check)} Removed from local config.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
// choice === "1" — local only, fall through
|
||||
} else {
|
||||
// Not owner — can only remove locally
|
||||
console.log(` ${bold("1)")} Remove from this device ${dim("(you can re-add later)")}`);
|
||||
console.log(` ${bold("2)")} Cancel`);
|
||||
if (!ownerCheck && userId) {
|
||||
console.log(dim(`\n ${yellow(icons.warn)} Only the mesh owner can delete it from the server.`));
|
||||
}
|
||||
console.log("");
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
if (choice === "2") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
|
||||
}
|
||||
}
|
||||
|
||||
// Local-only removal
|
||||
const removed = leaveMesh(slug);
|
||||
if (removed) {
|
||||
console.log(` ${green(icons.check)} Removed "${slug}" from this device.`);
|
||||
console.log(dim(` Re-add anytime with: claudemesh mesh add <invite-url>`));
|
||||
} else {
|
||||
console.error(` Mesh "${slug}" not found in local config.`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
281
apps/cli-v2/src/commands/doctor.ts
Normal file
281
apps/cli-v2/src/commands/doctor.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* `claudemesh doctor` — diagnostic checks.
|
||||
*
|
||||
* Walks through the install + runtime preconditions and prints each
|
||||
* as pass/fail with a fix hint on failure. Exit 0 if everything
|
||||
* passes, 1 otherwise.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readConfig, getConfigPath } from "~/services/config/facade.js";
|
||||
import { VERSION, URLS } from "~/constants/urls.js";
|
||||
|
||||
interface Check {
|
||||
name: string;
|
||||
pass: boolean;
|
||||
detail?: string;
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
function checkNode(): Check {
|
||||
const major = Number(process.versions.node.split(".")[0]);
|
||||
return {
|
||||
name: "Node.js >= 20",
|
||||
pass: major >= 20,
|
||||
detail: `v${process.versions.node}`,
|
||||
fix: "Install Node 20 or newer (https://nodejs.org)",
|
||||
};
|
||||
}
|
||||
|
||||
function checkClaudeOnPath(): Check {
|
||||
const res =
|
||||
platform() === "win32"
|
||||
? spawnSync("where", ["claude"])
|
||||
: spawnSync("sh", ["-c", "command -v claude"]);
|
||||
const onPath = res.status === 0;
|
||||
const location = onPath ? res.stdout.toString().trim().split("\n")[0] : undefined;
|
||||
return {
|
||||
name: "claude binary on PATH",
|
||||
pass: onPath,
|
||||
detail: location,
|
||||
fix: "Install Claude Code (https://claude.com/claude-code)",
|
||||
};
|
||||
}
|
||||
|
||||
function checkMcpRegistered(): Check {
|
||||
const claudeConfig = join(homedir(), ".claude.json");
|
||||
if (!existsSync(claudeConfig)) {
|
||||
return {
|
||||
name: "claudemesh MCP registered in ~/.claude.json",
|
||||
pass: false,
|
||||
fix: "Run `claudemesh install`",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(claudeConfig, "utf-8")) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
const registered = Boolean(cfg.mcpServers?.["claudemesh"]);
|
||||
return {
|
||||
name: "claudemesh MCP registered in ~/.claude.json",
|
||||
pass: registered,
|
||||
fix: registered ? undefined : "Run `claudemesh install`",
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
name: "claudemesh MCP registered in ~/.claude.json",
|
||||
pass: false,
|
||||
detail: e instanceof Error ? e.message : String(e),
|
||||
fix: "Check ~/.claude.json for JSON parse errors",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkHooksRegistered(): Check {
|
||||
const settings = join(homedir(), ".claude", "settings.json");
|
||||
if (!existsSync(settings)) {
|
||||
return {
|
||||
name: "Status hooks registered in ~/.claude/settings.json",
|
||||
pass: false,
|
||||
fix: "Run `claudemesh install` (remove --no-hooks)",
|
||||
};
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(settings, "utf-8");
|
||||
const has = raw.includes("claudemesh hook ");
|
||||
return {
|
||||
name: "Status hooks registered in ~/.claude/settings.json",
|
||||
pass: has,
|
||||
fix: has ? undefined : "Run `claudemesh install` (remove --no-hooks)",
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
name: "Status hooks registered in ~/.claude/settings.json",
|
||||
pass: false,
|
||||
detail: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkConfigFile(): Check {
|
||||
const path = getConfigPath();
|
||||
if (!existsSync(path)) {
|
||||
return {
|
||||
name: "~/.claudemesh/config.json exists and parses",
|
||||
pass: true,
|
||||
detail: "not created yet (fine — no meshes joined)",
|
||||
};
|
||||
}
|
||||
try {
|
||||
readConfig();
|
||||
const st = statSync(path);
|
||||
const mode = (st.mode & 0o777).toString(8);
|
||||
const secure = platform() === "win32" || mode === "600";
|
||||
return {
|
||||
name: "~/.claudemesh/config.json parses + chmod 0600",
|
||||
pass: secure,
|
||||
detail: platform() === "win32" ? "chmod skipped on Windows" : `0${mode}`,
|
||||
fix: secure ? undefined : `chmod 600 ${path}`,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
name: "~/.claudemesh/config.json exists and parses",
|
||||
pass: false,
|
||||
detail: e instanceof Error ? e.message : String(e),
|
||||
fix: "Inspect or delete ~/.claudemesh/config.json and re-join",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkKeypairs(): Check {
|
||||
try {
|
||||
const cfg = readConfig();
|
||||
if (cfg.meshes.length === 0) {
|
||||
return {
|
||||
name: "Mesh keypairs valid",
|
||||
pass: true,
|
||||
detail: "no meshes joined",
|
||||
};
|
||||
}
|
||||
for (const m of cfg.meshes) {
|
||||
if (m.pubkey.length !== 64 || !/^[0-9a-f]+$/.test(m.pubkey)) {
|
||||
return {
|
||||
name: "Mesh keypairs valid",
|
||||
pass: false,
|
||||
detail: `${m.slug}: pubkey malformed`,
|
||||
fix: `Leave + re-join the mesh: claudemesh leave ${m.slug}`,
|
||||
};
|
||||
}
|
||||
if (m.secretKey.length !== 128 || !/^[0-9a-f]+$/.test(m.secretKey)) {
|
||||
return {
|
||||
name: "Mesh keypairs valid",
|
||||
pass: false,
|
||||
detail: `${m.slug}: secret key malformed`,
|
||||
fix: `Leave + re-join the mesh: claudemesh leave ${m.slug}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: "Mesh keypairs valid",
|
||||
pass: true,
|
||||
detail: `${cfg.meshes.length} mesh(es)`,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
name: "Mesh keypairs valid",
|
||||
pass: false,
|
||||
detail: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkBrokerWs(): Promise<Check> {
|
||||
const wsUrl = URLS.BROKER;
|
||||
const start = Date.now();
|
||||
try {
|
||||
const WebSocket = (await import("ws")).default;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const result = await new Promise<Check>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
try { ws.close(); } catch { /* noop */ }
|
||||
resolve({
|
||||
name: "Broker WebSocket reachable",
|
||||
pass: false,
|
||||
detail: `timeout after 5s (${wsUrl})`,
|
||||
fix: "Check firewall/proxy. Broker at ic.claudemesh.com:443 over WSS.",
|
||||
});
|
||||
}, 5000);
|
||||
ws.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
const latency = Date.now() - start;
|
||||
try { ws.close(); } catch { /* noop */ }
|
||||
resolve({
|
||||
name: "Broker WebSocket reachable",
|
||||
pass: true,
|
||||
detail: `${latency}ms to ${wsUrl}`,
|
||||
});
|
||||
});
|
||||
ws.once("error", (e) => {
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
name: "Broker WebSocket reachable",
|
||||
pass: false,
|
||||
detail: e.message,
|
||||
fix: "Check network. Broker URL can be overridden via CLAUDEMESH_BROKER_URL.",
|
||||
});
|
||||
});
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
return {
|
||||
name: "Broker WebSocket reachable",
|
||||
pass: false,
|
||||
detail: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkNpmLatest(): Promise<Check> {
|
||||
try {
|
||||
const res = await fetch(URLS.NPM_REGISTRY, { signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) {
|
||||
return { name: "CLI up-to-date", pass: true, detail: `npm unreachable (${res.status}) — skipped` };
|
||||
}
|
||||
const body = (await res.json()) as { "dist-tags"?: { alpha?: string; latest?: string } };
|
||||
const latest = body["dist-tags"]?.alpha ?? body["dist-tags"]?.latest;
|
||||
if (!latest) return { name: "CLI up-to-date", pass: true, detail: "no dist-tag — skipped" };
|
||||
const up = latest === VERSION;
|
||||
return {
|
||||
name: "CLI up-to-date",
|
||||
pass: up,
|
||||
detail: up ? `latest ${latest}` : `installed ${VERSION} → latest ${latest}`,
|
||||
fix: up ? undefined : "npm i -g claudemesh-cli@alpha",
|
||||
};
|
||||
} catch {
|
||||
return { name: "CLI up-to-date", pass: true, detail: "npm check skipped" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDoctor(): 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 red = (s: string): string => (useColor ? `\x1b[31m${s}\x1b[39m` : s);
|
||||
|
||||
console.log(`claudemesh doctor (v${VERSION})`);
|
||||
console.log("─".repeat(60));
|
||||
|
||||
const checks: Check[] = [
|
||||
checkNode(),
|
||||
checkClaudeOnPath(),
|
||||
checkMcpRegistered(),
|
||||
checkHooksRegistered(),
|
||||
checkConfigFile(),
|
||||
checkKeypairs(),
|
||||
await checkBrokerWs(),
|
||||
await checkNpmLatest(),
|
||||
];
|
||||
|
||||
for (const c of checks) {
|
||||
const mark = c.pass ? green("✓") : red("✗");
|
||||
const detail = c.detail ? dim(` (${c.detail})`) : "";
|
||||
console.log(`${mark} ${c.name}${detail}`);
|
||||
if (!c.pass && c.fix) {
|
||||
console.log(dim(` → ${c.fix}`));
|
||||
}
|
||||
}
|
||||
|
||||
const failing = checks.filter((c) => !c.pass);
|
||||
console.log("");
|
||||
if (failing.length === 0) {
|
||||
console.log(green("All checks passed."));
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(red(`${failing.length} check(s) failed.`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
176
apps/cli-v2/src/commands/grants.ts
Normal file
176
apps/cli-v2/src/commands/grants.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* `claudemesh grant / revoke / grants / block` — per-peer capability grants.
|
||||
*
|
||||
* Claudemesh's original threat model treats all mesh members as trusted, so
|
||||
* every peer can send you messages and read your summary. These commands add
|
||||
* a local filter: the broker still forwards messages, but the MCP server
|
||||
* drops disallowed kinds before they reach Claude Code.
|
||||
*
|
||||
* Grants are stored in ~/.claudemesh/grants.json keyed on
|
||||
* (mesh_slug, peer_pubkey). Default = read + dm (backwards-compatible).
|
||||
* The `block` command sets an empty grant set (equivalent to revoke-all).
|
||||
*
|
||||
* Full grant-enforcement on the broker side is out of scope for this pass
|
||||
* — see .artifacts/specs/2026-04-15-per-peer-capabilities.md for the
|
||||
* server-side rollout plan. Client-side enforcement handles the 80% case
|
||||
* (spam / noise) without needing a broker migration.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { withMesh } from "./connect.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export type Capability =
|
||||
| "read"
|
||||
| "dm"
|
||||
| "broadcast"
|
||||
| "state-read"
|
||||
| "state-write"
|
||||
| "file-read";
|
||||
|
||||
const ALL_CAPS: Capability[] = ["read", "dm", "broadcast", "state-read", "state-write", "file-read"];
|
||||
const DEFAULT_CAPS: Capability[] = ["read", "dm", "broadcast", "state-read"];
|
||||
|
||||
type GrantStore = Record<string, Record<string, Capability[]>>; // mesh → pubkey → caps
|
||||
|
||||
const GRANT_FILE = join(homedir(), ".claudemesh", "grants.json");
|
||||
|
||||
function readGrants(): GrantStore {
|
||||
if (!existsSync(GRANT_FILE)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(GRANT_FILE, "utf-8")) as GrantStore;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeGrants(g: GrantStore): void {
|
||||
const dir = join(homedir(), ".claudemesh");
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(GRANT_FILE, JSON.stringify(g, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
function resolveCaps(input: string[]): Capability[] {
|
||||
if (input.includes("all")) return [...ALL_CAPS];
|
||||
return input.filter((c): c is Capability => (ALL_CAPS as string[]).includes(c));
|
||||
}
|
||||
|
||||
async function resolvePeer(meshSlug: string, name: string): Promise<{ displayName: string; pubkey: string } | null> {
|
||||
return await withMesh({ meshSlug }, async (client) => {
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find((p) => p.displayName === name || p.pubkey === name || p.pubkey.startsWith(name));
|
||||
return match ? { displayName: match.displayName, pubkey: match.pubkey } : null;
|
||||
});
|
||||
}
|
||||
|
||||
function pickMesh(slug?: string): string | null {
|
||||
const cfg = readConfig();
|
||||
if (slug) return cfg.meshes.find((m) => m.slug === slug) ? slug : null;
|
||||
return cfg.meshes[0]?.slug ?? null;
|
||||
}
|
||||
|
||||
export async function runGrant(peer: string | undefined, caps: string[], opts: { mesh?: string } = {}): Promise<number> {
|
||||
if (!peer || caps.length === 0) {
|
||||
render.err("Usage: claudemesh grant <peer> <capability...>");
|
||||
render.hint(`Capabilities: ${ALL_CAPS.join(", ")}, all`);
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const mesh = pickMesh(opts.mesh);
|
||||
if (!mesh) { render.err("No matching mesh — join one first."); return EXIT.NOT_FOUND; }
|
||||
const resolved = await resolvePeer(mesh, peer);
|
||||
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
|
||||
const wanted = resolveCaps(caps);
|
||||
if (wanted.length === 0) { render.err(`Unknown capabilities: ${caps.join(", ")}`); return EXIT.INVALID_ARGS; }
|
||||
|
||||
const store = readGrants();
|
||||
const meshGrants = store[mesh] ?? {};
|
||||
const existing = meshGrants[resolved.pubkey] ?? DEFAULT_CAPS.slice();
|
||||
const merged = Array.from(new Set([...existing, ...wanted]));
|
||||
meshGrants[resolved.pubkey] = merged;
|
||||
store[mesh] = meshGrants;
|
||||
writeGrants(store);
|
||||
|
||||
render.ok(`Granted ${wanted.join(", ")} to ${resolved.displayName} on ${mesh}.`);
|
||||
render.kv([["now", merged.join(", ")]]);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runRevoke(peer: string | undefined, caps: string[], opts: { mesh?: string } = {}): Promise<number> {
|
||||
if (!peer || caps.length === 0) {
|
||||
render.err("Usage: claudemesh revoke <peer> <capability...>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const mesh = pickMesh(opts.mesh);
|
||||
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
|
||||
const resolved = await resolvePeer(mesh, peer);
|
||||
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
|
||||
const wanted = caps.includes("all") ? ALL_CAPS.slice() : resolveCaps(caps);
|
||||
|
||||
const store = readGrants();
|
||||
const meshGrants = store[mesh] ?? {};
|
||||
const existing = meshGrants[resolved.pubkey] ?? DEFAULT_CAPS.slice();
|
||||
const after = existing.filter((c) => !wanted.includes(c));
|
||||
meshGrants[resolved.pubkey] = after;
|
||||
store[mesh] = meshGrants;
|
||||
writeGrants(store);
|
||||
|
||||
render.ok(`Revoked ${wanted.join(", ")} from ${resolved.displayName} on ${mesh}.`);
|
||||
render.kv([["now", after.length ? after.join(", ") : "(none)"]]);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runBlock(peer: string | undefined, opts: { mesh?: string } = {}): Promise<number> {
|
||||
if (!peer) { render.err("Usage: claudemesh block <peer>"); return EXIT.INVALID_ARGS; }
|
||||
const mesh = pickMesh(opts.mesh);
|
||||
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
|
||||
const resolved = await resolvePeer(mesh, peer);
|
||||
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
|
||||
const store = readGrants();
|
||||
const meshGrants = store[mesh] ?? {};
|
||||
meshGrants[resolved.pubkey] = [];
|
||||
store[mesh] = meshGrants;
|
||||
writeGrants(store);
|
||||
render.ok(`Blocked ${resolved.displayName} on ${mesh} (all capabilities revoked).`);
|
||||
render.hint(`Undo with: claudemesh grant ${resolved.displayName} all --mesh ${mesh}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runGrants(opts: { mesh?: string; json?: boolean } = {}): Promise<number> {
|
||||
const mesh = pickMesh(opts.mesh);
|
||||
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
|
||||
const store = readGrants();
|
||||
const meshGrants = store[mesh] ?? {};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ schema_version: "1.0", mesh, grants: meshGrants }, null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
render.section(`grants on ${mesh}`);
|
||||
const peerPubkeys = Object.keys(meshGrants);
|
||||
if (peerPubkeys.length === 0) {
|
||||
render.info("(no overrides — all peers use default caps: " + DEFAULT_CAPS.join(", ") + ")");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
await withMesh({ meshSlug: mesh }, async (client) => {
|
||||
const peers = await client.listPeers();
|
||||
const byPk = new Map(peers.map((p) => [p.pubkey, p.displayName]));
|
||||
for (const [pk, caps] of Object.entries(meshGrants)) {
|
||||
const name = byPk.get(pk) ?? `${pk.slice(0, 10)}…`;
|
||||
render.kv([[name, caps.length ? caps.join(", ") : "(blocked)"]]);
|
||||
}
|
||||
});
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
/** Used by the MCP inbound-message path. Returns true if the capability is allowed. */
|
||||
export function isAllowed(meshSlug: string, peerPubkey: string, cap: Capability): boolean {
|
||||
const store = readGrants();
|
||||
const entry = store[meshSlug]?.[peerPubkey];
|
||||
if (entry === undefined) return DEFAULT_CAPS.includes(cap);
|
||||
return entry.includes(cap);
|
||||
}
|
||||
123
apps/cli-v2/src/commands/hook.ts
Normal file
123
apps/cli-v2/src/commands/hook.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* `claudemesh hook <status>` — Claude Code hook handler.
|
||||
*
|
||||
* Registered as a Stop + UserPromptSubmit hook by `claudemesh install`.
|
||||
* On each turn boundary, Claude Code invokes:
|
||||
*
|
||||
* Stop → `claudemesh hook idle`
|
||||
* UserPromptSubmit → `claudemesh hook working`
|
||||
*
|
||||
* We read the Claude Code hook JSON payload from stdin (contains cwd +
|
||||
* session_id), then POST `/hook/set-status` to EVERY joined mesh's
|
||||
* broker with {cwd, pid, status, session_id}. Each broker looks up
|
||||
* its local presence row by (pid, cwd) and updates status.
|
||||
*
|
||||
* Fire-and-forget, silent. Hooks must NEVER block Claude Code or
|
||||
* surface errors to the user. Debug logging available via
|
||||
* CLAUDEMESH_HOOK_DEBUG=1.
|
||||
*
|
||||
* Why send to every broker? A user joined to multiple meshes has
|
||||
* one presence row per mesh, each on its own broker. A turn boundary
|
||||
* updates the status on every broker where this session is active.
|
||||
* Brokers that don't have a matching presence just queue the signal
|
||||
* in pending_status (harmless, TTL-swept).
|
||||
*/
|
||||
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
|
||||
const DEBUG = process.env.CLAUDEMESH_HOOK_DEBUG === "1";
|
||||
|
||||
function debug(msg: string): void {
|
||||
if (DEBUG) console.error(`[claudemesh-hook] ${msg}`);
|
||||
}
|
||||
|
||||
/** WS URL → HTTP URL (same host, swap scheme). */
|
||||
function wsToHttp(wsUrl: string): string {
|
||||
try {
|
||||
const u = new URL(wsUrl);
|
||||
const httpScheme = u.protocol === "wss:" ? "https:" : "http:";
|
||||
return `${httpScheme}//${u.host}`;
|
||||
} catch {
|
||||
return wsUrl;
|
||||
}
|
||||
}
|
||||
|
||||
async function readStdinJson(): Promise<Record<string, unknown>> {
|
||||
if (process.stdin.isTTY) return {};
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = process.stdin;
|
||||
try {
|
||||
for await (const chunk of reader) {
|
||||
chunks.push(chunk as Uint8Array);
|
||||
if (chunks.reduce((n, c) => n + c.length, 0) > 256 * 1024) break;
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function postHook(
|
||||
brokerWsUrl: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const base = wsToHttp(brokerWsUrl);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), 1000);
|
||||
await fetch(`${base}/hook/set-status`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(t));
|
||||
} catch (e) {
|
||||
debug(`post failed ${base}: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runHook(args: string[]): Promise<void> {
|
||||
const status = args[0];
|
||||
if (!status || !["idle", "working", "dnd"].includes(status)) {
|
||||
// Silent no-op — we never want a hook to surface an error.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Read Claude Code's stdin payload for cwd + session_id.
|
||||
const stdinTimeout = new Promise<Record<string, unknown>>((r) =>
|
||||
setTimeout(() => r({}), 500),
|
||||
);
|
||||
const payload = await Promise.race([readStdinJson(), stdinTimeout]);
|
||||
const cwd =
|
||||
(typeof payload.cwd === "string" && payload.cwd) ||
|
||||
process.env.CLAUDE_PROJECT_DIR ||
|
||||
process.cwd();
|
||||
const sessionId =
|
||||
(typeof payload.session_id === "string" && payload.session_id) || "";
|
||||
|
||||
// Fan out to EVERY joined mesh's broker in parallel.
|
||||
let config;
|
||||
try {
|
||||
config = readConfig();
|
||||
} catch (e) {
|
||||
debug(`config load failed: ${e instanceof Error ? e.message : e}`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (config.meshes.length === 0) {
|
||||
debug("no joined meshes, nothing to do");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const body = { cwd, pid: process.ppid, status, session_id: sessionId };
|
||||
debug(
|
||||
`status=${status} cwd=${cwd} meshes=${config.meshes.length} session=${sessionId.slice(0, 8)}`,
|
||||
);
|
||||
|
||||
// Dedupe by brokerUrl — if multiple meshes share a broker, one POST
|
||||
// covers them (broker resolves presence by cwd+pid regardless).
|
||||
const brokerUrls = [...new Set(config.meshes.map((m) => m.brokerUrl))];
|
||||
await Promise.all(brokerUrls.map((url) => postHook(url, body)));
|
||||
process.exit(0);
|
||||
}
|
||||
60
apps/cli-v2/src/commands/inbox.ts
Normal file
60
apps/cli-v2/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.js";
|
||||
import type { InboundPush } from "~/services/broker/facade.js";
|
||||
|
||||
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("");
|
||||
}
|
||||
});
|
||||
}
|
||||
29
apps/cli-v2/src/commands/index.ts
Normal file
29
apps/cli-v2/src/commands/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export { runJoin } from "./join.js";
|
||||
export { newMesh } from "./new.js";
|
||||
export { invite } from "./invite.js";
|
||||
export { runList } from "./list.js";
|
||||
export { rename } from "./rename.js";
|
||||
export { runLeave } from "./leave.js";
|
||||
export { runPeers } from "./peers.js";
|
||||
export { runSend } from "./send.js";
|
||||
export { runInbox } from "./inbox.js";
|
||||
export { runStateGet, runStateSet } from "./state.js";
|
||||
export { runInfo } from "./info.js";
|
||||
export { remember } from "./remember.js";
|
||||
export { recall } from "./recall.js";
|
||||
export { runRemind } from "./remind.js";
|
||||
export { runProfile } from "./profile.js";
|
||||
export { runStatus } from "./status.js";
|
||||
export { runDoctor } from "./doctor.js";
|
||||
export { register } from "./register.js";
|
||||
export { login } from "./login.js";
|
||||
export { logout } from "./logout.js";
|
||||
export { whoami } from "./whoami.js";
|
||||
export { runInstall } from "./install.js";
|
||||
export { uninstall } from "./uninstall.js";
|
||||
export { runSync } from "./sync.js";
|
||||
export { runWelcome } from "./welcome.js";
|
||||
export { runHook } from "./hook.js";
|
||||
export { runMcp } from "./mcp.js";
|
||||
export { runSeedTestMesh } from "./seed-test-mesh.js";
|
||||
export { withMesh } from "./connect.js";
|
||||
58
apps/cli-v2/src/commands/info.ts
Normal file
58
apps/cli-v2/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.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
|
||||
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 = readConfig();
|
||||
|
||||
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)}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
564
apps/cli-v2/src/commands/install.ts
Normal file
564
apps/cli-v2/src/commands/install.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* `claudemesh install` / `uninstall` — manage Claude Code MCP registration.
|
||||
*
|
||||
* install:
|
||||
* 1. Preflight: bun is on PATH, this package's MCP entry is on disk.
|
||||
* 2. Read ~/.claude.json (or empty object if absent).
|
||||
* 3. Add/update `mcpServers.claudemesh` with the resolved entry path.
|
||||
* 4. Write back with 0600 perms.
|
||||
* 5. Verify via read-back, print success.
|
||||
*
|
||||
* uninstall:
|
||||
* 1. Read ~/.claude.json (bail if missing).
|
||||
* 2. Delete `mcpServers.claudemesh` if present.
|
||||
* 3. Write back.
|
||||
*
|
||||
* Both are idempotent — re-running install is a no-op if the entry is
|
||||
* already correct, and uninstall is a no-op if no entry exists.
|
||||
*/
|
||||
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
|
||||
const MCP_NAME = "claudemesh";
|
||||
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
|
||||
const CLAUDE_SETTINGS = join(homedir(), ".claude", "settings.json");
|
||||
const HOOK_COMMAND_STOP = "claudemesh hook idle";
|
||||
const HOOK_COMMAND_USER_PROMPT = "claudemesh hook working";
|
||||
const HOOK_MARKER = "claudemesh hook ";
|
||||
|
||||
type McpEntry = {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
interface HookCommand {
|
||||
type: "command";
|
||||
command: string;
|
||||
}
|
||||
interface HookMatcher {
|
||||
matcher?: string;
|
||||
hooks: HookCommand[];
|
||||
}
|
||||
type HooksConfig = Record<string, HookMatcher[]>;
|
||||
|
||||
function readClaudeConfig(): Record<string, unknown> {
|
||||
if (!existsSync(CLAUDE_CONFIG)) return {};
|
||||
const text = readFileSync(CLAUDE_CONFIG, "utf-8").trim();
|
||||
if (!text) return {};
|
||||
try {
|
||||
return JSON.parse(text) as Record<string, unknown>;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`failed to parse ${CLAUDE_CONFIG}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timestamped backup of ~/.claude.json before any write.
|
||||
*/
|
||||
function backupClaudeConfig(): void {
|
||||
if (!existsSync(CLAUDE_CONFIG)) return;
|
||||
const backupDir = join(dirname(CLAUDE_CONFIG), ".claude", "backups");
|
||||
mkdirSync(backupDir, { recursive: true });
|
||||
const ts = Date.now();
|
||||
const dest = join(backupDir, `.claude.json.pre-claudemesh.${ts}`);
|
||||
copyFileSync(CLAUDE_CONFIG, dest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
|
||||
* patches ONLY the `claudemesh` MCP entry. Never touches other keys.
|
||||
* Returns the action taken ("added" | "updated" | "unchanged").
|
||||
*/
|
||||
function patchMcpServer(entry: McpEntry): "added" | "updated" | "unchanged" {
|
||||
backupClaudeConfig();
|
||||
const cfg = readClaudeConfig();
|
||||
const servers =
|
||||
((cfg.mcpServers as Record<string, McpEntry>) ?? {});
|
||||
if (!cfg.mcpServers) cfg.mcpServers = servers;
|
||||
|
||||
const existing = servers[MCP_NAME];
|
||||
let action: "added" | "updated" | "unchanged";
|
||||
if (!existing) {
|
||||
servers[MCP_NAME] = entry;
|
||||
action = "added";
|
||||
} else if (entriesEqual(existing, entry)) {
|
||||
return "unchanged";
|
||||
} else {
|
||||
servers[MCP_NAME] = entry;
|
||||
action = "updated";
|
||||
}
|
||||
|
||||
flushClaudeConfig(cfg);
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-merge-write: re-reads ~/.claude.json at write time and
|
||||
* removes ONLY the `claudemesh` MCP entry. Never touches other keys.
|
||||
* Returns true if an entry was removed.
|
||||
*/
|
||||
function removeMcpServer(): boolean {
|
||||
if (!existsSync(CLAUDE_CONFIG)) return false;
|
||||
backupClaudeConfig();
|
||||
const cfg = readClaudeConfig();
|
||||
const servers = cfg.mcpServers as Record<string, McpEntry> | undefined;
|
||||
if (!servers || !(MCP_NAME in servers)) return false;
|
||||
delete servers[MCP_NAME];
|
||||
cfg.mcpServers = servers;
|
||||
flushClaudeConfig(cfg);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Low-level write — callers must backup + merge first. */
|
||||
function flushClaudeConfig(obj: Record<string, unknown>): void {
|
||||
mkdirSync(dirname(CLAUDE_CONFIG), { recursive: true });
|
||||
writeFileSync(
|
||||
CLAUDE_CONFIG,
|
||||
JSON.stringify(obj, null, 2) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
try {
|
||||
chmodSync(CLAUDE_CONFIG, 0o600);
|
||||
} catch {
|
||||
/* windows has no chmod */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Check `bun` is on PATH — OS-agnostic, node:child_process. */
|
||||
function bunAvailable(): boolean {
|
||||
const res =
|
||||
platform() === "win32"
|
||||
? spawnSync("where", ["bun"])
|
||||
: spawnSync("sh", ["-c", "command -v bun"]);
|
||||
return res.status === 0;
|
||||
}
|
||||
|
||||
/** Absolute path to this CLI's entry file. */
|
||||
function resolveEntry(): string {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
// When bundled (dist/index.js), this file IS the entry → return self.
|
||||
// When running from source (src/index.ts via bun), walk up to the
|
||||
// dir + resolve index.ts.
|
||||
if (here.endsWith("/dist/index.js") || here.endsWith("\\dist\\index.js")) {
|
||||
return here;
|
||||
}
|
||||
return resolve(dirname(here), "..", "index.ts");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the MCP server entry for Claude Code's config.
|
||||
*
|
||||
* Two modes:
|
||||
* - Installed globally (npm i -g claudemesh-cli): use `claudemesh`
|
||||
* as the command, relies on it being on PATH.
|
||||
* - Local dev (bun apps/cli/src/index.ts): use `bun <absolute-path>`.
|
||||
*/
|
||||
function buildMcpEntry(entryPath: string): McpEntry {
|
||||
const isBundled = entryPath.endsWith("/dist/index.js") ||
|
||||
entryPath.endsWith("\\dist\\index.js");
|
||||
if (isBundled) {
|
||||
return {
|
||||
command: "claudemesh",
|
||||
args: ["mcp"],
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: "bun",
|
||||
args: [entryPath, "mcp"],
|
||||
};
|
||||
}
|
||||
|
||||
function entriesEqual(a: McpEntry, b: McpEntry): boolean {
|
||||
return (
|
||||
a.command === b.command &&
|
||||
JSON.stringify(a.args ?? []) === JSON.stringify(b.args ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
function readClaudeSettings(): Record<string, unknown> {
|
||||
if (!existsSync(CLAUDE_SETTINGS)) return {};
|
||||
const text = readFileSync(CLAUDE_SETTINGS, "utf-8").trim();
|
||||
if (!text) return {};
|
||||
try {
|
||||
return JSON.parse(text) as Record<string, unknown>;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`failed to parse ${CLAUDE_SETTINGS}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function writeClaudeSettings(obj: Record<string, unknown>): void {
|
||||
mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
|
||||
writeFileSync(
|
||||
CLAUDE_SETTINGS,
|
||||
JSON.stringify(obj, null, 2) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All claudemesh MCP tool names, prefixed for allowedTools.
|
||||
* These let Claude Code use claudemesh tools without --dangerously-skip-permissions.
|
||||
*/
|
||||
const CLAUDEMESH_TOOLS = [
|
||||
"mcp__claudemesh__cancel_scheduled",
|
||||
"mcp__claudemesh__check_messages",
|
||||
"mcp__claudemesh__claim_task",
|
||||
"mcp__claudemesh__complete_task",
|
||||
"mcp__claudemesh__create_stream",
|
||||
"mcp__claudemesh__create_task",
|
||||
"mcp__claudemesh__delete_file",
|
||||
"mcp__claudemesh__file_status",
|
||||
"mcp__claudemesh__forget",
|
||||
"mcp__claudemesh__get_context",
|
||||
"mcp__claudemesh__get_file",
|
||||
"mcp__claudemesh__get_state",
|
||||
"mcp__claudemesh__grant_file_access",
|
||||
"mcp__claudemesh__graph_execute",
|
||||
"mcp__claudemesh__graph_query",
|
||||
"mcp__claudemesh__join_group",
|
||||
"mcp__claudemesh__leave_group",
|
||||
"mcp__claudemesh__list_collections",
|
||||
"mcp__claudemesh__list_contexts",
|
||||
"mcp__claudemesh__list_files",
|
||||
"mcp__claudemesh__list_peers",
|
||||
"mcp__claudemesh__list_scheduled",
|
||||
"mcp__claudemesh__list_state",
|
||||
"mcp__claudemesh__list_streams",
|
||||
"mcp__claudemesh__list_tasks",
|
||||
"mcp__claudemesh__mesh_execute",
|
||||
"mcp__claudemesh__mesh_info",
|
||||
"mcp__claudemesh__mesh_query",
|
||||
"mcp__claudemesh__mesh_schema",
|
||||
"mcp__claudemesh__message_status",
|
||||
"mcp__claudemesh__ping_mesh",
|
||||
"mcp__claudemesh__publish",
|
||||
"mcp__claudemesh__recall",
|
||||
"mcp__claudemesh__remember",
|
||||
"mcp__claudemesh__schedule_reminder",
|
||||
"mcp__claudemesh__send_message",
|
||||
"mcp__claudemesh__set_state",
|
||||
"mcp__claudemesh__set_status",
|
||||
"mcp__claudemesh__set_summary",
|
||||
"mcp__claudemesh__share_context",
|
||||
"mcp__claudemesh__share_file",
|
||||
"mcp__claudemesh__subscribe",
|
||||
"mcp__claudemesh__vector_delete",
|
||||
"mcp__claudemesh__vector_search",
|
||||
"mcp__claudemesh__vector_store",
|
||||
];
|
||||
|
||||
/**
|
||||
* Pre-approve all claudemesh MCP tools in allowedTools.
|
||||
* Merges into any existing list — never overwrites other entries.
|
||||
* Returns which tools were added vs already present.
|
||||
*/
|
||||
function installAllowedTools(): { added: string[]; unchanged: number } {
|
||||
const settings = readClaudeSettings();
|
||||
const existing = new Set<string>((settings.allowedTools as string[] | undefined) ?? []);
|
||||
const toAdd = CLAUDEMESH_TOOLS.filter((t) => !existing.has(t));
|
||||
if (toAdd.length > 0) {
|
||||
settings.allowedTools = [...Array.from(existing), ...toAdd];
|
||||
writeClaudeSettings(settings);
|
||||
}
|
||||
return { added: toAdd, unchanged: CLAUDEMESH_TOOLS.length - toAdd.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove claudemesh tools from allowedTools.
|
||||
* Leaves all other entries intact. Returns count removed.
|
||||
*/
|
||||
function uninstallAllowedTools(): number {
|
||||
if (!existsSync(CLAUDE_SETTINGS)) return 0;
|
||||
const settings = readClaudeSettings();
|
||||
const existing = (settings.allowedTools as string[] | undefined) ?? [];
|
||||
const toolSet = new Set(CLAUDEMESH_TOOLS);
|
||||
const kept = existing.filter((t) => !toolSet.has(t));
|
||||
const removed = existing.length - kept.length;
|
||||
if (removed > 0) {
|
||||
settings.allowedTools = kept;
|
||||
writeClaudeSettings(settings);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Stop + UserPromptSubmit hook entry to ~/.claude/settings.json,
|
||||
* idempotent on the command string. Returns counts for reporting.
|
||||
*/
|
||||
function installHooks(): { added: number; unchanged: number } {
|
||||
const settings = readClaudeSettings();
|
||||
const hooks = ((settings.hooks ??= {}) as HooksConfig) ?? {};
|
||||
let added = 0;
|
||||
let unchanged = 0;
|
||||
|
||||
const ensure = (event: string, command: string): void => {
|
||||
const list = (hooks[event] ??= []);
|
||||
const alreadyPresent = list.some((entry) =>
|
||||
(entry.hooks ?? []).some((h) => h.command === command),
|
||||
);
|
||||
if (alreadyPresent) {
|
||||
unchanged += 1;
|
||||
return;
|
||||
}
|
||||
list.push({ hooks: [{ type: "command", command }] });
|
||||
added += 1;
|
||||
};
|
||||
ensure("Stop", HOOK_COMMAND_STOP);
|
||||
ensure("UserPromptSubmit", HOOK_COMMAND_USER_PROMPT);
|
||||
|
||||
settings.hooks = hooks;
|
||||
writeClaudeSettings(settings);
|
||||
return { added, unchanged };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every hook entry whose command contains "claudemesh hook "
|
||||
* from ~/.claude/settings.json. Idempotent. Returns removed count.
|
||||
*/
|
||||
function uninstallHooks(): number {
|
||||
if (!existsSync(CLAUDE_SETTINGS)) return 0;
|
||||
const settings = readClaudeSettings();
|
||||
const hooks = settings.hooks as HooksConfig | undefined;
|
||||
if (!hooks) return 0;
|
||||
let removed = 0;
|
||||
for (const event of Object.keys(hooks)) {
|
||||
const kept: HookMatcher[] = [];
|
||||
for (const entry of hooks[event] ?? []) {
|
||||
const filtered = (entry.hooks ?? []).filter(
|
||||
(h) => !(h.command ?? "").includes(HOOK_MARKER),
|
||||
);
|
||||
removed += (entry.hooks ?? []).length - filtered.length;
|
||||
if (filtered.length > 0) kept.push({ ...entry, hooks: filtered });
|
||||
}
|
||||
if (kept.length === 0) delete hooks[event];
|
||||
else hooks[event] = kept;
|
||||
}
|
||||
settings.hooks = hooks;
|
||||
writeClaudeSettings(settings);
|
||||
return removed;
|
||||
}
|
||||
|
||||
function installStatusLine(): { installed: boolean } {
|
||||
const settings = readClaudeSettings();
|
||||
const cmd = `claudemesh status-line`;
|
||||
const current = (settings as { statusLine?: { command?: string } }).statusLine;
|
||||
// If the user has their own statusLine command, don't clobber it.
|
||||
if (current?.command && !current.command.includes("claudemesh status-line")) {
|
||||
return { installed: false };
|
||||
}
|
||||
(settings as { statusLine?: { type: string; command: string } }).statusLine = {
|
||||
type: "command",
|
||||
command: cmd,
|
||||
};
|
||||
writeClaudeSettings(settings);
|
||||
return { installed: true };
|
||||
}
|
||||
|
||||
export function runInstall(args: string[] = []): void {
|
||||
const skipHooks = args.includes("--no-hooks");
|
||||
const wantStatusLine = args.includes("--status-line");
|
||||
console.log("claudemesh install");
|
||||
console.log("------------------");
|
||||
|
||||
const entry = resolveEntry();
|
||||
const isBundled = entry.endsWith("/dist/index.js") ||
|
||||
entry.endsWith("\\dist\\index.js");
|
||||
|
||||
// Dev mode (running from src/) requires bun on PATH; bundled mode
|
||||
// (npm install -g) just uses node + the claudemesh bin shim.
|
||||
if (!isBundled && !bunAvailable()) {
|
||||
console.error(
|
||||
"✗ `bun` is not on PATH. Install Bun first: https://bun.com",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!existsSync(entry)) {
|
||||
console.error(`✗ MCP entry not found at ${entry}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const desired = buildMcpEntry(entry);
|
||||
const action = patchMcpServer(desired);
|
||||
|
||||
// Read-back verification.
|
||||
const verify = readClaudeConfig();
|
||||
const verifyServers = (verify.mcpServers ?? {}) as Record<string, McpEntry>;
|
||||
const stored = verifyServers[MCP_NAME];
|
||||
if (!stored || !entriesEqual(stored, desired)) {
|
||||
console.error(
|
||||
`✗ post-write verification failed — ${CLAUDE_CONFIG} may be corrupt`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ANSI color helpers — stick to 8-color set so terminals without
|
||||
// truecolor still render. Fall back to plain if NO_COLOR or dumb TERM.
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
|
||||
console.log(`✓ MCP server "${MCP_NAME}" ${action}`);
|
||||
console.log(dim(` config: ${CLAUDE_CONFIG}`));
|
||||
console.log(
|
||||
dim(
|
||||
` command: ${desired.command}${desired.args?.length ? " " + desired.args.join(" ") : ""}`,
|
||||
),
|
||||
);
|
||||
|
||||
// allowedTools — pre-approve claudemesh MCP tools so peers don't need
|
||||
// --dangerously-skip-permissions just to call mesh tools.
|
||||
try {
|
||||
const { added, unchanged } = installAllowedTools();
|
||||
if (added.length > 0) {
|
||||
console.log(
|
||||
`✓ allowedTools: ${added.length} claudemesh tools pre-approved${unchanged > 0 ? `, ${unchanged} already present` : ""}`,
|
||||
);
|
||||
console.log(dim(` This lets claudemesh tools run without --dangerously-skip-permissions.`));
|
||||
console.log(dim(` Your existing allowedTools entries were preserved.`));
|
||||
} else {
|
||||
console.log(`✓ allowedTools: all ${unchanged} claudemesh tools already pre-approved`);
|
||||
}
|
||||
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools update failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks — status accuracy (Stop/UserPromptSubmit → POST /hook/set-status).
|
||||
if (!skipHooks) {
|
||||
try {
|
||||
const { added, unchanged } = installHooks();
|
||||
if (added > 0) {
|
||||
console.log(
|
||||
`✓ Hooks registered (Stop + UserPromptSubmit) → ${added} added, ${unchanged} already present`,
|
||||
);
|
||||
} else {
|
||||
console.log(`✓ Hooks already registered (${unchanged} present)`);
|
||||
}
|
||||
console.log(dim(` config: ${CLAUDE_SETTINGS}`));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ hook registration failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
console.error(
|
||||
" (MCP is still installed — hooks just skip. Retry with --no-hooks to suppress.)",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(dim("· Hooks skipped (--no-hooks)"));
|
||||
}
|
||||
|
||||
// Opt-in status line (shows mesh + peer count in Claude Code).
|
||||
if (wantStatusLine) {
|
||||
try {
|
||||
const { installed } = installStatusLine();
|
||||
if (installed) {
|
||||
console.log(`✓ Claude Code statusLine → \`claudemesh status-line\``);
|
||||
console.log(dim(` Shows: ◇ <mesh> · <online>/<total> online · <you>`));
|
||||
} else {
|
||||
console.log(dim("· statusLine already set to a custom command — left alone"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`⚠ statusLine install failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user has any meshes joined — nudge them if not.
|
||||
let hasMeshes = false;
|
||||
try {
|
||||
const meshConfig = readConfig();
|
||||
hasMeshes = meshConfig.meshes.length > 0;
|
||||
} catch {
|
||||
// Config missing or corrupt — treat as no meshes.
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(yellow(bold("⚠ RESTART CLAUDE CODE")) + yellow(" for MCP tools to appear."));
|
||||
|
||||
if (!hasMeshes) {
|
||||
console.log("");
|
||||
console.log(yellow("No meshes joined.") + " To connect with peers:");
|
||||
console.log(
|
||||
` ${bold("claudemesh <invite-url>")}` +
|
||||
dim(" — joins + launches in one step"),
|
||||
);
|
||||
console.log(
|
||||
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
|
||||
);
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(
|
||||
`Next: ${bold("claudemesh")}` + dim(" — launch with your joined mesh"),
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(dim("Optional:"));
|
||||
console.log(dim(` claudemesh url-handler install # click-to-launch from email`));
|
||||
console.log(dim(` claudemesh install --status-line # live peer count in Claude Code`));
|
||||
console.log(dim(` claudemesh completions zsh # shell completions`));
|
||||
}
|
||||
|
||||
export function runUninstall(): void {
|
||||
console.log("claudemesh uninstall");
|
||||
console.log("--------------------");
|
||||
|
||||
// MCP entry — only removes claudemesh, never touches other servers.
|
||||
if (removeMcpServer()) {
|
||||
console.log(`✓ MCP server "${MCP_NAME}" removed`);
|
||||
} else {
|
||||
console.log(`· MCP server "${MCP_NAME}" not present`);
|
||||
}
|
||||
|
||||
// allowedTools
|
||||
try {
|
||||
const removed = uninstallAllowedTools();
|
||||
if (removed > 0) {
|
||||
console.log(`✓ allowedTools: ${removed} claudemesh tools removed`);
|
||||
} else {
|
||||
console.log("· No claudemesh allowedTools to remove");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ allowedTools removal failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Hooks
|
||||
try {
|
||||
const removed = uninstallHooks();
|
||||
if (removed > 0) {
|
||||
console.log(`✓ Hooks removed (${removed} entries)`);
|
||||
} else {
|
||||
console.log("· No claudemesh hooks to remove");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`⚠ hook removal failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("Restart Claude Code to drop the MCP connection + hooks.");
|
||||
}
|
||||
96
apps/cli-v2/src/commands/invite.ts
Normal file
96
apps/cli-v2/src/commands/invite.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { generateInvite } from "~/services/invite/generate.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { writeClipboard } from "~/services/clipboard/facade.js";
|
||||
import { green, bold, dim, icons } from "~/ui/styles.js";
|
||||
import { renderQrAsync } from "~/ui/qr.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
|
||||
});
|
||||
}
|
||||
|
||||
export async function invite(
|
||||
email?: string,
|
||||
opts: { mesh?: string; expires?: string; uses?: number; role?: string; json?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const auth = getStoredToken();
|
||||
if (!auth) {
|
||||
console.error(" Not signed in. Run `claudemesh login` first.");
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error(" No meshes. Create one with `claudemesh mesh create <name>`.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
|
||||
// Resolve which mesh to share
|
||||
let meshSlug = opts.mesh;
|
||||
if (!meshSlug) {
|
||||
if (config.meshes.length === 1) {
|
||||
meshSlug = config.meshes[0]!.slug;
|
||||
} else {
|
||||
// Show picker
|
||||
console.log("\n Select mesh to share:\n");
|
||||
config.meshes.forEach((m, i) => {
|
||||
console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`);
|
||||
});
|
||||
console.log("");
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
const idx = parseInt(choice, 10) - 1;
|
||||
meshSlug = config.meshes[idx >= 0 && idx < config.meshes.length ? idx : 0]!.slug;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateInvite(meshSlug, {
|
||||
email,
|
||||
expires_in: opts.expires ?? "7d",
|
||||
max_uses: opts.uses,
|
||||
role: opts.role,
|
||||
});
|
||||
|
||||
const copied = writeClipboard(result.url);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ schema_version: "1.0", ...result, copied }, null, 2));
|
||||
} else {
|
||||
if (email) {
|
||||
if (result.emailed) {
|
||||
console.log(`\n ${green(icons.check)} Invite sent to ${bold(email)}`);
|
||||
if (copied) console.log(` ${green(icons.check)} Link also copied to clipboard`);
|
||||
} else {
|
||||
console.log(`\n ${icons.cross} Email to ${bold(email)} was NOT sent (server did not send).`);
|
||||
console.log(` ${dim("Share the link manually:")}`);
|
||||
console.log(` ${result.url}`);
|
||||
if (copied) console.log(` ${green(icons.check)} Link copied to clipboard`);
|
||||
}
|
||||
} else {
|
||||
console.log(`\n ${green(icons.check)} Invite link${copied ? " copied to clipboard" : ""}:`);
|
||||
console.log(` ${result.url}`);
|
||||
// Print QR for phone→laptop pairing. Small variant is ~17 lines tall.
|
||||
const qr = await renderQrAsync(result.url, { small: true });
|
||||
console.log("");
|
||||
for (const line of qr.split("\n")) console.log(` ${line}`);
|
||||
}
|
||||
console.log(`\n ${dim("Expires " + result.expires_at + ". Anyone with this link can join \"" + meshSlug + "\".")}\n`);
|
||||
}
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("403") || msg.includes("permission")) {
|
||||
console.error(` ${icons.cross} You don't have permission to invite to "${meshSlug}".`);
|
||||
console.error(` ${dim("Ask the mesh owner to grant you invite permissions.")}`);
|
||||
} else {
|
||||
console.error(` ${icons.cross} Failed: ${msg}`);
|
||||
}
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
}
|
||||
193
apps/cli-v2/src/commands/join.ts
Normal file
193
apps/cli-v2/src/commands/join.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* `claudemesh join <invite-link-or-code>` — full join flow.
|
||||
*
|
||||
* Accepts either:
|
||||
* - v2 short invite: `claudemesh.com/i/<code>` or bare `<code>`
|
||||
* → POSTs to /api/public/invites/:code/claim, unseals root_key,
|
||||
* persists mesh + fresh ed25519 identity.
|
||||
* - v1 legacy invite: `ic://join/<token>` or `https://.../join/<token>`
|
||||
* → parses signed payload, calls broker /join, persists.
|
||||
*
|
||||
* v1 continues to work throughout v0.1.x. v1 endpoints 410 Gone at v0.2.0.
|
||||
*/
|
||||
|
||||
import { parseInviteLink } from "~/services/invite/facade.js";
|
||||
import { enrollWithBroker } from "~/services/invite/facade.js";
|
||||
import { generateKeypair } from "~/services/crypto/facade.js";
|
||||
import { readConfig, writeConfig, getConfigPath } from "~/services/config/facade.js";
|
||||
import { claimInviteV2, parseV2InviteInput } from "~/services/invite/facade.js";
|
||||
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 "~/constants/urls.js";
|
||||
|
||||
/** 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 = readConfig();
|
||||
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,
|
||||
});
|
||||
writeConfig(config);
|
||||
|
||||
console.log("");
|
||||
console.log(`✓ Joined mesh ${claim.meshId} via v2 invite`);
|
||||
console.log(` member id: ${claim.memberId}`);
|
||||
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}…`);
|
||||
console.log(` broker: ${env.CLAUDEMESH_BROKER_URL}`);
|
||||
console.log(` config: ${getConfigPath()}`);
|
||||
console.log("");
|
||||
console.log("Restart Claude Code to pick up the new mesh.");
|
||||
}
|
||||
|
||||
export async function runJoin(args: string[]): Promise<void> {
|
||||
const link = args[0];
|
||||
if (!link) {
|
||||
console.error("Usage: claudemesh join <invite-url-or-code>");
|
||||
console.error("");
|
||||
console.error("Examples:");
|
||||
console.error(" claudemesh join https://claudemesh.com/i/abc12345");
|
||||
console.error(" claudemesh join abc12345");
|
||||
console.error(" claudemesh join ic://join/eyJ2IjoxLC4uLn0 (v1 legacy)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Try v2 first — short code / `/i/<code>` URL.
|
||||
const v2Code = parseV2InviteInput(link);
|
||||
if (v2Code) {
|
||||
await runJoinV2(v2Code);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Parse + verify signature client-side.
|
||||
let invite;
|
||||
try {
|
||||
invite = await parseInviteLink(link);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const { payload, token } = invite;
|
||||
console.log(`Joining mesh "${payload.mesh_slug}" (${payload.mesh_id})…`);
|
||||
|
||||
// 2. Generate keypair.
|
||||
const keypair = await generateKeypair();
|
||||
|
||||
// 3. Enroll with broker.
|
||||
const displayName = `${hostname()}-${process.pid}`;
|
||||
let enroll;
|
||||
try {
|
||||
enroll = await enrollWithBroker({
|
||||
brokerWsUrl: payload.broker_url,
|
||||
inviteToken: token,
|
||||
invitePayload: payload,
|
||||
peerPubkey: keypair.publicKey,
|
||||
displayName,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`claudemesh: broker enrollment failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Persist.
|
||||
const config = readConfig();
|
||||
config.meshes = config.meshes.filter(
|
||||
(m) => m.slug !== payload.mesh_slug,
|
||||
);
|
||||
config.meshes.push({
|
||||
meshId: payload.mesh_id,
|
||||
memberId: enroll.memberId,
|
||||
slug: payload.mesh_slug,
|
||||
name: payload.mesh_slug,
|
||||
pubkey: keypair.publicKey,
|
||||
secretKey: keypair.secretKey,
|
||||
brokerUrl: payload.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
writeConfig(config);
|
||||
|
||||
// 4b. Store invite token for per-session re-enrollment (launch --name).
|
||||
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||
const inviteFile = join(configDir, `invite-${payload.mesh_slug}.txt`);
|
||||
try {
|
||||
mkdirSync(dirname(inviteFile), { recursive: true });
|
||||
writeFileSync(inviteFile, link, "utf-8");
|
||||
} catch {
|
||||
// Non-fatal — launch will fall back to shared identity.
|
||||
}
|
||||
|
||||
// 5. Report.
|
||||
console.log("");
|
||||
console.log(
|
||||
`✓ Joined "${payload.mesh_slug}" as ${displayName}${enroll.alreadyMember ? " (already a member — re-enrolled with same pubkey)" : ""}`,
|
||||
);
|
||||
console.log(` member id: ${enroll.memberId}`);
|
||||
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}…`);
|
||||
console.log(` broker: ${payload.broker_url}`);
|
||||
console.log(` config: ${getConfigPath()}`);
|
||||
console.log("");
|
||||
console.log("Restart Claude Code to pick up the new mesh.");
|
||||
}
|
||||
823
apps/cli-v2/src/commands/launch.ts
Normal file
823
apps/cli-v2/src/commands/launch.ts
Normal file
@@ -0,0 +1,823 @@
|
||||
// @ts-nocheck — v1 port, runtime-tested
|
||||
/**
|
||||
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
||||
*
|
||||
* Flags are defined in index.ts (citty command) — that is the source of
|
||||
* truth. This file receives already-parsed flags and rawArgs.
|
||||
*
|
||||
* Flow:
|
||||
* 1. 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 { 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 { readConfig, getConfigPath } from "~/services/config/facade.js";
|
||||
import type { Config, JoinedMesh, GroupEntry } from "~/services/config/facade.js";
|
||||
import { startCallbackListener, generatePairingCode } from "~/services/auth/facade.js";
|
||||
import { openBrowser } from "~/services/spawn/facade.js";
|
||||
import { BrokerClient } from "~/services/broker/facade.js";
|
||||
|
||||
// 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 "~/ui/styles.js";
|
||||
import {
|
||||
enterFullScreen, exitFullScreen, writeCentered, termSize,
|
||||
drawTopBar, drawBottomBar, menuSelect, textInput, confirmPrompt,
|
||||
} from "~/ui/screen.js";
|
||||
import { createSpinner, FRAME_HEIGHT } from "~/ui/spinner.js";
|
||||
|
||||
interface LaunchWizardResult {
|
||||
mesh: JoinedMesh;
|
||||
role: string | null;
|
||||
groups: GroupEntry[];
|
||||
messageMode: "push" | "inbox" | "off";
|
||||
skipPermissions: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen launch wizard — spinning logo + interactive config.
|
||||
* Mesh selection, role, groups, message mode, permissions — all in one TUI.
|
||||
* Falls back to plain text on non-TTY.
|
||||
*/
|
||||
async function runLaunchWizard(opts: {
|
||||
displayName: string;
|
||||
meshes: JoinedMesh[];
|
||||
selectedMesh: JoinedMesh | null;
|
||||
existingRole: string | null;
|
||||
existingGroups: GroupEntry[];
|
||||
existingMessageMode: "push" | "inbox" | "off" | null;
|
||||
skipPermConfirm: boolean;
|
||||
}): Promise<LaunchWizardResult> {
|
||||
if (!process.stdout.isTTY) {
|
||||
return {
|
||||
mesh: opts.selectedMesh ?? opts.meshes[0]!,
|
||||
role: opts.existingRole,
|
||||
groups: opts.existingGroups,
|
||||
messageMode: opts.existingMessageMode ?? "push",
|
||||
skipPermissions: opts.skipPermConfirm,
|
||||
};
|
||||
}
|
||||
|
||||
const { rows } = termSize();
|
||||
enterFullScreen();
|
||||
drawTopBar();
|
||||
|
||||
// Spinning logo centered in upper portion
|
||||
const logoTop = Math.floor((rows - FRAME_HEIGHT - 16) / 2);
|
||||
const brandRow = logoTop + FRAME_HEIGHT + 1;
|
||||
const subtitleRow = brandRow + 1;
|
||||
const formRow = subtitleRow + 2;
|
||||
|
||||
writeCentered(brandRow, boldOrange("claudemesh"));
|
||||
writeCentered(subtitleRow, tDim("peer mesh for Claude Code"));
|
||||
|
||||
const spinner = createSpinner({
|
||||
render(lines) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
writeCentered(logoTop + i, lines[i]!);
|
||||
}
|
||||
},
|
||||
interval: 70,
|
||||
});
|
||||
spinner.start();
|
||||
|
||||
// Show detected info
|
||||
let row = formRow;
|
||||
writeCentered(row, `Directory ${tGreen("✓")} ${process.cwd()}`);
|
||||
row++;
|
||||
writeCentered(row, `Name ${tGreen("✓")} ${opts.displayName}`);
|
||||
row += 2;
|
||||
|
||||
// Mesh selection
|
||||
let mesh: JoinedMesh;
|
||||
if (opts.selectedMesh) {
|
||||
mesh = opts.selectedMesh;
|
||||
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
|
||||
row++;
|
||||
} else if (opts.meshes.length === 1) {
|
||||
mesh = opts.meshes[0]!;
|
||||
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
|
||||
row++;
|
||||
} else {
|
||||
spinner.stop();
|
||||
const choice = await menuSelect({
|
||||
title: "Select mesh",
|
||||
items: opts.meshes.map(m => m.slug),
|
||||
row,
|
||||
});
|
||||
mesh = opts.meshes[choice]!;
|
||||
// Redraw as confirmed
|
||||
for (let i = 0; i < opts.meshes.length + 1; i++) {
|
||||
writeCentered(row + i, " ");
|
||||
}
|
||||
writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`);
|
||||
spinner.start();
|
||||
row++;
|
||||
}
|
||||
|
||||
row++;
|
||||
|
||||
// Interactive fields
|
||||
let role = opts.existingRole;
|
||||
let groups = opts.existingGroups;
|
||||
let messageMode = opts.existingMessageMode ?? "push" as "push" | "inbox" | "off";
|
||||
|
||||
// Role input
|
||||
if (role === null) {
|
||||
spinner.stop();
|
||||
const answer = await textInput({ label: "Role", row, placeholder: "optional — press Enter to skip" });
|
||||
if (answer) role = answer;
|
||||
spinner.start();
|
||||
row++;
|
||||
} else {
|
||||
writeCentered(row, `Role ${tGreen("✓")} ${role}`);
|
||||
row++;
|
||||
}
|
||||
|
||||
// Groups input
|
||||
if (groups.length === 0) {
|
||||
spinner.stop();
|
||||
const answer = await textInput({ label: "Groups", row, placeholder: "comma-separated, optional" });
|
||||
if (answer) groups = parseGroupsString(answer);
|
||||
spinner.start();
|
||||
row++;
|
||||
} else {
|
||||
const tags = groups.map(g => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ");
|
||||
writeCentered(row, `Groups ${tGreen("✓")} ${tags}`);
|
||||
row++;
|
||||
}
|
||||
|
||||
// Message mode selection
|
||||
if (opts.existingMessageMode === null) {
|
||||
row++;
|
||||
spinner.stop();
|
||||
const choice = await menuSelect({
|
||||
title: "Message mode",
|
||||
items: [
|
||||
"Push (real-time, peers can interrupt)",
|
||||
"Inbox (held until you check)",
|
||||
"Off (tools only, no messages)",
|
||||
],
|
||||
row,
|
||||
});
|
||||
messageMode = (["push", "inbox", "off"] as const)[choice];
|
||||
spinner.start();
|
||||
row += 5;
|
||||
} else {
|
||||
writeCentered(row, `Messages ${tGreen("✓")} ${messageMode}`);
|
||||
row++;
|
||||
}
|
||||
|
||||
// Permissions confirmation
|
||||
let skipPermissions = opts.skipPermConfirm;
|
||||
if (!skipPermissions) {
|
||||
row++;
|
||||
spinner.stop();
|
||||
writeCentered(row, tDim("Claude will run with --dangerously-skip-permissions,"));
|
||||
writeCentered(row + 1, tDim("bypassing ALL permission prompts — not just claudemesh."));
|
||||
row += 3;
|
||||
const confirmed = await confirmPrompt({
|
||||
message: boldOrange("Autonomous mode?"),
|
||||
row,
|
||||
defaultYes: true,
|
||||
});
|
||||
if (!confirmed) {
|
||||
exitFullScreen();
|
||||
console.log(" Run without autonomous mode:");
|
||||
console.log(" claude --dangerously-load-development-channels server:claudemesh\n");
|
||||
process.exit(0);
|
||||
}
|
||||
skipPermissions = true;
|
||||
spinner.start();
|
||||
}
|
||||
|
||||
// Final animation
|
||||
row += 2;
|
||||
writeCentered(row, tDim("Launching Claude Code..."));
|
||||
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
spinner.stop();
|
||||
exitFullScreen();
|
||||
|
||||
return { mesh, role, groups, messageMode, skipPermissions };
|
||||
}
|
||||
|
||||
function printBanner(name: string, meshSlug: string, role: string | null, groups: GroupEntry[], messageMode: "push" | "inbox" | "off"): void {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
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 roleSuffix = role ? ` (${role})` : "";
|
||||
const groupTags = groups.length
|
||||
? " [" + groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||
: "";
|
||||
|
||||
const rule = "─".repeat(60);
|
||||
console.log(bold(`claudemesh launch`) + dim(` — as ${name}${roleSuffix} on ${meshSlug}${groupTags} [${messageMode}]`));
|
||||
console.log(rule);
|
||||
if (messageMode === "push") {
|
||||
console.log("Peer messages arrive as <channel> reminders in real-time.");
|
||||
} else if (messageMode === "inbox") {
|
||||
console.log("Peer messages held in inbox. Use check_messages to read.");
|
||||
} else {
|
||||
console.log("Messages off. Use check_messages to poll manually.");
|
||||
}
|
||||
console.log("Peers send text only — they cannot call tools or read files.");
|
||||
console.log(dim(`Config: ${getConfigPath()}`));
|
||||
console.log(rule);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
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 ?? process.env.USER ?? process.env.USERNAME ?? hostname());
|
||||
const enroll = await enrollWithBroker({
|
||||
brokerWsUrl: invite.payload.broker_url,
|
||||
inviteToken: invite.token,
|
||||
invitePayload: invite.payload,
|
||||
peerPubkey: keypair.publicKey,
|
||||
displayName,
|
||||
});
|
||||
const config = readConfig();
|
||||
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 { writeConfig } = await import("~/services/config/facade.js");
|
||||
writeConfig(config);
|
||||
console.log(
|
||||
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Load config, pick mesh.
|
||||
const config = readConfig();
|
||||
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("~/services/crypto/facade.js");
|
||||
const keypair = await generateKeypair();
|
||||
const displayNameForSync = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
|
||||
|
||||
const { syncWithBroker } = await import("~/services/auth/facade.js");
|
||||
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
|
||||
|
||||
// Write all meshes to config
|
||||
const { writeConfig } = await import("~/services/config/facade.js");
|
||||
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;
|
||||
writeConfig(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 ?? process.env.USER ?? process.env.USERNAME ?? hostname());
|
||||
|
||||
let role: string | null = args.role;
|
||||
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
|
||||
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
|
||||
|
||||
// `-y` (skipPermConfirm) implies fully non-interactive — skip the wizard
|
||||
// entirely and use sensible defaults (role=member, no groups, push mode).
|
||||
// Same applies to `--quiet` and the post-sync path where we already picked.
|
||||
const nonInteractive = args.quiet || justSynced || args.skipPermConfirm;
|
||||
if (!nonInteractive) {
|
||||
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) {
|
||||
// No mesh picked yet + non-interactive — pick the first one deterministically.
|
||||
mesh = config.meshes[0]!;
|
||||
}
|
||||
|
||||
// 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 = [
|
||||
"--dangerously-load-development-channels",
|
||||
"server:claudemesh",
|
||||
...(claudeSessionId ? ["--session-id", claudeSessionId] : []),
|
||||
...(args.resume ? ["--resume", args.resume] : []),
|
||||
...(args.continueSession ? ["--continue"] : []),
|
||||
...(args.skipPermConfirm ? ["--dangerously-skip-permissions"] : []),
|
||||
...(args.systemPrompt ? ["--system-prompt", args.systemPrompt] : []),
|
||||
...filtered,
|
||||
];
|
||||
|
||||
// Resolve the full path to `claude` — when launched from a non-interactive
|
||||
// shell (e.g. nvm node shebang), ~/.local/bin may not be in PATH.
|
||||
const isWindows = process.platform === "win32";
|
||||
let claudeBin = "claude";
|
||||
if (!isWindows) {
|
||||
const candidates = [
|
||||
join(homedir(), ".local", "bin", "claude"),
|
||||
"/usr/local/bin/claude",
|
||||
join(homedir(), ".claude", "bin", "claude"),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (existsSync(c)) { claudeBin = c; break; }
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Define cleanup — runs on every exit path via process.on('exit').
|
||||
// Synchronous-only (rmSync + writeFileSync) so it works inside the
|
||||
// 'exit' event, which does not allow async work.
|
||||
const cleanup = (): void => {
|
||||
// Remove mesh MCP entries from ~/.claude.json
|
||||
if (meshMcpEntries.length > 0) {
|
||||
try {
|
||||
const claudeConfigPath = join(homedir(), ".claude.json");
|
||||
const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
|
||||
const mcpServers = claudeConfig.mcpServers ?? {};
|
||||
for (const { key } of meshMcpEntries) {
|
||||
delete mcpServers[key];
|
||||
}
|
||||
claudeConfig.mcpServers = mcpServers;
|
||||
writeFileSync(claudeConfigPath, JSON.stringify(claudeConfig, null, 2) + "\n", "utf-8");
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
// Ephemeral config dir
|
||||
try {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch { /* best effort */ }
|
||||
};
|
||||
|
||||
// Register cleanup on every exit path — including normal exit, uncaught
|
||||
// throws, and fatal signals. process.on('exit') fires synchronously, which
|
||||
// is what the rmSync + writeFileSync above need.
|
||||
process.on("exit", cleanup);
|
||||
|
||||
// 8. Hard-reset the TTY before handing control to claude.
|
||||
//
|
||||
// Every interactive element in the pre-launch flow — the full-screen
|
||||
// wizard (tui/screen.ts), the permission confirmation, the callback-
|
||||
// listener paste prompt, the mesh picker — attaches listeners to
|
||||
// process.stdin, toggles raw mode, hides the cursor, and sometimes
|
||||
// enters the alt-screen. Those helpers do best-effort cleanup in their
|
||||
// own finally blocks, but any leak — an orphaned 'data' listener, a
|
||||
// still-raw TTY, a pending render paint — means the parent node process
|
||||
// keeps competing with claude's Ink TUI for the same keystrokes and
|
||||
// stdout frames. Symptoms: dropped keystrokes at the claude prompt, or
|
||||
// the wizard visibly repainting on top of claude after launch.
|
||||
//
|
||||
// Defensive reset here is cheap and guarantees a clean TTY regardless
|
||||
// of what the wizard helpers did or didn't restore.
|
||||
if (process.stdin.isTTY) {
|
||||
try { process.stdin.setRawMode(false); } catch { /* not a TTY under some parents */ }
|
||||
}
|
||||
process.stdin.removeAllListeners("data");
|
||||
process.stdin.removeAllListeners("keypress");
|
||||
process.stdin.removeAllListeners("readable");
|
||||
process.stdin.pause();
|
||||
if (process.stdout.isTTY) {
|
||||
process.stdout.write("\x1b[?25h"); // show cursor
|
||||
process.stdout.write("\x1b[?1049l"); // exit alt-screen if any wizard step entered it
|
||||
}
|
||||
|
||||
// 9. Block-and-wait on claude with spawnSync.
|
||||
//
|
||||
// Why spawnSync instead of spawn + child.on('exit'):
|
||||
// - spawn keeps the parent node event loop running alongside claude.
|
||||
// Any stray listener, setImmediate, or async wizard tail-end can
|
||||
// still fire during claude's lifetime, stealing input or painting
|
||||
// over claude's TUI.
|
||||
// - spawnSync blocks the parent event loop completely until claude
|
||||
// exits. No listeners fire. Nothing paints. The parent is effectively
|
||||
// suspended, and claude has exclusive ownership of the TTY.
|
||||
//
|
||||
// Signal forwarding: claude inherits the TTY process group via
|
||||
// stdio: "inherit". When the user hits Ctrl-C, the terminal sends
|
||||
// SIGINT to the whole group. Claude handles it (Ink unmounts, exits
|
||||
// cleanly); spawnSync returns with result.signal='SIGINT'. We re-raise
|
||||
// the same signal on the parent so it dies the same way.
|
||||
const result = spawnSync(claudeBin, claudeArgs, {
|
||||
stdio: "inherit",
|
||||
shell: isWindows,
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDEMESH_CONFIG_DIR: tmpDir,
|
||||
CLAUDEMESH_DISPLAY_NAME: displayName,
|
||||
...(claudeSessionId ? { CLAUDEMESH_SESSION_ID: claudeSessionId } : {}),
|
||||
MCP_TIMEOUT: process.env.MCP_TIMEOUT ?? "30000",
|
||||
MAX_MCP_OUTPUT_TOKENS: process.env.MAX_MCP_OUTPUT_TOKENS ?? "50000",
|
||||
...(role ? { CLAUDEMESH_ROLE: role } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// 10. Handle the result. Cleanup runs automatically via process.on('exit').
|
||||
if (result.error) {
|
||||
const err = result.error as NodeJS.ErrnoException;
|
||||
if (err.code === "ENOENT") {
|
||||
console.error("✗ `claude` not found on PATH. Install Claude Code first.");
|
||||
} else {
|
||||
console.error(`✗ failed to launch claude: ${err.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.signal) {
|
||||
// Re-raise the same signal so the parent dies the same way the child did.
|
||||
process.kill(process.pid, result.signal);
|
||||
return;
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 0);
|
||||
}
|
||||
25
apps/cli-v2/src/commands/leave.ts
Normal file
25
apps/cli-v2/src/commands/leave.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* `claudemesh leave <slug>` — remove a mesh from local config.
|
||||
*
|
||||
* Does NOT (yet) notify the broker. In 15b+ this will send a
|
||||
* best-effort revoke request before removing the entry.
|
||||
*/
|
||||
|
||||
import { readConfig, writeConfig } from "~/services/config/facade.js";
|
||||
|
||||
export function runLeave(args: string[]): void {
|
||||
const slug = args[0];
|
||||
if (!slug) {
|
||||
console.error("Usage: claudemesh leave <slug>");
|
||||
process.exit(1);
|
||||
}
|
||||
const config = readConfig();
|
||||
const before = config.meshes.length;
|
||||
config.meshes = config.meshes.filter((m) => m.slug !== slug);
|
||||
if (config.meshes.length === before) {
|
||||
console.error(`claudemesh: no joined mesh with slug "${slug}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
writeConfig(config);
|
||||
console.log(`Left mesh "${slug}". Remaining: ${config.meshes.length}`);
|
||||
}
|
||||
104
apps/cli-v2/src/commands/list.ts
Normal file
104
apps/cli-v2/src/commands/list.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* `claudemesh mesh list` — merged view of server + local meshes.
|
||||
*/
|
||||
|
||||
import { readConfig, getConfigPath } from "~/services/config/facade.js";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { request } from "~/services/api/facade.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
import { bold, dim, green, yellow, red } from "~/ui/styles.js";
|
||||
|
||||
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
|
||||
interface ServerMesh {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
role: string;
|
||||
is_owner: boolean;
|
||||
member_count: number;
|
||||
active_peers: number;
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
export async function runList(): Promise<void> {
|
||||
const config = readConfig();
|
||||
const auth = getStoredToken();
|
||||
|
||||
// Try to fetch from server
|
||||
let serverMeshes: ServerMesh[] = [];
|
||||
if (auth) {
|
||||
try {
|
||||
let userId = "";
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
|
||||
userId = payload.sub ?? "";
|
||||
} catch {}
|
||||
|
||||
if (userId) {
|
||||
const res = await request<{ meshes: ServerMesh[] }>({
|
||||
path: `/cli/meshes?user_id=${userId}`,
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
serverMeshes = res.meshes ?? [];
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Merge: server meshes + local-only meshes
|
||||
const localSlugs = new Set(config.meshes.map(m => m.slug));
|
||||
const serverSlugs = new Set(serverMeshes.map(m => m.slug));
|
||||
|
||||
const allSlugs = new Set([...localSlugs, ...serverSlugs]);
|
||||
|
||||
if (allSlugs.size === 0) {
|
||||
console.log("\n No meshes yet.\n");
|
||||
console.log(" Create one: claudemesh mesh create <name>");
|
||||
console.log(" Join one: claudemesh mesh add <invite-url>\n");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\n Your meshes:\n");
|
||||
|
||||
for (const slug of allSlugs) {
|
||||
const local = config.meshes.find(m => m.slug === slug);
|
||||
const server = serverMeshes.find(m => m.slug === slug);
|
||||
|
||||
const name = server?.name ?? local?.name ?? slug;
|
||||
const role = server?.role ?? "member";
|
||||
const isOwner = server?.is_owner ?? false;
|
||||
const roleLabel = isOwner ? "owner" : role;
|
||||
const memberCount = server?.member_count;
|
||||
const activePeers = server?.active_peers ?? 0;
|
||||
|
||||
// Status indicator
|
||||
const inLocal = localSlugs.has(slug);
|
||||
const inServer = serverSlugs.has(slug);
|
||||
let status: string;
|
||||
let icon: string;
|
||||
|
||||
if (inLocal && inServer) {
|
||||
icon = green("●");
|
||||
status = activePeers > 0 ? green(`${activePeers} online`) : dim("synced");
|
||||
} else if (inLocal && !inServer) {
|
||||
icon = yellow("●");
|
||||
status = yellow("local only");
|
||||
} else {
|
||||
icon = dim("○");
|
||||
status = dim("not added locally");
|
||||
}
|
||||
|
||||
const memberInfo = memberCount ? dim(`${memberCount} member${memberCount !== 1 ? "s" : ""}`) : "";
|
||||
const parts = [roleLabel, memberInfo, status].filter(Boolean);
|
||||
|
||||
console.log(` ${icon} ${bold(name)} ${dim(slug)}`);
|
||||
console.log(` ${parts.join(" · ")}`);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
if (serverMeshes.some(m => !localSlugs.has(m.slug))) {
|
||||
console.log(dim(" ○ = server only — run `claudemesh mesh add` to use locally"));
|
||||
}
|
||||
console.log(dim(` Config: ${getConfigPath()}`));
|
||||
console.log("");
|
||||
}
|
||||
118
apps/cli-v2/src/commands/login.ts
Normal file
118
apps/cli-v2/src/commands/login.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { loginWithDeviceCode, getStoredToken, clearToken, storeToken } from "~/services/auth/facade.js";
|
||||
import { my } from "~/services/api/facade.js";
|
||||
import { green, dim, bold, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
|
||||
});
|
||||
}
|
||||
|
||||
async function loginWithToken(): Promise<number> {
|
||||
console.log(`\n Paste a token from ${dim(URLS.API_BASE + "/token")}`);
|
||||
console.log(` ${dim("Generate one in your browser, then paste it here.")}\n`);
|
||||
|
||||
const token = await prompt(" Token: ");
|
||||
if (!token) {
|
||||
console.error(` ${icons.cross} No token provided.`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
// Decode JWT to get user info
|
||||
let user = { id: "", display_name: "", email: "" };
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts[1]) {
|
||||
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()) as {
|
||||
sub?: string; email?: string; name?: string; exp?: number;
|
||||
};
|
||||
if (payload.exp && payload.exp < Date.now() / 1000) {
|
||||
console.error(` ${icons.cross} Token expired. Generate a new one.`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
user = {
|
||||
id: payload.sub ?? "",
|
||||
display_name: payload.name ?? payload.email ?? "",
|
||||
email: payload.email ?? "",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
console.error(` ${icons.cross} Invalid token format.`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
storeToken({ session_token: token, user, token_source: "manual" });
|
||||
console.log(` ${green(icons.check)} Signed in as ${user.display_name || user.email || "user"}.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
async function syncMeshes(token: string): Promise<void> {
|
||||
try {
|
||||
const meshes = await my.getMeshes(token);
|
||||
if (meshes.length > 0) {
|
||||
const names = meshes.map((m) => m.slug).join(", ");
|
||||
console.log(` ${green(icons.check)} Synced ${meshes.length} mesh${meshes.length === 1 ? "" : "es"}: ${names}`);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function login(): Promise<number> {
|
||||
const existing = getStoredToken();
|
||||
if (existing) {
|
||||
const name = existing.user.display_name || existing.user.email || "unknown";
|
||||
console.log(`\n Already signed in as ${bold(name)}.`);
|
||||
console.log("");
|
||||
console.log(` ${bold("1)")} Continue as ${name}`);
|
||||
console.log(` ${bold("2)")} Sign in via browser`);
|
||||
console.log(` ${bold("3)")} Paste a token from ${dim("claudemesh.com/token")}`);
|
||||
console.log(` ${bold("4)")} Sign out`);
|
||||
console.log("");
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
|
||||
if (choice === "1") {
|
||||
console.log(`\n ${green(icons.check)} Continuing as ${name}.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
if (choice === "4") {
|
||||
clearToken();
|
||||
console.log(` ${green(icons.check)} Signed out.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
if (choice === "3") {
|
||||
clearToken();
|
||||
return loginWithToken();
|
||||
}
|
||||
// choice === "2" → fall through to browser login
|
||||
clearToken();
|
||||
console.log(` ${dim("Signing in…")}`);
|
||||
} else {
|
||||
// Not logged in — show auth options
|
||||
console.log(`\n ${bold("claudemesh")} — sign in to connect your terminal`);
|
||||
console.log("");
|
||||
console.log(` ${bold("1)")} Sign in via browser ${dim("(opens automatically)")}`);
|
||||
console.log(` ${bold("2)")} Paste a token from ${dim("claudemesh.com/token")}`);
|
||||
console.log("");
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
|
||||
if (choice === "2") {
|
||||
return loginWithToken();
|
||||
}
|
||||
// choice === "1" → fall through to browser login
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await loginWithDeviceCode();
|
||||
console.log(` ${green(icons.check)} Signed in as ${result.user.display_name}.`);
|
||||
await syncMeshes(result.session_token);
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
console.error(` ${icons.cross} Login failed: ${err instanceof Error ? err.message : err}`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
}
|
||||
22
apps/cli-v2/src/commands/logout.ts
Normal file
22
apps/cli-v2/src/commands/logout.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { logout as doLogout } from "~/services/auth/facade.js";
|
||||
import { green, yellow, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function logout(): Promise<number> {
|
||||
try {
|
||||
const { revoked } = await doLogout();
|
||||
|
||||
if (revoked) {
|
||||
console.log(` ${green(icons.check)} Revoked session on claudemesh.com`);
|
||||
} else {
|
||||
console.log(` ${yellow(icons.warn)} Could not revoke session on claudemesh.com.`);
|
||||
console.log(` Revoke manually at https://claudemesh.com/dashboard/settings/sessions`);
|
||||
}
|
||||
console.log(` ${green(icons.check)} Removed local credentials.`);
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
console.error(` ${icons.cross} Logout failed: ${err instanceof Error ? err.message : err}`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
}
|
||||
9
apps/cli-v2/src/commands/mcp.ts
Normal file
9
apps/cli-v2/src/commands/mcp.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { startMcpServer } from "~/mcp/server.js";
|
||||
|
||||
export async function runMcp(): Promise<never> {
|
||||
await startMcpServer();
|
||||
await new Promise(() => {});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { runMcp as _stub };
|
||||
48
apps/cli-v2/src/commands/new.ts
Normal file
48
apps/cli-v2/src/commands/new.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { create as createMesh } from "~/services/mesh/facade.js";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { green, dim, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function newMesh(
|
||||
name: string,
|
||||
opts: { template?: string; description?: string; json?: boolean },
|
||||
): Promise<number> {
|
||||
if (!name) {
|
||||
console.error(" Usage: claudemesh mesh create <name>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
|
||||
if (!getStoredToken()) {
|
||||
console.log(dim(" Not signed in — starting login…\n"));
|
||||
const { login } = await import("./login.js");
|
||||
const loginResult = await login();
|
||||
if (loginResult !== EXIT.SUCCESS) return loginResult;
|
||||
console.log("");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createMesh(name, {
|
||||
template: opts.template,
|
||||
description: opts.description,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
|
||||
} else {
|
||||
console.log(`\n ${green(icons.check)} Created "${result.slug}" (id: ${result.id})`);
|
||||
console.log(` ${green(icons.check)} You're the owner`);
|
||||
console.log(` ${green(icons.check)} Joined locally`);
|
||||
console.log(`\n Share with: claudemesh mesh share\n`);
|
||||
}
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("409") || msg.includes("already exists")) {
|
||||
console.error(` ${icons.cross} A mesh with this name already exists. Try a different name.`);
|
||||
} else {
|
||||
console.error(` ${icons.cross} Failed: ${msg}`);
|
||||
}
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
}
|
||||
82
apps/cli-v2/src/commands/peers.ts
Normal file
82
apps/cli-v2/src/commands/peers.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* `claudemesh peers` — list connected peers in the mesh.
|
||||
*
|
||||
* Shows all meshes by default, or filter with --mesh.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
|
||||
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);
|
||||
|
||||
const config = readConfig();
|
||||
|
||||
// If --mesh specified, show only that one. Otherwise show all.
|
||||
const slugs = flags.mesh
|
||||
? [flags.mesh]
|
||||
: config.meshes.map(m => m.slug);
|
||||
|
||||
if (slugs.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const allJson: Array<{ mesh: string; peers: unknown[] }> = [];
|
||||
|
||||
for (const slug of slugs) {
|
||||
try {
|
||||
await withMesh({ meshSlug: slug }, async (client, mesh) => {
|
||||
const peers = await client.listPeers();
|
||||
|
||||
if (flags.json) {
|
||||
allJson.push({ mesh: mesh.slug, peers });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(`Peers on ${mesh.slug}`) + dim(` (${peers.length})`));
|
||||
console.log("");
|
||||
|
||||
if (peers.length === 0) {
|
||||
console.log(dim(" No peers connected."));
|
||||
} else {
|
||||
for (const p of peers) {
|
||||
const groups = p.groups.length
|
||||
? " [" + p.groups.map((g: { name: string; role?: string }) =>
|
||||
`@${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("");
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(dim(` Could not connect to ${slug}: ${e instanceof Error ? e.message : String(e)}`));
|
||||
console.log("");
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(slugs.length === 1 ? allJson[0]?.peers : allJson, null, 2));
|
||||
}
|
||||
}
|
||||
114
apps/cli-v2/src/commands/profile.ts
Normal file
114
apps/cli-v2/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 { readConfig } from "~/services/config/facade.js";
|
||||
import { BrokerClient } from "~/services/broker/facade.js";
|
||||
|
||||
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 = readConfig();
|
||||
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 ?? ""))}`);
|
||||
}
|
||||
35
apps/cli-v2/src/commands/recall.ts
Normal file
35
apps/cli-v2/src/commands/recall.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { allClients } from "~/services/broker/facade.js";
|
||||
import { dim, bold } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function recall(
|
||||
query: string,
|
||||
opts: { mesh?: string; json?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const client = allClients()[0];
|
||||
if (!client) {
|
||||
console.error("Not connected to any mesh.");
|
||||
return EXIT.NETWORK_ERROR;
|
||||
}
|
||||
|
||||
const memories = await client.recall(query);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(memories, null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log(dim("No memories found."));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
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} \u00B7 ${new Date(m.rememberedAt).toLocaleString()}`));
|
||||
console.log("");
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
8
apps/cli-v2/src/commands/register.ts
Normal file
8
apps/cli-v2/src/commands/register.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { login } from "./login.js";
|
||||
|
||||
// Register and login use the same device-code flow.
|
||||
// The browser page (/cli-auth) redirects to /auth/login if not authenticated,
|
||||
// which has a "Don't have an account? Register" link.
|
||||
export async function register(): Promise<number> {
|
||||
return login();
|
||||
}
|
||||
28
apps/cli-v2/src/commands/remember.ts
Normal file
28
apps/cli-v2/src/commands/remember.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { allClients } from "~/services/broker/facade.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function remember(
|
||||
content: string,
|
||||
opts: { mesh?: string; tags?: string; json?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const client = allClients()[0];
|
||||
if (!client) {
|
||||
console.error("Not connected to any mesh.");
|
||||
return EXIT.NETWORK_ERROR;
|
||||
}
|
||||
|
||||
const tags = opts.tags?.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
const id = await client.remember(content, tags);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ id, content, tags }));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
console.log(`\u2713 Remembered (${id.slice(0, 8)})`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
console.error("\u2717 Failed to store memory");
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
142
apps/cli-v2/src/commands/remind.ts
Normal file
142
apps/cli-v2/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.js";
|
||||
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
14
apps/cli-v2/src/commands/rename.ts
Normal file
14
apps/cli-v2/src/commands/rename.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { rename as renameMesh } from "~/services/mesh/facade.js";
|
||||
import { green, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function rename(slug: string, newName: string): Promise<number> {
|
||||
try {
|
||||
await renameMesh(slug, newName);
|
||||
console.log(` ${green(icons.check)} Renamed "${slug}" to "${newName}"`);
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
console.error(` ${icons.cross} Failed: ${err instanceof Error ? err.message : err}`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
}
|
||||
44
apps/cli-v2/src/commands/seed-test-mesh.ts
Normal file
44
apps/cli-v2/src/commands/seed-test-mesh.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* `claudemesh seed-test-mesh` — dev-only helper for 15b testing.
|
||||
*
|
||||
* Writes a locally-valid JoinedMesh entry to ~/.claudemesh/config.json
|
||||
* so the MCP server can connect to a locally-running broker without
|
||||
* invite-link / crypto plumbing.
|
||||
*
|
||||
* Usage:
|
||||
* claudemesh seed-test-mesh <broker-url> <mesh-id> <member-id> <pubkey> <slug>
|
||||
*/
|
||||
|
||||
import { readConfig, writeConfig } from "~/services/config/facade.js";
|
||||
|
||||
export function runSeedTestMesh(args: string[]): void {
|
||||
const [brokerUrl, meshId, memberId, pubkey, slug] = args;
|
||||
if (!brokerUrl || !meshId || !memberId || !pubkey || !slug) {
|
||||
console.error(
|
||||
"Usage: claudemesh seed-test-mesh <broker-ws-url> <mesh-id> <member-id> <pubkey> <slug>",
|
||||
);
|
||||
console.error("");
|
||||
console.error(
|
||||
'Example: claudemesh seed-test-mesh "ws://localhost:7900/ws" mesh-123 member-abc aaa..aaa smoke-test',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const config = readConfig();
|
||||
// Remove any prior entry with same slug (idempotent).
|
||||
config.meshes = config.meshes.filter((m) => m.slug !== slug);
|
||||
config.meshes.push({
|
||||
meshId,
|
||||
memberId,
|
||||
slug,
|
||||
name: `Test: ${slug}`,
|
||||
pubkey,
|
||||
secretKey: "dev-only-stub", // real keypair generated during join in Step 17
|
||||
brokerUrl,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
writeConfig(config);
|
||||
console.log(`Seeded mesh "${slug}" (${meshId}) into local config.`);
|
||||
console.log(
|
||||
`Run \`claudemesh mcp\` to connect, or register with Claude Code via \`claudemesh install\`.`,
|
||||
);
|
||||
}
|
||||
51
apps/cli-v2/src/commands/send.ts
Normal file
51
apps/cli-v2/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.js";
|
||||
import type { Priority } from "~/services/broker/facade.js";
|
||||
|
||||
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-v2/src/commands/state.ts
Normal file
75
apps/cli-v2/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.js";
|
||||
|
||||
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()}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
69
apps/cli-v2/src/commands/status-line.ts
Normal file
69
apps/cli-v2/src/commands/status-line.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* `claudemesh status-line` — one-line renderer for Claude Code's
|
||||
* `statusLine` setting.
|
||||
*
|
||||
* Must be FAST (Claude Code polls it between every turn) — zero network
|
||||
* I/O. Reads only local config + a peer-state cache maintained by the
|
||||
* MCP server (~/.claudemesh/peer-cache.json, updated on every
|
||||
* list_peers call).
|
||||
*
|
||||
* Output format:
|
||||
* ◇ <mesh> · <online>/<total> peers · <you>
|
||||
* or:
|
||||
* ◇ claudemesh (not joined)
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
interface PeerCacheEntry {
|
||||
total: number;
|
||||
online: number;
|
||||
updatedAt: string;
|
||||
you?: string;
|
||||
}
|
||||
|
||||
type PeerCache = Record<string, PeerCacheEntry>;
|
||||
|
||||
export async function runStatusLine(): Promise<number> {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
process.stdout.write("◇ claudemesh (not joined)");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
const cachePath = join(homedir(), ".claudemesh", "peer-cache.json");
|
||||
let cache: PeerCache = {};
|
||||
if (existsSync(cachePath)) {
|
||||
try {
|
||||
cache = JSON.parse(readFileSync(cachePath, "utf-8")) as PeerCache;
|
||||
} catch {
|
||||
// corrupt — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the most-recently-used mesh if multiple.
|
||||
const pick = config.meshes[0]!;
|
||||
const entry = cache[pick.slug];
|
||||
|
||||
const age = entry ? Date.now() - new Date(entry.updatedAt).getTime() : Infinity;
|
||||
const fresh = age < 60_000; // < 1 min = live
|
||||
|
||||
if (entry && fresh) {
|
||||
const you = entry.you ? ` · ${entry.you}` : "";
|
||||
process.stdout.write(`◇ ${pick.slug} · ${entry.online}/${entry.total} online${you}`);
|
||||
} else if (entry) {
|
||||
process.stdout.write(`◇ ${pick.slug} · ${entry.online}/${entry.total} (stale)`);
|
||||
} else {
|
||||
process.stdout.write(`◇ ${pick.slug} · idle`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
} catch {
|
||||
// Never break the status line — just print nothing.
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
}
|
||||
103
apps/cli-v2/src/commands/status.ts
Normal file
103
apps/cli-v2/src/commands/status.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* `claudemesh status` — one-shot health report.
|
||||
*
|
||||
* Reports CLI version, config path + permissions, each joined mesh
|
||||
* with broker reachability (WS handshake probe). Exit 0 if every
|
||||
* mesh's broker is reachable, 1 otherwise.
|
||||
*/
|
||||
|
||||
import { statSync, existsSync } from "node:fs";
|
||||
import WebSocket from "ws";
|
||||
import { readConfig, getConfigPath } from "~/services/config/facade.js";
|
||||
import { VERSION } from "~/constants/urls.js";
|
||||
|
||||
interface MeshStatus {
|
||||
slug: string;
|
||||
brokerUrl: string;
|
||||
pubkey: string;
|
||||
reachable: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(url);
|
||||
const timer = setTimeout(() => {
|
||||
try { ws.terminate(); } catch { /* noop */ }
|
||||
resolve({ ok: false, error: "timeout" });
|
||||
}, timeoutMs);
|
||||
ws.on("open", () => {
|
||||
clearTimeout(timer);
|
||||
try { ws.close(); } catch { /* noop */ }
|
||||
resolve({ ok: true });
|
||||
});
|
||||
ws.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: false, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function runStatus(): 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 red = (s: string): string => (useColor ? `\x1b[31m${s}\x1b[39m` : s);
|
||||
|
||||
console.log(`claudemesh status (v${VERSION})`);
|
||||
console.log("─".repeat(60));
|
||||
|
||||
const configPath = getConfigPath();
|
||||
let configPerms = "missing";
|
||||
if (existsSync(configPath)) {
|
||||
const st = statSync(configPath);
|
||||
const mode = (st.mode & 0o777).toString(8).padStart(4, "0");
|
||||
configPerms = mode === "0600" ? `${mode} ✓` : `${mode} ⚠ (expected 0600)`;
|
||||
}
|
||||
console.log(`Config: ${configPath} (${configPerms})`);
|
||||
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.log("");
|
||||
console.log(dim("No meshes joined. Run `claudemesh join <invite-url>` to get started."));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`Meshes (${config.meshes.length}):`);
|
||||
|
||||
const results: MeshStatus[] = [];
|
||||
for (const m of config.meshes) {
|
||||
process.stdout.write(` ${m.slug.padEnd(20)} probing ${m.brokerUrl}… `);
|
||||
const probe = await probeBroker(m.brokerUrl);
|
||||
results.push({
|
||||
slug: m.slug,
|
||||
brokerUrl: m.brokerUrl,
|
||||
pubkey: m.pubkey,
|
||||
reachable: probe.ok,
|
||||
error: probe.error,
|
||||
});
|
||||
if (probe.ok) {
|
||||
console.log(green("reachable"));
|
||||
} else {
|
||||
console.log(red(`unreachable (${probe.error})`));
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
for (const r of results) {
|
||||
console.log(dim(` ${r.slug}: pubkey ${r.pubkey.slice(0, 16)}…`));
|
||||
}
|
||||
|
||||
const allOk = results.every((r) => r.reachable);
|
||||
console.log("");
|
||||
if (allOk) {
|
||||
console.log(green("All meshes reachable."));
|
||||
process.exit(0);
|
||||
} else {
|
||||
const broken = results.filter((r) => !r.reachable).length;
|
||||
console.log(red(`${broken} of ${results.length} mesh(es) unreachable.`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
89
apps/cli-v2/src/commands/sync.ts
Normal file
89
apps/cli-v2/src/commands/sync.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* `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 { readConfig, writeConfig } from "~/services/config/facade.js";
|
||||
import { startCallbackListener, generatePairingCode, syncWithBroker } from "~/services/auth/facade.js";
|
||||
import { openBrowser } from "~/services/spawn/facade.js";
|
||||
import { generateKeypair } from "~/services/crypto/facade.js";
|
||||
|
||||
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 = readConfig();
|
||||
|
||||
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;
|
||||
writeConfig(config);
|
||||
|
||||
if (added > 0) {
|
||||
console.log(green(`✓ Added ${added} new mesh(es)`));
|
||||
} else {
|
||||
console.log(`Already up to date (${config.meshes.length} meshes)`);
|
||||
}
|
||||
}
|
||||
228
apps/cli-v2/src/commands/test.ts
Normal file
228
apps/cli-v2/src/commands/test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* `claudemesh test` — integration test battery against live broker.
|
||||
*
|
||||
* Creates a temporary mesh, runs all operations, verifies results,
|
||||
* then cleans up. Safe to run repeatedly.
|
||||
*/
|
||||
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { create as createMesh, leave as leaveMesh } from "~/services/mesh/facade.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { request } from "~/services/api/facade.js";
|
||||
import { generateKeypair, sign, verify } from "~/services/crypto/facade.js";
|
||||
import { BrokerClient } from "~/services/broker/facade.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
import { runAllChecks } from "~/services/health/facade.js";
|
||||
import { green, red, dim, bold, yellow, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
ms: number;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
async function run(name: string, fn: () => Promise<string>): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const detail = await fn();
|
||||
results.push({ name, ok: true, detail, ms: Date.now() - start });
|
||||
console.log(` ${green(icons.check)} ${name.padEnd(18)} ${dim(detail)}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
results.push({ name, ok: false, detail, ms: Date.now() - start });
|
||||
console.log(` ${red(icons.cross)} ${name.padEnd(18)} ${red(detail)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTest(): Promise<number> {
|
||||
const started = Date.now();
|
||||
const meshSlug = `test-e2e-${Date.now().toString(36)}`;
|
||||
|
||||
console.log("");
|
||||
console.log(` ${bold("claudemesh integration test")}`);
|
||||
console.log(` ${dim("─".repeat(40))}`);
|
||||
console.log("");
|
||||
|
||||
// --- Auth ---
|
||||
const auth = getStoredToken();
|
||||
if (!auth) {
|
||||
console.log(` ${red(icons.cross)} Not signed in. Run ${bold("claudemesh login")} first.\n`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
let userId = "";
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
|
||||
userId = payload.sub ?? "";
|
||||
} catch {}
|
||||
|
||||
await run("auth", async () => {
|
||||
if (!userId) throw new Error("invalid token");
|
||||
return `signed in as ${auth.user.display_name || auth.user.email}`;
|
||||
});
|
||||
|
||||
// --- Doctor checks (non-blocking — warns but doesn't fail) ---
|
||||
{
|
||||
const checks = runAllChecks();
|
||||
const failed = checks.filter(c => !c.ok);
|
||||
if (failed.length > 0) {
|
||||
const warns = failed.map(c => c.name).join(", ");
|
||||
console.log(` ${yellow(icons.warn)} ${"doctor".padEnd(18)} ${dim(warns + " (non-blocking)")}`);
|
||||
} else {
|
||||
console.log(` ${green(icons.check)} ${"doctor".padEnd(18)} ${dim(checks.length + " checks passed")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Crypto ---
|
||||
await run("crypto", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const sig = await sign("test-message", kp.secretKey);
|
||||
const valid = await verify("test-message", sig, kp.publicKey);
|
||||
if (!valid) throw new Error("signature verification failed");
|
||||
const tampered = await verify("tampered", sig, kp.publicKey);
|
||||
if (tampered) throw new Error("tampered message should not verify");
|
||||
return "keypair + sign + verify round-trip";
|
||||
});
|
||||
|
||||
// --- Mesh create ---
|
||||
let meshId = "";
|
||||
const createOk = await run("create", async () => {
|
||||
const result = await createMesh(meshSlug);
|
||||
meshId = result.id;
|
||||
return `created "${result.slug}" (${result.id.slice(0, 8)}…)`;
|
||||
});
|
||||
|
||||
if (!createOk) {
|
||||
console.log(`\n ${red("Aborting — mesh creation failed.")}\n`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
await run("list", async () => {
|
||||
const config = readConfig();
|
||||
const found = config.meshes.find(m => m.slug === meshSlug);
|
||||
if (!found) throw new Error("mesh not in local config");
|
||||
return `found ${meshSlug} in local config`;
|
||||
});
|
||||
|
||||
// --- Server list ---
|
||||
await run("server list", async () => {
|
||||
const res = await request<{ meshes: Array<{ slug: string }> }>({
|
||||
path: `/cli/meshes?user_id=${userId}`,
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
const found = res.meshes?.find(m => m.slug === meshSlug);
|
||||
if (!found) throw new Error("mesh not on server");
|
||||
return `found ${meshSlug} on server (${res.meshes.length} total)`;
|
||||
});
|
||||
|
||||
// --- Connect (broker WS) ---
|
||||
const config = readConfig();
|
||||
const meshConfig = config.meshes.find(m => m.slug === meshSlug);
|
||||
let client: BrokerClient | null = null;
|
||||
|
||||
if (meshConfig) {
|
||||
await run("connect", async () => {
|
||||
client = new BrokerClient(meshConfig, { displayName: "test-runner" });
|
||||
await client.connect();
|
||||
if (client.status !== "open") throw new Error("status: " + client.status);
|
||||
return "broker connected, hello_ack received";
|
||||
});
|
||||
|
||||
// --- Peers ---
|
||||
if (client) {
|
||||
await run("peers", async () => {
|
||||
const peers = await client!.listPeers();
|
||||
return `${peers.length} peer(s) online`;
|
||||
});
|
||||
|
||||
// --- Send ---
|
||||
await run("send", async () => {
|
||||
const result = await client!.send("*", "test-battery-ping", "low");
|
||||
if (!result.ok) throw new Error(result.error ?? "send failed");
|
||||
return `broadcast sent (${result.messageId?.slice(0, 8)}…)`;
|
||||
});
|
||||
|
||||
// --- Remember ---
|
||||
let memoryId: string | null = null;
|
||||
await run("remember", async () => {
|
||||
memoryId = await client!.remember("integration test battery memory probe", ["test", "e2e"]);
|
||||
if (!memoryId) throw new Error("no memory ID returned");
|
||||
return `stored (${memoryId.slice(0, 8)}…)`;
|
||||
});
|
||||
|
||||
// --- Recall (postgres full-text search) ---
|
||||
await run("recall", async () => {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const memories = await client!.recall("integration test battery");
|
||||
if (memories.length === 0) throw new Error("no memories found");
|
||||
return `${memories.length} result(s)`;
|
||||
});
|
||||
|
||||
// --- State ---
|
||||
const stateVal = "test-value-" + Date.now();
|
||||
await run("state set", async () => {
|
||||
await client!.setState("test-e2e-key", stateVal);
|
||||
return "key written";
|
||||
});
|
||||
|
||||
await run("state get", async () => {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const result = await client!.getState("test-e2e-key");
|
||||
if (!result) throw new Error("key not found");
|
||||
if (String(result.value) !== stateVal) throw new Error(`expected ${stateVal}, got ${result.value}`);
|
||||
return `read back: ${String(result.value).slice(0, 20)}…`;
|
||||
});
|
||||
|
||||
// --- Clean up memory ---
|
||||
if (memoryId) {
|
||||
await run("forget", async () => {
|
||||
await client!.forget(memoryId!);
|
||||
return "memory cleaned up";
|
||||
});
|
||||
}
|
||||
|
||||
// --- Disconnect ---
|
||||
await run("disconnect", async () => {
|
||||
client!.close();
|
||||
return "connection closed";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete mesh ---
|
||||
await run("delete", async () => {
|
||||
// Server-side delete
|
||||
await request({
|
||||
path: `/cli/mesh/${meshSlug}`,
|
||||
method: "DELETE",
|
||||
body: { user_id: userId },
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
leaveMesh(meshSlug);
|
||||
return `deleted "${meshSlug}" from server + local`;
|
||||
});
|
||||
|
||||
// --- Summary ---
|
||||
const passed = results.filter(r => r.ok).length;
|
||||
const failed = results.filter(r => !r.ok).length;
|
||||
const totalMs = Date.now() - started;
|
||||
|
||||
console.log("");
|
||||
if (failed === 0) {
|
||||
console.log(` ${green(bold(`${passed}/${results.length} passed`))} ${dim(`(${(totalMs / 1000).toFixed(1)}s)`)}`);
|
||||
} else {
|
||||
console.log(` ${red(bold(`${failed} failed`))}, ${green(`${passed} passed`)} ${dim(`(${(totalMs / 1000).toFixed(1)}s)`)}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
return failed > 0 ? EXIT.INTERNAL_ERROR : EXIT.SUCCESS;
|
||||
}
|
||||
58
apps/cli-v2/src/commands/uninstall.ts
Normal file
58
apps/cli-v2/src/commands/uninstall.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { PATHS } from "~/constants/paths.js";
|
||||
import { green, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function uninstall(): Promise<number> {
|
||||
let removed = 0;
|
||||
|
||||
// Remove MCP server from ~/.claude.json
|
||||
if (existsSync(PATHS.CLAUDE_JSON)) {
|
||||
try {
|
||||
const raw = readFileSync(PATHS.CLAUDE_JSON, "utf-8");
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
const servers = config.mcpServers as Record<string, unknown> | undefined;
|
||||
if (servers && "claudemesh" in servers) {
|
||||
delete servers.claudemesh;
|
||||
writeFileSync(PATHS.CLAUDE_JSON, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
||||
console.log(` ${green(icons.check)} Removed MCP server from ~/.claude.json`);
|
||||
removed++;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Remove only claudemesh hooks from ~/.claude/settings.json
|
||||
if (existsSync(PATHS.CLAUDE_SETTINGS)) {
|
||||
try {
|
||||
const raw = readFileSync(PATHS.CLAUDE_SETTINGS, "utf-8");
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
const hooks = config.hooks as Record<string, unknown[]> | undefined;
|
||||
if (hooks) {
|
||||
let removedHooks = 0;
|
||||
for (const [event, entries] of Object.entries(hooks)) {
|
||||
if (!Array.isArray(entries)) continue;
|
||||
const filtered = entries.filter((h: unknown) => {
|
||||
const cmd = typeof h === "object" && h !== null && "command" in h ? String((h as Record<string, unknown>).command) : "";
|
||||
return !cmd.includes("claudemesh");
|
||||
});
|
||||
if (filtered.length < entries.length) {
|
||||
removedHooks += entries.length - filtered.length;
|
||||
if (filtered.length === 0) delete hooks[event];
|
||||
else hooks[event] = filtered;
|
||||
}
|
||||
}
|
||||
if (removedHooks > 0) {
|
||||
writeFileSync(PATHS.CLAUDE_SETTINGS, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
||||
console.log(` ${green(icons.check)} Removed ${removedHooks} claudemesh hook(s) from settings.json`);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (removed === 0) {
|
||||
console.log(" Nothing to remove — claudemesh was not installed.");
|
||||
}
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
99
apps/cli-v2/src/commands/upgrade.ts
Normal file
99
apps/cli-v2/src/commands/upgrade.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* `claudemesh upgrade` — self-update the CLI to the latest alpha.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Query npm for the latest @alpha dist-tag.
|
||||
* 2. If we're behind, run `npm i -g claudemesh-cli@alpha` via the same
|
||||
* npm that installed us (detected from argv[1] path walk).
|
||||
* 3. Print before/after versions.
|
||||
*
|
||||
* For users who got the CLI via the `/install` shell flow (portable Node
|
||||
* in ~/.claudemesh), we call that npm directly so nothing else on the
|
||||
* system is touched.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { URLS, VERSION } from "~/constants/urls.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
async function latestAlpha(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(URLS.NPM_REGISTRY, { signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { "dist-tags"?: { alpha?: string; latest?: string } };
|
||||
return body["dist-tags"]?.alpha ?? body["dist-tags"]?.latest ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findNpm(): { npm: string; prefix?: string } {
|
||||
// Portable install path (`/install.sh` puts npm in ~/.claudemesh/node/bin/npm)
|
||||
const portable = join(process.env.HOME ?? "", ".claudemesh", "node", "bin", "npm");
|
||||
if (existsSync(portable)) {
|
||||
return { npm: portable, prefix: join(process.env.HOME ?? "", ".claudemesh") };
|
||||
}
|
||||
// argv[1] → .../node_modules/claudemesh-cli/dist/entrypoints/cli.js
|
||||
// walk up to find a sibling npm binary.
|
||||
let cur = resolve(process.argv[1] ?? ".");
|
||||
for (let i = 0; i < 6; i++) {
|
||||
cur = dirname(cur);
|
||||
const candidate = join(cur, "bin", "npm");
|
||||
if (existsSync(candidate)) return { npm: candidate };
|
||||
}
|
||||
// Fallback to PATH.
|
||||
return { npm: "npm" };
|
||||
}
|
||||
|
||||
export async function runUpgrade(opts: { check?: boolean; yes?: boolean } = {}): Promise<number> {
|
||||
render.section("claudemesh upgrade");
|
||||
render.kv([
|
||||
["installed", VERSION],
|
||||
["checking", "npm registry…"],
|
||||
]);
|
||||
|
||||
const latest = await latestAlpha();
|
||||
if (!latest) {
|
||||
render.warn("Could not reach npm registry — skipped.");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
render.kv([["latest", latest]]);
|
||||
|
||||
if (latest === VERSION) {
|
||||
render.blank();
|
||||
render.ok(`Already on latest (${latest}).`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
if (opts.check) {
|
||||
render.blank();
|
||||
render.warn(`Update available: ${VERSION} → ${latest}`);
|
||||
render.hint("Run: claudemesh upgrade");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
const { npm, prefix } = findNpm();
|
||||
const args = ["install", "-g"];
|
||||
if (prefix) args.push("--prefix", prefix);
|
||||
args.push("claudemesh-cli@alpha");
|
||||
|
||||
render.blank();
|
||||
render.info(`Updating ${VERSION} → ${latest}…`);
|
||||
render.hint(`${npm} ${args.join(" ")}`);
|
||||
render.blank();
|
||||
|
||||
const res = spawnSync(npm, args, { stdio: "inherit" });
|
||||
if (res.status !== 0) {
|
||||
render.err(`npm exited with status ${res.status}`);
|
||||
render.hint("Try: npm i -g claudemesh-cli@alpha");
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
render.blank();
|
||||
render.ok(`Upgraded to ${latest}.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
178
apps/cli-v2/src/commands/url-handler.ts
Normal file
178
apps/cli-v2/src/commands/url-handler.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* `claudemesh url-handler <install|uninstall>` — register a `claudemesh://`
|
||||
* URL scheme handler with the OS so click-to-launch from email/web works.
|
||||
*
|
||||
* Scheme: `claudemesh://join/<code-or-token>` or `claudemesh://i/<code>`.
|
||||
* When activated, the OS opens the handler, which runs
|
||||
* claudemesh https://claudemesh.com/i/<code>
|
||||
* (inline join + launch path via the bare-URL dispatch in cli.ts).
|
||||
*
|
||||
* Platforms:
|
||||
* - darwin → LSRegisterURL via a per-user .app bundle in
|
||||
* ~/Library/Application\ Support/claudemesh/ClaudemeshHandler.app
|
||||
* - linux → xdg-mime default + a .desktop file in
|
||||
* ~/.local/share/applications/claudemesh.desktop
|
||||
* - win32 → HKCU\Software\Classes\claudemesh (registry write)
|
||||
*/
|
||||
|
||||
import { platform, homedir } from "node:os";
|
||||
import { existsSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
function resolveClaudemeshBin(): string {
|
||||
// argv[1] points to the running binary; prefer that over $PATH so we
|
||||
// register the exact install the user ran.
|
||||
return process.argv[1] ?? "claudemesh";
|
||||
}
|
||||
|
||||
function installDarwin(): number {
|
||||
const binPath = resolveClaudemeshBin();
|
||||
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
|
||||
const contents = join(appDir, "Contents");
|
||||
const macOS = join(contents, "MacOS");
|
||||
mkdirSync(macOS, { recursive: true });
|
||||
|
||||
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key><string>com.claudemesh.handler</string>
|
||||
<key>CFBundleName</key><string>Claudemesh</string>
|
||||
<key>CFBundleExecutable</key><string>open-url</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleSignature</key><string>????</string>
|
||||
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||
<key>LSUIElement</key><true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key><string>Claudemesh Invite</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array><string>claudemesh</string></array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>`;
|
||||
writeFileSync(join(contents, "Info.plist"), plist);
|
||||
|
||||
// Tiny shell shim: parse the URL and re-invoke the CLI in a Terminal
|
||||
// window so the user sees launch output.
|
||||
const shim = `#!/bin/sh
|
||||
URL="$1"
|
||||
CODE=\${URL#claudemesh://}
|
||||
CODE=\${CODE#i/}
|
||||
CODE=\${CODE#join/}
|
||||
# Open a Terminal window so the user can see claude launching
|
||||
osascript <<EOF
|
||||
tell application "Terminal"
|
||||
activate
|
||||
do script "${binPath.replace(/"/g, '\\"')} https://claudemesh.com/i/$CODE"
|
||||
end tell
|
||||
EOF
|
||||
`;
|
||||
const shimPath = join(macOS, "open-url");
|
||||
writeFileSync(shimPath, shim);
|
||||
chmodSync(shimPath, 0o755);
|
||||
|
||||
// Re-register with Launch Services so the scheme resolves here.
|
||||
const lsreg = spawnSync("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", ["-f", appDir], { encoding: "utf-8" });
|
||||
if (lsreg.status !== 0) {
|
||||
console.log(" ⚠ lsregister returned non-zero; scheme may not activate until Finder rescans.");
|
||||
}
|
||||
console.log(` ✓ Registered claudemesh:// scheme on macOS`);
|
||||
console.log(` app bundle: ${appDir}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function installLinux(): number {
|
||||
const binPath = resolveClaudemeshBin();
|
||||
const appsDir = join(homedir(), ".local", "share", "applications");
|
||||
mkdirSync(appsDir, { recursive: true });
|
||||
const desktop = `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Claudemesh
|
||||
Comment=Claudemesh invite handler
|
||||
Exec=${binPath} %u
|
||||
StartupNotify=false
|
||||
Terminal=true
|
||||
MimeType=x-scheme-handler/claudemesh;
|
||||
NoDisplay=true
|
||||
`;
|
||||
const desktopPath = join(appsDir, "claudemesh.desktop");
|
||||
writeFileSync(desktopPath, desktop);
|
||||
|
||||
const xdg1 = spawnSync("xdg-mime", ["default", "claudemesh.desktop", "x-scheme-handler/claudemesh"], { encoding: "utf-8" });
|
||||
if (xdg1.status !== 0) {
|
||||
console.log(" ⚠ xdg-mime not available — skipped mime default registration");
|
||||
}
|
||||
const xdg2 = spawnSync("update-desktop-database", [appsDir], { encoding: "utf-8" });
|
||||
xdg2.status ?? 0; // best effort
|
||||
console.log(` ✓ Registered claudemesh:// scheme on Linux`);
|
||||
console.log(` desktop entry: ${desktopPath}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function installWindows(): number {
|
||||
const binPath = resolveClaudemeshBin().replace(/\//g, "\\");
|
||||
const lines = [
|
||||
`Windows Registry Editor Version 5.00`,
|
||||
``,
|
||||
`[HKEY_CURRENT_USER\\Software\\Classes\\claudemesh]`,
|
||||
`@="URL:Claudemesh Invite"`,
|
||||
`"URL Protocol"=""`,
|
||||
``,
|
||||
`[HKEY_CURRENT_USER\\Software\\Classes\\claudemesh\\shell\\open\\command]`,
|
||||
`@="\\"${binPath.replace(/\\/g, "\\\\")}\\" \\"%1\\""`,
|
||||
];
|
||||
const regPath = join(homedir(), "claudemesh-handler.reg");
|
||||
writeFileSync(regPath, lines.join("\r\n"));
|
||||
const res = spawnSync("reg.exe", ["import", regPath], { encoding: "utf-8" });
|
||||
if (res.status !== 0) {
|
||||
console.log(` ⚠ reg.exe import failed. Manual: double-click ${regPath}`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
console.log(` ✓ Registered claudemesh:// scheme on Windows`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function uninstallDarwin(): number {
|
||||
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
|
||||
if (existsSync(appDir)) rmSync(appDir, { recursive: true, force: true });
|
||||
console.log(" ✓ Removed claudemesh:// handler on macOS");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function uninstallLinux(): number {
|
||||
const desktopPath = join(homedir(), ".local", "share", "applications", "claudemesh.desktop");
|
||||
if (existsSync(desktopPath)) rmSync(desktopPath, { force: true });
|
||||
console.log(" ✓ Removed claudemesh:// handler on Linux");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function uninstallWindows(): number {
|
||||
spawnSync("reg.exe", ["delete", "HKCU\\Software\\Classes\\claudemesh", "/f"], { encoding: "utf-8" });
|
||||
console.log(" ✓ Removed claudemesh:// handler on Windows");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runUrlHandler(action: string | undefined): Promise<number> {
|
||||
const act = action ?? "install";
|
||||
const p = platform();
|
||||
if (act === "install") {
|
||||
if (p === "darwin") return installDarwin();
|
||||
if (p === "linux") return installLinux();
|
||||
if (p === "win32") return installWindows();
|
||||
} else if (act === "uninstall" || act === "remove") {
|
||||
if (p === "darwin") return uninstallDarwin();
|
||||
if (p === "linux") return uninstallLinux();
|
||||
if (p === "win32") return uninstallWindows();
|
||||
} else {
|
||||
console.error("Usage: claudemesh url-handler <install|uninstall>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
console.error(`Unsupported platform: ${p}`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
95
apps/cli-v2/src/commands/verify.ts
Normal file
95
apps/cli-v2/src/commands/verify.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* `claudemesh verify [peer]` — show safety numbers for a peer.
|
||||
*
|
||||
* A safety number is a derived, human-readable fingerprint of the peer's
|
||||
* ed25519 public key plus your own. Both parties see the same digits,
|
||||
* so out-of-band comparison (call, in-person) detects MITM.
|
||||
*
|
||||
* Format: 6 groups of 5 decimal digits. Rendered from the first 15 bytes
|
||||
* of SHA-256(sorted(your_pubkey ++ peer_pubkey)). Matches the Signal /
|
||||
* Whatsapp pattern so users don't have to learn a new mental model.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
function safetyNumber(myPubkey: string, peerPubkey: string): string {
|
||||
const a = Buffer.from(myPubkey, "hex");
|
||||
const b = Buffer.from(peerPubkey, "hex");
|
||||
const [lo, hi] = Buffer.compare(a, b) < 0 ? [a, b] : [b, a];
|
||||
const hash = createHash("sha256").update(lo).update(hi).digest();
|
||||
// Take first 15 bytes, split into 6 groups of 20 bits → 5 decimal digits each.
|
||||
const bits: number[] = [];
|
||||
for (let i = 0; i < 15; i++) {
|
||||
for (let b = 7; b >= 0; b--) {
|
||||
bits.push((hash[i]! >> b) & 1);
|
||||
}
|
||||
}
|
||||
const groups: string[] = [];
|
||||
for (let g = 0; g < 6; g++) {
|
||||
let val = 0;
|
||||
for (let i = 0; i < 20; i++) val = val * 2 + bits[g * 20 + i]!;
|
||||
groups.push(String(val % 100000).padStart(5, "0"));
|
||||
}
|
||||
return groups.join(" ");
|
||||
}
|
||||
|
||||
export async function runVerify(
|
||||
target: string | undefined,
|
||||
opts: { mesh?: string; json?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const clay = (s: string) => (useColor ? `\x1b[38;2;217;119;87m${s}\x1b[39m` : s);
|
||||
|
||||
const config = readConfig();
|
||||
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
|
||||
if (!meshSlug) {
|
||||
console.error(" No meshes joined. Run `claudemesh join <url>` first.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
const mesh = config.meshes.find((m) => m.slug === meshSlug);
|
||||
if (!mesh) {
|
||||
console.error(` Mesh "${meshSlug}" not found locally.`);
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
|
||||
return await withMesh({ meshSlug }, async (client) => {
|
||||
const peers = await client.listPeers();
|
||||
const targets = target
|
||||
? peers.filter((p) => p.displayName === target || p.pubkey === target || p.pubkey.startsWith(target))
|
||||
: peers;
|
||||
if (targets.length === 0) {
|
||||
console.error(` No peer matching "${target ?? "(all)"}" on mesh ${meshSlug}.`);
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(targets.map((p) => ({
|
||||
mesh: meshSlug,
|
||||
peer: p.displayName,
|
||||
pubkey: p.pubkey,
|
||||
safetyNumber: safetyNumber(mesh.pubkey, p.pubkey),
|
||||
})), null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(` ${dim("— safety numbers on")} ${bold(meshSlug)}`);
|
||||
console.log("");
|
||||
for (const p of targets) {
|
||||
const sn = safetyNumber(mesh.pubkey, p.pubkey);
|
||||
console.log(` ${bold(p.displayName)}`);
|
||||
console.log(` ${clay(sn)}`);
|
||||
console.log(` ${dim(`pubkey ${p.pubkey.slice(0, 16)}…`)}`);
|
||||
console.log("");
|
||||
}
|
||||
console.log(dim(" Compare these digits with your peer (phone, in person, not chat)."));
|
||||
console.log(dim(" If they match on both sides, the channel is not being intercepted."));
|
||||
console.log("");
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
72
apps/cli-v2/src/commands/welcome.ts
Normal file
72
apps/cli-v2/src/commands/welcome.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* `claudemesh` with no args + no joined meshes → unified onboarding.
|
||||
*
|
||||
* One flow, one keystroke per decision. Collapses the old three-branch
|
||||
* picker (signup / login / join) into a linear path:
|
||||
*
|
||||
* 1. Already have an invite URL? → paste it, run the bare-URL join+launch.
|
||||
* (no account needed — invites are self-signed capabilities)
|
||||
* 2. Else: open the browser for sign-in + mesh creation at claudemesh.com
|
||||
* and fall back to paste-sync when the browser hand-off lands.
|
||||
*
|
||||
* The branch that used to be "register" collapses into the browser flow
|
||||
* (the web handles signup + mesh creation as one wizard there).
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { renderWelcome } from "~/ui/welcome/index.js";
|
||||
import { login } from "./login.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { isInviteUrl, normaliseInviteUrl } from "~/utils/url.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
function prompt(q: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(q, (a) => { rl.close(); resolve(a.trim()); });
|
||||
});
|
||||
}
|
||||
|
||||
export async function runWelcome(): Promise<number> {
|
||||
const config = readConfig();
|
||||
if (config.meshes.length > 0) return EXIT.SUCCESS;
|
||||
|
||||
renderWelcome();
|
||||
|
||||
render.info("Do you already have an invite link? (y/n) [n]");
|
||||
const hasInvite = (await prompt(" > ")).toLowerCase().startsWith("y");
|
||||
|
||||
if (hasInvite) {
|
||||
render.blank();
|
||||
render.info("Paste your invite link (claudemesh.com/i/... or claudemesh://...)");
|
||||
const raw = await prompt(" > ");
|
||||
if (!raw || !isInviteUrl(raw)) {
|
||||
render.err("That doesn't look like a claudemesh invite URL.");
|
||||
render.hint("Check your email — the link starts with https://claudemesh.com/i/");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const normalised = normaliseInviteUrl(raw);
|
||||
render.blank();
|
||||
render.ok(`Joining via ${normalised}`);
|
||||
const { runLaunch } = await import("./launch.js");
|
||||
await runLaunch(
|
||||
{
|
||||
join: normalised,
|
||||
name: process.env.USER ?? process.env.USERNAME ?? undefined,
|
||||
yes: false,
|
||||
},
|
||||
[],
|
||||
);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
// No invite → browser-first sign-in + mesh creation.
|
||||
render.blank();
|
||||
render.info("Opening claudemesh.com so you can sign in and create your first mesh.");
|
||||
render.hint("After sign-in, paste the sync token back here when prompted.");
|
||||
render.blank();
|
||||
return await login();
|
||||
}
|
||||
|
||||
export { runWelcome as _stub };
|
||||
26
apps/cli-v2/src/commands/whoami.ts
Normal file
26
apps/cli-v2/src/commands/whoami.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { whoAmI } from "~/services/auth/facade.js";
|
||||
import { dim, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function whoami(opts: { json?: boolean }): Promise<number> {
|
||||
const result = await whoAmI();
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
if (!result.signed_in) {
|
||||
console.log(` Not signed in. Run \`claudemesh login\` to sign in.`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
console.log(`\n Signed in as ${result.user!.display_name} (${result.user!.email})`);
|
||||
console.log(` Token source: ${result.token_source} ${dim("(~/.claudemesh/auth.json)")}`);
|
||||
if (result.meshes) {
|
||||
console.log(` Meshes: ${result.meshes.owned} owned, ${result.meshes.guest} guest`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
14
apps/cli-v2/src/constants/exit-codes.ts
Normal file
14
apps/cli-v2/src/constants/exit-codes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const EXIT = {
|
||||
SUCCESS: 0,
|
||||
USER_CANCELLED: 1,
|
||||
AUTH_FAILED: 2,
|
||||
INVALID_ARGS: 3,
|
||||
NETWORK_ERROR: 4,
|
||||
NOT_FOUND: 5,
|
||||
ALREADY_EXISTS: 6,
|
||||
PERMISSION_DENIED: 7,
|
||||
INTERNAL_ERROR: 8,
|
||||
CLAUDE_MISSING: 9,
|
||||
} as const;
|
||||
|
||||
export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
|
||||
5
apps/cli-v2/src/constants/index.ts
Normal file
5
apps/cli-v2/src/constants/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { EXIT } from "./exit-codes.js";
|
||||
export type { ExitCode } from "./exit-codes.js";
|
||||
export { PATHS } from "./paths.js";
|
||||
export { URLS } from "./urls.js";
|
||||
export { TIMINGS } from "./timings.js";
|
||||
22
apps/cli-v2/src/constants/paths.ts
Normal file
22
apps/cli-v2/src/constants/paths.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const home = homedir();
|
||||
|
||||
export const PATHS = {
|
||||
CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || join(home, ".claudemesh"),
|
||||
get CONFIG_FILE() {
|
||||
return join(this.CONFIG_DIR, "config.json");
|
||||
},
|
||||
get AUTH_FILE() {
|
||||
return join(this.CONFIG_DIR, "auth.json");
|
||||
},
|
||||
get KEYS_DIR() {
|
||||
return join(this.CONFIG_DIR, "keys");
|
||||
},
|
||||
get LAST_USED_FILE() {
|
||||
return join(this.CONFIG_DIR, "last-used.json");
|
||||
},
|
||||
CLAUDE_JSON: join(home, ".claude.json"),
|
||||
CLAUDE_SETTINGS: join(home, ".claude", "settings.json"),
|
||||
} as const;
|
||||
11
apps/cli-v2/src/constants/timings.ts
Normal file
11
apps/cli-v2/src/constants/timings.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const TIMINGS = {
|
||||
DEVICE_CODE_POLL_MS: 1500,
|
||||
DEVICE_CODE_TIMEOUT_MS: 5 * 60 * 1000,
|
||||
WS_RECONNECT_BASE_MS: 1000,
|
||||
WS_RECONNECT_MAX_MS: 30_000,
|
||||
UPDATE_CHECK_INTERVAL_MS: 24 * 60 * 60 * 1000,
|
||||
TELEGRAM_CONNECT_TIMEOUT_MS: 5 * 60 * 1000,
|
||||
TELEGRAM_POLL_INTERVAL_MS: 2000,
|
||||
API_TIMEOUT_MS: 15_000,
|
||||
API_RETRY_COUNT: 2,
|
||||
} as const;
|
||||
14
apps/cli-v2/src/constants/urls.ts
Normal file
14
apps/cli-v2/src/constants/urls.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const URLS = {
|
||||
BROKER: process.env.CLAUDEMESH_BROKER_URL ?? "wss://ic.claudemesh.com/ws",
|
||||
API_BASE: process.env.CLAUDEMESH_API_URL ?? "https://claudemesh.com",
|
||||
DASHBOARD: "https://claudemesh.com/dashboard",
|
||||
NPM_REGISTRY: "https://registry.npmjs.org/claudemesh-cli",
|
||||
} as const;
|
||||
|
||||
export const VERSION = "1.0.0-alpha.27";
|
||||
|
||||
export const env = {
|
||||
CLAUDEMESH_BROKER_URL: URLS.BROKER,
|
||||
CLAUDEMESH_CONFIG_DIR: process.env.CLAUDEMESH_CONFIG_DIR || undefined,
|
||||
CLAUDEMESH_DEBUG: process.env.CLAUDEMESH_DEBUG === "1" || process.env.CLAUDEMESH_DEBUG === "true",
|
||||
};
|
||||
200
apps/cli-v2/src/entrypoints/cli.ts
Normal file
200
apps/cli-v2/src/entrypoints/cli.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env node
|
||||
import { parseArgv } from "~/cli/argv.js";
|
||||
import { installSignalHandlers } from "~/cli/handlers/signal.js";
|
||||
import { installErrorHandlers } from "~/cli/handlers/error.js";
|
||||
import { showUpdateNotice } from "~/cli/update-notice.js";
|
||||
import { VERSION } from "~/constants/urls.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { renderVersion } from "~/cli/output/version.js";
|
||||
import { isInviteUrl, normaliseInviteUrl } from "~/utils/url.js";
|
||||
|
||||
installSignalHandlers();
|
||||
installErrorHandlers();
|
||||
|
||||
const { command, positionals, flags } = parseArgv(process.argv);
|
||||
|
||||
const HELP = `
|
||||
claudemesh — peer mesh for Claude Code sessions
|
||||
${VERSION}
|
||||
|
||||
USAGE
|
||||
claudemesh auto-connect to your mesh
|
||||
claudemesh <invite-url> join a mesh, then launch
|
||||
claudemesh launch --name <n> --join <url> join + launch in one step
|
||||
|
||||
Mesh
|
||||
claudemesh create <name> create a new mesh
|
||||
claudemesh join <url> join a mesh (accepts short /i/ or long /join/ link)
|
||||
claudemesh launch [slug] launch Claude Code on a mesh (alias: connect)
|
||||
claudemesh list show your meshes (alias: ls)
|
||||
claudemesh delete [slug] delete a mesh (alias: rm)
|
||||
claudemesh rename <slug> <name> rename a mesh
|
||||
claudemesh share [email] share mesh (invite link / send email)
|
||||
|
||||
Messaging
|
||||
claudemesh peers see who's online
|
||||
claudemesh send <to> <msg> send a message
|
||||
claudemesh inbox drain pending messages
|
||||
claudemesh state get|set|list shared state
|
||||
claudemesh remember <text> store a memory
|
||||
claudemesh recall <query> search memories
|
||||
claudemesh remind ... schedule a reminder
|
||||
claudemesh profile view or edit your profile
|
||||
claudemesh info mesh overview
|
||||
|
||||
Auth
|
||||
claudemesh login sign in (browser or paste token)
|
||||
claudemesh register create account + sign in
|
||||
claudemesh logout sign out
|
||||
claudemesh whoami show current identity
|
||||
|
||||
Security
|
||||
claudemesh verify [peer] show ed25519 safety numbers (SAS)
|
||||
claudemesh grant <peer> <cap> grant capability (dm, broadcast, state-read, all)
|
||||
claudemesh revoke <peer> <cap> revoke capability (or 'all')
|
||||
claudemesh block <peer> revoke all capabilities (silent drop)
|
||||
claudemesh grants list per-peer overrides for current mesh
|
||||
claudemesh backup [file] encrypt config → portable recovery file
|
||||
claudemesh restore <file> restore config from a backup file
|
||||
|
||||
Setup
|
||||
claudemesh install register MCP server + hooks
|
||||
claudemesh uninstall remove MCP server + hooks
|
||||
claudemesh doctor diagnose issues (broker, node, claude)
|
||||
claudemesh status check broker connectivity
|
||||
claudemesh sync refresh mesh list from dashboard
|
||||
claudemesh completions <shell> emit bash / zsh / fish completion script
|
||||
claudemesh url-handler install register claudemesh:// click-to-launch
|
||||
claudemesh upgrade self-update to latest alpha (rustup-style)
|
||||
|
||||
Flags
|
||||
--version, -V show version
|
||||
--help, -h show this help
|
||||
--json machine-readable output
|
||||
--mesh <slug> override mesh selection
|
||||
-y, --yes skip confirmations
|
||||
-q, --quiet suppress non-essential output
|
||||
`;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (flags.help || flags.h) { console.log(HELP); process.exit(EXIT.SUCCESS); }
|
||||
if (flags.version || flags.V) { console.log(renderVersion()); process.exit(EXIT.SUCCESS); }
|
||||
|
||||
// Bare command or invite URL
|
||||
if (!command || isInviteUrl(command)) {
|
||||
// `claudemesh <invite-url>` → join + launch in one step.
|
||||
// `-y` skips all interactive prompts (role=member, no groups, push mode).
|
||||
if (command && isInviteUrl(command)) {
|
||||
const { runLaunch } = await import("~/commands/launch.js");
|
||||
await runLaunch({
|
||||
mesh: flags.mesh as string | undefined,
|
||||
name: flags.name as string | undefined,
|
||||
join: normaliseInviteUrl(command),
|
||||
yes: !!flags.y || !!flags.yes,
|
||||
resume: flags.resume as string | undefined,
|
||||
}, process.argv.slice(2));
|
||||
return;
|
||||
}
|
||||
const { readConfig } = await import("~/services/config/facade.js");
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
const { runWelcome } = await import("~/commands/welcome.js");
|
||||
process.exit(await runWelcome());
|
||||
}
|
||||
const { runLaunch } = await import("~/commands/launch.js");
|
||||
await runLaunch({
|
||||
mesh: flags.mesh as string | undefined,
|
||||
name: flags.name as string | undefined,
|
||||
yes: !!flags.y || !!flags.yes,
|
||||
resume: flags.resume as string | undefined,
|
||||
}, process.argv.slice(2));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case "help": { console.log(HELP); break; }
|
||||
|
||||
// Mesh management
|
||||
case "create": case "new": { const { newMesh } = await import("~/commands/new.js"); process.exit(await newMesh(positionals[0] ?? "", { json: !!flags.json })); break; }
|
||||
case "add": case "join": { const { runJoin } = await import("~/commands/join.js"); await runJoin(positionals); break; }
|
||||
case "connect": case "launch": {
|
||||
const { runLaunch } = await import("~/commands/launch.js");
|
||||
await runLaunch({
|
||||
mesh: positionals[0] ?? flags.mesh as string,
|
||||
name: flags.name as string,
|
||||
join: flags.join as string,
|
||||
yes: !!flags.y || !!flags.yes,
|
||||
resume: flags.resume as string,
|
||||
}, process.argv.slice(2));
|
||||
break;
|
||||
}
|
||||
case "disconnect": { console.log(" Connection closed."); process.exit(EXIT.SUCCESS); break; }
|
||||
case "list": case "ls": { const { runList } = await import("~/commands/list.js"); await runList(); break; }
|
||||
case "delete": case "rm": { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[0] ?? "", { yes: !!flags.y || !!flags.yes })); break; }
|
||||
case "rename": { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[0] ?? "", positionals[1] ?? "")); break; }
|
||||
case "share": case "invite": { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; }
|
||||
|
||||
// Messaging
|
||||
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: !!flags.json }); break; }
|
||||
case "send": { const { runSend } = await import("~/commands/send.js"); await runSend({}, positionals[0] ?? "", positionals.slice(1).join(" ")); break; }
|
||||
case "inbox": { const { runInbox } = await import("~/commands/inbox.js"); await runInbox({ json: !!flags.json }); break; }
|
||||
case "state": {
|
||||
const sub = positionals[0];
|
||||
if (sub === "set") { const { runStateSet } = await import("~/commands/state.js"); await runStateSet({}, positionals[1] ?? "", positionals[2] ?? ""); }
|
||||
else if (sub === "list") { const { runStateList } = await import("~/commands/state.js"); await runStateList({}); }
|
||||
else { const { runStateGet } = await import("~/commands/state.js"); await runStateGet({}, positionals[0] ?? ""); }
|
||||
break;
|
||||
}
|
||||
case "info": { const { runInfo } = await import("~/commands/info.js"); await runInfo({}); break; }
|
||||
case "remember": { const { remember } = await import("~/commands/remember.js"); process.exit(await remember(positionals.join(" "), { tags: flags.tags as string, json: !!flags.json })); break; }
|
||||
case "recall": { const { recall } = await import("~/commands/recall.js"); process.exit(await recall(positionals.join(" "), { json: !!flags.json })); break; }
|
||||
case "remind": { const { runRemind } = await import("~/commands/remind.js"); await runRemind({ mesh: flags.mesh as string }, positionals); break; }
|
||||
case "profile": { const { runProfile } = await import("~/commands/profile.js"); await runProfile(flags as any); break; }
|
||||
|
||||
// Auth
|
||||
case "login": { const { login } = await import("~/commands/login.js"); process.exit(await login()); break; }
|
||||
case "register": { const { register } = await import("~/commands/register.js"); process.exit(await register()); break; }
|
||||
case "logout": { const { logout } = await import("~/commands/logout.js"); process.exit(await logout()); break; }
|
||||
case "whoami": { const { whoami } = await import("~/commands/whoami.js"); process.exit(await whoami({ json: !!flags.json })); break; }
|
||||
|
||||
// Setup
|
||||
case "install": { const { runInstall } = await import("~/commands/install.js"); runInstall(positionals); break; }
|
||||
case "uninstall": { const { uninstall } = await import("~/commands/uninstall.js"); process.exit(await uninstall()); break; }
|
||||
case "doctor": { const { runDoctor } = await import("~/commands/doctor.js"); await runDoctor(); break; }
|
||||
case "status": { const { runStatus } = await import("~/commands/status.js"); await runStatus(); break; }
|
||||
case "sync": { const { runSync } = await import("~/commands/sync.js"); await runSync({ force: !!flags.force }); break; }
|
||||
|
||||
// Test
|
||||
case "test": { const { runTest } = await import("~/commands/test.js"); process.exit(await runTest()); break; }
|
||||
|
||||
// CLI utilities
|
||||
case "completions": { const { runCompletions } = await import("~/commands/completions.js"); process.exit(await runCompletions(positionals[0])); break; }
|
||||
case "verify": { const { runVerify } = await import("~/commands/verify.js"); process.exit(await runVerify(positionals[0], { mesh: flags.mesh as string | undefined, json: !!flags.json })); break; }
|
||||
case "url-handler": { const { runUrlHandler } = await import("~/commands/url-handler.js"); process.exit(await runUrlHandler(positionals[0])); break; }
|
||||
case "status-line": { const { runStatusLine } = await import("~/commands/status-line.js"); process.exit(await runStatusLine()); break; }
|
||||
case "backup": { const { runBackup } = await import("~/commands/backup.js"); process.exit(await runBackup(positionals[0])); break; }
|
||||
case "restore": { const { runRestore } = await import("~/commands/backup.js"); process.exit(await runRestore(positionals[0])); break; }
|
||||
case "upgrade": case "update": { const { runUpgrade } = await import("~/commands/upgrade.js"); process.exit(await runUpgrade({ check: !!flags.check, yes: !!flags.y || !!flags.yes })); break; }
|
||||
case "grant": { const { runGrant } = await import("~/commands/grants.js"); process.exit(await runGrant(positionals[0], positionals.slice(1), { mesh: flags.mesh as string | undefined })); break; }
|
||||
case "revoke": { const { runRevoke } = await import("~/commands/grants.js"); process.exit(await runRevoke(positionals[0], positionals.slice(1), { mesh: flags.mesh as string | undefined })); break; }
|
||||
case "block": { const { runBlock } = await import("~/commands/grants.js"); process.exit(await runBlock(positionals[0], { mesh: flags.mesh as string | undefined })); break; }
|
||||
case "grants": { const { runGrants } = await import("~/commands/grants.js"); process.exit(await runGrants({ mesh: flags.mesh as string | undefined, json: !!flags.json })); break; }
|
||||
|
||||
// Internal
|
||||
case "mcp": { const { runMcp } = await import("~/commands/mcp.js"); await runMcp(); break; }
|
||||
case "hook": { const { runHook } = await import("~/commands/hook.js"); await runHook(positionals); break; }
|
||||
case "seed-test-mesh": { const { runSeedTestMesh } = await import("~/commands/seed-test-mesh.js"); runSeedTestMesh(positionals); break; }
|
||||
|
||||
default: {
|
||||
console.error(` Unknown command: ${command}. Run \`claudemesh --help\` for usage.`);
|
||||
process.exit(EXIT.INVALID_ARGS);
|
||||
}
|
||||
}
|
||||
|
||||
showUpdateNotice(VERSION).catch(() => {});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal: " + (err instanceof Error ? err.message : String(err)));
|
||||
process.exit(EXIT.INTERNAL_ERROR);
|
||||
});
|
||||
6
apps/cli-v2/src/entrypoints/mcp.ts
Normal file
6
apps/cli-v2/src/entrypoints/mcp.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { startMcpServer } from "~/mcp/server.js";
|
||||
|
||||
startMcpServer().catch((err) => {
|
||||
process.stderr.write(`MCP server error: ${err instanceof Error ? err.message : err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
10
apps/cli-v2/src/locales/en.ts
Normal file
10
apps/cli-v2/src/locales/en.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const en = {
|
||||
welcome: "Welcome to claudemesh",
|
||||
signed_in_as: "Signed in as {name}",
|
||||
mesh_created: 'Created "{slug}"',
|
||||
invite_copied: "Invite URL copied to clipboard",
|
||||
logout_success: "Signed out",
|
||||
error_network: "Could not reach {url}. Check your connection.",
|
||||
error_auth: "Authentication failed. Run \`claudemesh login\` to sign in.",
|
||||
error_not_found: "{resource} not found",
|
||||
} as const;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user