refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

- apps/cli/ is now the canonical CLI (was apps/cli-v2/).
- apps/cli/ legacy v0 archived as branch 'legacy-cli-archive' and tag
  'cli-v0-legacy-final' before deletion; git history preserves it too.
- .github/workflows/release-cli.yml paths updated.
- pnpm-lock.yaml regenerated.

Broker-side peer-grant enforcement (spec: 2026-04-15-per-peer-capabilities):
- 0020_peer-grants.sql adds peer_grants jsonb + GIN index on mesh.member.
- handleSend in broker fetches recipient grant maps once per send, drops
  messages silently when sender lacks the required capability.
- POST /cli/mesh/:slug/grants to update from CLI; broker_messages_dropped_by_grant_total metric.
- CLI grant/revoke/block now mirror to broker via syncToBroker.

Auto-migrate on broker startup:
- apps/broker/src/migrate.ts runs drizzle migrate with pg_advisory_lock
  before the HTTP server binds. Exits non-zero on failure so Coolify
  healthcheck fails closed.
- Dockerfile copies packages/db/migrations into /app/migrations.
- postgres 3.4.5 added as direct broker dep.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-15 08:44:52 +01:00
parent c9ede3d469
commit ee12510ef1
374 changed files with 14706 additions and 11307 deletions

View 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/");

View File

@@ -1,117 +0,0 @@
#!/usr/bin/env bun
/**
* Full join → connect → send round-trip.
*
* Uses a mesh already seeded in the DB (reads /tmp/cli-seed.json).
* Creates a fresh invite link, runs the join command, connects with
* the newly-generated member identity, sends a message to peer B,
* asserts receipt.
*/
// Run this script with CLAUDEMESH_CONFIG_DIR=/tmp/... set in env —
// ESM imports hoist above statements, so we can't set process.env
// after the `import { env }` side effect has already run.
import { readFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { BrokerClient } from "../src/ws/client";
import type { JoinedMesh } from "../src/state/config";
import { loadConfig, getConfigPath } from "../src/state/config";
if (!process.env.CLAUDEMESH_CONFIG_DIR) {
console.error(
"Run with: CLAUDEMESH_CONFIG_DIR=/tmp/claudemesh-join-test-rt bun scripts/join-roundtrip.ts",
);
process.exit(1);
}
execSync(`rm -rf "${process.env.CLAUDEMESH_CONFIG_DIR}"`, {
stdio: "ignore",
});
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
meshId: string;
peerB: { memberId: string; pubkey: string; secretKey: string };
};
async function main(): Promise<void> {
// 1. Build invite.
const link = execSync("bun scripts/make-invite.ts").toString().trim();
console.log("[rt] invite:", link.slice(0, 60) + "…");
// 2. Run `claudemesh join` with the same CONFIG_DIR.
const joinOut = execSync(`bun src/index.ts join "${link}"`, {
env: {
...process.env,
CLAUDEMESH_CONFIG_DIR: "/tmp/claudemesh-join-test-rt",
},
}).toString();
console.log("[rt] join output (tail):");
console.log(
joinOut
.split("\n")
.slice(-7)
.map((l) => " " + l)
.join("\n"),
);
// 3. Load the fresh config and connect as the new peer.
console.log(`[rt] loading config from: ${getConfigPath()}`);
const config = loadConfig();
console.log(`[rt] loaded ${config.meshes.length} mesh(es)`);
const joined = config.meshes.find((m) => m.slug === "smoke-test");
if (!joined) throw new Error("smoke-test mesh not found in config");
const joinedMesh: JoinedMesh = joined;
console.log(
`[rt] joined member_id=${joinedMesh.memberId} pubkey=${joinedMesh.pubkey.slice(0, 16)}`,
);
// 4. Connect also as peer-B (the target) so we can observe receipt.
// Uses the real keypair from the seed (needed for crypto_box decrypt).
const targetMesh: JoinedMesh = {
...joinedMesh,
memberId: seed.peerB.memberId,
slug: "rt-join-b",
pubkey: seed.peerB.pubkey,
secretKey: seed.peerB.secretKey,
};
const joiner = new BrokerClient(joinedMesh);
const target = new BrokerClient(targetMesh);
let received = "";
target.onPush((m) => {
received = m.plaintext ?? "";
console.log(`[rt] target got: "${received}"`);
});
await Promise.all([joiner.connect(), target.connect()]);
console.log(`[rt] joiner=${joiner.status} target=${target.status}`);
const res = await joiner.send(
seed.peerB.pubkey,
"sent-by-newly-joined-peer",
"now",
);
console.log("[rt] send result:", res);
for (let i = 0; i < 30 && !received; i++) {
await new Promise((r) => setTimeout(r, 100));
}
joiner.close();
target.close();
if (!res.ok) {
console.error("✗ FAIL: send did not ack");
process.exit(1);
}
if (received !== "sent-by-newly-joined-peer") {
console.error(`✗ FAIL: receive mismatch: "${received}"`);
process.exit(1);
}
console.log("✓ join → connect → send → receive FLOW PASSED");
process.exit(0);
}
main().catch((e) => {
console.error("✗ FAIL:", e instanceof Error ? e.message : e);
process.exit(1);
});

View File

@@ -1,23 +0,0 @@
#!/usr/bin/env bun
/**
* Emit the signed invite link produced by the broker's seed-test-mesh.
*
* The seed script (apps/broker/scripts/seed-test-mesh.ts) creates a
* mesh with an owner keypair and a signed invite row, then writes
* both into /tmp/cli-seed.json. We just echo its inviteLink here so
* downstream test scripts can pipe it.
*/
import { readFileSync } from "node:fs";
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
inviteLink: string;
};
if (!seed.inviteLink) {
console.error(
"seed missing inviteLink — re-run apps/broker/scripts/seed-test-mesh.ts",
);
process.exit(1);
}
console.log(seed.inviteLink);

View File

@@ -1,87 +0,0 @@
#!/usr/bin/env bun
/**
* End-to-end round-trip: two BrokerClient instances talking via the
* broker. Runs against a live broker + seeded DB.
*
* Reads /tmp/cli-seed.json (output of broker's scripts/seed-test-mesh.ts),
* connects peer A and peer B, sends a message from A to B, waits for
* the push on B, asserts receipt + sender pubkey.
*/
import { readFileSync } from "node:fs";
import { BrokerClient } from "../src/ws/client";
import type { JoinedMesh } from "../src/state/config";
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
meshId: string;
peerA: { memberId: string; pubkey: string; secretKey: string };
peerB: { memberId: string; pubkey: string; secretKey: string };
};
const brokerUrl = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
const meshA: JoinedMesh = {
meshId: seed.meshId,
memberId: seed.peerA.memberId,
slug: "rt-a",
name: "roundtrip-a",
pubkey: seed.peerA.pubkey,
secretKey: seed.peerA.secretKey,
brokerUrl,
joinedAt: new Date().toISOString(),
};
const meshB: JoinedMesh = {
...meshA,
memberId: seed.peerB.memberId,
slug: "rt-b",
pubkey: seed.peerB.pubkey,
secretKey: seed.peerB.secretKey,
};
async function main(): Promise<void> {
const a = new BrokerClient(meshA, { debug: true });
const b = new BrokerClient(meshB, { debug: true });
let received: string | null = null;
let receivedSender: string | null = null;
b.onPush((msg) => {
received = msg.plaintext;
receivedSender = msg.senderPubkey;
console.log(`[b] push (kind=${msg.kind}): "${received}" from ${receivedSender?.slice(0, 16)}`);
});
console.log("[rt] connecting A + B…");
await Promise.all([a.connect(), b.connect()]);
console.log(`[rt] A: ${a.status}, B: ${b.status}`);
console.log("[rt] A → B …");
const result = await a.send(seed.peerB.pubkey, "hello from A", "now");
console.log("[rt] send result:", result);
// Wait up to 3s for the push to land.
for (let i = 0; i < 30 && !received; i++) {
await new Promise((r) => setTimeout(r, 100));
}
a.close();
b.close();
if (!received) {
console.error("✗ FAIL: no push received");
process.exit(1);
}
if (received !== "hello from A") {
console.error(`✗ FAIL: body mismatch: "${received}"`);
process.exit(1);
}
if (receivedSender !== seed.peerA.pubkey) {
console.error(`✗ FAIL: sender mismatch: "${receivedSender}"`);
process.exit(1);
}
console.log("✓ round-trip PASSED");
process.exit(0);
}
main().catch((e) => {
console.error("✗ FAIL:", e instanceof Error ? e.message : e);
process.exit(1);
});