@iamken/cloudtunnel 0.11.4 → 0.12.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
@@ -2,14 +2,14 @@
2
2
  import {
3
3
  listZones,
4
4
  resolveZone
5
- } from "./chunk-OT3UCBBF.js";
5
+ } from "./chunk-J6FFC5ZO.js";
6
6
  import {
7
7
  createCname,
8
8
  deleteDnsRecord,
9
9
  findCname,
10
10
  findDnsRecords,
11
11
  isManagedDns
12
- } from "./chunk-FILVDOPL.js";
12
+ } from "./chunk-XKPTCMTJ.js";
13
13
  import {
14
14
  CliError,
15
15
  DEFAULT_API_BASE,
@@ -18,6 +18,7 @@ import {
18
18
  binDir,
19
19
  cfPaginate,
20
20
  cfRequest,
21
+ configDir,
21
22
  configFile,
22
23
  confirm,
23
24
  dim,
@@ -42,7 +43,7 @@ import {
42
43
  say,
43
44
  scanCacheFile,
44
45
  selectOne
45
- } from "./chunk-7XACBYL7.js";
46
+ } from "./chunk-ZIMTYVHN.js";
46
47
 
47
48
  // src/index.ts
48
49
  import { Command } from "commander";
@@ -50,7 +51,7 @@ import { createRequire } from "module";
50
51
  import pc2 from "picocolors";
51
52
 
52
53
  // src/config/legacy-migrate.ts
53
- import { existsSync as existsSync3, readFileSync, renameSync, writeFileSync as writeFileSync4 } from "fs";
54
+ import { existsSync as existsSync3, readFileSync as readFileSync3, renameSync, writeFileSync as writeFileSync4 } from "fs";
54
55
 
55
56
  // src/core/service.ts
56
57
  import { join as join5 } from "path";
@@ -134,6 +135,14 @@ function formatTunnelSpec(s) {
134
135
  }
135
136
 
136
137
  // src/core/service-exec.ts
138
+ var SERVICE_SCHEMA = 2;
139
+ var SCHEMA_RE = /cloudtunnel-schema:\s*(\d+)/;
140
+ var schemaMarker = `cloudtunnel-schema: ${SERVICE_SCHEMA}`;
141
+ function schemaOf(text2) {
142
+ const m = SCHEMA_RE.exec(text2);
143
+ return m ? Number(m[1]) : 1;
144
+ }
145
+ var unxml = (s) => s.replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
137
146
  var fqdnFor = (subdomain, zone) => subdomain === "@" ? zone : `${subdomain}.${zone}`;
138
147
  var serviceSlug = (fqdn) => fqdn.replace(/[^a-zA-Z0-9]+/g, "-");
139
148
  function buildUpArgs(p) {
@@ -192,19 +201,25 @@ __export(service_systemd_exports, {
192
201
  install: () => install,
193
202
  label: () => label,
194
203
  legacyUnitExists: () => legacyUnitExists,
204
+ listInstalled: () => listInstalled,
205
+ parseUnit: () => parseUnit,
206
+ prepareChanges: () => prepareChanges,
195
207
  removeLegacyUnit: () => removeLegacyUnit,
196
208
  state: () => state,
197
- uninstall: () => uninstall
209
+ uninstall: () => uninstall,
210
+ uninstallLabel: () => uninstallLabel
198
211
  });
199
212
  import { execFileSync } from "child_process";
200
- import { existsSync, writeFileSync } from "fs";
213
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "fs";
201
214
  import { tmpdir } from "os";
202
215
  import { dirname, join as join2 } from "path";
216
+ var UNIT_DIR = "/etc/systemd/system";
203
217
  var label = (fqdn) => `cloudtunnel-${serviceSlug(fqdn)}.service`;
204
- var unitPath = (fqdn) => `/etc/systemd/system/${label(fqdn)}`;
218
+ var unitPath = (fqdn) => `${UNIT_DIR}/${label(fqdn)}`;
205
219
  function buildUnit(d) {
206
220
  const nodeBin = dirname(d.nodePath);
207
221
  return [
222
+ `# ${schemaMarker}`,
208
223
  "[Unit]",
209
224
  `Description=cloudtunnel ${d.fqdn} (Cloudflare Tunnel)`,
210
225
  "After=network-online.target",
@@ -219,6 +234,8 @@ function buildUnit(d) {
219
234
  `ExecStart=${d.nodePath} ${d.scriptPath} ${d.argv.join(" ")}`,
220
235
  "Restart=always",
221
236
  "RestartSec=5",
237
+ "KillMode=mixed",
238
+ "TimeoutStopSec=15",
222
239
  "",
223
240
  "[Install]",
224
241
  "WantedBy=multi-user.target",
@@ -247,20 +264,49 @@ function assertSupported() {
247
264
  }
248
265
  function install(d) {
249
266
  assertSupported();
250
- const tmp = join2(tmpdir(), label(d.fqdn));
267
+ const name = label(d.fqdn);
268
+ const tmp = join2(tmpdir(), name);
251
269
  writeFileSync(tmp, buildUnit(d), { mode: 420 });
252
270
  privileged(["install", "-m", "0644", tmp, unitPath(d.fqdn)]);
253
271
  privileged(["systemctl", "daemon-reload"]);
254
- privileged(["systemctl", "enable", "--now", label(d.fqdn)]);
272
+ privileged(["systemctl", "enable", name]);
273
+ privileged(["systemctl", "restart", name]);
255
274
  }
256
275
  function uninstall(fqdn) {
276
+ uninstallLabel(label(fqdn));
277
+ }
278
+ function uninstallLabel(name) {
257
279
  try {
258
- privileged(["systemctl", "disable", "--now", label(fqdn)]);
280
+ privileged(["systemctl", "disable", "--now", name]);
259
281
  } catch {
260
282
  }
261
- privileged(["rm", "-f", unitPath(fqdn)]);
283
+ privileged(["rm", "-f", `${UNIT_DIR}/${name}`]);
262
284
  privileged(["systemctl", "daemon-reload"]);
263
285
  }
286
+ function prepareChanges() {
287
+ const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
288
+ if (!isRoot) execFileSync("sudo", ["-v"], { stdio: "inherit" });
289
+ }
290
+ function parseUnit(name, text2, running) {
291
+ const exec = /^ExecStart=(.+)$/m.exec(text2)?.[1]?.trim();
292
+ return { label: name, command: exec ? exec.split(/\s+/) : [], schema: schemaOf(text2), running };
293
+ }
294
+ function listInstalled() {
295
+ let files = [];
296
+ try {
297
+ files = readdirSync(UNIT_DIR).filter((f) => /^cloudtunnel-.+\.service$/.test(f));
298
+ } catch {
299
+ return [];
300
+ }
301
+ return files.map((f) => {
302
+ let text2 = "";
303
+ try {
304
+ text2 = readFileSync(`${UNIT_DIR}/${f}`, "utf8");
305
+ } catch {
306
+ }
307
+ return parseUnit(f, text2, ["active", "activating", "reloading"].includes(query(["is-active", f])));
308
+ });
309
+ }
264
310
  function state(fqdn) {
265
311
  const name = label(fqdn);
266
312
  if (query(["is-active", name]) === "active") return "active";
@@ -289,11 +335,15 @@ __export(service_launchd_exports, {
289
335
  buildPlist: () => buildPlist,
290
336
  install: () => install2,
291
337
  label: () => label2,
338
+ listInstalled: () => listInstalled2,
339
+ parsePlist: () => parsePlist,
340
+ prepareChanges: () => prepareChanges2,
292
341
  state: () => state2,
293
- uninstall: () => uninstall2
342
+ uninstall: () => uninstall2,
343
+ uninstallLabel: () => uninstallLabel2
294
344
  });
295
345
  import { execFileSync as execFileSync2 } from "child_process";
296
- import { existsSync as existsSync2, mkdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
346
+ import { existsSync as existsSync2, mkdirSync, readdirSync as readdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
297
347
  import { dirname as dirname2, join as join3 } from "path";
298
348
  import os2 from "os";
299
349
  var label2 = (fqdn) => `com.cloudtunnel.${serviceSlug(fqdn)}`;
@@ -308,6 +358,7 @@ function buildPlist(d) {
308
358
  '<?xml version="1.0" encoding="UTF-8"?>',
309
359
  '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
310
360
  '<plist version="1.0">',
361
+ `<!-- ${schemaMarker} -->`,
311
362
  "<dict>",
312
363
  ` <key>Label</key><string>${xml(label2(d.fqdn))}</string>`,
313
364
  " <key>ProgramArguments</key>",
@@ -316,6 +367,7 @@ function buildPlist(d) {
316
367
  " </array>",
317
368
  " <key>RunAtLoad</key><true/>",
318
369
  " <key>KeepAlive</key><true/>",
370
+ " <key>ThrottleInterval</key><integer>10</integer>",
319
371
  " <key>EnvironmentVariables</key>",
320
372
  " <dict>",
321
373
  ` <key>PATH</key><string>${xml(path)}</string>`,
@@ -336,6 +388,11 @@ function launchctl(args) {
336
388
  return out ? out.toString() : "";
337
389
  }
338
390
  }
391
+ function waitUnloaded(agentLabel) {
392
+ for (let i = 0; i < 30 && launchctl(["list", agentLabel]).trim(); i++) {
393
+ execFileSync2("sleep", ["0.5"]);
394
+ }
395
+ }
339
396
  function assertSupported2() {
340
397
  }
341
398
  function install2(d) {
@@ -344,13 +401,41 @@ function install2(d) {
344
401
  const plist = plistPath(d.fqdn);
345
402
  writeFileSync2(plist, buildPlist(d), { mode: 420 });
346
403
  launchctl(["unload", "-w", plist]);
404
+ waitUnloaded(label2(d.fqdn));
347
405
  execFileSync2("launchctl", ["load", "-w", plist], { stdio: "inherit" });
348
406
  }
349
407
  function uninstall2(fqdn) {
350
- const plist = plistPath(fqdn);
408
+ uninstallLabel2(label2(fqdn));
409
+ }
410
+ function uninstallLabel2(agentLabel) {
411
+ const plist = join3(agentsDir(), `${agentLabel}.plist`);
351
412
  launchctl(["unload", "-w", plist]);
352
413
  rmSync(plist, { force: true });
353
414
  }
415
+ function prepareChanges2() {
416
+ }
417
+ function parsePlist(agentLabel, text2, running) {
418
+ const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text2)?.[1] ?? "";
419
+ const command = [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unxml(m[1] ?? ""));
420
+ return { label: agentLabel, command, schema: schemaOf(text2), running };
421
+ }
422
+ function listInstalled2() {
423
+ let files = [];
424
+ try {
425
+ files = readdirSync2(agentsDir()).filter((f) => /^com\.cloudtunnel\..+\.plist$/.test(f));
426
+ } catch {
427
+ return [];
428
+ }
429
+ return files.map((f) => {
430
+ const agentLabel = f.replace(/\.plist$/, "");
431
+ let text2 = "";
432
+ try {
433
+ text2 = readFileSync2(join3(agentsDir(), f), "utf8");
434
+ } catch {
435
+ }
436
+ return parsePlist(agentLabel, text2, /"PID"\s*=/.test(launchctl(["list", agentLabel])));
437
+ });
438
+ }
354
439
  function state2(fqdn) {
355
440
  const info = launchctl(["list", label2(fqdn)]);
356
441
  if (/"PID"\s*=/.test(info)) return "active";
@@ -364,8 +449,12 @@ __export(service_windows_exports, {
364
449
  buildTaskXml: () => buildTaskXml,
365
450
  install: () => install3,
366
451
  label: () => label3,
452
+ listInstalled: () => listInstalled3,
453
+ parseTaskXml: () => parseTaskXml,
454
+ prepareChanges: () => prepareChanges3,
367
455
  state: () => state3,
368
- uninstall: () => uninstall3
456
+ uninstall: () => uninstall3,
457
+ uninstallLabel: () => uninstallLabel3
369
458
  });
370
459
  import { execFileSync as execFileSync3 } from "child_process";
371
460
  import { writeFileSync as writeFileSync3 } from "fs";
@@ -378,7 +467,7 @@ function buildTaskXml(d) {
378
467
  return [
379
468
  '<?xml version="1.0" encoding="UTF-16"?>',
380
469
  '<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
381
- ` <RegistrationInfo><Description>cloudtunnel ${xml2(d.fqdn)} (Cloudflare Tunnel)</Description></RegistrationInfo>`,
470
+ ` <RegistrationInfo><Description>cloudtunnel ${xml2(d.fqdn)} (Cloudflare Tunnel) [${schemaMarker}]</Description></RegistrationInfo>`,
382
471
  ` <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${xml2(d.user)}</UserId></LogonTrigger></Triggers>`,
383
472
  ` <Principals><Principal id="Author"><UserId>${xml2(d.user)}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>`,
384
473
  " <Settings>",
@@ -389,7 +478,7 @@ function buildTaskXml(d) {
389
478
  " <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>",
390
479
  " <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>",
391
480
  " <Enabled>true</Enabled>",
392
- " </Settings>",
481
+ "</Settings>",
393
482
  ' <Actions Context="Author">',
394
483
  ` <Exec><Command>${xml2(d.nodePath)}</Command><Arguments>${xml2(args)}</Arguments></Exec>`,
395
484
  " </Actions>",
@@ -409,12 +498,32 @@ function assertSupported3() {
409
498
  }
410
499
  function install3(d) {
411
500
  const file = join4(tmpdir2(), `${d.slug}.task.xml`);
501
+ const name = label3(d.fqdn);
412
502
  writeFileSync3(file, "\uFEFF" + buildTaskXml(d), { encoding: "utf16le" });
413
- execFileSync3("schtasks", ["/Create", "/TN", label3(d.fqdn), "/XML", file, "/F"], { stdio: "inherit" });
414
- schtasks(["/Run", "/TN", label3(d.fqdn)]);
503
+ schtasks(["/End", "/TN", name]);
504
+ execFileSync3("schtasks", ["/Create", "/TN", name, "/XML", file, "/F"], { stdio: "inherit" });
505
+ schtasks(["/Run", "/TN", name]);
415
506
  }
416
507
  function uninstall3(fqdn) {
417
- schtasks(["/Delete", "/TN", label3(fqdn), "/F"]);
508
+ uninstallLabel3(label3(fqdn));
509
+ }
510
+ function uninstallLabel3(name) {
511
+ schtasks(["/End", "/TN", name]);
512
+ schtasks(["/Delete", "/TN", name, "/F"]);
513
+ }
514
+ function prepareChanges3() {
515
+ }
516
+ function splitArgs(args) {
517
+ return [...args.matchAll(/"([^"]*)"|(\S+)/g)].map((m) => m[1] ?? m[2] ?? "");
518
+ }
519
+ function parseTaskXml(name, xmlText, running) {
520
+ const command = unxml(/<Command>([\s\S]*?)<\/Command>/.exec(xmlText)?.[1] ?? "");
521
+ const args = unxml(/<Arguments>([\s\S]*?)<\/Arguments>/.exec(xmlText)?.[1] ?? "");
522
+ return { label: name, command: command ? [command, ...splitArgs(args)] : [], schema: schemaOf(xmlText), running };
523
+ }
524
+ function listInstalled3() {
525
+ const names = schtasks(["/Query", "/FO", "CSV", "/NH"]).split(/\r?\n/).map((line) => /^"([^"]+)"/.exec(line)?.[1]?.replace(/^\\/, "")).filter((n) => !!n && /^cloudtunnel\\/i.test(n));
526
+ return [...new Set(names)].map((name) => parseTaskXml(name, schtasks(["/Query", "/TN", name, "/XML"]), true));
418
527
  }
419
528
  function state3(fqdn) {
420
529
  const out = schtasks(["/Query", "/TN", label3(fqdn), "/FO", "LIST"]);
@@ -461,6 +570,15 @@ function installServiceForSpec(params) {
461
570
  function uninstallService(fqdn) {
462
571
  pick()?.uninstall(fqdn);
463
572
  }
573
+ function listInstalledServices() {
574
+ return pick()?.listInstalled() ?? [];
575
+ }
576
+ function uninstallServiceLabel(label4) {
577
+ pick()?.uninstallLabel(label4);
578
+ }
579
+ function prepareServiceChanges() {
580
+ pick()?.prepareChanges();
581
+ }
464
582
  function serviceState(fqdn) {
465
583
  return pick()?.state(fqdn) ?? "none";
466
584
  }
@@ -489,7 +607,7 @@ async function migrateLegacyProfiles() {
489
607
  if (!existsSync3(profilesFile) || existsSync3(skipMarker)) return;
490
608
  let profiles;
491
609
  try {
492
- profiles = JSON.parse(readFileSync(profilesFile, "utf8"));
610
+ profiles = JSON.parse(readFileSync3(profilesFile, "utf8"));
493
611
  } catch {
494
612
  return;
495
613
  }
@@ -843,7 +961,7 @@ async function ensureAuth() {
843
961
  // src/connector/binary.ts
844
962
  import { execFileSync as execFileSync5 } from "child_process";
845
963
  import { createHash } from "crypto";
846
- import { chmodSync, existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync5 } from "fs";
964
+ import { chmodSync, existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
847
965
  import { join as join6 } from "path";
848
966
  import { gunzipSync } from "zlib";
849
967
  var PINNED_VERSION = "2026.7.3";
@@ -874,7 +992,7 @@ function cachedPath() {
874
992
  }
875
993
  function isMusl() {
876
994
  try {
877
- return process.platform === "linux" && readFileSync2("/usr/bin/ldd", "utf8").includes("musl");
995
+ return process.platform === "linux" && readFileSync4("/usr/bin/ldd", "utf8").includes("musl");
878
996
  } catch {
879
997
  return false;
880
998
  }
@@ -941,17 +1059,115 @@ function extractTgz(bytes) {
941
1059
  });
942
1060
  }
943
1061
 
1062
+ // src/cloudflare/tunnels.ts
1063
+ var MANAGED_TUNNEL_PREFIX = "ct-";
1064
+ function isManagedTunnel(tunnel) {
1065
+ return tunnel.name.startsWith(MANAGED_TUNNEL_PREFIX);
1066
+ }
1067
+ async function createTunnel(cf, name) {
1068
+ const env = await cfRequest(cf.token, "POST", `/accounts/${cf.accountId}/cfd_tunnel`, {
1069
+ name,
1070
+ config_src: "cloudflare"
1071
+ });
1072
+ return env.result;
1073
+ }
1074
+ function listTunnels(cf) {
1075
+ return cfPaginate(cf.token, `/accounts/${cf.accountId}/cfd_tunnel?is_deleted=false`);
1076
+ }
1077
+ async function getTunnel(cf, id) {
1078
+ return (await cfRequest(cf.token, "GET", `/accounts/${cf.accountId}/cfd_tunnel/${id}`)).result;
1079
+ }
1080
+ async function deleteTunnel(cf, id) {
1081
+ await cfRequest(cf.token, "DELETE", `/accounts/${cf.accountId}/cfd_tunnel/${id}`);
1082
+ }
1083
+ async function cleanupConnections(cf, id) {
1084
+ await cfRequest(cf.token, "DELETE", `/accounts/${cf.accountId}/cfd_tunnel/${id}/connections`);
1085
+ }
1086
+ async function deleteTunnelWithConnections(cf, id) {
1087
+ try {
1088
+ await deleteTunnel(cf, id);
1089
+ } catch (err) {
1090
+ if (err instanceof CliError && /active connections/i.test(err.message)) {
1091
+ await cleanupConnections(cf, id);
1092
+ await deleteTunnel(cf, id);
1093
+ } else {
1094
+ throw err;
1095
+ }
1096
+ }
1097
+ }
1098
+ async function getTunnelToken(cf, id) {
1099
+ return (await cfRequest(cf.token, "GET", `/accounts/${cf.accountId}/cfd_tunnel/${id}/token`)).result;
1100
+ }
1101
+ async function putIngress(cf, id, ingress) {
1102
+ await cfRequest(cf.token, "PUT", `/accounts/${cf.accountId}/cfd_tunnel/${id}/configurations`, {
1103
+ config: { ingress }
1104
+ });
1105
+ }
1106
+ async function getConnections(cf, id) {
1107
+ const env = await cfRequest(
1108
+ cf.token,
1109
+ "GET",
1110
+ `/accounts/${cf.accountId}/cfd_tunnel/${id}/connections`
1111
+ );
1112
+ return env.result ?? [];
1113
+ }
1114
+
1115
+ // src/connector/health.ts
1116
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1117
+ async function waitHealthy(cf, tunnelId, opts = {}) {
1118
+ const deadline = Date.now() + (opts.timeoutMs ?? 3e4);
1119
+ while (Date.now() < deadline) {
1120
+ if (opts.signal?.aborted) return "dead";
1121
+ try {
1122
+ const connections = await getConnections(cf, tunnelId);
1123
+ if (connections.length > 0) return "healthy";
1124
+ } catch {
1125
+ }
1126
+ await sleep(2e3);
1127
+ }
1128
+ return opts.signal?.aborted ? "dead" : "provisioning";
1129
+ }
1130
+ function extractMetricsPort(log) {
1131
+ const matches = [...log.matchAll(/Starting metrics server on 127\.0\.0\.1:(\d+)\/metrics/g)];
1132
+ if (!matches.length) return void 0;
1133
+ const last = matches[matches.length - 1];
1134
+ const port = Number(last?.[1]);
1135
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : void 0;
1136
+ }
1137
+ async function checkLocalReady(port, opts = {}) {
1138
+ const controller = new AbortController();
1139
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 2e3);
1140
+ try {
1141
+ const res = await fetch(`http://127.0.0.1:${port}/ready`, { signal: controller.signal });
1142
+ if (!res.ok) return { healthy: false, readyConnections: 0, status: res.status };
1143
+ const data = await res.json().catch(() => ({}));
1144
+ const readyConnections = Number(data.readyConnections) || 0;
1145
+ return { healthy: readyConnections > 0, readyConnections, status: res.status };
1146
+ } catch {
1147
+ return { healthy: false, readyConnections: 0 };
1148
+ } finally {
1149
+ clearTimeout(timer);
1150
+ }
1151
+ }
1152
+ async function probeEntryState(entry, gone) {
1153
+ if (gone || entry.state !== "running" || !entry.pid) return "down";
1154
+ if (!entry.metricsPort) return "up";
1155
+ const ready = await checkLocalReady(entry.metricsPort, { timeoutMs: 300 });
1156
+ return ready.healthy ? "up" : "reconnecting";
1157
+ }
1158
+
944
1159
  // src/core/up-runner.ts
945
- import { join as join7 } from "path";
946
- import { readFileSync as readFileSync6, existsSync as existsSync7 } from "fs";
1160
+ import { join as join9 } from "path";
1161
+ import { readFileSync as readFileSync9, existsSync as existsSync8 } from "fs";
947
1162
  import * as clack2 from "@clack/prompts";
948
1163
 
949
1164
  // src/connector/process.ts
950
1165
  import { execFileSync as execFileSync7, spawn as spawn2 } from "child_process";
951
- import { openSync } from "fs";
1166
+ import { openSync, renameSync as renameSync3, statSync } from "fs";
1167
+ import { createServer } from "net";
952
1168
 
953
1169
  // src/connector/registry.ts
954
- import { existsSync as existsSync5, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync6 } from "fs";
1170
+ import { existsSync as existsSync5, readFileSync as readFileSync5, renameSync as renameSync2, writeFileSync as writeFileSync6 } from "fs";
955
1171
  import { execFileSync as execFileSync6 } from "child_process";
956
1172
  import { readFile } from "fs/promises";
957
1173
  import os3 from "os";
@@ -961,7 +1177,7 @@ function entryFqdn(e) {
961
1177
  }
962
1178
  function currentBootId() {
963
1179
  try {
964
- return readFileSync3("/proc/sys/kernel/random/boot_id", "utf8").trim();
1180
+ return readFileSync5("/proc/sys/kernel/random/boot_id", "utf8").trim();
965
1181
  } catch {
966
1182
  if (process.platform === "darwin") {
967
1183
  try {
@@ -976,7 +1192,7 @@ function currentBootId() {
976
1192
  }
977
1193
  function readRegistry() {
978
1194
  try {
979
- return JSON.parse(readFileSync3(registryFile, "utf8"));
1195
+ return JSON.parse(readFileSync5(registryFile, "utf8"));
980
1196
  } catch {
981
1197
  return {};
982
1198
  }
@@ -1066,14 +1282,17 @@ async function isOurConnector(entry) {
1066
1282
  }
1067
1283
  return true;
1068
1284
  }
1285
+ function markConnectorRunning(fqdn, patch) {
1286
+ return patchEntry(fqdn, { bootId: currentBootId(), ...patch, state: "running" });
1287
+ }
1069
1288
  async function reconcile() {
1070
1289
  const entries = listEntries();
1071
1290
  for (const entry of entries) {
1072
- if (entry.state === "running" && !await isOurConnector(entry)) {
1291
+ if (entry.state === "running" && entry.pid && !await isOurConnector(entry)) {
1073
1292
  const fqdn = entryFqdn(entry);
1074
1293
  await mutateRegistry((reg) => {
1075
1294
  const e = reg[fqdn];
1076
- if (e) {
1295
+ if (e && e.state === "running" && e.pid === entry.pid) {
1077
1296
  e.state = "stopped";
1078
1297
  delete e.pid;
1079
1298
  }
@@ -1084,11 +1303,31 @@ async function reconcile() {
1084
1303
  }
1085
1304
 
1086
1305
  // src/connector/process.ts
1087
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1306
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
1307
+ var MAX_LOG_BYTES = 5 * 1024 * 1024;
1308
+ function pickFreePort() {
1309
+ return new Promise((resolve, reject) => {
1310
+ const srv = createServer();
1311
+ srv.unref();
1312
+ srv.on("error", reject);
1313
+ srv.listen(0, "127.0.0.1", () => {
1314
+ const addr = srv.address();
1315
+ const port = typeof addr === "object" && addr ? addr.port : 0;
1316
+ srv.close(() => port ? resolve(port) : reject(new Error("no free port")));
1317
+ });
1318
+ });
1319
+ }
1320
+ function rotateLog(logFile) {
1321
+ try {
1322
+ if (statSync(logFile).size > MAX_LOG_BYTES) renameSync3(logFile, `${logFile}.1`);
1323
+ } catch {
1324
+ }
1325
+ }
1088
1326
  function startConnector(opts) {
1089
- const args = ["tunnel", "--metrics", "127.0.0.1:0", "run"];
1327
+ const args = ["tunnel", "--metrics", `127.0.0.1:${opts.metricsPort ?? 0}`, "run"];
1090
1328
  if (opts.protocol) args.push("--protocol", opts.protocol);
1091
1329
  const env = { ...process.env, TUNNEL_TOKEN: opts.token };
1330
+ rotateLog(opts.logFile);
1092
1331
  const fd = openSync(opts.logFile, "a", 384);
1093
1332
  const child = spawn2(opts.bin, args, { env, detached: opts.detach, stdio: ["ignore", fd, fd] });
1094
1333
  if (!child.pid) throw new CliError("Failed to start the cloudflared connector.");
@@ -1116,7 +1355,7 @@ async function stopConnector(entry) {
1116
1355
  } catch {
1117
1356
  return false;
1118
1357
  }
1119
- await sleep(3e3);
1358
+ await sleep2(3e3);
1120
1359
  if (await isOurConnector(entry)) {
1121
1360
  try {
1122
1361
  process.kill(pid, "SIGKILL");
@@ -1126,101 +1365,52 @@ async function stopConnector(entry) {
1126
1365
  return true;
1127
1366
  }
1128
1367
 
1129
- // src/cloudflare/tunnels.ts
1130
- var MANAGED_TUNNEL_PREFIX = "ct-";
1131
- function isManagedTunnel(tunnel) {
1132
- return tunnel.name.startsWith(MANAGED_TUNNEL_PREFIX);
1133
- }
1134
- async function createTunnel(cf, name) {
1135
- const env = await cfRequest(cf.token, "POST", `/accounts/${cf.accountId}/cfd_tunnel`, {
1136
- name,
1137
- config_src: "cloudflare"
1368
+ // src/connector/hostname-lock.ts
1369
+ import { mkdirSync as mkdirSync2 } from "fs";
1370
+ import { join as join7 } from "path";
1371
+ import lockfile2 from "proper-lockfile";
1372
+ var locksDir = join7(configDir, "locks");
1373
+ var STALE_MS = 15e3;
1374
+ var UPDATE_MS = 5e3;
1375
+ var lockPath = (fqdn) => join7(locksDir, serviceSlug(fqdn));
1376
+ function busyError(fqdn) {
1377
+ return new CliError(`${fqdn} is already being served by another cloudtunnel process.`, {
1378
+ hint: "a boot service may own it \u2014 see `cloudtunnel ls`; release it with `cloudtunnel delete <#>`"
1138
1379
  });
1139
- return env.result;
1140
1380
  }
1141
- function listTunnels(cf) {
1142
- return cfPaginate(cf.token, `/accounts/${cf.accountId}/cfd_tunnel?is_deleted=false`);
1143
- }
1144
- async function getTunnel(cf, id) {
1145
- return (await cfRequest(cf.token, "GET", `/accounts/${cf.accountId}/cfd_tunnel/${id}`)).result;
1146
- }
1147
- async function deleteTunnel(cf, id) {
1148
- await cfRequest(cf.token, "DELETE", `/accounts/${cf.accountId}/cfd_tunnel/${id}`);
1149
- }
1150
- async function cleanupConnections(cf, id) {
1151
- await cfRequest(cf.token, "DELETE", `/accounts/${cf.accountId}/cfd_tunnel/${id}/connections`);
1152
- }
1153
- async function deleteTunnelWithConnections(cf, id) {
1381
+ async function acquireHostnameLock(fqdn, opts = {}) {
1382
+ mkdirSync2(locksDir, { recursive: true, mode: 448 });
1383
+ const waits = Math.ceil((opts.waitMs ?? 0) / 1e3);
1154
1384
  try {
1155
- await deleteTunnel(cf, id);
1385
+ await takeLock(fqdn, waits);
1156
1386
  } catch (err) {
1157
- if (err instanceof CliError && /active connections/i.test(err.message)) {
1158
- await cleanupConnections(cf, id);
1159
- await deleteTunnel(cf, id);
1160
- } else {
1161
- throw err;
1162
- }
1387
+ if (err.code === "ELOCKED") throw busyError(fqdn);
1388
+ throw err;
1163
1389
  }
1164
1390
  }
1165
- async function getTunnelToken(cf, id) {
1166
- return (await cfRequest(cf.token, "GET", `/accounts/${cf.accountId}/cfd_tunnel/${id}/token`)).result;
1167
- }
1168
- async function putIngress(cf, id, ingress) {
1169
- await cfRequest(cf.token, "PUT", `/accounts/${cf.accountId}/cfd_tunnel/${id}/configurations`, {
1170
- config: { ingress }
1171
- });
1172
- }
1173
- async function getConnections(cf, id) {
1174
- const env = await cfRequest(
1175
- cf.token,
1176
- "GET",
1177
- `/accounts/${cf.accountId}/cfd_tunnel/${id}/connections`
1178
- );
1179
- return env.result ?? [];
1180
- }
1181
-
1182
- // src/connector/health.ts
1183
- var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
1184
- async function waitHealthy(cf, tunnelId, opts = {}) {
1185
- const deadline = Date.now() + (opts.timeoutMs ?? 3e4);
1186
- while (Date.now() < deadline) {
1187
- if (opts.signal?.aborted) return "dead";
1188
- try {
1189
- const connections = await getConnections(cf, tunnelId);
1190
- if (connections.length > 0) return "healthy";
1191
- } catch {
1391
+ function takeLock(fqdn, waits) {
1392
+ return lockfile2.lock(lockPath(fqdn), {
1393
+ realpath: false,
1394
+ stale: STALE_MS,
1395
+ update: UPDATE_MS,
1396
+ retries: waits > 0 ? { retries: waits, minTimeout: 1e3, maxTimeout: 1e3 } : 0,
1397
+ onCompromised: () => {
1398
+ say.debug(`[lock] ${fqdn} lock was compromised; re-taking it`);
1399
+ takeLock(fqdn, 0).catch((err) => {
1400
+ say.warn(`Lost ${fqdn} to another cloudtunnel process (${err.message}) \u2014 exiting.`);
1401
+ process.exit(1);
1402
+ });
1192
1403
  }
1193
- await sleep2(2e3);
1194
- }
1195
- return opts.signal?.aborted ? "dead" : "provisioning";
1196
- }
1197
- function extractMetricsPort(log) {
1198
- const matches = [...log.matchAll(/Starting metrics server on 127\.0\.0\.1:(\d+)\/metrics/g)];
1199
- if (!matches.length) return void 0;
1200
- const last = matches[matches.length - 1];
1201
- const port = Number(last?.[1]);
1202
- return Number.isInteger(port) && port > 0 && port <= 65535 ? port : void 0;
1404
+ });
1203
1405
  }
1204
- async function checkLocalReady(port, opts = {}) {
1205
- const controller = new AbortController();
1206
- const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 2e3);
1406
+ async function assertHostnameFree(fqdn) {
1407
+ let held = false;
1207
1408
  try {
1208
- const res = await fetch(`http://127.0.0.1:${port}/ready`, { signal: controller.signal });
1209
- if (!res.ok) return { healthy: false, readyConnections: 0, status: res.status };
1210
- const data = await res.json().catch(() => ({}));
1211
- const readyConnections = Number(data.readyConnections) || 0;
1212
- return { healthy: readyConnections > 0, readyConnections, status: res.status };
1409
+ held = await lockfile2.check(lockPath(fqdn), { realpath: false, stale: STALE_MS });
1213
1410
  } catch {
1214
- return { healthy: false, readyConnections: 0 };
1215
- } finally {
1216
- clearTimeout(timer);
1411
+ held = false;
1217
1412
  }
1218
- }
1219
- async function probeEntryState(entry, gone) {
1220
- if (gone || entry.state !== "running" || !entry.pid) return "down";
1221
- if (!entry.metricsPort) return "up";
1222
- const ready = await checkLocalReady(entry.metricsPort, { timeoutMs: 300 });
1223
- return ready.healthy ? "up" : "reconnecting";
1413
+ if (held) throw busyError(fqdn);
1224
1414
  }
1225
1415
 
1226
1416
  // src/core/orchestrator-create.ts
@@ -1296,6 +1486,10 @@ async function createTunnelSubdomain(cf, opts) {
1296
1486
  const host = resolveHostSpec(opts, opts.defaultZone);
1297
1487
  const zone = await resolveZone(cf.token, host.zone);
1298
1488
  const existingList = await findDnsRecords(cf.token, zone.id, host.hostname);
1489
+ if (opts.reuse) {
1490
+ const reused = await tryReuseTunnel(cf, zone.id, host, opts, existingList);
1491
+ if (reused) return reused;
1492
+ }
1299
1493
  if (existingList.length > 0) {
1300
1494
  const isTunnelRecord = existingList.every((r) => r.content.endsWith(".cfargotunnel.com"));
1301
1495
  if (!isTunnelRecord && !opts.force) {
@@ -1309,9 +1503,14 @@ async function createTunnelSubdomain(cf, opts) {
1309
1503
  throw new CliError("Cancelled.", { exitCode: 130 });
1310
1504
  }
1311
1505
  }
1506
+ const prev = getEntry(host.hostname);
1507
+ if (prev) await stopConnector(prev);
1312
1508
  for (const rec of existingList) {
1313
1509
  await releaseHostname(cf, zone.id, rec);
1314
1510
  }
1511
+ } else {
1512
+ const prev = getEntry(host.hostname);
1513
+ if (prev) await stopConnector(prev);
1315
1514
  }
1316
1515
  await upsertEntry(host.hostname, {
1317
1516
  subdomain: host.subdomain,
@@ -1320,7 +1519,9 @@ async function createTunnelSubdomain(cf, opts) {
1320
1519
  port: opts.port,
1321
1520
  proto: opts.proto,
1322
1521
  host: opts.host,
1323
- state: "provisioning"
1522
+ state: "provisioning",
1523
+ pid: void 0,
1524
+ metricsPort: void 0
1324
1525
  });
1325
1526
  let tunnelId;
1326
1527
  let dnsRecordId;
@@ -1342,6 +1543,25 @@ async function createTunnelSubdomain(cf, opts) {
1342
1543
  throw err;
1343
1544
  }
1344
1545
  }
1546
+ async function tryReuseTunnel(cf, zoneId, host, opts, existing) {
1547
+ const prev = getEntry(host.hostname);
1548
+ const tunnelId = prev?.tunnelId;
1549
+ const record = existing[0];
1550
+ if (!prev || !tunnelId || existing.length !== 1 || record?.content !== `${tunnelId}.cfargotunnel.com`) return null;
1551
+ let tunnel;
1552
+ try {
1553
+ tunnel = await getTunnel(cf, tunnelId);
1554
+ } catch (err) {
1555
+ if (err instanceof CliError && err.status === 404) return null;
1556
+ throw err;
1557
+ }
1558
+ if (!isManagedTunnel(tunnel) || tunnel.deleted_at) return null;
1559
+ await putIngress(cf, tunnelId, buildIngress({ hostname: host.hostname, port: opts.port, proto: opts.proto, host: opts.host }));
1560
+ const token = await getTunnelToken(cf, tunnelId);
1561
+ await stopConnector(prev);
1562
+ await recordRunning(host, zoneId, tunnelId, record.id, opts);
1563
+ return { host, tunnelId, token };
1564
+ }
1345
1565
  async function recordRunning(host, zoneId, tunnelId, dnsRecordId, opts) {
1346
1566
  await upsertEntry(host.hostname, {
1347
1567
  subdomain: host.subdomain,
@@ -1388,8 +1608,105 @@ async function rollback(cf, zoneId, tunnelId, dnsRecordId, hostname) {
1388
1608
  return clean;
1389
1609
  }
1390
1610
 
1611
+ // src/core/service-inventory.ts
1612
+ import { existsSync as existsSync6 } from "fs";
1613
+
1614
+ // src/core/transport-protocol.ts
1615
+ function parseTransportProtocol(value) {
1616
+ if (value === "auto" || value === "http2" || value === "quic") return value;
1617
+ throw new CliError(`Invalid protocol "${value}".`, { hint: "use auto, http2, or quic" });
1618
+ }
1619
+
1620
+ // src/core/service-inventory.ts
1621
+ function flagValue(argv, flag) {
1622
+ const i = argv.indexOf(flag);
1623
+ return i >= 0 ? argv[i + 1] : void 0;
1624
+ }
1625
+ function parseServiceArgv(argv) {
1626
+ const zone = flagValue(argv, "-d");
1627
+ if (!zone) return void 0;
1628
+ const proto = flagValue(argv, "--proto") === "https" ? "https" : "http";
1629
+ try {
1630
+ if (argv[0] === "relay" && argv[1]) {
1631
+ return { command: "relay", subdomain: argv[1], port: 0, zone, proto };
1632
+ }
1633
+ if (argv[0] === "up" && argv[1]) {
1634
+ const spec = parseTunnelSpec(argv[1]);
1635
+ if (spec.subdomain === void 0) return void 0;
1636
+ const protocolFlag = flagValue(argv, "--protocol");
1637
+ return {
1638
+ subdomain: spec.subdomain,
1639
+ port: spec.port,
1640
+ host: spec.host,
1641
+ zone,
1642
+ proto,
1643
+ protocol: protocolFlag ? parseTransportProtocol(protocolFlag) : void 0
1644
+ };
1645
+ }
1646
+ } catch {
1647
+ return void 0;
1648
+ }
1649
+ return void 0;
1650
+ }
1651
+ function classifyDefinition(def, canonicalLabel, exists = existsSync6) {
1652
+ const [nodePath, scriptPath, ...argv] = def.command;
1653
+ const params = parseServiceArgv(argv);
1654
+ if (!params) return void 0;
1655
+ const fqdn = fqdnFor(params.subdomain, params.zone);
1656
+ const canonical = def.label.toLowerCase() === canonicalLabel(fqdn).toLowerCase();
1657
+ let issue;
1658
+ if (!canonical) issue = "duplicate";
1659
+ else if (!nodePath || !scriptPath || !exists(nodePath) || !exists(scriptPath)) issue = "broken";
1660
+ else if (def.schema < SERVICE_SCHEMA || !argv.includes("--keep-alive")) issue = "stale";
1661
+ else if (!def.running) issue = "dead";
1662
+ return { label: def.label, fqdn, params, canonical, issue };
1663
+ }
1664
+ function inventoryServices() {
1665
+ return listInstalledServices().map((def) => classifyDefinition(def, serviceName)).filter((s) => !!s);
1666
+ }
1667
+ function planRepair(services, opts = {}) {
1668
+ const byFqdn = /* @__PURE__ */ new Map();
1669
+ for (const s of services) byFqdn.set(s.fqdn, [...byFqdn.get(s.fqdn) ?? [], s]);
1670
+ const actions = [];
1671
+ for (const [fqdn, group] of byFqdn) {
1672
+ const main2 = group.find((s) => s.canonical);
1673
+ const extras = group.filter((s) => !s.canonical);
1674
+ const action = { fqdn, remove: extras.map((s) => s.label) };
1675
+ if (!main2) action.reinstall = { params: extras[0].params, reason: "duplicate" };
1676
+ else if (main2.issue) action.reinstall = { params: main2.params, reason: main2.issue };
1677
+ else if (opts.restartRunning) action.reinstall = { params: main2.params, reason: "outdated" };
1678
+ if (action.reinstall || action.remove.length) actions.push(action);
1679
+ }
1680
+ return actions;
1681
+ }
1682
+ function runRepair(actions) {
1683
+ const result = { fixed: [], failed: [] };
1684
+ if (actions.length === 0) return result;
1685
+ prepareServiceChanges();
1686
+ for (const a of actions) {
1687
+ try {
1688
+ for (const label4 of a.remove) uninstallServiceLabel(label4);
1689
+ if (a.reinstall) installServiceForSpec(a.reinstall.params);
1690
+ result.fixed.push(a.fqdn);
1691
+ } catch (err) {
1692
+ result.failed.push({ fqdn: a.fqdn, error: err.message });
1693
+ }
1694
+ }
1695
+ return result;
1696
+ }
1697
+ function serviceIssuesByFqdn() {
1698
+ const map = /* @__PURE__ */ new Map();
1699
+ try {
1700
+ for (const a of planRepair(inventoryServices())) {
1701
+ map.set(a.fqdn, a.reinstall?.reason ?? "duplicate");
1702
+ }
1703
+ } catch {
1704
+ }
1705
+ return map;
1706
+ }
1707
+
1391
1708
  // src/core/unmanaged-scan-cache.ts
1392
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync7 } from "fs";
1709
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync7 } from "fs";
1393
1710
  function saveUnmanagedScan(rows) {
1394
1711
  try {
1395
1712
  ensureDirs();
@@ -1401,7 +1718,7 @@ function saveUnmanagedScan(rows) {
1401
1718
  }
1402
1719
  function lookupUnmanagedByIndex(num) {
1403
1720
  try {
1404
- const cache = JSON.parse(readFileSync4(scanCacheFile, "utf8"));
1721
+ const cache = JSON.parse(readFileSync6(scanCacheFile, "utf8"));
1405
1722
  return cache[String(num)];
1406
1723
  } catch {
1407
1724
  return void 0;
@@ -1485,8 +1802,8 @@ async function resolveRemoteTarget(cf, target) {
1485
1802
  }
1486
1803
  const tunnel = matches[0];
1487
1804
  if (!tunnel) return null;
1488
- const { listCargoCnames } = await import("./dns-BNDEFUCM.js");
1489
- const { listZones: listZones3 } = await import("./zones-XNCXYCB4.js");
1805
+ const { listCargoCnames } = await import("./dns-VUPKRH46.js");
1806
+ const { listZones: listZones3 } = await import("./zones-CF4XFFZZ.js");
1490
1807
  for (const zone of await listZones3(cf.token)) {
1491
1808
  const rec = (await listCargoCnames(cf.token, zone.id)).find((r) => tunnelIdFromCname2(r.content) === tunnel.id);
1492
1809
  if (rec) return { tunnel, fqdn: rec.name };
@@ -1507,25 +1824,29 @@ async function removeTunnelById(cf, tunnel, opts = {}) {
1507
1824
  async function listAll(cf, opts = {}) {
1508
1825
  const entries = await reconcile();
1509
1826
  const tunnels = new Map((await listTunnels(cf)).map((t) => [t.id, t]));
1827
+ const issues = serviceIssuesByFqdn();
1510
1828
  const rows = await Promise.all(entries.map(async (e) => {
1511
1829
  const fqdn = entryFqdn(e);
1512
1830
  const gone = e.tunnelId ? !tunnels.has(e.tunnelId) : false;
1513
1831
  const svc = serviceState(fqdn);
1514
1832
  const state4 = await probeEntryState(e, gone);
1833
+ const issue = issues.get(fqdn);
1834
+ const svcLabel = svc === "none" ? "-" : svc;
1515
1835
  return {
1516
1836
  num: e.index ? String(e.index) : "-",
1517
1837
  url: `https://${fqdn}`,
1518
1838
  target: serviceUrl(e.proto, e.host ?? "localhost", e.port),
1519
1839
  protocol: e.protocol ?? "auto",
1520
1840
  state: state4,
1521
- service: svc === "none" ? "-" : svc,
1841
+ service: issue ? `${svcLabel} (${issue})` : svcLabel,
1522
1842
  pid: e.state === "running" && e.pid ? String(e.pid) : "-",
1523
- managed: true
1843
+ managed: true,
1844
+ serviceIssue: issue
1524
1845
  };
1525
1846
  }));
1526
1847
  if (opts.all) {
1527
- const { listCargoCnames } = await import("./dns-BNDEFUCM.js");
1528
- const { listZones: listZones3 } = await import("./zones-XNCXYCB4.js");
1848
+ const { listCargoCnames } = await import("./dns-VUPKRH46.js");
1849
+ const { listZones: listZones3 } = await import("./zones-CF4XFFZZ.js");
1529
1850
  const tracked = new Set(entries.map(entryFqdn));
1530
1851
  const unmanaged = [];
1531
1852
  for (const zone of await listZones3(cf.token)) {
@@ -1547,19 +1868,27 @@ async function listAll(cf, opts = {}) {
1547
1868
  }
1548
1869
 
1549
1870
  // src/core/watchdog.ts
1550
- import { readFileSync as readFileSync5, existsSync as existsSync6 } from "fs";
1871
+ import { readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
1551
1872
  var activeWatchdogs = /* @__PURE__ */ new Map();
1552
1873
  var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
1874
+ var EXIT_RESTART_GAP_MS = 5e3;
1553
1875
  function resolveMetricsPort(logFile, fallback) {
1554
- if (existsSync6(logFile)) {
1876
+ if (existsSync7(logFile)) {
1555
1877
  try {
1556
- const port = extractMetricsPort(readFileSync5(logFile, "utf8"));
1878
+ const port = extractMetricsPort(readFileSync7(logFile, "utf8"));
1557
1879
  if (port) return port;
1558
1880
  } catch {
1559
1881
  }
1560
1882
  }
1561
1883
  return fallback;
1562
1884
  }
1885
+ async function freshMetricsPort() {
1886
+ try {
1887
+ return await pickFreePort();
1888
+ } catch {
1889
+ return void 0;
1890
+ }
1891
+ }
1563
1892
  async function verifyRemoteTunnel(cf, tunnelId) {
1564
1893
  try {
1565
1894
  await getTunnel(cf, tunnelId);
@@ -1569,25 +1898,39 @@ async function verifyRemoteTunnel(cf, tunnelId) {
1569
1898
  return true;
1570
1899
  }
1571
1900
  }
1572
- async function tier1Restart(ctx, state4) {
1573
- state4.lastRestartAt = Date.now();
1574
- state4.tier1Restarts++;
1575
- say.warn(`[watchdog] Health check failed for ${ctx.fqdn} -> restarting connector (Tier 1)...`);
1576
- await stopConnector({ pid: ctx.pid, bootId: currentBootId() });
1901
+ async function launchConnector(ctx) {
1902
+ const metricsPort = await freshMetricsPort();
1577
1903
  const conn = startConnector({
1578
1904
  bin: ctx.bin,
1579
1905
  token: ctx.token,
1580
1906
  detach: false,
1581
1907
  logFile: ctx.logFile,
1582
1908
  protocol: ctx.protocol,
1909
+ metricsPort,
1583
1910
  onExit: () => {
1584
- void onConnectorExited(ctx.fqdn);
1911
+ void notifyConnectorExited(ctx.fqdn);
1585
1912
  }
1586
1913
  });
1914
+ if (activeWatchdogs.get(ctx.fqdn)?.ctx !== ctx) {
1915
+ await stopConnector({ pid: conn.pid, bootId: currentBootId() });
1916
+ return;
1917
+ }
1587
1918
  ctx.pid = conn.pid;
1588
1919
  await sleep3(1e3);
1589
- ctx.metricsPort = resolveMetricsPort(ctx.logFile, ctx.metricsPort);
1590
- await patchEntry(ctx.fqdn, { pid: conn.pid, metricsPort: ctx.metricsPort, bootId: currentBootId() });
1920
+ ctx.metricsPort = metricsPort ?? resolveMetricsPort(ctx.logFile, ctx.metricsPort);
1921
+ await markConnectorRunning(ctx.fqdn, {
1922
+ pid: conn.pid,
1923
+ metricsPort: ctx.metricsPort,
1924
+ bootId: currentBootId(),
1925
+ tunnelId: ctx.tunnelId
1926
+ });
1927
+ }
1928
+ async function tier1Restart(ctx, state4) {
1929
+ state4.lastRestartAt = Date.now();
1930
+ state4.tier1Restarts++;
1931
+ say.warn(`[watchdog] Health check failed for ${ctx.fqdn} -> restarting connector (Tier 1)...`);
1932
+ await stopConnector({ pid: ctx.pid, bootId: currentBootId() });
1933
+ await launchConnector(ctx);
1591
1934
  state4.consecutiveFailures = 0;
1592
1935
  }
1593
1936
  async function tier2Reprovision(ctx, state4) {
@@ -1598,36 +1941,46 @@ async function tier2Reprovision(ctx, state4) {
1598
1941
  const result = await createTunnelSubdomain(ctx.cf, { ...ctx.item, force: true, yes: true });
1599
1942
  ctx.tunnelId = result.tunnelId;
1600
1943
  ctx.token = result.token;
1601
- const conn = startConnector({
1602
- bin: ctx.bin,
1603
- token: ctx.token,
1604
- detach: false,
1605
- logFile: ctx.logFile,
1606
- protocol: ctx.protocol,
1607
- onExit: () => {
1608
- void onConnectorExited(ctx.fqdn);
1609
- }
1610
- });
1611
- ctx.pid = conn.pid;
1612
- await sleep3(1e3);
1613
- ctx.metricsPort = resolveMetricsPort(ctx.logFile, ctx.metricsPort);
1614
- await patchEntry(ctx.fqdn, { pid: conn.pid, metricsPort: ctx.metricsPort, bootId: currentBootId(), tunnelId: ctx.tunnelId });
1944
+ await launchConnector(ctx);
1615
1945
  state4.consecutiveFailures = 0;
1616
1946
  state4.tier1Restarts = 0;
1617
1947
  }
1618
- async function onConnectorExited(fqdn) {
1948
+ async function guardedRestart(fqdn, run2) {
1949
+ const item = activeWatchdogs.get(fqdn);
1950
+ if (!item || item.state.restarting) return;
1951
+ item.state.restarting = true;
1952
+ try {
1953
+ await run2();
1954
+ } catch (err) {
1955
+ say.warn(`[watchdog] Recovery for ${fqdn} failed: ${err.message} \u2014 retrying on the next check`);
1956
+ } finally {
1957
+ item.state.restarting = false;
1958
+ }
1959
+ }
1960
+ async function notifyConnectorExited(fqdn) {
1619
1961
  const item = activeWatchdogs.get(fqdn);
1620
1962
  if (!item) return;
1621
1963
  const { ctx, state: state4 } = item;
1622
- if (Date.now() - state4.lastRestartAt < 5e3) return;
1623
- await tier1Restart(ctx, state4);
1964
+ if (state4.restarting || Date.now() - state4.lastRestartAt < EXIT_RESTART_GAP_MS) return;
1965
+ const run2 = state4.tier1Restarts >= 3 ? tier2Reprovision : tier1Restart;
1966
+ await guardedRestart(fqdn, () => run2(ctx, state4));
1624
1967
  }
1625
1968
  function startTunnelWatchdog(ctx, opts = {}) {
1626
1969
  stopTunnelWatchdog(ctx.fqdn);
1627
1970
  const intervalMs = opts.intervalMs ?? 15e3;
1628
1971
  const cooldownMs = opts.cooldownMs ?? 5e3;
1629
- const state4 = { consecutiveFailures: 0, tier1Restarts: 0, tier2Restarts: 0, lastRestartAt: 0 };
1972
+ const state4 = { consecutiveFailures: 0, tier1Restarts: 0, tier2Restarts: 0, lastRestartAt: 0, restarting: false };
1973
+ let checking = false;
1630
1974
  const check = async () => {
1975
+ if (state4.restarting || checking) return;
1976
+ checking = true;
1977
+ try {
1978
+ await runCheck();
1979
+ } finally {
1980
+ checking = false;
1981
+ }
1982
+ };
1983
+ const runCheck = async () => {
1631
1984
  if (!ctx.metricsPort) ctx.metricsPort = resolveMetricsPort(ctx.logFile, ctx.metricsPort);
1632
1985
  let healthy = false;
1633
1986
  if (ctx.metricsPort) {
@@ -1636,22 +1989,19 @@ function startTunnelWatchdog(ctx, opts = {}) {
1636
1989
  }
1637
1990
  if (healthy) {
1638
1991
  state4.consecutiveFailures = 0;
1992
+ state4.tier1Restarts = 0;
1639
1993
  return;
1640
1994
  }
1641
1995
  state4.consecutiveFailures++;
1642
1996
  if (state4.consecutiveFailures >= 3 && Date.now() - state4.lastRestartAt >= cooldownMs) {
1643
1997
  const remoteExists = await verifyRemoteTunnel(ctx.cf, ctx.tunnelId);
1644
- if (!remoteExists || state4.tier1Restarts >= 3) {
1645
- await tier2Reprovision(ctx, state4);
1646
- } else {
1647
- await tier1Restart(ctx, state4);
1648
- }
1998
+ const run2 = !remoteExists || state4.tier1Restarts >= 3 ? tier2Reprovision : tier1Restart;
1999
+ await guardedRestart(ctx.fqdn, () => run2(ctx, state4));
1649
2000
  }
1650
2001
  };
1651
2002
  const timer = setInterval(() => {
1652
- void check();
2003
+ check().catch((err) => say.debug(`[watchdog] check failed: ${err.message}`));
1653
2004
  }, intervalMs);
1654
- if (typeof timer.unref === "function") timer.unref();
1655
2005
  activeWatchdogs.set(ctx.fqdn, { timer, ctx, state: state4 });
1656
2006
  }
1657
2007
  function stopTunnelWatchdog(fqdn) {
@@ -1665,26 +2015,55 @@ function stopAllWatchdogs() {
1665
2015
  for (const [fqdn] of activeWatchdogs) stopTunnelWatchdog(fqdn);
1666
2016
  }
1667
2017
 
2018
+ // src/core/installed-version.ts
2019
+ import { readFileSync as readFileSync8, realpathSync as realpathSync2 } from "fs";
2020
+ import { dirname as dirname3, join as join8 } from "path";
2021
+ function readInstalledVersion() {
2022
+ try {
2023
+ const script = realpathSync2(process.argv[1] ?? "");
2024
+ const pkg2 = JSON.parse(readFileSync8(join8(dirname3(script), "..", "package.json"), "utf8"));
2025
+ return typeof pkg2.version === "string" ? pkg2.version : void 0;
2026
+ } catch {
2027
+ return void 0;
2028
+ }
2029
+ }
2030
+
1668
2031
  // src/core/up-runner.ts
1669
2032
  var sleep4 = (ms) => new Promise((r) => setTimeout(r, ms));
2033
+ var UPGRADE_RESTART_EXIT_CODE = 75;
2034
+ var VERSION_CHECK_INTERVAL_MS = 6e4;
2035
+ var LOCK_WAIT_MS = 3e4;
2036
+ var MAX_CREATE_BACKOFF_MS = 6e4;
1670
2037
  function logFileFor(fqdn) {
1671
- return join7(logDir, `${serviceSlug(fqdn)}.log`);
2038
+ return join9(logDir, `${serviceSlug(fqdn)}.log`);
2039
+ }
2040
+ function knownFqdn(item) {
2041
+ if (item.hostname) return item.hostname;
2042
+ return item.name !== void 0 && item.zone ? fqdnFor(item.name, item.zone) : void 0;
1672
2043
  }
1673
2044
  async function createWithBootRetry(cf, item, keepAlive) {
1674
- const deadline = Date.now() + (keepAlive ? 3e4 : 0);
2045
+ if (!keepAlive) return createTunnelSubdomain(cf, item);
1675
2046
  for (let attempt = 0; ; attempt++) {
1676
2047
  try {
1677
- return await createTunnelSubdomain(cf, item);
2048
+ return await createTunnelSubdomain(cf, { ...item, reuse: true });
1678
2049
  } catch (err) {
1679
- if (!keepAlive || Date.now() >= deadline) throw err;
1680
- say.debug(`[boot-retry] Waiting for network to create ${item.name ?? "tunnel"}...`);
1681
- await sleep4(Math.min(2 ** attempt * 1e3, 5e3));
2050
+ const wait = Math.min(2 ** attempt * 1e3, MAX_CREATE_BACKOFF_MS);
2051
+ say.warn(`Could not create ${item.name ?? "tunnel"} (${err.message}) \u2014 retrying in ${wait / 1e3}s`);
2052
+ await sleep4(wait);
1682
2053
  }
1683
2054
  }
1684
2055
  }
2056
+ async function freshMetricsPort2() {
2057
+ try {
2058
+ return await pickFreePort();
2059
+ } catch {
2060
+ return void 0;
2061
+ }
2062
+ }
1685
2063
  async function startTunnels(cf, bin, items, opts = {}) {
1686
2064
  const started = [];
1687
2065
  let tornDown = false;
2066
+ const supervised = !!opts.keepAlive && !opts.detach;
1688
2067
  const teardownAll = async (code) => {
1689
2068
  if (tornDown) return;
1690
2069
  tornDown = true;
@@ -1692,7 +2071,8 @@ async function startTunnels(cf, bin, items, opts = {}) {
1692
2071
  try {
1693
2072
  for (const s of started) {
1694
2073
  if (opts.keepAlive) {
1695
- await stopConnector({ pid: s.pid, bootId: currentBootId() });
2074
+ const current = getEntry(s.fqdn);
2075
+ await stopConnector(current?.pid ? current : { pid: s.pid, bootId: currentBootId() });
1696
2076
  } else {
1697
2077
  try {
1698
2078
  await removeTunnelSubdomain(cf, s.fqdn, { force: true, quiet: true });
@@ -1707,6 +2087,12 @@ async function startTunnels(cf, bin, items, opts = {}) {
1707
2087
  process.exit(code);
1708
2088
  }
1709
2089
  };
2090
+ for (const item of items) {
2091
+ const fqdn = knownFqdn(item);
2092
+ if (!fqdn) continue;
2093
+ if (opts.detach) await assertHostnameFree(fqdn);
2094
+ else await acquireHostnameLock(fqdn, { waitMs: opts.keepAlive ? LOCK_WAIT_MS : 0 });
2095
+ }
1710
2096
  const spin = clack2.spinner();
1711
2097
  spin.start(items.length > 1 ? "Creating tunnels\u2026" : "Creating tunnel\u2026");
1712
2098
  for (const item of items) {
@@ -1714,28 +2100,35 @@ async function startTunnels(cf, bin, items, opts = {}) {
1714
2100
  const result = await createWithBootRetry(cf, item, opts.keepAlive);
1715
2101
  const fqdn = result.host.hostname;
1716
2102
  const logFile = logFileFor(fqdn);
2103
+ const knownPort = await freshMetricsPort2();
1717
2104
  const conn = startConnector({
1718
2105
  bin,
1719
2106
  token: result.token,
1720
2107
  detach: !!opts.detach,
1721
2108
  logFile,
1722
2109
  protocol: opts.protocol,
2110
+ metricsPort: knownPort,
1723
2111
  onExit: opts.detach ? void 0 : (code) => {
1724
- if (!tornDown && !opts.keepAlive) {
1725
- say.warn(`Connector for ${fqdn} exited.`);
1726
- void teardownAll(code ?? 1);
2112
+ if (tornDown) return;
2113
+ if (opts.keepAlive) {
2114
+ void notifyConnectorExited(fqdn);
2115
+ return;
1727
2116
  }
2117
+ say.warn(`Connector for ${fqdn} exited.`);
2118
+ void teardownAll(code ?? 1);
1728
2119
  }
1729
2120
  });
1730
- await sleep4(600);
1731
- let metricsPort;
1732
- if (existsSync7(logFile)) {
1733
- try {
1734
- metricsPort = extractMetricsPort(readFileSync6(logFile, "utf8"));
1735
- } catch {
2121
+ let metricsPort = knownPort;
2122
+ if (!metricsPort) {
2123
+ await sleep4(600);
2124
+ if (existsSync8(logFile)) {
2125
+ try {
2126
+ metricsPort = extractMetricsPort(readFileSync9(logFile, "utf8"));
2127
+ } catch {
2128
+ }
1736
2129
  }
1737
2130
  }
1738
- await patchEntry(fqdn, { pid: conn.pid, bootId: currentBootId(), logFile, metricsPort, protocol: opts.protocol });
2131
+ await markConnectorRunning(fqdn, { pid: conn.pid, bootId: currentBootId(), logFile, metricsPort, protocol: opts.protocol });
1739
2132
  started.push({
1740
2133
  fqdn,
1741
2134
  subdomain: result.host.subdomain,
@@ -1743,7 +2136,7 @@ async function startTunnels(cf, bin, items, opts = {}) {
1743
2136
  target: serviceUrl(item.proto, item.host ?? "localhost", item.port),
1744
2137
  pid: conn.pid
1745
2138
  });
1746
- if (!opts.detach) {
2139
+ if (supervised) {
1747
2140
  startTunnelWatchdog({
1748
2141
  cf,
1749
2142
  bin,
@@ -1768,6 +2161,7 @@ async function startTunnels(cf, bin, items, opts = {}) {
1768
2161
  for (const sig of ["SIGINT", "SIGHUP", "SIGTERM"]) {
1769
2162
  process.on(sig, () => void teardownAll(0));
1770
2163
  }
2164
+ if (supervised) watchForUpgrade(() => void teardownAll(UPGRADE_RESTART_EXIT_CODE));
1771
2165
  spin.message("Connecting to the Cloudflare edge\u2026");
1772
2166
  const healths = await Promise.all(started.map((s) => waitHealthy(cf, s.tunnelId, { timeoutMs: 3e4 })));
1773
2167
  const live = healths.filter((h) => h === "healthy").length;
@@ -1776,6 +2170,20 @@ async function startTunnels(cf, bin, items, opts = {}) {
1776
2170
  clack2.note(lines.join("\n"), `${live}/${started.length} live`);
1777
2171
  say.dim(opts.keepAlive ? "Service running with auto-heal watchdog." : "Ctrl-C stops and releases them.");
1778
2172
  }
2173
+ function watchForUpgrade(restart) {
2174
+ if (process.platform === "win32") return;
2175
+ const startedWith = readInstalledVersion();
2176
+ if (!startedWith) return;
2177
+ const timer = setInterval(() => {
2178
+ const now = readInstalledVersion();
2179
+ if (now && now !== startedWith) {
2180
+ clearInterval(timer);
2181
+ say.warn(`cloudtunnel ${startedWith} \u2192 ${now} installed \u2014 restarting onto the new version\u2026`);
2182
+ restart();
2183
+ }
2184
+ }, VERSION_CHECK_INTERVAL_MS);
2185
+ timer.unref();
2186
+ }
1779
2187
 
1780
2188
  // src/core/resolve-domain.ts
1781
2189
  async function resolveDomain(cf, opts, creds) {
@@ -1788,12 +2196,6 @@ async function resolveDomain(cf, opts, creds) {
1788
2196
  throw new CliError("Multiple domains in this account \u2014 pick one.", { hint: "pass -d <domain>" });
1789
2197
  }
1790
2198
 
1791
- // src/core/transport-protocol.ts
1792
- function parseTransportProtocol(value) {
1793
- if (value === "auto" || value === "http2" || value === "quic") return value;
1794
- throw new CliError(`Invalid protocol "${value}".`, { hint: "use auto, http2, or quic" });
1795
- }
1796
-
1797
2199
  // src/commands/up.ts
1798
2200
  function promptOrExit(value) {
1799
2201
  if (clack3.isCancel(value)) {
@@ -1857,7 +2259,7 @@ async function runUp(specArgs, opts) {
1857
2259
  }
1858
2260
  await startTunnels(cf, bin, items, { detach: opts.detach, protocol, keepAlive: opts.keepAlive });
1859
2261
  }
1860
- var SERVICE_UP_TIMEOUT_MS = 2e4;
2262
+ var SERVICE_UP_TIMEOUT_MS = 3e4;
1861
2263
  var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1862
2264
  async function registerServices(cf, items, domain, proto, protocol) {
1863
2265
  assertServiceSupported();
@@ -1869,6 +2271,7 @@ async function registerServices(cf, items, domain, proto, protocol) {
1869
2271
  for (const item of items) {
1870
2272
  const subdomain = item.name;
1871
2273
  const fqdn = subdomain === "@" ? domain : `${subdomain}.${domain}`;
2274
+ await patchEntry(fqdn, { state: "provisioning", pid: void 0, metricsPort: void 0 });
1872
2275
  installServiceForSpec({ subdomain, port: item.port, host: item.host, zone: domain, proto, protocol });
1873
2276
  fqdns.push(fqdn);
1874
2277
  }
@@ -1893,11 +2296,21 @@ async function waitServicesUp(fqdns, timeoutMs) {
1893
2296
  while (pending.size > 0) {
1894
2297
  for (const fqdn of [...pending]) {
1895
2298
  const entry = getEntry(fqdn);
1896
- if (entry?.state === "running" && entry.pid) pending.delete(fqdn);
2299
+ if (entry?.state === "running" && entry.pid && await isOurConnector(entry)) {
2300
+ if (!entry.metricsPort || (await checkLocalReady(entry.metricsPort, { timeoutMs: 500 })).healthy) {
2301
+ pending.delete(fqdn);
2302
+ }
2303
+ }
1897
2304
  }
1898
2305
  if (pending.size === 0 || Date.now() >= deadline) break;
1899
2306
  await delay(500);
1900
2307
  }
2308
+ for (const fqdn of [...pending]) {
2309
+ const entry = getEntry(fqdn);
2310
+ if (entry?.state === "running" && entry.pid && await isOurConnector(entry)) {
2311
+ pending.delete(fqdn);
2312
+ }
2313
+ }
1901
2314
  return [...pending];
1902
2315
  }
1903
2316
  function registerUp(program) {
@@ -1918,6 +2331,8 @@ function registerLs(program) {
1918
2331
  ["#", "URL", "TARGET", "PROTOCOL", "STATE", "SERVICE", "PID"],
1919
2332
  rows.map((r) => [r.num, r.url, r.target, r.protocol, r.state, r.service, r.pid])
1920
2333
  );
2334
+ const needRepair = rows.filter((r) => r.serviceIssue).length;
2335
+ if (needRepair) say.warn(`${needRepair} boot service(s) need repair \u2192 cloudtunnel service repair`);
1921
2336
  });
1922
2337
  }
1923
2338
 
@@ -1976,19 +2391,19 @@ function registerDelete(program) {
1976
2391
  }
1977
2392
 
1978
2393
  // src/commands/logs.ts
1979
- import { closeSync, existsSync as existsSync8, openSync as openSync2, readFileSync as readFileSync7, readSync, statSync, watch } from "fs";
2394
+ import { closeSync, existsSync as existsSync9, openSync as openSync2, readFileSync as readFileSync10, readSync, statSync as statSync2, watch } from "fs";
1980
2395
  function printTail(file, n) {
1981
- const lines = readFileSync7(file, "utf8").split("\n");
2396
+ const lines = readFileSync10(file, "utf8").split("\n");
1982
2397
  const tail = lines.slice(-n).join("\n");
1983
2398
  process.stdout.write(tail.endsWith("\n") ? tail : `${tail}
1984
2399
  `);
1985
- return statSync(file).size;
2400
+ return statSync2(file).size;
1986
2401
  }
1987
2402
  function follow(file, fromPos) {
1988
2403
  let pos = fromPos;
1989
2404
  say.dim("\u2014 following (Ctrl-C to stop) \u2014");
1990
2405
  const watcher = watch(file, () => {
1991
- const size = statSync(file).size;
2406
+ const size = statSync2(file).size;
1992
2407
  if (size < pos) {
1993
2408
  pos = 0;
1994
2409
  return;
@@ -2010,7 +2425,7 @@ function follow(file, fromPos) {
2010
2425
  function registerLogs(program) {
2011
2426
  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) => {
2012
2427
  const { fqdn, entry } = resolveTarget(name);
2013
- if (!entry?.logFile || !existsSync8(entry.logFile)) {
2428
+ if (!entry?.logFile || !existsSync9(entry.logFile)) {
2014
2429
  throw new CliError(`No logs for ${fqdn} yet.`, { hint: "start it with `cloudtunnel up` or `cloudtunnel run`" });
2015
2430
  }
2016
2431
  const n = Math.max(1, Number(opts.lines) || 50);
@@ -2022,7 +2437,7 @@ function registerLogs(program) {
2022
2437
  // src/commands/relay.ts
2023
2438
  import { openSync as openSync3 } from "fs";
2024
2439
  import { spawn as spawn3 } from "child_process";
2025
- import { join as join8 } from "path";
2440
+ import { join as join10 } from "path";
2026
2441
  import pc from "picocolors";
2027
2442
 
2028
2443
  // src/core/api-proxy-server.ts
@@ -2129,7 +2544,7 @@ function spawnDetachedRelay(sub, domain) {
2129
2544
  const script = process.argv[1];
2130
2545
  if (!script) throw new CliError("Cannot resolve the cloudtunnel executable path.");
2131
2546
  ensureDirs();
2132
- const logFile = join8(logDir, `relay-${sub === "@" ? "root" : sub}.log`);
2547
+ const logFile = join10(logDir, `relay-${sub === "@" ? "root" : sub}.log`);
2133
2548
  const fd = openSync3(logFile, "a", 384);
2134
2549
  const args = [script, "relay", sub, "-d", domain, "-f", "-y"];
2135
2550
  const child = spawn3(process.execPath, args, { detached: true, stdio: ["ignore", fd, fd] });
@@ -2187,11 +2602,68 @@ async function runRelay(sub, opts) {
2187
2602
  force: opts.force,
2188
2603
  yes: opts.yes
2189
2604
  };
2190
- await startTunnels(cf, bin, [item], {});
2605
+ await startTunnels(cf, bin, [item], { keepAlive: opts.keepAlive });
2191
2606
  printRelayReady(fqdn, secret, "foreground");
2192
2607
  }
2193
2608
  function registerRelay(program) {
2194
- 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));
2609
+ 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("--keep-alive", "supervised mode with auto-heal watchdog (used by the boot service)").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));
2610
+ }
2611
+
2612
+ // src/commands/service.ts
2613
+ function describeAction(a) {
2614
+ const parts = [];
2615
+ if (a.reinstall) parts.push(`reinstall + restart (${a.reinstall.reason})`);
2616
+ if (a.remove.length) parts.push(`remove duplicate ${a.remove.join(", ")}`);
2617
+ return `${a.fqdn}: ${parts.join(" \xB7 ")}`;
2618
+ }
2619
+ async function repairWithPrompt(actions, opts = {}) {
2620
+ for (const a of actions) say.dim(` ${describeAction(a)}`);
2621
+ if (opts.dryRun) return false;
2622
+ const needsSudo = process.platform === "linux" ? " (uses sudo)" : "";
2623
+ if (!opts.yes && !await confirm(`Repair ${actions.length} boot service(s) now?${needsSudo}`)) return false;
2624
+ const res = runRepair(actions);
2625
+ for (const fqdn of res.fixed) say.ok(`Repaired ${fqdn}`);
2626
+ for (const f of res.failed) say.warn(`Could not repair ${f.fqdn}: ${f.error}`);
2627
+ if (res.failed.length) {
2628
+ throw new CliError(`${res.failed.length} boot service(s) could not be repaired.`, { hint: "re-run `cloudtunnel service repair`" });
2629
+ }
2630
+ return res.fixed.length > 0;
2631
+ }
2632
+ function registerService(program) {
2633
+ const service = program.command("service").description("Manage boot services (installed with `up --service`)");
2634
+ service.command("repair").description("Regenerate + restart boot services that are outdated, broken, stopped, or duplicated").option("-y, --yes", "don't ask for confirmation").option("--dry-run", "show what would be repaired without doing it").action(async (opts) => {
2635
+ assertServiceSupported();
2636
+ const services = inventoryServices();
2637
+ const actions = planRepair(services, { restartRunning: process.platform === "win32" });
2638
+ if (actions.length === 0) {
2639
+ say.ok(`All ${services.length} boot service(s) are up to date and running.`);
2640
+ return;
2641
+ }
2642
+ say.info(`${actions.length} boot service(s) need repair:`);
2643
+ await repairWithPrompt(actions, opts);
2644
+ });
2645
+ }
2646
+
2647
+ // src/core/service-upgrade-check.ts
2648
+ var INFO_ARGS = /* @__PURE__ */ new Set(["-h", "--help", "-v", "--version", "help"]);
2649
+ function remember(version) {
2650
+ saveConfig({ ...loadConfig(), serviceCheckVersion: version });
2651
+ }
2652
+ async function maybeRepairServicesAfterUpgrade(version, argv) {
2653
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return;
2654
+ const rest = argv.slice(2);
2655
+ if (rest[0] === "service" || rest[0] === "delete" || rest.some((a) => INFO_ARGS.has(a))) return;
2656
+ if (loadConfig().serviceCheckVersion === version) return;
2657
+ try {
2658
+ const actions = planRepair(inventoryServices(), { restartRunning: process.platform === "win32" });
2659
+ if (actions.length) {
2660
+ say.warn(`cloudtunnel ${version}: ${actions.length} boot service(s) need repair to stay up:`);
2661
+ await repairWithPrompt(actions);
2662
+ }
2663
+ } catch (err) {
2664
+ say.warn(`Boot-service check failed: ${err.message} \u2014 run \`cloudtunnel service repair\``);
2665
+ }
2666
+ remember(version);
2195
2667
  }
2196
2668
 
2197
2669
  // src/index.ts
@@ -2211,7 +2683,7 @@ function buildProgram() {
2211
2683
  ""
2212
2684
  ].join("\n")
2213
2685
  );
2214
- for (const register of [registerLogin, registerUp, registerLs, registerDelete, registerLogs, registerRelay]) {
2686
+ for (const register of [registerLogin, registerUp, registerLs, registerDelete, registerLogs, registerRelay, registerService]) {
2215
2687
  register(program);
2216
2688
  }
2217
2689
  return program;
@@ -2225,6 +2697,7 @@ function shouldMigrate(argv) {
2225
2697
  async function main() {
2226
2698
  configureProxy();
2227
2699
  if (shouldMigrate(process.argv)) await migrateLegacyProfiles();
2700
+ await maybeRepairServicesAfterUpgrade(pkg.version, process.argv);
2228
2701
  const program = buildProgram();
2229
2702
  try {
2230
2703
  await program.parseAsync(process.argv);