@rine-network/cli 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.js +434 -63
  2. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { Command } from "commander";
3
- import { HttpClient, RineApiError, UUID_RE, addMlsGroupMember, agentKeysExist, cacheToken, decryptGroupMessage, decryptMessage, encryptGroupMessage, encryptMessage, encryptMlsGroupMessage, externalJoinMlsGroup, fetchAgents, fetchAndIngestPendingSKDistributions, fetchOAuthToken, fetchRecipientKeys, formatError, fromBase64Url, generateAgentKeys, generatePqKeyPair, getAgentPublicKeys, getCredentialEntry, getOrCreateSenderKey, getOrRefreshToken, ingestSenderKeyDistribution, initMlsGroup, isBareAgentName, loadAgentKeys, loadCredentials, loadMlsState, loadTokenCache, normalizeHandle, performAgentCreation, performRegistration, processMlsWelcomes, resolveAgent, resolveApiUrl, resolveConfigDir, resolveHandleViaWebFinger, resolveToUuid, saveAgentKeys, saveCredentials, savePqEncryptionKey, saveTokenCache, syncMlsGroup, toBase64Url, validateEncryptionKey, validateSigningKey, validateSlug } from "@rine-network/core";
3
+ import { HttpClient, RineApiError, UUID_RE, addMlsGroupMember, agentKeysExist, buildIssueCertDeps, cacheToken, connectTunnel, decryptGroupMessage, decryptMessage, deleteHookSecret, encryptGroupMessage, encryptMessage, encryptMlsGroupMessage, ensureCert, externalJoinMlsGroup, fetchAgents, fetchAndIngestPendingSKDistributions, fetchOAuthToken, fetchRecipientKeys, formatError, fromBase64Url, generateAgentKeys, generateHookSecret, generatePqKeyPair, getAgentPublicKeys, getCredentialEntry, getOrCreateSenderKey, getOrRefreshToken, handleWebhookRequest, ingestSenderKeyDistribution, initMlsGroup, isBareAgentName, isValidHookName, listMyInvites, loadAgentKeys, loadCredentials, loadHookSecret, loadMlsState, loadTokenCache, normalizeHandle, performAgentCreation, performRegistration, processMlsWelcomes, resolveAgent, resolveApiUrl, resolveConfigDir, resolveHandleViaWebFinger, resolveToUuid, revokeAndWipeCache, saveAgentKeys, saveCredentials, saveHookSecret, savePqEncryptionKey, saveTokenCache, sendGroupInviteNotification, startTlsListener, syncMlsGroup, toBase64Url, validateEncryptionKey, validateSigningKey, validateSlug } from "@rine-network/core";
4
4
  import readline from "node:readline";
5
5
  import * as fs from "node:fs";
6
+ import { readFileSync } from "node:fs";
6
7
  import { join } from "node:path";
7
8
  import { createEventSource } from "eventsource-client";
8
9
  //#region src/output.ts
@@ -711,6 +712,18 @@ async function addMemberToMls(client, configDir, apiUrl, groupId, memberAgentId,
711
712
  if (!json) printText(`Warning: MLS add-member failed: ${err instanceof Error ? err.message : String(err)}`);
712
713
  }
713
714
  }
715
+ async function sendInviteNotification(client, configDir, apiUrl, groupId, inviteeAgentId, message, as, json) {
716
+ try {
717
+ const group = await client.get(`/groups/${groupId}`);
718
+ await sendGroupInviteNotification(client, configDir, await resolveAgent(apiUrl, await fetchAgents(client), void 0, as), inviteeAgentId, {
719
+ group_id: groupId,
720
+ group_handle: group.handle,
721
+ message: message ?? null
722
+ });
723
+ } catch (err) {
724
+ if (!json) printText(`Warning: invite notification failed: ${err instanceof Error ? err.message : String(err)}`);
725
+ }
726
+ }
714
727
  function groupRow(g) {
715
728
  return {
716
729
  ID: g.id,
@@ -754,6 +767,15 @@ function requestRow(r) {
754
767
  Created: r.created_at
755
768
  };
756
769
  }
770
+ function inviteRow(r) {
771
+ return {
772
+ Group: r.group_handle ?? r.group_name ?? "",
773
+ "Group ID": r.group_id,
774
+ "Invited By": r.invited_by ?? "",
775
+ Message: r.message ?? "",
776
+ Created: r.created_at
777
+ };
778
+ }
757
779
  function registerGroup(program) {
758
780
  const group = program.command("group").description("Group management");
759
781
  group.command("create").description("Create a new group").requiredOption("--name <name>", "Group name (DNS-safe slug)").option("--enrollment <policy>", "Enrollment policy (open|closed|majority|unanimity)").option("--visibility <vis>", "Visibility (public|private)").option("--isolated", "Isolate group communication").option("--vote-duration <hours>", "Vote duration in hours (1-72)").action(withClient(program, async ({ client, gOpts, configDir, apiUrl }, opts) => {
@@ -852,6 +874,7 @@ function registerGroup(program) {
852
874
  if (opts.message) body.message = opts.message;
853
875
  const data = await client.post(`/groups/${groupId}/invite`, body);
854
876
  await addMemberToMls(client, configDir, apiUrl, groupId, agentId, gOpts.as, gOpts.json);
877
+ await sendInviteNotification(client, configDir, apiUrl, groupId, agentId, opts.message, gOpts.as, gOpts.json);
855
878
  if (gOpts.json) printJson(data);
856
879
  else {
857
880
  printText("Invitation sent");
@@ -863,6 +886,11 @@ function registerGroup(program) {
863
886
  if (gOpts.json) printJson(data);
864
887
  else printTable(data.items.map(requestRow));
865
888
  }));
889
+ group.command("invites").description("List group invitations addressed to you").action(withClient(program, async ({ client, gOpts, extraHeaders }) => {
890
+ const items = await listMyInvites(client, extraHeaders);
891
+ if (gOpts.json) printJson(items);
892
+ else printTable(items.map(inviteRow));
893
+ }));
866
894
  group.command("vote").description("Vote on a join request").argument("<group-id>", "Group ID").argument("<request-id>", "Join request ID").requiredOption("--vote <vote>", "Vote: approve or deny").action(withClient(program, async ({ client, gOpts, configDir, apiUrl }, groupId, requestId, opts) => {
867
895
  if (opts.vote !== "approve" && opts.vote !== "deny") {
868
896
  printError("--vote must be 'approve' or 'deny'");
@@ -878,6 +906,106 @@ function registerGroup(program) {
878
906
  }));
879
907
  }
880
908
  //#endregion
909
+ //#region src/commands/hook.ts
910
+ const SIGNATURE_HEADER = "X-Hub-Signature-256";
911
+ const CONTENT_TYPE = "application/json";
912
+ function registerHook(program) {
913
+ const hook = program.command("hook").description("Manage inbound webhook funnel hooks");
914
+ hook.command("create").description("Allocate a funnel hook and print its GitHub setup (secret shown once)").option("--agent <id>", "Agent ID (defaults to your agent)").option("--name <name>", "Hook name (default: default)", "default").action(withClient(program, async ({ client, gOpts, apiUrl, configDir }, opts) => {
915
+ if (!isValidHookName(opts.name)) {
916
+ printError(`Invalid hook name '${opts.name}': use lowercase letters, digits and hyphens (max 32).`);
917
+ process.exitCode = 2;
918
+ return;
919
+ }
920
+ const agentId = await resolveAgent(apiUrl, await fetchAgents(client), opts.agent, gOpts.as);
921
+ const secret = generateHookSecret();
922
+ saveHookSecret(configDir, agentId, opts.name, secret);
923
+ let created;
924
+ try {
925
+ created = await client.post(`/agents/${agentId}/funnel/hooks`, { hook_name: opts.name });
926
+ } catch (err) {
927
+ deleteHookSecret(configDir, agentId, opts.name);
928
+ throw err;
929
+ }
930
+ printCreated(created, opts.name, secret, gOpts.json);
931
+ }));
932
+ hook.command("list").description("List your agent's funnel hooks").option("--agent <id>", "Agent ID (defaults to your agent)").action(withClient(program, async ({ client, gOpts, apiUrl }, opts) => {
933
+ const agentId = await resolveAgent(apiUrl, await fetchAgents(client), opts.agent, gOpts.as);
934
+ const data = await client.get(`/agents/${agentId}/funnel/hooks`, {});
935
+ if (gOpts.json) printJson(data);
936
+ else printTable(data.items.map((h) => ({
937
+ Name: h.hook_name,
938
+ Hostname: h.hostname,
939
+ Active: h.active,
940
+ Created: h.created_at
941
+ })));
942
+ }));
943
+ hook.command("delete").description("Delete a funnel hook and purge its local secret").option("--agent <id>", "Agent ID (defaults to your agent)").requiredOption("--name <name>", "Hook name to delete").option("--yes", "Skip confirmation prompt").action(withClient(program, async ({ client, gOpts, apiUrl, configDir }, opts) => {
944
+ const agentId = await resolveAgent(apiUrl, await fetchAgents(client), opts.agent, gOpts.as);
945
+ if (!opts.yes && !gOpts.json) {
946
+ if (!await promptConfirm(`Delete funnel hook '${opts.name}'?`)) return;
947
+ }
948
+ try {
949
+ await client.delete(`/agents/${agentId}/funnel/hooks/${opts.name}`);
950
+ } catch (err) {
951
+ if (opts.yes && err instanceof RineApiError && err.status === 404) {
952
+ deleteHookSecret(configDir, agentId, opts.name);
953
+ printMutationOk("Funnel hook already absent", gOpts.json);
954
+ return;
955
+ }
956
+ throw err;
957
+ }
958
+ deleteHookSecret(configDir, agentId, opts.name);
959
+ printMutationOk("Funnel hook deleted", gOpts.json);
960
+ }));
961
+ }
962
+ /** Print the print-once GitHub setup block (the secret is shown exactly once). */
963
+ function printCreated(created, name, secret, json) {
964
+ const publicUrl = `https://${created.hostname}/${name}`;
965
+ if (json) {
966
+ printJson({
967
+ hook: {
968
+ name: created.hook_name,
969
+ hostname: created.hostname,
970
+ agent_id: created.agent_id
971
+ },
972
+ public_url: publicUrl,
973
+ secret,
974
+ signature_header: SIGNATURE_HEADER,
975
+ content_type: CONTENT_TYPE
976
+ });
977
+ return;
978
+ }
979
+ printTable([
980
+ {
981
+ Field: "Hook",
982
+ Value: created.hook_name
983
+ },
984
+ {
985
+ Field: "Hostname",
986
+ Value: created.hostname
987
+ },
988
+ {
989
+ Field: "Payload URL",
990
+ Value: publicUrl
991
+ },
992
+ {
993
+ Field: "Content type",
994
+ Value: CONTENT_TYPE
995
+ },
996
+ {
997
+ Field: "Signature header",
998
+ Value: SIGNATURE_HEADER
999
+ },
1000
+ {
1001
+ Field: "Secret",
1002
+ Value: secret
1003
+ }
1004
+ ]);
1005
+ console.log(`\nGitHub: Settings → Webhooks → Payload URL = ${publicUrl}, Content type = ${CONTENT_TYPE}, Secret = the secret above.`);
1006
+ console.log("Save this secret now — it is not stored on rine and cannot be retrieved.");
1007
+ }
1008
+ //#endregion
881
1009
  //#region src/commands/keys.ts
882
1010
  function getKeysDir(configDir, agentId) {
883
1011
  return join(configDir, "keys", agentId);
@@ -1325,68 +1453,6 @@ function registerMessages(program) {
1325
1453
  addMessageCommands(program.command("message").description("Message operations (aliases: send/read/inbox/reply)"), program);
1326
1454
  }
1327
1455
  //#endregion
1328
- //#region src/commands/poll-token.ts
1329
- function registerPollToken(program) {
1330
- program.command("poll-token").description("Generate or revoke a poll token for inbox monitoring").option("--agent <id>", "Agent ID (auto-resolved for single-agent orgs)").option("--revoke", "Revoke the poll token").action(withClient(program, async ({ client, gOpts, profileName, configDir, apiUrl }, opts) => {
1331
- const agentId = await resolveAgent(apiUrl, await fetchAgents(client), opts.agent, gOpts.as);
1332
- if (opts.revoke) {
1333
- await client.delete(`/agents/${agentId}/poll-token`);
1334
- const creds = loadCredentials(configDir);
1335
- if (creds[profileName]) {
1336
- delete creds[profileName].poll_url;
1337
- saveCredentials(configDir, creds);
1338
- }
1339
- printMutationOk("Poll token revoked", gOpts.json);
1340
- return;
1341
- }
1342
- const data = await client.post(`/agents/${agentId}/poll-token`, {});
1343
- const creds = loadCredentials(configDir);
1344
- if (creds[profileName]) {
1345
- creds[profileName].poll_url = data.poll_url;
1346
- saveCredentials(configDir, creds);
1347
- }
1348
- if (gOpts.json) printJson(data);
1349
- else {
1350
- console.log(`Poll URL: ${data.poll_url}`);
1351
- console.log("Credentials updated.");
1352
- }
1353
- }));
1354
- }
1355
- //#endregion
1356
- //#region src/commands/org.ts
1357
- function renderOrg(data, opts) {
1358
- if (opts.json) {
1359
- printJson(data);
1360
- return;
1361
- }
1362
- printTable([{
1363
- ID: data.id,
1364
- Name: data.name,
1365
- "Contact Email": data.contact_email ?? "",
1366
- Country: data.country_code ?? "",
1367
- Created: data.created_at
1368
- }]);
1369
- }
1370
- function registerOrg(program) {
1371
- const org = program.command("org").description("Organization management");
1372
- org.command("get").description("Get organization details").action(withClient(program, async ({ client, gOpts }) => {
1373
- renderOrg(await client.get("/org"), gOpts);
1374
- }));
1375
- org.command("update").description("Update organization details").option("--name <name>", "Organization name").option("--contact-email <email>", "Contact email address").option("--country-code <cc>", "ISO country code (e.g. DE)").action(withClient(program, async ({ client, gOpts }, opts) => {
1376
- const body = {};
1377
- if (opts.name) body.name = opts.name;
1378
- if (opts.contactEmail) body.contact_email = opts.contactEmail;
1379
- if (opts.countryCode) body.country_code = opts.countryCode;
1380
- if (Object.keys(body).length === 0) {
1381
- printError("At least one of --name, --contact-email, --country-code is required");
1382
- process.exitCode = 2;
1383
- return;
1384
- }
1385
- await client.patch("/org", body);
1386
- printMutationOk("Org updated", gOpts.json);
1387
- }));
1388
- }
1389
- //#endregion
1390
1456
  //#region src/commands/onboard.ts
1391
1457
  function registerOnboard(program) {
1392
1458
  program.command("onboard").description("Register a new organization and create your first agent").requiredOption("--email <email>", "Email address").requiredOption("--name <name>", "Organization name").requiredOption("--slug <slug>", "Organization slug").requiredOption("--agent <agent-name>", "Name for the first agent").option("--[no-]human-oversight", "Require human oversight (default: true)").option("--unlisted", "Mark agent as unlisted (not in public directory)").action(withErrorHandler(program, async (gOpts, opts) => {
@@ -1452,6 +1518,68 @@ function registerOnboard(program) {
1452
1518
  }));
1453
1519
  }
1454
1520
  //#endregion
1521
+ //#region src/commands/org.ts
1522
+ function renderOrg(data, opts) {
1523
+ if (opts.json) {
1524
+ printJson(data);
1525
+ return;
1526
+ }
1527
+ printTable([{
1528
+ ID: data.id,
1529
+ Name: data.name,
1530
+ "Contact Email": data.contact_email ?? "",
1531
+ Country: data.country_code ?? "",
1532
+ Created: data.created_at
1533
+ }]);
1534
+ }
1535
+ function registerOrg(program) {
1536
+ const org = program.command("org").description("Organization management");
1537
+ org.command("get").description("Get organization details").action(withClient(program, async ({ client, gOpts }) => {
1538
+ renderOrg(await client.get("/org"), gOpts);
1539
+ }));
1540
+ org.command("update").description("Update organization details").option("--name <name>", "Organization name").option("--contact-email <email>", "Contact email address").option("--country-code <cc>", "ISO country code (e.g. DE)").action(withClient(program, async ({ client, gOpts }, opts) => {
1541
+ const body = {};
1542
+ if (opts.name) body.name = opts.name;
1543
+ if (opts.contactEmail) body.contact_email = opts.contactEmail;
1544
+ if (opts.countryCode) body.country_code = opts.countryCode;
1545
+ if (Object.keys(body).length === 0) {
1546
+ printError("At least one of --name, --contact-email, --country-code is required");
1547
+ process.exitCode = 2;
1548
+ return;
1549
+ }
1550
+ await client.patch("/org", body);
1551
+ printMutationOk("Org updated", gOpts.json);
1552
+ }));
1553
+ }
1554
+ //#endregion
1555
+ //#region src/commands/poll-token.ts
1556
+ function registerPollToken(program) {
1557
+ program.command("poll-token").description("Generate or revoke a poll token for inbox monitoring").option("--agent <id>", "Agent ID (auto-resolved for single-agent orgs)").option("--revoke", "Revoke the poll token").action(withClient(program, async ({ client, gOpts, profileName, configDir, apiUrl }, opts) => {
1558
+ const agentId = await resolveAgent(apiUrl, await fetchAgents(client), opts.agent, gOpts.as);
1559
+ if (opts.revoke) {
1560
+ await client.delete(`/agents/${agentId}/poll-token`);
1561
+ const creds = loadCredentials(configDir);
1562
+ if (creds[profileName]) {
1563
+ delete creds[profileName].poll_url;
1564
+ saveCredentials(configDir, creds);
1565
+ }
1566
+ printMutationOk("Poll token revoked", gOpts.json);
1567
+ return;
1568
+ }
1569
+ const data = await client.post(`/agents/${agentId}/poll-token`, {});
1570
+ const creds = loadCredentials(configDir);
1571
+ if (creds[profileName]) {
1572
+ creds[profileName].poll_url = data.poll_url;
1573
+ saveCredentials(configDir, creds);
1574
+ }
1575
+ if (gOpts.json) printJson(data);
1576
+ else {
1577
+ console.log(`Poll URL: ${data.poll_url}`);
1578
+ console.log("Credentials updated.");
1579
+ }
1580
+ }));
1581
+ }
1582
+ //#endregion
1455
1583
  //#region src/commands/register.ts
1456
1584
  function registerRegister(program) {
1457
1585
  program.command("register").description("Register a new organization (two-step PoW flow)").requiredOption("--email <email>", "Email address").requiredOption("--name <name>", "Organization name").requiredOption("--slug <slug>", "Organization slug").action(withErrorHandler(program, async (gOpts, opts) => {
@@ -1477,6 +1605,247 @@ function registerRegister(program) {
1477
1605
  }));
1478
1606
  }
1479
1607
  //#endregion
1608
+ //#region src/commands/relay.ts
1609
+ function emitLifecycle$1(json, data) {
1610
+ if (json) console.log(JSON.stringify({
1611
+ event: "lifecycle",
1612
+ data
1613
+ }));
1614
+ }
1615
+ function jitteredDelayMs$1(backoffSeconds) {
1616
+ return Math.round(backoffSeconds * (.5 + Math.random()) * 1e3);
1617
+ }
1618
+ function sleep(ms) {
1619
+ return new Promise((r) => setTimeout(r, ms));
1620
+ }
1621
+ /**
1622
+ * Parse --port into a valid TCP port. `0` is accepted as "OS-assign an ephemeral
1623
+ * port" (it is NOT coerced to the default — that was the L3 footgun); a
1624
+ * non-integer or out-of-range value throws a clear error rather than silently
1625
+ * falling back. The default flag value "8443" parses straight through.
1626
+ */
1627
+ function parsePort(raw) {
1628
+ const port = Number(raw);
1629
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid --port '${raw}': expected an integer 0-65535.`);
1630
+ return port;
1631
+ }
1632
+ function isRevoked(err) {
1633
+ return typeof err === "object" && err !== null && err.revoked === true;
1634
+ }
1635
+ /**
1636
+ * Resolve the HMAC secret: --secret-env > --secret-file > the persisted file.
1637
+ * Returns "" (and warns, naming the three sources tried) when none is found — an
1638
+ * empty secret can never match GitHub's real signature, so every request drops
1639
+ * safely rather than relaying an unverified body.
1640
+ */
1641
+ function resolveSecret(opts, configDir, agentId) {
1642
+ if (opts.secretEnv) {
1643
+ const v = process.env[opts.secretEnv];
1644
+ if (v) return v.trim();
1645
+ }
1646
+ if (opts.secretFile) return readFileSync(opts.secretFile, "utf-8").trim();
1647
+ const stored = loadHookSecret(configDir, agentId, opts.hook);
1648
+ if (stored) return stored;
1649
+ process.stderr.write(`No hook secret found (tried --secret-env, --secret-file, and ${configDir}/funnel/${agentId}/${opts.hook}.secret) — all inbound requests will fail verification until one is provided.\n`);
1650
+ return "";
1651
+ }
1652
+ function registerRelay(program) {
1653
+ program.command("relay").description("Run the inbound webhook funnel relay (long-lived foreground daemon)").option("--agent <id>", "Agent ID (defaults to your agent)").option("--hook <name>", "Hook name to relay", "default").option("--port <n>", "Local TLS listener port", "8443").option("--secret-file <path>", "Read the HMAC secret from a file").option("--secret-env <VAR>", "Read the HMAC secret from an env var").option("--staging", "Use the Let's Encrypt staging CA (dev)").option("--verbose", "Verbose reconnect logging").action(async (opts) => {
1654
+ try {
1655
+ const gOpts = program.opts();
1656
+ const configDir = resolveConfigDir();
1657
+ const apiUrl = resolveApiUrl();
1658
+ const { client, profileName, entry } = await createClient(configDir, apiUrl, gOpts.profile);
1659
+ const agentId = await resolveAgent(apiUrl, await fetchAgents(client), opts.agent, gOpts.as);
1660
+ if (!agentKeysExist(configDir, agentId)) {
1661
+ printError(`Agent identity keys not found under ${configDir}/keys/${agentId}/ — register first.`);
1662
+ process.exitCode = 2;
1663
+ return;
1664
+ }
1665
+ await runRelayLoop({
1666
+ opts,
1667
+ gOpts,
1668
+ configDir,
1669
+ apiUrl,
1670
+ agentId,
1671
+ client,
1672
+ profileName,
1673
+ entry
1674
+ });
1675
+ } catch (err) {
1676
+ printError(err instanceof Error ? err.message : String(err));
1677
+ process.exitCode = 1;
1678
+ }
1679
+ });
1680
+ }
1681
+ async function runRelayLoop(ctx) {
1682
+ const { opts, gOpts, configDir, apiUrl, agentId, client, profileName, entry } = ctx;
1683
+ const json = gOpts.json;
1684
+ const port = parsePort(opts.port);
1685
+ const secret = resolveSecret(opts, configDir, agentId);
1686
+ const options = { staging: opts.staging };
1687
+ let backoff = 1;
1688
+ let attempt = 0;
1689
+ let stopped = false;
1690
+ let lastDeps;
1691
+ let certHandle;
1692
+ process.on("SIGINT", () => {
1693
+ stopped = true;
1694
+ process.exitCode = 0;
1695
+ });
1696
+ while (!stopped) {
1697
+ attempt++;
1698
+ let listener;
1699
+ try {
1700
+ const token = await getOrRefreshToken(configDir, apiUrl, entry, profileName, {
1701
+ force: false,
1702
+ envToken: process.env.RINE_TOKEN
1703
+ });
1704
+ const { hostname, control_ws_url: controlWsUrl } = await resolveHook(client, agentId, opts.hook);
1705
+ certHandle = hookLabel(hostname);
1706
+ lastDeps = await buildIssueCertDeps({
1707
+ configDir,
1708
+ options,
1709
+ http: client,
1710
+ agentId
1711
+ });
1712
+ const cert = await ensureCert({
1713
+ handle: certHandle,
1714
+ agentId,
1715
+ delegationId: certHandle,
1716
+ configDir,
1717
+ options,
1718
+ deps: lastDeps
1719
+ });
1720
+ emitLifecycle$1(json, {
1721
+ state: "cert_ready",
1722
+ hostname
1723
+ });
1724
+ const ref = {};
1725
+ const tunnel = await connectTunnel({
1726
+ controlWsUrl,
1727
+ token,
1728
+ agentId,
1729
+ hostname,
1730
+ openLocalPipe: (frame) => {
1731
+ if (!ref.live) throw new Error("listener not ready");
1732
+ return ref.live.openLocalPipe(frame);
1733
+ }
1734
+ });
1735
+ emitLifecycle$1(json, { state: "tunnel_connected" });
1736
+ const live = await startTlsListener({
1737
+ port,
1738
+ certPem: cert.certPem,
1739
+ keyPem: cert.keyPem,
1740
+ onRequest: (req) => handleWebhookRequest({
1741
+ configDir,
1742
+ agentId,
1743
+ hookName: opts.hook,
1744
+ secret,
1745
+ client,
1746
+ emitLifecycle: (ev) => json && console.log(JSON.stringify(ev))
1747
+ }, req).then(() => void 0)
1748
+ });
1749
+ listener = live;
1750
+ ref.live = live;
1751
+ emitLifecycle$1(json, {
1752
+ state: "listener_ready",
1753
+ port: live.port
1754
+ });
1755
+ backoff = 1;
1756
+ const close = await waitForClose(tunnel, () => stopped);
1757
+ tunnel.close();
1758
+ live.close();
1759
+ if (stopped) break;
1760
+ if (close?.clean !== false) {
1761
+ emitLifecycle$1(json, {
1762
+ state: "tunnel_reconnecting",
1763
+ attempt: attempt + 1,
1764
+ backoff_ms: 0,
1765
+ reason: "server_close"
1766
+ });
1767
+ backoff = 1;
1768
+ } else {
1769
+ const delay = jitteredDelayMs$1(backoff);
1770
+ emitLifecycle$1(json, {
1771
+ state: "tunnel_reconnecting",
1772
+ attempt: attempt + 1,
1773
+ backoff_ms: delay,
1774
+ reason: "error"
1775
+ });
1776
+ await sleep(delay);
1777
+ backoff = Math.min(backoff * 2, 30);
1778
+ }
1779
+ } catch (err) {
1780
+ listener?.close();
1781
+ if (stopped) break;
1782
+ if (isRevoked(err)) {
1783
+ emitLifecycle$1(json, { state: "tunnel_revoked" });
1784
+ if (lastDeps && certHandle) await revokeAndWipeCache({
1785
+ handle: certHandle,
1786
+ configDir,
1787
+ options,
1788
+ deps: lastDeps
1789
+ }).catch(() => {});
1790
+ process.exitCode = 1;
1791
+ return;
1792
+ }
1793
+ const delay = jitteredDelayMs$1(backoff);
1794
+ emitLifecycle$1(json, {
1795
+ state: "tunnel_reconnecting",
1796
+ attempt: attempt + 1,
1797
+ backoff_ms: delay,
1798
+ reason: "error"
1799
+ });
1800
+ if (opts.verbose) process.stderr.write(`Reconnecting (attempt ${attempt + 1}, error): ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
1801
+ await sleep(delay);
1802
+ backoff = Math.min(backoff * 2, 30);
1803
+ }
1804
+ }
1805
+ emitLifecycle$1(json, {
1806
+ state: "stopped",
1807
+ reason: "signal"
1808
+ });
1809
+ }
1810
+ /**
1811
+ * Resolve when the tunnel WS closes (carrying the classified close so the caller
1812
+ * can pick clean-vs-error reconnect) OR a SIGINT arrives (resolves `undefined`;
1813
+ * the caller's `stopped` flag short-circuits before the reconnect branch).
1814
+ */
1815
+ function waitForClose(tunnel, isStopped) {
1816
+ return new Promise((resolve) => {
1817
+ if (isStopped()) return resolve(void 0);
1818
+ tunnel.onClose((close) => resolve(close));
1819
+ process.once("SIGINT", () => {
1820
+ tunnel.close();
1821
+ resolve(void 0);
1822
+ });
1823
+ });
1824
+ }
1825
+ /**
1826
+ * The server hook row — the SINGLE source of truth, used VERBATIM. Its
1827
+ * `hostname` (the `{hook}--{slug}.hook.rine.network` form) goes into the BIND and
1828
+ * (via its label) the cert CN/SAN; its `control_ws_url` is the broker control-WS
1829
+ * endpoint the relay dials. NEITHER is reconstructed client-side: a fabricated
1830
+ * `<uuid>.hook.rine.network` matches no funnel_hook row (BIND would NAK, the cert
1831
+ * would never match GitHub's TLS), and the API base resolves to the primary IP /
1832
+ * Caddy, which has no funnel route. On a missing row or a transient lookup
1833
+ * failure we THROW so the loop backs off and retries.
1834
+ */
1835
+ async function resolveHook(client, agentId, hookName) {
1836
+ const hook = (await client.get(`/agents/${agentId}/funnel/hooks`, {}))?.items?.find((h) => h.hook_name === hookName);
1837
+ if (!hook?.hostname || !hook.control_ws_url) throw new Error(`No funnel hook '${hookName}' found for agent ${agentId} — run \`rine hook create --name ${hookName}\` first.`);
1838
+ return hook;
1839
+ }
1840
+ /**
1841
+ * The DNS LABEL of a hook hostname — everything before `.hook.rine.network`. The
1842
+ * cert is issued for this label via hookFqdn(label), so the leaf's CN/SAN equals
1843
+ * the public hostname GitHub connects to (no name mismatch on the TLS handshake).
1844
+ */
1845
+ function hookLabel(hostname) {
1846
+ return hostname.endsWith(".hook.rine.network") ? hostname.slice(0, -18) : hostname;
1847
+ }
1848
+ //#endregion
1480
1849
  //#region src/commands/stream.ts
1481
1850
  function emitLifecycle(gOpts, monitor, data) {
1482
1851
  if (gOpts.json) {
@@ -1762,8 +2131,10 @@ registerMessages(program);
1762
2131
  registerGroup(program);
1763
2132
  registerDiscover(program);
1764
2133
  registerWebhook(program);
2134
+ registerHook(program);
1765
2135
  registerKeys(program);
1766
2136
  registerStream(program);
2137
+ registerRelay(program);
1767
2138
  registerPollToken(program);
1768
2139
  program.parse();
1769
2140
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rine-network/cli",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "CLI client for rine.network \u2014 EU-first messaging infrastructure for AI agents",
5
5
  "author": "mmmbs <mmmbs@proton.me>",
6
6
  "license": "EUPL-1.2",
@@ -28,7 +28,7 @@
28
28
  "dev": "tsx src/main.ts"
29
29
  },
30
30
  "dependencies": {
31
- "@rine-network/core": "^0.5.1",
31
+ "@rine-network/core": "^0.6.0",
32
32
  "commander": "^12.0.0",
33
33
  "eventsource-client": "^1.1.0"
34
34
  },