@iamken/cloudtunnel 0.2.0 → 0.4.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.
package/dist/index.js CHANGED
@@ -31,8 +31,8 @@ import { Command } from "commander";
31
31
  import { createRequire } from "module";
32
32
  import pc2 from "picocolors";
33
33
 
34
- // src/commands/login.ts
35
- import * as clack from "@clack/prompts";
34
+ // src/config/legacy-migrate.ts
35
+ import { existsSync as existsSync2, readFileSync, renameSync, writeFileSync as writeFileSync2 } from "fs";
36
36
 
37
37
  // src/ui/output.ts
38
38
  import pc from "picocolors";
@@ -78,6 +78,250 @@ async function selectOne(message, items, label) {
78
78
  return items[Number(value)];
79
79
  }
80
80
 
81
+ // src/core/systemd.ts
82
+ import { execFileSync } from "child_process";
83
+ import { existsSync, realpathSync, writeFileSync } from "fs";
84
+ import os, { tmpdir } from "os";
85
+ import { dirname, join } from "path";
86
+
87
+ // src/core/ingress.ts
88
+ var HOSTNAME_RE = /^[a-zA-Z0-9.-]+$/;
89
+ var IPV6_RE = /^[0-9a-fA-F:.]+$/;
90
+ function validateHost(host) {
91
+ let h = host.trim();
92
+ const bracketed = h.startsWith("[") && h.endsWith("]");
93
+ if (bracketed) h = h.slice(1, -1);
94
+ const isV6 = bracketed || h.includes("::") || (h.match(/:/g)?.length ?? 0) >= 2;
95
+ const ok = h.length > 0 && (isV6 ? IPV6_RE.test(h) : HOSTNAME_RE.test(h));
96
+ if (!ok) {
97
+ throw new CliError(`Invalid host "${host}".`, {
98
+ hint: "use a hostname, IPv4, or IPv6 literal (e.g. 192.168.1.5 or ::1) \u2014 no port, scheme, or path"
99
+ });
100
+ }
101
+ return h;
102
+ }
103
+ function serviceUrl(proto, host, port) {
104
+ const authority = host.includes(":") ? `[${host}]` : host;
105
+ return `${proto}://${authority}:${port}`;
106
+ }
107
+ function buildIngress(opts) {
108
+ return [
109
+ { hostname: opts.hostname, service: serviceUrl(opts.proto, opts.host ?? "localhost", opts.port) },
110
+ { service: "http_status:404" }
111
+ ];
112
+ }
113
+
114
+ // src/core/tunnel-spec.ts
115
+ function parseTunnelSpec(spec) {
116
+ const raw = spec.trim();
117
+ const bad = (hint) => new CliError(`Invalid spec "${spec}".`, { hint });
118
+ if (!raw) throw bad("use [subdomain:]port[@host], e.g. api:8080 or api:8080@192.168.1.20");
119
+ let rest = raw;
120
+ let subdomain;
121
+ if (rest.startsWith("@")) {
122
+ subdomain = "@";
123
+ rest = rest.slice(1);
124
+ if (rest.startsWith(":")) rest = rest.slice(1);
125
+ }
126
+ let host;
127
+ const at = rest.indexOf("@");
128
+ if (at >= 0) {
129
+ host = validateHost(rest.slice(at + 1));
130
+ rest = rest.slice(0, at);
131
+ }
132
+ const parts = rest.split(":");
133
+ let portStr;
134
+ if (parts.length === 1) {
135
+ portStr = parts[0];
136
+ } else if (parts.length === 2) {
137
+ if (subdomain === void 0) {
138
+ if (!parts[0]) throw bad("subdomain label is empty");
139
+ subdomain = parts[0];
140
+ } else if (parts[0]) {
141
+ throw bad("unexpected label after '@' root marker");
142
+ }
143
+ portStr = parts[1];
144
+ } else {
145
+ throw bad("too many ':' \u2014 spec is [subdomain:]port[@host] (protocol via --proto)");
146
+ }
147
+ const port = Number(portStr);
148
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
149
+ throw bad("port must be a number 1\u201365535");
150
+ }
151
+ if (subdomain !== void 0 && subdomain !== "@" && !/^[a-zA-Z0-9-]+$/.test(subdomain)) {
152
+ throw bad("subdomain may contain only letters, digits, and hyphens");
153
+ }
154
+ return { subdomain, port, ...host ? { host } : {} };
155
+ }
156
+ function formatTunnelSpec(s) {
157
+ return `${s.subdomain}:${s.port}${s.host ? `@${s.host}` : ""}`;
158
+ }
159
+
160
+ // src/core/systemd.ts
161
+ function serviceName(fqdn) {
162
+ return `cloudtunnel-${fqdn.replace(/[^a-zA-Z0-9]+/g, "-")}.service`;
163
+ }
164
+ function unitPath(fqdn) {
165
+ return `/etc/systemd/system/${serviceName(fqdn)}`;
166
+ }
167
+ function buildUnit(p) {
168
+ const nodeBin = dirname(p.nodePath);
169
+ const proto = p.proto === "https" ? " --proto https" : "";
170
+ const protocol = p.protocol ? ` --protocol ${p.protocol}` : "";
171
+ return [
172
+ "[Unit]",
173
+ `Description=cloudtunnel ${p.fqdn} (Cloudflare Tunnel)`,
174
+ "After=network-online.target",
175
+ "Wants=network-online.target",
176
+ "",
177
+ "[Service]",
178
+ "Type=simple",
179
+ `User=${p.user}`,
180
+ `Environment=HOME=${p.home}`,
181
+ `Environment=PATH=${nodeBin}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
182
+ `ExecStart=${p.nodePath} ${p.scriptPath} up ${p.spec} -d ${p.zone}${proto}${protocol} -f -y`,
183
+ "Restart=on-failure",
184
+ "RestartSec=5",
185
+ "",
186
+ "[Install]",
187
+ "WantedBy=multi-user.target",
188
+ ""
189
+ ].join("\n");
190
+ }
191
+ function assertSystemd() {
192
+ if (process.platform !== "linux") {
193
+ throw new CliError("Service registration is Linux/systemd only.", {
194
+ hint: "on macOS/Windows run `cloudtunnel up <spec> --detach` at login instead"
195
+ });
196
+ }
197
+ try {
198
+ execFileSync("systemctl", ["--version"], { stdio: "ignore" });
199
+ } catch {
200
+ throw new CliError("systemd (systemctl) was not found on this host.");
201
+ }
202
+ }
203
+ function privileged(args) {
204
+ const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
205
+ const argv = isRoot ? args : ["sudo", ...args];
206
+ execFileSync(argv[0], argv.slice(1), { stdio: "inherit" });
207
+ }
208
+ function query(args) {
209
+ try {
210
+ return execFileSync("systemctl", args, { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim();
211
+ } catch (err) {
212
+ const out = err.stdout;
213
+ return out ? out.toString().trim() : "";
214
+ }
215
+ }
216
+ function entryScript() {
217
+ const p = process.argv[1];
218
+ if (!p) throw new CliError("Cannot resolve the cloudtunnel executable path.");
219
+ return realpathSync(p);
220
+ }
221
+ function installServiceForSpec(params) {
222
+ assertSystemd();
223
+ const fqdn = params.subdomain === "@" ? params.zone : `${params.subdomain}.${params.zone}`;
224
+ const unit = buildUnit({
225
+ fqdn,
226
+ spec: formatTunnelSpec({ subdomain: params.subdomain, port: params.port, host: params.host }),
227
+ zone: params.zone,
228
+ proto: params.proto,
229
+ user: os.userInfo().username,
230
+ home: os.homedir(),
231
+ nodePath: process.execPath,
232
+ scriptPath: entryScript(),
233
+ protocol: params.protocol
234
+ });
235
+ const tmp = join(tmpdir(), serviceName(fqdn));
236
+ writeFileSync(tmp, unit, { mode: 420 });
237
+ privileged(["install", "-m", "0644", tmp, unitPath(fqdn)]);
238
+ privileged(["systemctl", "daemon-reload"]);
239
+ privileged(["systemctl", "enable", "--now", serviceName(fqdn)]);
240
+ }
241
+ function uninstallService(fqdn) {
242
+ assertSystemd();
243
+ try {
244
+ privileged(["systemctl", "disable", "--now", serviceName(fqdn)]);
245
+ } catch {
246
+ }
247
+ privileged(["rm", "-f", unitPath(fqdn)]);
248
+ privileged(["systemctl", "daemon-reload"]);
249
+ }
250
+ function legacyUnitExists(profile) {
251
+ return existsSync(`/etc/systemd/system/cloudtunnel-${profile}.service`);
252
+ }
253
+ function removeLegacyUnit(profile) {
254
+ const name = `cloudtunnel-${profile}.service`;
255
+ try {
256
+ privileged(["systemctl", "disable", "--now", name]);
257
+ } catch {
258
+ }
259
+ privileged(["rm", "-f", `/etc/systemd/system/${name}`]);
260
+ privileged(["systemctl", "daemon-reload"]);
261
+ }
262
+ function serviceState(fqdn) {
263
+ if (process.platform !== "linux") return "none";
264
+ const name = serviceName(fqdn);
265
+ if (query(["is-active", name]) === "active") return "active";
266
+ const enabled = query(["is-enabled", name]);
267
+ if (enabled === "enabled" || enabled === "enabled-runtime") return "enabled";
268
+ if (enabled === "disabled" || enabled === "static") return "disabled";
269
+ return "none";
270
+ }
271
+
272
+ // src/config/legacy-migrate.ts
273
+ var skipMarker = `${profilesFile}.migrate-skip`;
274
+ async function migrateLegacyProfiles() {
275
+ if (!existsSync2(profilesFile) || existsSync2(skipMarker)) return;
276
+ let profiles;
277
+ try {
278
+ profiles = JSON.parse(readFileSync(profilesFile, "utf8"));
279
+ } catch {
280
+ return;
281
+ }
282
+ const legacy = Object.entries(profiles).filter(([name]) => legacyUnitExists(name));
283
+ if (legacy.length === 0) {
284
+ try {
285
+ renameSync(profilesFile, `${profilesFile}.migrated`);
286
+ } catch {
287
+ }
288
+ return;
289
+ }
290
+ const ok = await confirm(`Found ${legacy.length} boot service(s) from an older cloudtunnel. Migrate them now? (needs sudo)`);
291
+ if (!ok) {
292
+ writeFileSync2(skipMarker, "");
293
+ say.dim(` Skipped. Delete ${skipMarker} to be asked again.`);
294
+ return;
295
+ }
296
+ let migrated = 0;
297
+ try {
298
+ for (const [name, profile] of legacy) {
299
+ for (const svc of profile.services ?? []) {
300
+ const zone = svc.domain ?? profile.domain;
301
+ if (!zone) continue;
302
+ installServiceForSpec({
303
+ subdomain: svc.name,
304
+ port: svc.port,
305
+ host: svc.host,
306
+ zone,
307
+ proto: svc.proto,
308
+ protocol: profile.protocol
309
+ });
310
+ migrated++;
311
+ }
312
+ removeLegacyUnit(name);
313
+ }
314
+ renameSync(profilesFile, `${profilesFile}.migrated`);
315
+ say.ok(`Migrated ${migrated} boot service(s). See them with: cloudtunnel ls`);
316
+ } catch (err) {
317
+ writeFileSync2(skipMarker, "");
318
+ say.warn(`Migration incomplete: ${err.message}. Won't retry automatically (delete ${skipMarker} to retry).`);
319
+ }
320
+ }
321
+
322
+ // src/commands/login.ts
323
+ import * as clack from "@clack/prompts";
324
+
81
325
  // src/config/token-url.ts
82
326
  import { spawn } from "child_process";
83
327
  var REQUIRED_SCOPES = [
@@ -217,9 +461,7 @@ function registerLogin(program) {
217
461
  }
218
462
 
219
463
  // src/commands/up.ts
220
- import { join as join2 } from "path";
221
- import { readFileSync as readFileSync4 } from "fs";
222
- import * as clack2 from "@clack/prompts";
464
+ import * as clack3 from "@clack/prompts";
223
465
 
224
466
  // src/config/ensure-auth.ts
225
467
  async function ensureAuth() {
@@ -236,10 +478,10 @@ async function ensureAuth() {
236
478
  }
237
479
 
238
480
  // src/connector/binary.ts
239
- import { execFileSync } from "child_process";
481
+ import { execFileSync as execFileSync2 } from "child_process";
240
482
  import { createHash } from "crypto";
241
- import { chmodSync, existsSync, readFileSync, writeFileSync } from "fs";
242
- import { join } from "path";
483
+ import { chmodSync, existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
484
+ import { join as join2 } from "path";
243
485
  var PINNED_VERSION = "2025.1.0";
244
486
  var RELEASE_BASE = `https://github.com/cloudflare/cloudflared/releases/download/${PINNED_VERSION}`;
245
487
  var ASSETS = {
@@ -251,18 +493,18 @@ var ASSETS = {
251
493
  };
252
494
  function binaryWorks(bin) {
253
495
  try {
254
- execFileSync(bin, ["--version"], { stdio: "ignore" });
496
+ execFileSync2(bin, ["--version"], { stdio: "ignore" });
255
497
  return true;
256
498
  } catch {
257
499
  return false;
258
500
  }
259
501
  }
260
502
  function cachedPath() {
261
- return join(binDir, process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
503
+ return join2(binDir, process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
262
504
  }
263
505
  function isMusl() {
264
506
  try {
265
- return process.platform === "linux" && readFileSync("/usr/bin/ldd", "utf8").includes("musl");
507
+ return process.platform === "linux" && readFileSync2("/usr/bin/ldd", "utf8").includes("musl");
266
508
  } catch {
267
509
  return false;
268
510
  }
@@ -270,7 +512,7 @@ function isMusl() {
270
512
  async function ensureCloudflared() {
271
513
  if (binaryWorks("cloudflared")) return "cloudflared";
272
514
  const cached = cachedPath();
273
- if (existsSync(cached) && binaryWorks(cached)) return cached;
515
+ if (existsSync3(cached) && binaryWorks(cached)) return cached;
274
516
  return downloadCloudflared(cached);
275
517
  }
276
518
  async function downloadCloudflared(dest) {
@@ -298,7 +540,7 @@ async function downloadCloudflared(dest) {
298
540
  }
299
541
  ensureDirs();
300
542
  const binary = asset.archive ? extractTgz(bytes) : bytes;
301
- writeFileSync(dest, binary, { mode: 493 });
543
+ writeFileSync3(dest, binary, { mode: 493 });
302
544
  chmodSync(dest, 493);
303
545
  if (!binaryWorks(dest)) throw new CliError("Downloaded cloudflared is not runnable.");
304
546
  return dest;
@@ -309,26 +551,33 @@ function extractTgz(_bytes) {
309
551
  });
310
552
  }
311
553
 
554
+ // src/core/up-runner.ts
555
+ import { join as join3 } from "path";
556
+ import * as clack2 from "@clack/prompts";
557
+
312
558
  // src/connector/process.ts
313
- import { execFileSync as execFileSync2, spawn as spawn2 } from "child_process";
559
+ import { execFileSync as execFileSync3, spawn as spawn2 } from "child_process";
314
560
  import { openSync } from "fs";
315
561
 
316
562
  // src/connector/registry.ts
317
- import { existsSync as existsSync2, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "fs";
563
+ import { existsSync as existsSync4, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync4 } from "fs";
318
564
  import { readFile } from "fs/promises";
319
- import os from "os";
565
+ import os2 from "os";
320
566
  import lockfile from "proper-lockfile";
567
+ function entryFqdn(e) {
568
+ return e.subdomain === "@" ? e.zone : `${e.subdomain}.${e.zone}`;
569
+ }
321
570
  function currentBootId() {
322
571
  try {
323
- return readFileSync2("/proc/sys/kernel/random/boot_id", "utf8").trim();
572
+ return readFileSync3("/proc/sys/kernel/random/boot_id", "utf8").trim();
324
573
  } catch {
325
- const bootMinute = Math.floor((Date.now() - os.uptime() * 1e3) / 6e4);
326
- return `boot-${bootMinute}-${os.hostname()}`;
574
+ const bootMinute = Math.floor((Date.now() - os2.uptime() * 1e3) / 6e4);
575
+ return `boot-${bootMinute}-${os2.hostname()}`;
327
576
  }
328
577
  }
329
578
  function readRegistry() {
330
579
  try {
331
- return JSON.parse(readFileSync2(registryFile, "utf8"));
580
+ return JSON.parse(readFileSync3(registryFile, "utf8"));
332
581
  } catch {
333
582
  return {};
334
583
  }
@@ -336,12 +585,12 @@ function readRegistry() {
336
585
  function writeRegistry(reg) {
337
586
  ensureDirs();
338
587
  const tmp = `${registryFile}.tmp`;
339
- writeFileSync2(tmp, JSON.stringify(reg, null, 2), { mode: 384 });
340
- renameSync(tmp, registryFile);
588
+ writeFileSync4(tmp, JSON.stringify(reg, null, 2), { mode: 384 });
589
+ renameSync2(tmp, registryFile);
341
590
  }
342
591
  async function mutateRegistry(fn) {
343
592
  ensureDirs();
344
- if (!existsSync2(registryFile)) writeFileSync2(registryFile, "{}", { mode: 384 });
593
+ if (!existsSync4(registryFile)) writeFileSync4(registryFile, "{}", { mode: 384 });
345
594
  const release = await lockfile.lock(registryFile, { retries: { retries: 10, minTimeout: 50 } });
346
595
  try {
347
596
  const reg = readRegistry();
@@ -414,7 +663,7 @@ async function reconcile() {
414
663
  const entries = listEntries();
415
664
  for (const entry of entries) {
416
665
  if (entry.state === "running" && !await isOurConnector(entry)) {
417
- const fqdn = `${entry.subdomain}.${entry.zone}`;
666
+ const fqdn = entryFqdn(entry);
418
667
  await mutateRegistry((reg) => {
419
668
  const e = reg[fqdn];
420
669
  if (e) {
@@ -449,7 +698,7 @@ async function stopConnector(entry) {
449
698
  const pid = entry.pid;
450
699
  if (process.platform === "win32") {
451
700
  try {
452
- execFileSync2("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
701
+ execFileSync3("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
453
702
  } catch {
454
703
  return false;
455
704
  }
@@ -542,14 +791,6 @@ async function waitHealthy(cf, tunnelId, opts = {}) {
542
791
  // src/core/orchestrator-create.ts
543
792
  import { randomInt as randomInt2 } from "crypto";
544
793
 
545
- // src/core/ingress.ts
546
- function buildIngress(opts) {
547
- return [
548
- { hostname: opts.hostname, service: `${opts.proto}://localhost:${opts.port}` },
549
- { service: "http_status:404" }
550
- ];
551
- }
552
-
553
794
  // src/core/slug.ts
554
795
  import { randomInt } from "crypto";
555
796
  var ADJECTIVES = [
@@ -641,6 +882,7 @@ async function createTunnelSubdomain(cf, opts) {
641
882
  zoneId: zone.id,
642
883
  port: opts.port,
643
884
  proto: opts.proto,
885
+ host: opts.host,
644
886
  state: "provisioning"
645
887
  });
646
888
  let tunnelId;
@@ -651,7 +893,7 @@ async function createTunnelSubdomain(cf, opts) {
651
893
  const tunnel = await createTunnel(cf, `${MANAGED_TUNNEL_PREFIX}${label}-${suffix}`);
652
894
  tunnelId = tunnel.id;
653
895
  const token = await getTunnelToken(cf, tunnelId);
654
- await putIngress(cf, tunnelId, buildIngress({ hostname: host.hostname, port: opts.port, proto: opts.proto }));
896
+ await putIngress(cf, tunnelId, buildIngress({ hostname: host.hostname, port: opts.port, proto: opts.proto, host: opts.host }));
655
897
  const record = await createCname(cf.token, zone.id, host.hostname, tunnelId);
656
898
  dnsRecordId = record.id;
657
899
  await recordRunning(host, zone.id, tunnelId, dnsRecordId, opts);
@@ -672,6 +914,7 @@ async function recordRunning(host, zoneId, tunnelId, dnsRecordId, opts) {
672
914
  dnsRecordId,
673
915
  port: opts.port,
674
916
  proto: opts.proto,
917
+ host: opts.host,
675
918
  bootId: currentBootId(),
676
919
  state: "running"
677
920
  });
@@ -717,20 +960,20 @@ function resolveTarget(target) {
717
960
  const entries = listEntries();
718
961
  if (/^\d+$/.test(target)) {
719
962
  const byIndex = entries.find((e) => e.index === Number(target));
720
- if (byIndex) return { fqdn: `${byIndex.subdomain}.${byIndex.zone}`, entry: byIndex };
963
+ if (byIndex) return { fqdn: entryFqdn(byIndex), entry: byIndex };
721
964
  }
722
965
  const byId = entries.filter((e) => e.tunnelId?.startsWith(target));
723
966
  const matches = byId.length > 0 ? byId : entries.filter((e) => e.subdomain === target);
724
967
  if (matches.length > 1) {
725
968
  throw new CliError(`"${target}" matches multiple subdomains.`, {
726
- hint: `use a full hostname or a longer id: ${matches.map((m) => `${m.subdomain}.${m.zone}`).join(", ")}`
969
+ hint: `use a full hostname or a longer id: ${matches.map(entryFqdn).join(", ")}`
727
970
  });
728
971
  }
729
972
  const entry = matches[0];
730
973
  if (!entry) {
731
974
  throw new CliError(`No tracked subdomain matching "${target}".`, { hint: "see `cloudtunnel ls` for the #, name, or id" });
732
975
  }
733
- return { fqdn: `${entry.subdomain}.${entry.zone}`, entry };
976
+ return { fqdn: entryFqdn(entry), entry };
734
977
  }
735
978
  async function removeTunnelSubdomain(cf, target, opts = {}) {
736
979
  const { fqdn, entry } = resolveTarget(target);
@@ -780,12 +1023,15 @@ async function listAll(cf, opts = {}) {
780
1023
  const entries = await reconcile();
781
1024
  const tunnels = new Map((await listTunnels(cf)).map((t) => [t.id, t]));
782
1025
  const rows = entries.map((e) => {
1026
+ const fqdn = entryFqdn(e);
783
1027
  const gone = e.tunnelId ? !tunnels.has(e.tunnelId) : false;
1028
+ const svc = serviceState(fqdn);
784
1029
  return {
785
1030
  num: e.index ? String(e.index) : "-",
786
- hostname: `${e.subdomain}.${e.zone}`,
787
- port: `${e.proto}://localhost:${e.port}`,
1031
+ url: `https://${fqdn}`,
1032
+ target: serviceUrl(e.proto, e.host ?? "localhost", e.port),
788
1033
  state: !gone && e.state === "running" ? "up" : "down",
1034
+ service: svc === "none" ? "-" : svc,
789
1035
  pid: e.state === "running" && e.pid ? String(e.pid) : "-",
790
1036
  managed: true
791
1037
  };
@@ -793,11 +1039,11 @@ async function listAll(cf, opts = {}) {
793
1039
  if (opts.all) {
794
1040
  const { listCargoCnames } = await import("./dns-PAPFSYFP.js");
795
1041
  const { listZones: listZones3 } = await import("./zones-YNGQYXAF.js");
796
- const tracked = new Set(entries.map((e) => `${e.subdomain}.${e.zone}`));
1042
+ const tracked = new Set(entries.map(entryFqdn));
797
1043
  for (const zone of await listZones3(cf.token)) {
798
1044
  for (const rec of await listCargoCnames(cf.token, zone.id)) {
799
1045
  if (!tracked.has(rec.name)) {
800
- rows.push({ num: "-", hostname: rec.name, port: "-", state: "unmanaged", pid: "-", managed: false });
1046
+ rows.push({ num: "-", url: `https://${rec.name}`, target: "-", state: "unmanaged", service: "-", pid: "-", managed: false });
801
1047
  }
802
1048
  }
803
1049
  }
@@ -805,68 +1051,108 @@ async function listAll(cf, opts = {}) {
805
1051
  return rows;
806
1052
  }
807
1053
 
808
- // src/core/profiles.ts
809
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
810
- function parseTransportProtocol(value) {
811
- if (value === "auto" || value === "http2" || value === "quic") return value;
812
- throw new CliError(`Invalid protocol "${value}".`, { hint: "use auto, http2, or quic" });
813
- }
814
- function readProfiles() {
815
- try {
816
- return JSON.parse(readFileSync3(profilesFile, "utf8"));
817
- } catch {
818
- return {};
819
- }
820
- }
821
- function writeProfiles(profiles) {
822
- ensureDirs();
823
- writeFileSync3(profilesFile, JSON.stringify(profiles, null, 2), { mode: 384 });
824
- }
825
- function listProfiles() {
826
- return Object.entries(readProfiles()).map(([name, profile]) => ({ name, profile }));
1054
+ // src/core/up-runner.ts
1055
+ function logFileFor(subdomain) {
1056
+ return join3(logDir, `${subdomain === "@" ? "root" : subdomain}.log`);
827
1057
  }
828
- function getProfile(name) {
829
- const profile = readProfiles()[name];
830
- if (!profile) {
831
- throw new CliError(`No profile named "${name}".`, { hint: "list them with `cloudtunnel profiles`" });
1058
+ async function startTunnels(cf, bin, items, opts = {}) {
1059
+ const started = [];
1060
+ let tornDown = false;
1061
+ const teardownAll = async (code) => {
1062
+ if (tornDown) return;
1063
+ tornDown = true;
1064
+ try {
1065
+ for (const s of started) {
1066
+ try {
1067
+ await removeTunnelSubdomain(cf, s.fqdn, { force: true, quiet: true });
1068
+ } catch {
1069
+ }
1070
+ }
1071
+ if (process.stdout.isTTY) clack2.outro(`Stopped \xB7 released ${started.length} subdomain(s)`);
1072
+ } catch (err) {
1073
+ reportError(err);
1074
+ } finally {
1075
+ process.exit(code);
1076
+ }
1077
+ };
1078
+ const spin = clack2.spinner();
1079
+ spin.start(items.length > 1 ? "Creating tunnels\u2026" : "Creating tunnel\u2026");
1080
+ for (const item of items) {
1081
+ spin.message(`Creating ${item.name ?? "tunnel"} (:${item.port})\u2026`);
1082
+ const result = await createTunnelSubdomain(cf, item);
1083
+ const fqdn = result.host.hostname;
1084
+ const logFile = logFileFor(result.host.subdomain);
1085
+ const conn = startConnector({
1086
+ bin,
1087
+ token: result.token,
1088
+ detach: !!opts.detach,
1089
+ logFile,
1090
+ protocol: opts.protocol,
1091
+ onExit: opts.detach ? void 0 : (code) => {
1092
+ if (!tornDown) {
1093
+ say.warn(`Connector for ${fqdn} exited.`);
1094
+ void teardownAll(code ?? 1);
1095
+ }
1096
+ }
1097
+ });
1098
+ await patchEntry(fqdn, { pid: conn.pid, bootId: currentBootId(), logFile });
1099
+ started.push({
1100
+ fqdn,
1101
+ subdomain: result.host.subdomain,
1102
+ tunnelId: result.tunnelId,
1103
+ target: serviceUrl(item.proto, item.host ?? "localhost", item.port),
1104
+ pid: conn.pid
1105
+ });
832
1106
  }
833
- return profile;
834
- }
835
- function saveProfile(name, profile) {
836
- const profiles = readProfiles();
837
- profiles[name] = profile;
838
- writeProfiles(profiles);
839
- }
840
- function removeProfile(name) {
841
- const profiles = readProfiles();
842
- if (!profiles[name]) throw new CliError(`No profile named "${name}".`);
843
- delete profiles[name];
844
- writeProfiles(profiles);
845
- }
846
- function parseServiceSpec(spec) {
847
- const [name, portStr, proto] = spec.split(":");
848
- const port = Number(portStr);
849
- if (!name || !Number.isInteger(port) || port < 1 || port > 65535) {
850
- throw new CliError(`Invalid service "${spec}".`, { hint: "use name:port, e.g. api:3000 or web:5173:https" });
1107
+ if (opts.detach) {
1108
+ spin.stop(`${started.length} tunnel(s) started in the background`);
1109
+ const lines2 = started.map((s) => `${formatRoute(s.fqdn, s.target)} ${dim(`pid ${s.pid}`)}`);
1110
+ clack2.note(lines2.join("\n"), "running in background");
1111
+ if (process.stdout.isTTY) clack2.outro("Stop with: cloudtunnel delete <#|--all>");
1112
+ return;
851
1113
  }
852
- if (proto && proto !== "http" && proto !== "https") {
853
- throw new CliError(`Invalid protocol "${proto}" in "${spec}".`, { hint: "proto must be http or https" });
1114
+ for (const sig of ["SIGINT", "SIGHUP", "SIGTERM"]) {
1115
+ process.on(sig, () => void teardownAll(0));
854
1116
  }
855
- return { name, port, proto: proto ?? "http" };
1117
+ spin.message("Connecting to the Cloudflare edge\u2026");
1118
+ const healths = await Promise.all(started.map((s) => waitHealthy(cf, s.tunnelId, { timeoutMs: 3e4 })));
1119
+ const live = healths.filter((h) => h === "healthy").length;
1120
+ spin.stop(`${started.length} tunnel(s) started`);
1121
+ const lines = started.map((s, i) => `${formatRoute(s.fqdn, s.target)}${healths[i] === "healthy" ? "" : dim(` (${healths[i]})`)}`);
1122
+ clack2.note(lines.join("\n"), `${live}/${started.length} live`);
1123
+ say.dim("Ctrl-C stops and releases them.");
1124
+ }
1125
+
1126
+ // src/core/transport-protocol.ts
1127
+ function parseTransportProtocol(value) {
1128
+ if (value === "auto" || value === "http2" || value === "quic") return value;
1129
+ throw new CliError(`Invalid protocol "${value}".`, { hint: "use auto, http2, or quic" });
856
1130
  }
857
1131
 
858
1132
  // src/commands/up.ts
859
- function parsePort(port) {
860
- const n = Number(port);
861
- if (!Number.isInteger(n) || n < 1 || n > 65535) {
862
- throw new CliError(`Invalid port: ${port}`, { hint: "use a number 1\u201365535, e.g. `cloudtunnel 3000`" });
1133
+ function promptOrExit(value) {
1134
+ if (clack3.isCancel(value)) {
1135
+ clack3.cancel("Cancelled.");
1136
+ process.exit(130);
863
1137
  }
864
- return n;
1138
+ return value;
1139
+ }
1140
+ async function promptPort() {
1141
+ const input = promptOrExit(
1142
+ await clack3.text({
1143
+ message: "Port to expose",
1144
+ placeholder: "e.g. 3000",
1145
+ validate: (v) => {
1146
+ const n = Number(v);
1147
+ if (!Number.isInteger(n) || n < 1 || n > 65535) return "Enter a port 1\u201365535";
1148
+ return void 0;
1149
+ }
1150
+ })
1151
+ );
1152
+ return Number(input);
865
1153
  }
866
1154
  async function resolveDomain(cf, opts, creds) {
867
- if (opts.hostname) return void 0;
868
- const explicit = opts.domain ?? opts.zone;
869
- if (explicit) return explicit;
1155
+ if (opts.domain) return opts.domain;
870
1156
  const zones = await listZones(cf.token);
871
1157
  if (zones.length === 0) throw new CliError("No domains found in this Cloudflare account.");
872
1158
  if (zones.length === 1) return zones[0].name;
@@ -874,109 +1160,66 @@ async function resolveDomain(cf, opts, creds) {
874
1160
  if (creds.defaultZone) return creds.defaultZone;
875
1161
  throw new CliError("Multiple domains in this account \u2014 pick one.", { hint: "pass -d <domain>" });
876
1162
  }
877
- async function resolveSubdomain(opts) {
878
- const explicit = opts.subdomain ?? opts.name;
879
- if (explicit || opts.hostname) return explicit;
880
- if (!process.stdin.isTTY) return void 0;
881
- const input = await clack2.text({ message: "Subdomain", placeholder: "blank = random \xB7 @ = root domain" });
882
- if (clack2.isCancel(input)) {
883
- clack2.cancel("Cancelled.");
884
- process.exit(130);
885
- }
1163
+ async function resolveSpecSubdomain(spec, opts) {
1164
+ if (spec.subdomain !== void 0) return spec.subdomain;
1165
+ if (opts.yes || !process.stdin.isTTY) return void 0;
1166
+ const input = promptOrExit(
1167
+ await clack3.text({ message: `Subdomain for :${spec.port}`, placeholder: "blank = random \xB7 @ = root domain" })
1168
+ );
886
1169
  return input.trim() || void 0;
887
1170
  }
888
- function showLogTail(logFile) {
889
- try {
890
- const tail = readFileSync4(logFile, "utf8").trim().split("\n").slice(-8).join("\n");
891
- if (tail) say.dim(tail);
892
- } catch {
893
- }
894
- }
895
- async function runUp(portArg, opts) {
896
- const port = parsePort(portArg);
1171
+ async function runUp(specArgs, opts) {
897
1172
  const protocol = opts.protocol ? parseTransportProtocol(opts.protocol) : void 0;
1173
+ const parsed = specArgs.length ? specArgs.map(parseTunnelSpec) : null;
1174
+ if (parsed === null && !process.stdin.isTTY) {
1175
+ throw new CliError("No tunnel spec given.", { hint: "e.g. cloudtunnel api:8080" });
1176
+ }
898
1177
  const creds = await ensureAuth();
899
1178
  const cf = resolveCf();
900
1179
  const bin = await ensureCloudflared();
901
- if (process.stdout.isTTY) clack2.intro("cloudtunnel");
1180
+ if (process.stdout.isTTY) clack3.intro("cloudtunnel");
1181
+ const specs = parsed ?? [{ port: await promptPort() }];
902
1182
  const domain = await resolveDomain(cf, opts, creds);
903
- const subdomain = await resolveSubdomain(opts);
904
- const result = await createTunnelSubdomain(cf, {
905
- port,
906
- proto: opts.proto,
907
- name: subdomain,
908
- zone: domain,
909
- hostname: opts.hostname,
910
- defaultZone: creds.defaultZone,
911
- force: opts.force,
912
- yes: opts.yes
913
- });
914
- const fqdn = result.host.hostname;
915
- const logLabel = result.host.subdomain === "@" ? "root" : result.host.subdomain;
916
- const logFile = join2(logDir, `${logLabel}.log`);
917
- const target = `${opts.proto}://localhost:${port}`;
918
- if (opts.detach) {
919
- const started2 = startConnector({ bin, token: result.token, detach: true, logFile, protocol });
920
- await patchEntry(fqdn, { pid: started2.pid, bootId: currentBootId(), logFile });
921
- clack2.note(formatRoute(fqdn, target), `pid ${started2.pid}`);
922
- if (process.stdout.isTTY) clack2.outro(`Stop it with: cloudtunnel down ${result.host.subdomain}`);
1183
+ const items = [];
1184
+ for (const spec of specs) {
1185
+ let name = await resolveSpecSubdomain(spec, opts);
1186
+ if (opts.service && name === void 0) name = randomSlug();
1187
+ items.push({
1188
+ port: spec.port,
1189
+ proto: opts.proto,
1190
+ name,
1191
+ zone: domain,
1192
+ host: spec.host,
1193
+ defaultZone: creds.defaultZone,
1194
+ force: opts.force,
1195
+ yes: opts.yes
1196
+ });
1197
+ }
1198
+ if (opts.service) {
1199
+ registerServices(items, domain, opts.proto, protocol);
923
1200
  return;
924
1201
  }
925
- const spin = clack2.spinner();
926
- let spinnerActive = true;
927
- const stopSpin = (msg) => {
928
- if (spinnerActive) {
929
- spinnerActive = false;
930
- spin.stop(msg);
931
- }
932
- };
933
- spin.start("Connecting to the Cloudflare edge\u2026");
934
- const controller = new AbortController();
935
- let tornDown = false;
936
- const teardown = async (exitCode) => {
937
- if (tornDown) return;
938
- tornDown = true;
939
- controller.abort();
940
- stopSpin("Stopping\u2026");
941
- try {
942
- await removeTunnelSubdomain(cf, fqdn, { force: true, quiet: true });
943
- clack2.outro(`Stopped \xB7 ${fqdn} released`);
944
- } catch (err) {
945
- reportError(err);
946
- } finally {
947
- process.exit(exitCode);
948
- }
949
- };
950
- const started = startConnector({
951
- bin,
952
- token: result.token,
953
- detach: false,
954
- logFile,
955
- protocol,
956
- onExit: (code) => {
957
- if (!tornDown) {
958
- stopSpin("cloudflared exited");
959
- showLogTail(logFile);
960
- void teardown(code ?? 1);
961
- }
962
- }
963
- });
964
- await patchEntry(fqdn, { pid: started.pid, bootId: currentBootId(), logFile });
965
- for (const sig of ["SIGINT", "SIGHUP", "SIGTERM"]) {
966
- process.on(sig, () => void teardown(0));
1202
+ await startTunnels(cf, bin, items, { detach: opts.detach, protocol });
1203
+ }
1204
+ function registerServices(items, domain, proto, protocol) {
1205
+ assertSystemd();
1206
+ if (!protocol) {
1207
+ say.warn("No edge protocol set \u2014 cloudflared will pick QUIC, which some networks drop.");
1208
+ say.dim(" \u2192 add --protocol http2 for UDP-hostile networks");
967
1209
  }
968
- const health = await waitHealthy(cf, result.tunnelId, { signal: controller.signal });
969
- if (health === "healthy") {
970
- stopSpin("Connected");
971
- clack2.note(`${formatRoute(fqdn, target)}
972
- ${dim("Ctrl-C stops and releases this subdomain")}`, "Live");
973
- } else if (health === "provisioning") {
974
- stopSpin("Provisioning");
975
- say.warn(`${fqdn} is not healthy yet \u2014 it should be live shortly.`);
1210
+ const done = [];
1211
+ for (const item of items) {
1212
+ const subdomain = item.name;
1213
+ const fqdn = subdomain === "@" ? domain : `${subdomain}.${domain}`;
1214
+ installServiceForSpec({ subdomain, port: item.port, host: item.host, zone: domain, proto, protocol });
1215
+ done.push(`${serviceName(fqdn)} \u2192 https://${fqdn}`);
976
1216
  }
1217
+ say.ok(`Registered ${done.length} boot service(s):`);
1218
+ for (const line of done) say.dim(` ${line}`);
1219
+ say.dim(" \u2192 check them: cloudtunnel ls \xB7 remove: cloudtunnel delete <#>");
977
1220
  }
978
1221
  function registerUp(program) {
979
- program.command("up").argument("<port>", "local port to expose (e.g. 3000)").description("Expose a local port at an HTTPS subdomain (also: `cloudtunnel <port>`)").option("-s, --subdomain <name>", "subdomain label (prompted, or random if left blank)").option("-d, --domain <domain>", "domain to create the subdomain under (prompted from a list if unset)").option("--name <name>", "alias of --subdomain").option("--zone <domain>", "alias of --domain").option("--hostname <fqdn>", "full hostname override (instead of --subdomain + --domain)").option("--detach", "run the connector in the background").option("-f, --force", "replace a non-tunnel DNS record occupying the hostname").option("-y, --yes", "don't ask before replacing an existing record").option("--proto <proto>", "local service protocol: http | https", "http").option("--protocol <proto>", "cloudflared edge transport: auto | http2 | quic (http2 for UDP-hostile networks)").action((port, opts) => runUp(port, opts));
1222
+ program.command("up", { isDefault: true }).argument("[specs...]", "tunnels to start: [subdomain:]port[@host] (e.g. api:8080 web:8081@localhost)").description("Start one or more tunnels (also: `cloudtunnel 8080`)").option("-d, --domain <domain>", "domain for the subdomains (prompted from a list if unset)").option("--proto <proto>", "local service protocol: http | https", "http").option("--protocol <proto>", "cloudflared edge transport: auto | http2 | quic (http2 for UDP-hostile networks)").option("--detach", "run the connectors in the background").option("--service", "register each subdomain as a systemd boot service (Linux; needs sudo)").option("-f, --force", "replace a non-tunnel DNS record occupying the hostname").option("-y, --yes", "don't prompt; don't ask before replacing an existing record").action((specs, opts) => runUp(specs, opts));
980
1223
  }
981
1224
 
982
1225
  // src/commands/ls.ts
@@ -990,15 +1233,25 @@ function registerLs(program) {
990
1233
  return;
991
1234
  }
992
1235
  printTable(
993
- ["#", "SUBDOMAIN", "TARGET", "STATE", "PID"],
994
- rows.map((r) => [r.num, r.hostname, r.port, r.state, r.pid])
1236
+ ["#", "URL", "TARGET", "STATE", "SERVICE", "PID"],
1237
+ rows.map((r) => [r.num, r.url, r.target, r.state, r.service, r.pid])
995
1238
  );
996
1239
  });
997
1240
  }
998
1241
 
999
- // src/commands/down.ts
1000
- function registerDown(program) {
1001
- program.command("down").aliases(["rm", "remove", "delete", "stop"]).argument("[target]", "subdomain name / hostname / id / # to release (omit with --all)").description("Stop and release a subdomain \u2014 removes the tunnel + DNS on Cloudflare").option("--all", "release every tracked subdomain").option("-f, --force", "release even a resource not created by cloudtunnel").option("--dry-run", "show what would be released without doing it").action(async (target, opts) => {
1242
+ // src/commands/delete.ts
1243
+ async function deleteOne(cf, fqdn, opts) {
1244
+ await removeTunnelSubdomain(cf, fqdn, { force: opts.force, dryRun: opts.dryRun });
1245
+ if (serviceState(fqdn) === "none") return;
1246
+ if (opts.dryRun) {
1247
+ say.info(`Would also remove boot service ${serviceName(fqdn)}`);
1248
+ return;
1249
+ }
1250
+ uninstallService(fqdn);
1251
+ say.ok(`Removed boot service ${serviceName(fqdn)}`);
1252
+ }
1253
+ function registerDelete(program) {
1254
+ program.command("delete").argument("[targets...]", "subdomains to remove by # / name / URL (omit with --all)").description("Release tunnel(s) \u2014 deletes the tunnel + DNS, and any systemd boot service").option("--all", "release every tracked subdomain").option("-f, --force", "release even a resource not created by cloudtunnel").option("--dry-run", "show what would be released without doing it").action(async (targets, opts) => {
1002
1255
  await ensureAuth();
1003
1256
  const cf = resolveCf();
1004
1257
  if (opts.all) {
@@ -1008,258 +1261,27 @@ function registerDown(program) {
1008
1261
  return;
1009
1262
  }
1010
1263
  for (const e of entries) {
1264
+ const fqdn = entryFqdn(e);
1011
1265
  try {
1012
- await removeTunnelSubdomain(cf, `${e.subdomain}.${e.zone}`, { force: opts.force, dryRun: opts.dryRun });
1266
+ await deleteOne(cf, fqdn, opts);
1013
1267
  } catch (err) {
1014
- say.warn(`Could not release ${e.subdomain}.${e.zone}: ${err.message}`);
1015
- }
1016
- }
1017
- return;
1018
- }
1019
- if (!target) throw new CliError("Pass a subdomain (name / id / #) or --all.");
1020
- await removeTunnelSubdomain(cf, target, { force: opts.force, dryRun: opts.dryRun });
1021
- });
1022
- }
1023
-
1024
- // src/commands/zones.ts
1025
- function registerZones(program) {
1026
- program.command("zones").description("List the zones (domains) available in your Cloudflare account").action(async () => {
1027
- await ensureAuth();
1028
- const cf = resolveCf();
1029
- const zones = await listZones(cf.token);
1030
- if (zones.length === 0) {
1031
- say.info("No zones in this account.");
1032
- return;
1033
- }
1034
- printTable(
1035
- ["ZONE", "STATUS", "ID"],
1036
- zones.map((z) => [z.name, z.status ?? "-", z.id])
1037
- );
1038
- });
1039
- }
1040
-
1041
- // src/commands/save.ts
1042
- function registerSave(program) {
1043
- program.command("save").argument("<profile>", "profile name, e.g. mb").argument("[services...]", "services as name:port[:proto], e.g. api:3000 web:5173").description("Save a group of services as a profile you can `run` together").option("--from-running", "snapshot the currently tracked tunnels instead of listing services").option("-d, --domain <domain>", "default domain for this profile").option("--protocol <proto>", "edge transport for this profile: auto | http2 | quic").action((profile, specs, opts) => {
1044
- let services;
1045
- if (opts.fromRunning) {
1046
- const entries = listEntries().filter((e) => e.tunnelId);
1047
- if (entries.length === 0) {
1048
- throw new CliError("No tunnels to snapshot.", { hint: "start some with `cloudtunnel up`, or pass services like api:3000" });
1049
- }
1050
- services = entries.map((e) => ({ name: e.subdomain, port: e.port, proto: e.proto, domain: e.zone }));
1051
- } else {
1052
- if (specs.length === 0) {
1053
- throw new CliError("No services given.", { hint: "e.g. `cloudtunnel save mb api:3000 web:5173`" });
1054
- }
1055
- services = specs.map(parseServiceSpec);
1056
- }
1057
- const protocol = opts.protocol ? parseTransportProtocol(opts.protocol) : void 0;
1058
- saveProfile(profile, { services, domain: opts.domain, protocol });
1059
- say.ok(`Saved profile "${profile}" (${services.length} service${services.length === 1 ? "" : "s"}). Run it: cloudtunnel run ${profile}`);
1060
- });
1061
- }
1062
-
1063
- // src/commands/run.ts
1064
- import { join as join3 } from "path";
1065
- import * as clack3 from "@clack/prompts";
1066
- async function runProfile(name, opts) {
1067
- const creds = await ensureAuth();
1068
- const cf = resolveCf();
1069
- const bin = await ensureCloudflared();
1070
- const profile = getProfile(name);
1071
- const protocol = opts.protocol ? parseTransportProtocol(opts.protocol) : profile.protocol;
1072
- if (process.stdout.isTTY) clack3.intro(`cloudtunnel \xB7 profile "${name}"`);
1073
- const spin = clack3.spinner();
1074
- spin.start("Creating tunnels\u2026");
1075
- const started = [];
1076
- for (const svc of profile.services) {
1077
- spin.message(`Creating ${svc.name} (:${svc.port})\u2026`);
1078
- const result = await createTunnelSubdomain(cf, {
1079
- port: svc.port,
1080
- proto: svc.proto,
1081
- name: svc.name,
1082
- zone: svc.domain ?? opts.domain ?? profile.domain,
1083
- defaultZone: creds.defaultZone,
1084
- force: opts.force,
1085
- yes: true
1086
- // batch: never prompt per service
1087
- });
1088
- const fqdn = result.host.hostname;
1089
- const logFile = join3(logDir, `${result.host.subdomain}.log`);
1090
- const conn = startConnector({
1091
- bin,
1092
- token: result.token,
1093
- detach: !!opts.detach,
1094
- logFile,
1095
- protocol,
1096
- onExit: opts.detach ? void 0 : () => say.warn(`Connector for ${fqdn} exited.`)
1097
- });
1098
- await patchEntry(fqdn, { pid: conn.pid, bootId: currentBootId(), logFile });
1099
- started.push({ fqdn, subdomain: result.host.subdomain, tunnelId: result.tunnelId, target: `${svc.proto}://localhost:${svc.port}`, pid: conn.pid });
1100
- }
1101
- if (opts.detach) {
1102
- spin.stop(`${started.length} service(s) started in the background`);
1103
- const lines2 = started.map((s) => `${formatRoute(s.fqdn, s.target)} ${dim(`pid ${s.pid}`)}`);
1104
- clack3.note(lines2.join("\n"), `profile "${name}" \u2014 running in background`);
1105
- if (process.stdout.isTTY) clack3.outro("Stop them with: cloudtunnel down --all");
1106
- return;
1107
- }
1108
- spin.message("Connecting to the Cloudflare edge\u2026");
1109
- const healths = await Promise.all(started.map((s) => waitHealthy(cf, s.tunnelId, { timeoutMs: 3e4 })));
1110
- const live = healths.filter((h) => h === "healthy").length;
1111
- spin.stop(`${started.length} service(s) started`);
1112
- const lines = started.map((s, i) => `${formatRoute(s.fqdn, s.target)}${healths[i] === "healthy" ? "" : dim(` (${healths[i]})`)}`);
1113
- clack3.note(lines.join("\n"), `profile "${name}" \u2014 ${live}/${started.length} live`);
1114
- say.dim("Ctrl-C stops and releases all of them.");
1115
- let tornDown = false;
1116
- const teardownAll = async (code) => {
1117
- if (tornDown) return;
1118
- tornDown = true;
1119
- try {
1120
- for (const s of started) {
1121
- try {
1122
- await removeTunnelSubdomain(cf, s.fqdn, { force: true, quiet: true });
1123
- } catch {
1268
+ say.warn(`Could not release ${fqdn}: ${err.message}`);
1124
1269
  }
1125
1270
  }
1126
- if (process.stdout.isTTY) clack3.outro(`Stopped \xB7 released ${started.length} subdomain(s)`);
1127
- } catch (err) {
1128
- reportError(err);
1129
- } finally {
1130
- process.exit(code);
1131
- }
1132
- };
1133
- for (const sig of ["SIGINT", "SIGHUP", "SIGTERM"]) {
1134
- process.on(sig, () => void teardownAll(0));
1135
- }
1136
- }
1137
- function registerRun(program) {
1138
- program.command("run").argument("<profile>", "name of a saved profile (see `cloudtunnel profiles`)").description("Start every service in a saved profile at once").option("-f, --force", "take over subdomains already occupied by another record").option("-d, --domain <domain>", "override the profile's domain for this run").option("--detach", "run all connectors in the background (stop with `cloudtunnel down --all`)").option("--protocol <proto>", "edge transport: auto | http2 | quic (overrides the profile's saved protocol)").action((name, opts) => runProfile(name, opts));
1139
- }
1140
-
1141
- // src/core/systemd.ts
1142
- import { execFileSync as execFileSync3 } from "child_process";
1143
- import { writeFileSync as writeFileSync4 } from "fs";
1144
- import { tmpdir } from "os";
1145
- import { dirname, join as join4 } from "path";
1146
- function serviceName(profile) {
1147
- return `cloudtunnel-${profile}.service`;
1148
- }
1149
- function unitPath(profile) {
1150
- return `/etc/systemd/system/${serviceName(profile)}`;
1151
- }
1152
- function buildUnit(p) {
1153
- const nodeBin = dirname(p.nodePath);
1154
- const proto = p.protocol ? ` --protocol ${p.protocol}` : "";
1155
- return [
1156
- "[Unit]",
1157
- `Description=cloudtunnel profile "${p.profile}" (Cloudflare Tunnel)`,
1158
- "After=network-online.target",
1159
- "Wants=network-online.target",
1160
- "",
1161
- "[Service]",
1162
- "Type=simple",
1163
- `User=${p.user}`,
1164
- `Environment=HOME=${p.home}`,
1165
- `Environment=PATH=${nodeBin}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
1166
- `ExecStart=${p.nodePath} ${p.scriptPath} run ${p.profile} -f${proto}`,
1167
- "Restart=on-failure",
1168
- "RestartSec=5",
1169
- "",
1170
- "[Install]",
1171
- "WantedBy=multi-user.target",
1172
- ""
1173
- ].join("\n");
1174
- }
1175
- function assertSystemd() {
1176
- if (process.platform !== "linux") {
1177
- throw new CliError("Service registration is Linux/systemd only.", {
1178
- hint: "on macOS/Windows run `cloudtunnel run <profile> --detach` at login instead"
1179
- });
1180
- }
1181
- try {
1182
- execFileSync3("systemctl", ["--version"], { stdio: "ignore" });
1183
- } catch {
1184
- throw new CliError("systemd (systemctl) was not found on this host.");
1185
- }
1186
- }
1187
- function privileged(args) {
1188
- const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
1189
- const argv = isRoot ? args : ["sudo", ...args];
1190
- execFileSync3(argv[0], argv.slice(1), { stdio: "inherit" });
1191
- }
1192
- function query(args) {
1193
- try {
1194
- return execFileSync3("systemctl", args, {
1195
- stdio: ["ignore", "pipe", "ignore"],
1196
- encoding: "utf8"
1197
- }).trim();
1198
- } catch (err) {
1199
- const out = err.stdout;
1200
- return out ? out.toString().trim() : "";
1201
- }
1202
- }
1203
- function installService(p) {
1204
- assertSystemd();
1205
- const tmp = join4(tmpdir(), serviceName(p.profile));
1206
- writeFileSync4(tmp, buildUnit(p), { mode: 420 });
1207
- privileged(["install", "-m", "0644", tmp, unitPath(p.profile)]);
1208
- privileged(["systemctl", "daemon-reload"]);
1209
- privileged(["systemctl", "enable", "--now", serviceName(p.profile)]);
1210
- }
1211
- function uninstallService(profile) {
1212
- assertSystemd();
1213
- try {
1214
- privileged(["systemctl", "disable", "--now", serviceName(profile)]);
1215
- } catch {
1216
- }
1217
- privileged(["rm", "-f", unitPath(profile)]);
1218
- privileged(["systemctl", "daemon-reload"]);
1219
- }
1220
- function serviceState(profile) {
1221
- if (process.platform !== "linux") return "none";
1222
- const name = serviceName(profile);
1223
- if (query(["is-active", name]) === "active") return "active";
1224
- const enabled = query(["is-enabled", name]);
1225
- if (enabled === "enabled" || enabled === "enabled-runtime") return "enabled";
1226
- if (enabled === "disabled" || enabled === "static") return "disabled";
1227
- return "none";
1228
- }
1229
-
1230
- // src/commands/profiles.ts
1231
- function formatService(state) {
1232
- return state === "none" ? dim("\u2013") : state;
1233
- }
1234
- function registerProfiles(program) {
1235
- program.command("profiles").description("List saved profiles (or delete one with --rm)").option("--rm <name>", "delete a profile").action((opts) => {
1236
- if (opts.rm) {
1237
- removeProfile(opts.rm);
1238
- say.ok(`Deleted profile "${opts.rm}".`);
1239
1271
  return;
1240
1272
  }
1241
- const profiles = listProfiles();
1242
- if (profiles.length === 0) {
1243
- say.info("No profiles yet. Create one: `cloudtunnel save mb api:3000 web:5173`");
1244
- return;
1273
+ if (targets.length === 0) throw new CliError("Pass a subdomain (# / name / URL) or --all.");
1274
+ for (const target of targets) {
1275
+ const { fqdn } = resolveTarget(target);
1276
+ await deleteOne(cf, fqdn, opts);
1245
1277
  }
1246
- printTable(
1247
- ["PROFILE", "SERVICES", "DOMAIN", "PROTOCOL", "SERVICE"],
1248
- profiles.map(({ name, profile }) => [
1249
- name,
1250
- profile.services.map((s) => `${s.name}:${s.port}`).join(", "),
1251
- profile.domain ?? "(default)",
1252
- profile.protocol ?? "auto",
1253
- formatService(serviceState(name))
1254
- ])
1255
- );
1256
1278
  });
1257
1279
  }
1258
1280
 
1259
1281
  // src/commands/logs.ts
1260
- import { closeSync, existsSync as existsSync3, openSync as openSync2, readFileSync as readFileSync5, readSync, statSync, watch } from "fs";
1282
+ import { closeSync, existsSync as existsSync5, openSync as openSync2, readFileSync as readFileSync4, readSync, statSync, watch } from "fs";
1261
1283
  function printTail(file, n) {
1262
- const lines = readFileSync5(file, "utf8").split("\n");
1284
+ const lines = readFileSync4(file, "utf8").split("\n");
1263
1285
  const tail = lines.slice(-n).join("\n");
1264
1286
  process.stdout.write(tail.endsWith("\n") ? tail : `${tail}
1265
1287
  `);
@@ -1291,7 +1313,7 @@ function follow(file, fromPos) {
1291
1313
  function registerLogs(program) {
1292
1314
  program.command("logs").argument("<target>", "subdomain name / hostname / id / #").description("Show the connector log for a subdomain (use -f to follow)").option("-f, --follow", "keep printing new log lines (like tail -f)").option("-n, --lines <n>", "number of lines to show", "50").action((name, opts) => {
1293
1315
  const { fqdn, entry } = resolveTarget(name);
1294
- if (!entry?.logFile || !existsSync3(entry.logFile)) {
1316
+ if (!entry?.logFile || !existsSync5(entry.logFile)) {
1295
1317
  throw new CliError(`No logs for ${fqdn} yet.`, { hint: "start it with `cloudtunnel up` or `cloudtunnel run`" });
1296
1318
  }
1297
1319
  const n = Math.max(1, Number(opts.lines) || 50);
@@ -1300,113 +1322,39 @@ function registerLogs(program) {
1300
1322
  });
1301
1323
  }
1302
1324
 
1303
- // src/commands/service.ts
1304
- import os2 from "os";
1305
- import { realpathSync } from "fs";
1306
- function entryScript() {
1307
- const p = process.argv[1];
1308
- if (!p) throw new CliError("Cannot resolve the cloudtunnel executable path.");
1309
- return realpathSync(p);
1310
- }
1311
- function enable(name, opts) {
1312
- const profile = getProfile(name);
1313
- let protocol = profile.protocol;
1314
- if (opts.protocol) {
1315
- protocol = parseTransportProtocol(opts.protocol);
1316
- saveProfile(name, { ...profile, protocol });
1317
- }
1318
- if (!protocol) {
1319
- say.warn("No edge protocol set \u2014 cloudflared will pick QUIC, which some networks drop.");
1320
- say.dim(" \u2192 set one with: cloudtunnel service enable " + name + " --protocol http2");
1321
- }
1322
- installService({
1323
- profile: name,
1324
- user: os2.userInfo().username,
1325
- home: os2.homedir(),
1326
- nodePath: process.execPath,
1327
- scriptPath: entryScript(),
1328
- protocol
1329
- });
1330
- say.ok(`Service ${serviceName(name)} enabled \u2014 starts on boot.`);
1331
- say.dim(` \u2192 check it: cloudtunnel service status ${name}`);
1332
- }
1333
- function disable(name) {
1334
- getProfile(name);
1335
- uninstallService(name);
1336
- say.ok(`Service ${serviceName(name)} disabled and removed.`);
1337
- }
1338
- function status(name) {
1339
- getProfile(name);
1340
- say.info(`${serviceName(name)}: ${serviceState(name)}`);
1341
- }
1342
- function registerService(program) {
1343
- const svc = program.command("service").description("Register a profile as a systemd service that starts on boot");
1344
- svc.command("enable").argument("<profile>", "profile to register").option("--protocol <proto>", "edge transport for the service: auto | http2 | quic").description("Install + enable a boot service for the profile (needs sudo)").action((name, opts) => enable(name, opts));
1345
- svc.command("disable").argument("<profile>", "profile to unregister").description("Stop, disable, and remove the profile's boot service (needs sudo)").action((name) => disable(name));
1346
- svc.command("status").argument("<profile>", "profile to check").description("Show the systemd state of the profile's service").action((name) => status(name));
1347
- }
1348
-
1349
1325
  // src/index.ts
1350
1326
  var require2 = createRequire(import.meta.url);
1351
1327
  var pkg = require2("../package.json");
1352
- var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
1353
- "login",
1354
- "up",
1355
- "ls",
1356
- "ps",
1357
- "down",
1358
- "rm",
1359
- "remove",
1360
- "delete",
1361
- "stop",
1362
- "logs",
1363
- "zones",
1364
- "save",
1365
- "run",
1366
- "profiles",
1367
- "service",
1368
- "help"
1369
- ]);
1370
- function applyBarePortAlias(argv) {
1371
- const args = argv.slice(2);
1372
- const first = args[0];
1373
- if (first && /^\d{1,5}$/.test(first) && !KNOWN_COMMANDS.has(first)) {
1374
- args.unshift("up");
1375
- }
1376
- return [argv[0], argv[1], ...args];
1377
- }
1378
1328
  function buildProgram() {
1379
1329
  const program = new Command();
1380
- program.name("cloudtunnel").description("Manage Cloudflare Tunnels and subdomains account-wide, from your terminal.").version(pkg.version, "-v, --version").showHelpAfterError();
1330
+ program.name("cloudtunnel").description("Expose local ports at HTTPS subdomains on your own Cloudflare domains.").version(pkg.version, "-v, --version").showHelpAfterError();
1381
1331
  program.addHelpText(
1382
1332
  "before",
1383
1333
  [
1384
1334
  pc2.bold("Quickstart:"),
1385
- ` ${pc2.cyan("cloudtunnel login")} once \u2014 paste a token (or set CLOUDFLARE_API_TOKEN)`,
1386
- ` ${pc2.cyan("cloudtunnel 3000")} \u2192 your local :3000 goes live at an HTTPS URL`,
1335
+ ` ${pc2.cyan("cloudtunnel login")} once \u2014 paste a token (or set CLOUDFLARE_API_TOKEN)`,
1336
+ ` ${pc2.cyan("cloudtunnel 8080")} your local :8080 goes live at an HTTPS URL`,
1337
+ ` ${pc2.cyan("cloudtunnel api:8080")} api.<domain> \u2192 localhost:8080`,
1338
+ ` ${pc2.cyan("cloudtunnel ls")} list tunnels ${pc2.dim("\xB7")} ${pc2.cyan("cloudtunnel delete <#>")} remove one`,
1387
1339
  ""
1388
1340
  ].join("\n")
1389
1341
  );
1390
- for (const register of [
1391
- registerLogin,
1392
- registerUp,
1393
- registerLs,
1394
- registerDown,
1395
- registerZones,
1396
- registerSave,
1397
- registerRun,
1398
- registerProfiles,
1399
- registerService,
1400
- registerLogs
1401
- ]) {
1342
+ for (const register of [registerLogin, registerUp, registerLs, registerDelete, registerLogs]) {
1402
1343
  register(program);
1403
1344
  }
1404
1345
  return program;
1405
1346
  }
1347
+ function shouldMigrate(argv) {
1348
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
1349
+ const rest = argv.slice(2);
1350
+ const infoFlag = /* @__PURE__ */ new Set(["-h", "--help", "-v", "--version", "help"]);
1351
+ return !rest.some((a) => infoFlag.has(a));
1352
+ }
1406
1353
  async function main() {
1354
+ if (shouldMigrate(process.argv)) await migrateLegacyProfiles();
1407
1355
  const program = buildProgram();
1408
1356
  try {
1409
- await program.parseAsync(applyBarePortAlias(process.argv));
1357
+ await program.parseAsync(process.argv);
1410
1358
  } catch (err) {
1411
1359
  process.exitCode = reportError(err);
1412
1360
  }