Files
claudemesh/apps/cli/src/commands/join.ts
Alejandro Gutiérrez 758ea0e42c feat(cli): invite-link parsing + join flow + keypair generation
End-to-end join: user runs `claudemesh join ic://join/<base64>` and
walks away with a signed member record + persistent keypair.

new modules:
- src/crypto/keypair.ts: libsodium ed25519 keypair generation. Format
  is crypto_sign_keypair raw bytes, hex-encoded (32-byte pub, 64-byte
  secret = seed || pub). Same format libsodium will need in Step 18
  for sign/verify.
- src/invite/parse.ts: ic://join/<base64url(JSON)> parser with Zod
  shape validation + expiry check. encodeInviteLink helper for tests.
- src/invite/enroll.ts: POST /join to broker, converts ws:// to http://
  transparently.

rewritten join command wires them together:
  1. parse invite → 2. generate keypair → 3. POST /join → 4. persist
  config → 5. print success.

state/config.ts: saveConfig now chmods the file to 0600 after write,
since it holds ed25519 secret keys. No-op on Windows.

signature verification (step 18) + invite-token one-time-use tracking
are deferred. For now the invite link is a plain bearer token; any
client with the link can join.

verified end-to-end via apps/cli/scripts/join-roundtrip.ts:
  build invite → run join subprocess → load new config → connect as
  new member → send A→B → receive push. Flow passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:36:32 +01:00

91 lines
2.7 KiB
TypeScript

/**
* `claudemesh join <invite-link>` — full join flow.
*
* 1. Parse + validate the ic://join/... link
* 2. Generate a fresh ed25519 keypair (libsodium)
* 3. POST /join to the broker → get member_id
* 4. Persist the mesh + keypair to ~/.claudemesh/config.json (0600)
* 5. Print success
*
* Signature verification + invite-token one-time-use land in Step 18.
*/
import { parseInviteLink } from "../invite/parse";
import { enrollWithBroker } from "../invite/enroll";
import { generateKeypair } from "../crypto/keypair";
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
import { hostname } from "node:os";
export async function runJoin(args: string[]): Promise<void> {
const link = args[0];
if (!link) {
console.error("Usage: claudemesh join <invite-link>");
console.error("");
console.error("Example: claudemesh join ic://join/eyJ2IjoxLC4uLn0");
process.exit(1);
}
// 1. Parse.
let invite;
try {
invite = parseInviteLink(link);
} catch (e) {
console.error(
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
const { payload } = 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,
meshId: payload.mesh_id,
peerPubkey: keypair.publicKey,
displayName,
role: payload.role,
});
} catch (e) {
console.error(
`claudemesh: broker enrollment failed: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
// 4. Persist.
const config = loadConfig();
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(),
});
saveConfig(config);
// 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.");
}