@iamken/cloudtunnel 0.9.0 → 0.10.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.
package/dist/index.js CHANGED
@@ -2,31 +2,46 @@
2
2
  import {
3
3
  listZones,
4
4
  resolveZone
5
- } from "./chunk-VR5RHOTJ.js";
5
+ } from "./chunk-NB3OEY2P.js";
6
6
  import {
7
7
  createCname,
8
8
  deleteDnsRecord,
9
9
  findCname,
10
10
  isManagedDns
11
- } from "./chunk-LG5ST75Z.js";
11
+ } from "./chunk-IDTF7SUY.js";
12
12
  import {
13
13
  CliError,
14
+ DEFAULT_API_BASE,
15
+ RELAY_SECRET_HEADER,
14
16
  __export,
15
17
  binDir,
16
18
  cfPaginate,
17
19
  cfRequest,
18
20
  configFile,
21
+ confirm,
22
+ dim,
19
23
  ensureDirs,
24
+ ensureRelaySecret,
25
+ fetchErrorReason,
26
+ formatRoute,
27
+ getApiBase,
20
28
  getCredentials,
29
+ getRelaySecret,
30
+ isHttpUrl,
21
31
  loadConfig,
22
32
  logDir,
33
+ note,
34
+ printTable,
23
35
  profilesFile,
36
+ redactToken,
24
37
  registryFile,
25
38
  reportError,
26
39
  resolveCf,
27
40
  saveConfig,
28
- scanCacheFile
29
- } from "./chunk-3KBLIPKG.js";
41
+ say,
42
+ scanCacheFile,
43
+ selectOne
44
+ } from "./chunk-ZIUYW2HQ.js";
30
45
 
31
46
  // src/index.ts
32
47
  import { Command } from "commander";
@@ -36,50 +51,6 @@ import pc2 from "picocolors";
36
51
  // src/config/legacy-migrate.ts
37
52
  import { existsSync as existsSync3, readFileSync, renameSync, writeFileSync as writeFileSync4 } from "fs";
38
53
 
39
- // src/ui/output.ts
40
- import pc from "picocolors";
41
- import Table from "cli-table3";
42
- import { cancel, confirm as clackConfirm, intro, isCancel, note, outro, select, spinner } from "@clack/prompts";
43
- async function confirm(message) {
44
- const answer = await clackConfirm({ message });
45
- return !isCancel(answer) && answer === true;
46
- }
47
- function redactToken(token) {
48
- if (!token) return "";
49
- const last4 = token.length > 4 ? token.slice(-4) : token;
50
- return `\u2022\u2022\u2022\u2022${last4}`;
51
- }
52
- var say = {
53
- info: (msg) => console.log(msg),
54
- ok: (msg) => console.log(pc.green(`\u2713 ${msg}`)),
55
- warn: (msg) => console.warn(pc.yellow(`! ${msg}`)),
56
- dim: (msg) => console.log(pc.dim(msg)),
57
- step: (msg) => console.log(pc.cyan(`\u2192 ${msg}`))
58
- };
59
- var dim = (s) => pc.dim(s);
60
- function formatRoute(host, target) {
61
- return `${pc.green(pc.bold(`https://${host}`))} ${pc.dim("\u2192")} ${pc.cyan(target)}`;
62
- }
63
- function printTable(head, rows) {
64
- const table = new Table({
65
- head: head.map((h) => pc.bold(h)),
66
- style: { head: [], border: [] }
67
- });
68
- for (const row of rows) table.push(row);
69
- console.log(table.toString());
70
- }
71
- async function selectOne(message, items, label4) {
72
- const value = await select({
73
- message,
74
- options: items.map((item, i) => ({ value: String(i), label: label4(item) }))
75
- });
76
- if (isCancel(value)) {
77
- cancel("Cancelled.");
78
- throw new CliError("Cancelled.", { exitCode: 130 });
79
- }
80
- return items[Number(value)];
81
- }
82
-
83
54
  // src/core/service.ts
84
55
  import { join as join5 } from "path";
85
56
 
@@ -165,6 +136,17 @@ function formatTunnelSpec(s) {
165
136
  var fqdnFor = (subdomain, zone) => subdomain === "@" ? zone : `${subdomain}.${zone}`;
166
137
  var serviceSlug = (fqdn) => fqdn.replace(/[^a-zA-Z0-9]+/g, "-");
167
138
  function buildUpArgs(p) {
139
+ if (p.command === "relay") {
140
+ return [
141
+ "relay",
142
+ p.subdomain,
143
+ "-d",
144
+ p.zone,
145
+ ...p.proto === "https" ? ["--proto", "https"] : [],
146
+ "-f",
147
+ "-y"
148
+ ];
149
+ }
168
150
  const spec = formatTunnelSpec({ subdomain: p.subdomain, port: p.port, host: p.host });
169
151
  return [
170
152
  "up",
@@ -572,15 +554,31 @@ function openBrowser(url) {
572
554
  }
573
555
 
574
556
  // src/config/resolve-identity.ts
575
- var API_BASE = "https://api.cloudflare.com/client/v4";
576
557
  async function cfGet(path, token) {
558
+ const base = getApiBase();
559
+ const viaRelay = base !== DEFAULT_API_BASE;
560
+ const secret = getRelaySecret();
561
+ const headers = {
562
+ Authorization: `Bearer ${token}`,
563
+ "Content-Type": "application/json"
564
+ };
565
+ if (secret && viaRelay) headers[RELAY_SECRET_HEADER] = secret;
577
566
  let res;
578
567
  try {
579
- res = await fetch(`${API_BASE}${path}`, {
580
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
568
+ res = await fetch(`${base}${path}`, { headers });
569
+ } catch (err) {
570
+ const reason = fetchErrorReason(err);
571
+ say.debug(`[cf] GET ${base}${path} -> network error: ${reason}${viaRelay ? " (relay)" : ""}`);
572
+ throw new CliError(`Could not reach the Cloudflare API (${reason})${viaRelay ? ` via relay ${base}` : ""}.`, {
573
+ hint: viaRelay ? "is the relay tunnel up and the base URL correct? run with CLOUDTUNNEL_DEBUG=1" : void 0
574
+ });
575
+ }
576
+ say.debug(`[cf] GET ${base}${path} -> ${res.status}${viaRelay ? " (relay)" : ""}`);
577
+ const body = await res.json().catch(() => ({}));
578
+ if (viaRelay && !res.ok && body.error && !body.errors) {
579
+ throw new CliError(`Relay rejected the request (${res.status}): ${body.error}.`, {
580
+ hint: res.status === 403 ? "does CLOUDTUNNEL_RELAY_SECRET match the relay's secret?" : `relay base: ${base}`
581
581
  });
582
- } catch {
583
- throw new CliError("Could not reach the Cloudflare API (network error).");
584
582
  }
585
583
  if (res.status === 401) {
586
584
  throw new CliError("Cloudflare rejected the token (invalid or expired).", {
@@ -592,7 +590,6 @@ async function cfGet(path, token) {
592
590
  hint: `token needs: ${REQUIRED_SCOPES.join(", ")}`
593
591
  });
594
592
  }
595
- const body = await res.json().catch(() => ({}));
596
593
  if (!res.ok || !body.success) {
597
594
  throw new CliError(`Cloudflare API error (${res.status}) on ${path}.`);
598
595
  }
@@ -638,6 +635,22 @@ async function acquireToken(opts) {
638
635
  return { token, fromEnv: false };
639
636
  }
640
637
  async function runLoginFlow(opts = {}) {
638
+ if (opts.apiBase && !isHttpUrl(opts.apiBase)) {
639
+ throw new CliError(`Invalid --api-base "${opts.apiBase}".`, {
640
+ hint: "must be an http(s) URL, e.g. https://cfapi.example.com/client/v4"
641
+ });
642
+ }
643
+ if (opts.tokenStdin && opts.relaySecretStdin) {
644
+ throw new CliError("Can't read both the token and the relay secret from stdin.", {
645
+ hint: "run login twice, or set one via env (CLOUDFLARE_API_TOKEN / CLOUDTUNNEL_RELAY_SECRET)"
646
+ });
647
+ }
648
+ if (opts.apiBase) process.env.CLOUDTUNNEL_API_BASE = opts.apiBase;
649
+ let relaySecretInput;
650
+ if (opts.relaySecretStdin) {
651
+ relaySecretInput = await readStdin();
652
+ if (relaySecretInput) process.env.CLOUDTUNNEL_RELAY_SECRET = relaySecretInput;
653
+ }
641
654
  if (process.stdout.isTTY) clack.intro("cloudtunnel \xB7 connect to Cloudflare");
642
655
  const { token, fromEnv } = await acquireToken(opts);
643
656
  const spin = clack.spinner();
@@ -660,12 +673,29 @@ async function runLoginFlow(opts = {}) {
660
673
  defaultZone = (await selectOne("Select a default domain", zones, (z) => z.name)).name;
661
674
  }
662
675
  }
663
- saveConfig({ apiToken: fromEnv ? void 0 : token, accountId: account.id, defaultZone });
676
+ saveConfig(buildMergedConfig(loadConfig(), {
677
+ token,
678
+ fromEnv,
679
+ accountId: account.id,
680
+ defaultZone,
681
+ apiBase: opts.apiBase,
682
+ relaySecret: relaySecretInput
683
+ }));
664
684
  const summary = `Logged in as ${account.name}${defaultZone ? ` \xB7 default domain ${defaultZone}` : ""}`;
665
685
  if (process.stdout.isTTY) clack.outro(summary);
666
686
  else say.ok(summary);
667
687
  if (!defaultZone) say.dim("No default domain set \u2014 pass -d <domain> on `up`, or re-run `login --zone <domain>`.");
668
688
  }
689
+ function buildMergedConfig(prev, args) {
690
+ return {
691
+ ...prev,
692
+ apiToken: args.fromEnv ? void 0 : args.token,
693
+ accountId: args.accountId,
694
+ defaultZone: args.defaultZone ?? prev.defaultZone,
695
+ apiBase: args.apiBase ?? prev.apiBase,
696
+ relaySecret: args.relaySecret ?? prev.relaySecret
697
+ };
698
+ }
669
699
  function showStatus() {
670
700
  const config = loadConfig();
671
701
  const token = process.env.CLOUDFLARE_API_TOKEN ?? config.apiToken;
@@ -677,10 +707,14 @@ function showStatus() {
677
707
  say.info(`Token: ${redactToken(token)} (${source})`);
678
708
  say.info(`Account: ${config.accountId ?? "(from env / unresolved)"}`);
679
709
  say.info(`Domain: ${config.defaultZone ?? "(none)"}`);
710
+ const baseSrc = process.env.CLOUDTUNNEL_API_BASE ? "env" : config.apiBase ? "config" : "default";
711
+ say.info(`Base: ${getApiBase()} (${baseSrc})`);
712
+ const secretSrc = process.env.CLOUDTUNNEL_RELAY_SECRET ? "env" : config.relaySecret ? "config" : void 0;
713
+ say.info(`Relay secret: ${secretSrc ? `set (${secretSrc})` : "(none)"}`);
680
714
  say.dim(`Config: ${configFile}`);
681
715
  }
682
716
  function registerLogin(program) {
683
- program.command("login").description("Authenticate with Cloudflare (paste a token once; account + domain auto-resolved)").option("--token-stdin", "read the API token from stdin (scriptable, avoids shell history)").option("--token <token>", "[discouraged] token as an argument (leaks into shell history)").option("--account <id>", "Cloudflare account id (auto-resolved when you have one account)").option("--zone <domain>", "default domain for new tunnels (auto-resolved when you have one)").option("--status", "show current identity (redacted) and exit").action(async (opts) => {
717
+ program.command("login").description("Authenticate with Cloudflare (paste a token once; account + domain auto-resolved)").option("--token-stdin", "read the API token from stdin (scriptable, avoids shell history)").option("--token <token>", "[discouraged] token as an argument (leaks into shell history)").option("--account <id>", "Cloudflare account id (auto-resolved when you have one account)").option("--zone <domain>", "default domain for new tunnels (auto-resolved when you have one)").option("--api-base <url>", "route the CF API through a relay (when api.cloudflare.com is blocked)").option("--relay-secret-stdin", "read the relay shared secret from stdin (pairs with --api-base)").option("--status", "show current identity (redacted) and exit").action(async (opts) => {
684
718
  if (opts.status) return showStatus();
685
719
  await runLoginFlow(opts);
686
720
  });
@@ -1301,8 +1335,8 @@ async function resolveRemoteTarget(cf, target) {
1301
1335
  }
1302
1336
  const tunnel = matches[0];
1303
1337
  if (!tunnel) return null;
1304
- const { listCargoCnames } = await import("./dns-C7WXQOED.js");
1305
- const { listZones: listZones3 } = await import("./zones-KPRAW4JL.js");
1338
+ const { listCargoCnames } = await import("./dns-2F5SYQNX.js");
1339
+ const { listZones: listZones3 } = await import("./zones-PCUEPXHD.js");
1306
1340
  for (const zone of await listZones3(cf.token)) {
1307
1341
  const rec = (await listCargoCnames(cf.token, zone.id)).find((r) => tunnelIdFromCname2(r.content) === tunnel.id);
1308
1342
  if (rec) return { tunnel, fqdn: rec.name };
@@ -1339,8 +1373,8 @@ async function listAll(cf, opts = {}) {
1339
1373
  };
1340
1374
  });
1341
1375
  if (opts.all) {
1342
- const { listCargoCnames } = await import("./dns-C7WXQOED.js");
1343
- const { listZones: listZones3 } = await import("./zones-KPRAW4JL.js");
1376
+ const { listCargoCnames } = await import("./dns-2F5SYQNX.js");
1377
+ const { listZones: listZones3 } = await import("./zones-PCUEPXHD.js");
1344
1378
  const tracked = new Set(entries.map(entryFqdn));
1345
1379
  const unmanaged = [];
1346
1380
  for (const zone of await listZones3(cf.token)) {
@@ -1433,6 +1467,17 @@ async function startTunnels(cf, bin, items, opts = {}) {
1433
1467
  say.dim("Ctrl-C stops and releases them.");
1434
1468
  }
1435
1469
 
1470
+ // src/core/resolve-domain.ts
1471
+ async function resolveDomain(cf, opts, creds) {
1472
+ if (opts.domain) return opts.domain;
1473
+ const zones = await listZones(cf.token);
1474
+ if (zones.length === 0) throw new CliError("No domains found in this Cloudflare account.");
1475
+ if (zones.length === 1) return zones[0].name;
1476
+ if (process.stdin.isTTY) return (await selectOne("Choose a domain", zones, (z) => z.name)).name;
1477
+ if (creds.defaultZone) return creds.defaultZone;
1478
+ throw new CliError("Multiple domains in this account \u2014 pick one.", { hint: "pass -d <domain>" });
1479
+ }
1480
+
1436
1481
  // src/core/transport-protocol.ts
1437
1482
  function parseTransportProtocol(value) {
1438
1483
  if (value === "auto" || value === "http2" || value === "quic") return value;
@@ -1461,15 +1506,6 @@ async function promptPort() {
1461
1506
  );
1462
1507
  return Number(input);
1463
1508
  }
1464
- async function resolveDomain(cf, opts, creds) {
1465
- if (opts.domain) return opts.domain;
1466
- const zones = await listZones(cf.token);
1467
- if (zones.length === 0) throw new CliError("No domains found in this Cloudflare account.");
1468
- if (zones.length === 1) return zones[0].name;
1469
- if (process.stdin.isTTY) return (await selectOne("Choose a domain", zones, (z) => z.name)).name;
1470
- if (creds.defaultZone) return creds.defaultZone;
1471
- throw new CliError("Multiple domains in this account \u2014 pick one.", { hint: "pass -d <domain>" });
1472
- }
1473
1509
  async function resolveSpecSubdomain(spec, opts) {
1474
1510
  if (spec.subdomain !== void 0) return spec.subdomain;
1475
1511
  if (opts.yes || !process.stdin.isTTY) return void 0;
@@ -1673,6 +1709,182 @@ function registerLogs(program) {
1673
1709
  });
1674
1710
  }
1675
1711
 
1712
+ // src/commands/relay.ts
1713
+ import { openSync as openSync3 } from "fs";
1714
+ import { spawn as spawn3 } from "child_process";
1715
+ import { join as join8 } from "path";
1716
+ import pc from "picocolors";
1717
+
1718
+ // src/core/api-proxy-server.ts
1719
+ import http from "http";
1720
+ var DEFAULT_UPSTREAM = "https://api.cloudflare.com";
1721
+ var SECRET_HEADER_LC = RELAY_SECRET_HEADER.toLowerCase();
1722
+ var HOP_BY_HOP = /* @__PURE__ */ new Set([
1723
+ "connection",
1724
+ "keep-alive",
1725
+ "proxy-authenticate",
1726
+ "proxy-authorization",
1727
+ "te",
1728
+ "trailer",
1729
+ "transfer-encoding",
1730
+ "upgrade",
1731
+ "host",
1732
+ "content-length"
1733
+ ]);
1734
+ function send(res, code, body) {
1735
+ res.writeHead(code, { "content-type": "application/json" });
1736
+ res.end(body === void 0 ? "" : JSON.stringify(body));
1737
+ }
1738
+ function startProxy(opts) {
1739
+ const upstream = opts.upstream ?? DEFAULT_UPSTREAM;
1740
+ const upstreamOrigin = new URL(upstream).origin;
1741
+ const log = opts.log ?? (() => {
1742
+ });
1743
+ const server = http.createServer((req, res) => {
1744
+ handle(req, res).catch(() => {
1745
+ if (!res.headersSent) send(res, 500, { error: "relay internal error" });
1746
+ else res.end();
1747
+ });
1748
+ });
1749
+ async function handle(req, res) {
1750
+ const trace = (status, note4) => log(`[relay] ${status} ${req.method} ${req.url ?? "-"}${note4 ? " " + note4 : ""}`);
1751
+ if (req.method === "CONNECT" || !req.url || !req.url.startsWith("/")) {
1752
+ trace(400, "bad-form");
1753
+ return send(res, 400, { error: "origin-form request required" });
1754
+ }
1755
+ let target;
1756
+ try {
1757
+ target = new URL(req.url, upstream);
1758
+ } catch {
1759
+ trace(400, "bad-path");
1760
+ return send(res, 400, { error: "bad request path" });
1761
+ }
1762
+ if (target.origin !== upstreamOrigin) {
1763
+ trace(400, "ssrf");
1764
+ return send(res, 400, { error: "path escapes upstream" });
1765
+ }
1766
+ if (req.headers[SECRET_HEADER_LC] !== opts.secret) {
1767
+ trace(403, "secret");
1768
+ return send(res, 403, { error: "relay secret missing or invalid" });
1769
+ }
1770
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
1771
+ let body;
1772
+ if (hasBody) {
1773
+ const chunks = [];
1774
+ for await (const c of req) chunks.push(c);
1775
+ body = chunks.length ? Buffer.concat(chunks) : void 0;
1776
+ }
1777
+ const headers = {};
1778
+ for (const [k, v] of Object.entries(req.headers)) {
1779
+ if (v === void 0) continue;
1780
+ const lk = k.toLowerCase();
1781
+ if (HOP_BY_HOP.has(lk) || lk === SECRET_HEADER_LC) continue;
1782
+ headers[k] = Array.isArray(v) ? v.join(", ") : v;
1783
+ }
1784
+ let up;
1785
+ try {
1786
+ up = await fetch(target.href, { method: req.method, headers, body, redirect: "manual" });
1787
+ } catch {
1788
+ trace(502, "upstream-error");
1789
+ return send(res, 502, { error: "relay upstream unreachable" });
1790
+ }
1791
+ trace(up.status);
1792
+ const outHeaders = {
1793
+ "content-type": up.headers.get("content-type") ?? "application/json"
1794
+ };
1795
+ const retryAfter = up.headers.get("retry-after");
1796
+ if (retryAfter) outHeaders["retry-after"] = retryAfter;
1797
+ res.writeHead(up.status, outHeaders);
1798
+ res.end(Buffer.from(await up.arrayBuffer()));
1799
+ }
1800
+ return new Promise((resolve, reject) => {
1801
+ server.once("error", reject);
1802
+ server.listen(0, "127.0.0.1", () => {
1803
+ const port = server.address().port;
1804
+ resolve({
1805
+ port,
1806
+ url: `http://127.0.0.1:${port}`,
1807
+ close: () => new Promise((res) => server.close(() => res()))
1808
+ });
1809
+ });
1810
+ });
1811
+ }
1812
+
1813
+ // src/commands/relay.ts
1814
+ var DEFAULT_SUB = "cfapi";
1815
+ function fqdnFor2(sub, domain) {
1816
+ return sub === "@" ? domain : `${sub}.${domain}`;
1817
+ }
1818
+ function spawnDetachedRelay(sub, domain) {
1819
+ const script = process.argv[1];
1820
+ if (!script) throw new CliError("Cannot resolve the cloudtunnel executable path.");
1821
+ ensureDirs();
1822
+ const logFile = join8(logDir, `relay-${sub === "@" ? "root" : sub}.log`);
1823
+ const fd = openSync3(logFile, "a", 384);
1824
+ const args = [script, "relay", sub, "-d", domain, "-f", "-y"];
1825
+ const child = spawn3(process.execPath, args, { detached: true, stdio: ["ignore", fd, fd] });
1826
+ child.unref();
1827
+ }
1828
+ function relayReadyLines(fqdn, secret, tty) {
1829
+ const url = `https://${fqdn}`;
1830
+ if (!tty) return [`relay ready at ${url}`];
1831
+ const base = `${url}/client/v4`;
1832
+ return [
1833
+ `URL ${pc.green(url)}`,
1834
+ `Secret ${pc.bold(secret)} ${pc.dim("(store it \u2014 the client needs it, shown once)")}`,
1835
+ "",
1836
+ pc.bold("On the blocked client:"),
1837
+ ` export CLOUDTUNNEL_API_BASE=${base}`,
1838
+ ` export CLOUDTUNNEL_RELAY_SECRET=${secret}`,
1839
+ ` printf %s "$CF_TOKEN" | cloudtunnel login --token-stdin`,
1840
+ pc.dim(" (mint the CF token on an unblocked host \u2014 dash.cloudflare.com is blocked too)")
1841
+ ];
1842
+ }
1843
+ function printRelayReady(fqdn, secret, kind) {
1844
+ const tty = !!process.stdout.isTTY;
1845
+ const lines = relayReadyLines(fqdn, secret, tty);
1846
+ if (tty) note(lines.join("\n"), "relay ready");
1847
+ else say.dim(lines[0]);
1848
+ if (tty && kind === "detach") say.dim(" \u2192 running in background \xB7 stop with: cloudtunnel delete " + fqdn);
1849
+ if (tty && kind === "service") say.dim(" \u2192 boot service installed \xB7 view: cloudtunnel ls \xB7 remove: cloudtunnel delete " + fqdn);
1850
+ }
1851
+ async function runRelay(sub, opts) {
1852
+ const creds = await ensureAuth();
1853
+ const cf = resolveCf();
1854
+ const domain = await resolveDomain(cf, opts, creds);
1855
+ const fqdn = fqdnFor2(sub, domain);
1856
+ if (opts.service) {
1857
+ assertServiceSupported();
1858
+ const secret2 = ensureRelaySecret();
1859
+ installServiceForSpec({ command: "relay", subdomain: sub, port: 0, zone: domain, proto: "http" });
1860
+ printRelayReady(fqdn, secret2, "service");
1861
+ return;
1862
+ }
1863
+ if (opts.detach) {
1864
+ const secret2 = ensureRelaySecret();
1865
+ spawnDetachedRelay(sub, domain);
1866
+ printRelayReady(fqdn, secret2, "detach");
1867
+ return;
1868
+ }
1869
+ const bin = await ensureCloudflared();
1870
+ const secret = ensureRelaySecret();
1871
+ const proxy = await startProxy({ secret, log: (line) => say.dim(line) });
1872
+ const item = {
1873
+ port: proxy.port,
1874
+ proto: "http",
1875
+ name: sub,
1876
+ zone: domain,
1877
+ defaultZone: creds.defaultZone,
1878
+ force: opts.force,
1879
+ yes: opts.yes
1880
+ };
1881
+ await startTunnels(cf, bin, [item], {});
1882
+ printRelayReady(fqdn, secret, "foreground");
1883
+ }
1884
+ function registerRelay(program) {
1885
+ program.command("relay [subdomain]").description("Expose a locked Cloudflare-API reverse proxy via a tunnel (for clients that can't reach api.cloudflare.com directly)").option("-d, --domain <domain>", "domain for the relay subdomain (prompted from a list if unset)").option("--detach", "run the relay in the background").option("--service", "register the relay as a boot service (systemd \xB7 launchd \xB7 Task Scheduler)").option("-f, --force", "replace a non-tunnel DNS record occupying the hostname").option("-y, --yes", "don't prompt before replacing an existing record").action((subdomain, opts) => runRelay(subdomain ?? DEFAULT_SUB, opts));
1886
+ }
1887
+
1676
1888
  // src/index.ts
1677
1889
  var require2 = createRequire(import.meta.url);
1678
1890
  var pkg = require2("../package.json");
@@ -1690,7 +1902,7 @@ function buildProgram() {
1690
1902
  ""
1691
1903
  ].join("\n")
1692
1904
  );
1693
- for (const register of [registerLogin, registerUp, registerLs, registerDelete, registerLogs]) {
1905
+ for (const register of [registerLogin, registerUp, registerLs, registerDelete, registerLogs, registerRelay]) {
1694
1906
  register(program);
1695
1907
  }
1696
1908
  return program;