@rine-network/cli 0.10.2 → 0.11.1

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 +406 -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, listMyInvites, loadAgentKeys, loadCredentials, loadMlsState, loadTokenCache, normalizeHandle, performAgentCreation, performRegistration, processMlsWelcomes, resolveAgent, resolveApiUrl, resolveConfigDir, resolveHandleViaWebFinger, resolveToUuid, saveAgentKeys, saveCredentials, savePqEncryptionKey, saveTokenCache, sendGroupInviteNotification, 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
@@ -905,6 +906,106 @@ function registerGroup(program) {
905
906
  }));
906
907
  }
907
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
908
1009
  //#region src/commands/keys.ts
909
1010
  function getKeysDir(configDir, agentId) {
910
1011
  return join(configDir, "keys", agentId);
@@ -1352,68 +1453,6 @@ function registerMessages(program) {
1352
1453
  addMessageCommands(program.command("message").description("Message operations (aliases: send/read/inbox/reply)"), program);
1353
1454
  }
1354
1455
  //#endregion
1355
- //#region src/commands/poll-token.ts
1356
- function registerPollToken(program) {
1357
- 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) => {
1358
- const agentId = await resolveAgent(apiUrl, await fetchAgents(client), opts.agent, gOpts.as);
1359
- if (opts.revoke) {
1360
- await client.delete(`/agents/${agentId}/poll-token`);
1361
- const creds = loadCredentials(configDir);
1362
- if (creds[profileName]) {
1363
- delete creds[profileName].poll_url;
1364
- saveCredentials(configDir, creds);
1365
- }
1366
- printMutationOk("Poll token revoked", gOpts.json);
1367
- return;
1368
- }
1369
- const data = await client.post(`/agents/${agentId}/poll-token`, {});
1370
- const creds = loadCredentials(configDir);
1371
- if (creds[profileName]) {
1372
- creds[profileName].poll_url = data.poll_url;
1373
- saveCredentials(configDir, creds);
1374
- }
1375
- if (gOpts.json) printJson(data);
1376
- else {
1377
- console.log(`Poll URL: ${data.poll_url}`);
1378
- console.log("Credentials updated.");
1379
- }
1380
- }));
1381
- }
1382
- //#endregion
1383
- //#region src/commands/org.ts
1384
- function renderOrg(data, opts) {
1385
- if (opts.json) {
1386
- printJson(data);
1387
- return;
1388
- }
1389
- printTable([{
1390
- ID: data.id,
1391
- Name: data.name,
1392
- "Contact Email": data.contact_email ?? "",
1393
- Country: data.country_code ?? "",
1394
- Created: data.created_at
1395
- }]);
1396
- }
1397
- function registerOrg(program) {
1398
- const org = program.command("org").description("Organization management");
1399
- org.command("get").description("Get organization details").action(withClient(program, async ({ client, gOpts }) => {
1400
- renderOrg(await client.get("/org"), gOpts);
1401
- }));
1402
- 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) => {
1403
- const body = {};
1404
- if (opts.name) body.name = opts.name;
1405
- if (opts.contactEmail) body.contact_email = opts.contactEmail;
1406
- if (opts.countryCode) body.country_code = opts.countryCode;
1407
- if (Object.keys(body).length === 0) {
1408
- printError("At least one of --name, --contact-email, --country-code is required");
1409
- process.exitCode = 2;
1410
- return;
1411
- }
1412
- await client.patch("/org", body);
1413
- printMutationOk("Org updated", gOpts.json);
1414
- }));
1415
- }
1416
- //#endregion
1417
1456
  //#region src/commands/onboard.ts
1418
1457
  function registerOnboard(program) {
1419
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) => {
@@ -1479,6 +1518,68 @@ function registerOnboard(program) {
1479
1518
  }));
1480
1519
  }
1481
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
1482
1583
  //#region src/commands/register.ts
1483
1584
  function registerRegister(program) {
1484
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) => {
@@ -1504,6 +1605,246 @@ function registerRegister(program) {
1504
1605
  }));
1505
1606
  }
1506
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 { hostname, control_ws_url: controlWsUrl } = await resolveHook(client, agentId, opts.hook);
1701
+ certHandle = hookLabel(hostname);
1702
+ lastDeps = await buildIssueCertDeps({
1703
+ configDir,
1704
+ options,
1705
+ http: client,
1706
+ agentId
1707
+ });
1708
+ const cert = await ensureCert({
1709
+ handle: certHandle,
1710
+ agentId,
1711
+ delegationId: certHandle,
1712
+ configDir,
1713
+ options,
1714
+ deps: lastDeps
1715
+ });
1716
+ emitLifecycle$1(json, {
1717
+ state: "cert_ready",
1718
+ hostname
1719
+ });
1720
+ const ref = {};
1721
+ const tunnel = await connectTunnel({
1722
+ controlWsUrl,
1723
+ token: await getOrRefreshToken(configDir, apiUrl, entry, profileName, {
1724
+ force: false,
1725
+ envToken: process.env.RINE_TOKEN
1726
+ }),
1727
+ agentId,
1728
+ hostname,
1729
+ openLocalPipe: (frame) => {
1730
+ if (!ref.live) throw new Error("listener not ready");
1731
+ return ref.live.openLocalPipe(frame);
1732
+ }
1733
+ });
1734
+ emitLifecycle$1(json, { state: "tunnel_connected" });
1735
+ const live = await startTlsListener({
1736
+ port,
1737
+ certPem: cert.certPem,
1738
+ keyPem: cert.keyPem,
1739
+ onRequest: (req) => handleWebhookRequest({
1740
+ configDir,
1741
+ agentId,
1742
+ hookName: opts.hook,
1743
+ secret,
1744
+ client,
1745
+ emitLifecycle: (ev) => json && console.log(JSON.stringify(ev))
1746
+ }, req).then(() => void 0)
1747
+ });
1748
+ listener = live;
1749
+ ref.live = live;
1750
+ emitLifecycle$1(json, {
1751
+ state: "listener_ready",
1752
+ port: live.port
1753
+ });
1754
+ backoff = 1;
1755
+ const close = await waitForClose(tunnel, () => stopped);
1756
+ tunnel.close();
1757
+ live.close();
1758
+ if (stopped) break;
1759
+ if (close?.clean !== false) {
1760
+ emitLifecycle$1(json, {
1761
+ state: "tunnel_reconnecting",
1762
+ attempt: attempt + 1,
1763
+ backoff_ms: 0,
1764
+ reason: "server_close"
1765
+ });
1766
+ backoff = 1;
1767
+ } else {
1768
+ const delay = jitteredDelayMs$1(backoff);
1769
+ emitLifecycle$1(json, {
1770
+ state: "tunnel_reconnecting",
1771
+ attempt: attempt + 1,
1772
+ backoff_ms: delay,
1773
+ reason: "error"
1774
+ });
1775
+ await sleep(delay);
1776
+ backoff = Math.min(backoff * 2, 30);
1777
+ }
1778
+ } catch (err) {
1779
+ listener?.close();
1780
+ if (stopped) break;
1781
+ if (isRevoked(err)) {
1782
+ emitLifecycle$1(json, { state: "tunnel_revoked" });
1783
+ if (lastDeps && certHandle) await revokeAndWipeCache({
1784
+ handle: certHandle,
1785
+ configDir,
1786
+ options,
1787
+ deps: lastDeps
1788
+ }).catch(() => {});
1789
+ process.exitCode = 1;
1790
+ return;
1791
+ }
1792
+ const delay = jitteredDelayMs$1(backoff);
1793
+ emitLifecycle$1(json, {
1794
+ state: "tunnel_reconnecting",
1795
+ attempt: attempt + 1,
1796
+ backoff_ms: delay,
1797
+ reason: "error"
1798
+ });
1799
+ if (opts.verbose) process.stderr.write(`Reconnecting (attempt ${attempt + 1}, error): ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
1800
+ await sleep(delay);
1801
+ backoff = Math.min(backoff * 2, 30);
1802
+ }
1803
+ }
1804
+ emitLifecycle$1(json, {
1805
+ state: "stopped",
1806
+ reason: "signal"
1807
+ });
1808
+ }
1809
+ /**
1810
+ * Resolve when the tunnel WS closes (carrying the classified close so the caller
1811
+ * can pick clean-vs-error reconnect) OR a SIGINT arrives (resolves `undefined`;
1812
+ * the caller's `stopped` flag short-circuits before the reconnect branch).
1813
+ */
1814
+ function waitForClose(tunnel, isStopped) {
1815
+ return new Promise((resolve) => {
1816
+ if (isStopped()) return resolve(void 0);
1817
+ tunnel.onClose((close) => resolve(close));
1818
+ process.once("SIGINT", () => {
1819
+ tunnel.close();
1820
+ resolve(void 0);
1821
+ });
1822
+ });
1823
+ }
1824
+ /**
1825
+ * The server hook row — the SINGLE source of truth, used VERBATIM. Its
1826
+ * `hostname` (the `{hook}--{slug}.hook.rine.network` form) goes into the BIND and
1827
+ * (via its label) the cert CN/SAN; its `control_ws_url` is the broker control-WS
1828
+ * endpoint the relay dials. NEITHER is reconstructed client-side: a fabricated
1829
+ * `<uuid>.hook.rine.network` matches no funnel_hook row (BIND would NAK, the cert
1830
+ * would never match GitHub's TLS), and the API base resolves to the primary IP /
1831
+ * Caddy, which has no funnel route. On a missing row or a transient lookup
1832
+ * failure we THROW so the loop backs off and retries.
1833
+ */
1834
+ async function resolveHook(client, agentId, hookName) {
1835
+ const hook = (await client.get(`/agents/${agentId}/funnel/hooks`, {}))?.items?.find((h) => h.hook_name === hookName);
1836
+ 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.`);
1837
+ return hook;
1838
+ }
1839
+ /**
1840
+ * The DNS LABEL of a hook hostname — everything before `.hook.rine.network`. The
1841
+ * cert is issued for this label via hookFqdn(label), so the leaf's CN/SAN equals
1842
+ * the public hostname GitHub connects to (no name mismatch on the TLS handshake).
1843
+ */
1844
+ function hookLabel(hostname) {
1845
+ return hostname.endsWith(".hook.rine.network") ? hostname.slice(0, -18) : hostname;
1846
+ }
1847
+ //#endregion
1507
1848
  //#region src/commands/stream.ts
1508
1849
  function emitLifecycle(gOpts, monitor, data) {
1509
1850
  if (gOpts.json) {
@@ -1789,8 +2130,10 @@ registerMessages(program);
1789
2130
  registerGroup(program);
1790
2131
  registerDiscover(program);
1791
2132
  registerWebhook(program);
2133
+ registerHook(program);
1792
2134
  registerKeys(program);
1793
2135
  registerStream(program);
2136
+ registerRelay(program);
1794
2137
  registerPollToken(program);
1795
2138
  program.parse();
1796
2139
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rine-network/cli",
3
- "version": "0.10.2",
3
+ "version": "0.11.1",
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.2",
31
+ "@rine-network/core": "^0.6.1",
32
32
  "commander": "^12.0.0",
33
33
  "eventsource-client": "^1.1.0"
34
34
  },