@dutodev/cli 1.7.0 → 1.8.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/README.md CHANGED
@@ -34,7 +34,7 @@ Requires Node 18 or later. On first run, the CLI downloads a pinned release of t
34
34
  duto 3000 --name web # https://web-<user>.duto.dev
35
35
  ```
36
36
 
37
- Every name you use with `--name` is reserved for you and keeps the same URL. Names take lowercase letters and numbers only, no hyphens.
37
+ Every name you use with `--name` keeps the same URL, and only you can use it. Names take lowercase letters and numbers only, no hyphens. The account limit counts named tunnels online at once: closed ones do not count, and names unused for 30 days are released.
38
38
 
39
39
  ## Several tunnels: `duto.yml`
40
40
 
@@ -49,6 +49,8 @@ tunnels:
49
49
 
50
50
  All tunnels start together, in one screen. The CLI looks for the file in the current folder and its parents, up to the git root. If you commit it, everyone on the team gets their own addresses (`web-<user>.duto.dev`) from the same file.
51
51
 
52
+ **Edit while it runs.** Save `duto.yml` and the change applies right away, without restarting: new tunnels open, removed ones close, and a new `port`, `host`, `headers`, `auth` or `allow` counts from the next request, on the same URL. Visitors signed in with a password stay signed in while their user is still in `auth`. A save with a mistake changes nothing: the tunnels keep the last valid config and the screen shows the error with its line until you save a fixed file. Tunnels opened with `n` on the screen are left alone. Saving `.env` or `.env.local` applies the file again with the new values.
53
+
52
54
  ### Host and headers
53
55
 
54
56
  By default, your local service gets the request as it arrived from the internet: `Host: web-<user>.duto.dev`, `X-Forwarded-Proto: https` and the Cloudflare headers (`CF-Connecting-IP` and others). That is enough for most apps. When your app decides things by domain, you have two tools per tunnel:
@@ -140,7 +142,7 @@ tunnels:
140
142
  ```
141
143
 
142
144
  - `auth` is a list of `user:password`. Use one pair per person. The first `:` separates the user from the password; the password is 8 to 128 characters.
143
- - `${VAR}` reads an environment variable. Always use it: that keeps the password out of git along with `duto.yml`. If the variable does not exist, the CLI stops and prints the line number. The name can only have uppercase letters, digits and `_` (`${ANA_PASSWORD}`); anything else, like `${password}`, is left as text and becomes part of the password.
145
+ - `${VAR}` reads an environment variable, or the `.env` and `.env.local` next to `duto.yml` (`.env.local` wins over `.env`; a variable exported in the shell wins over both). Always use it: that keeps the password out of git along with `duto.yml`. If the variable does not exist, the CLI stops and prints the line number. The name can only have uppercase letters, digits and `_` (`${ANA_PASSWORD}`); anything else, like `${password}`, is left as text and becomes part of the password.
144
146
  - In a one-line list in brackets, `${VAR}` needs quotes (`auth: ["client:${CLIENT_PASSWORD}"]`), because `{` is special in YAML. In a block list, as above, it does not.
145
147
  - On the command line, for a single tunnel: `duto 3000 --auth client:my-password1 --allow stripe`. Both options can be repeated. They only work with a port; with a `duto.yml`, put them in the file.
146
148
  - Careful: a password passed to `--auth` ends up in your shell history and is visible to anyone who runs `ps` on the machine. For anything beyond a quick test, use `duto.yml` with `${VAR}`.
@@ -1,5 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/ui/brand.ts
4
+ function colorOn(stream = process.stdout) {
5
+ if (process.env.NO_COLOR) return false;
6
+ if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
7
+ return Boolean(stream.isTTY);
8
+ }
9
+ var SGR = {
10
+ signal: "38;2;45;212;191",
11
+ muted: "38;2;138;143;139",
12
+ strong: "1",
13
+ ok: "38;2;74;222;128",
14
+ warn: "38;2;250;204;21",
15
+ err: "38;2;248;113;113"
16
+ };
17
+ function paint(tone, text, stream) {
18
+ return colorOn(stream) ? `\x1B[${SGR[tone]}m${text}\x1B[0m` : text;
19
+ }
20
+ var mark = () => `${paint("signal", "\u25B0")} ${paint("strong", "duto")}`;
21
+ var ok = (msg) => `${paint("signal", "\u2714")} ${msg}`;
22
+ var fail = (msg) => `${paint("err", "\u2716", process.stderr)} ${msg}`;
23
+ var warn = (msg) => `${paint("warn", "\u26A0")} ${msg}`;
24
+
3
25
  // ../shared/src/names.ts
4
26
  var EMAIL_SENDING_SUBDOMAINS = ["send"];
5
27
  var RESERVED_NAMES = [
@@ -477,6 +499,95 @@ function loadConfig() {
477
499
  return fresh;
478
500
  }
479
501
 
502
+ // src/ui/format.ts
503
+ function statusColor(status) {
504
+ if (status === null) return "muted";
505
+ if (status === 101) return "signal";
506
+ if (status >= 500) return "err";
507
+ if (status >= 400) return "warn";
508
+ if (status >= 300) return "muted";
509
+ return "ok";
510
+ }
511
+ function formatDuration(ms) {
512
+ return ms < 1e3 ? `${Math.round(ms)}ms` : `${(ms / 1e3).toFixed(1)}s`;
513
+ }
514
+ function formatBytes(n) {
515
+ if (n < 1024) return `${n} B`;
516
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
517
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
518
+ }
519
+ function formatTime(ms) {
520
+ const d = new Date(ms);
521
+ return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
522
+ }
523
+ function localLabel(t) {
524
+ return t.host ? `localhost:${t.port} (Host: ${t.host})` : `localhost:${t.port}`;
525
+ }
526
+
527
+ // src/ui/plain.ts
528
+ var KEEPING_LAST_CONFIG = "Tunnels keep the last valid config.";
529
+ function runPlain(session, out) {
530
+ const announced = /* @__PURE__ */ new Map();
531
+ const failed = /* @__PURE__ */ new Set();
532
+ const printed = /* @__PURE__ */ new Set();
533
+ let configError = null;
534
+ let reloads = 0;
535
+ const onTunnels = (state) => {
536
+ const keys = new Set(state.tunnels.map((t) => t.key));
537
+ for (const key of [...announced.keys()]) {
538
+ if (keys.has(key)) continue;
539
+ announced.delete(key);
540
+ out(`${paint("muted", "\u25CB")} ${key} closed`);
541
+ }
542
+ for (const key of [...failed]) if (!keys.has(key)) failed.delete(key);
543
+ for (const t of state.tunnels) {
544
+ if ((t.status === "live" || t.status === "no-listener") && t.url) {
545
+ const line = `${paint("signal", "\u25CF")} ${t.key} ${t.url}${t.auth ? " \u{1F512}" : ""} \u2192 ${localLabel(t)}`;
546
+ if (announced.get(t.key) !== line) {
547
+ announced.set(t.key, line);
548
+ out(line);
549
+ }
550
+ }
551
+ if (t.status !== "failed") failed.delete(t.key);
552
+ if (t.status === "failed" && t.error && !failed.has(t.key)) {
553
+ failed.add(t.key);
554
+ out(`${paint("err", "\u2716")} ${t.key} ${t.error}`);
555
+ }
556
+ }
557
+ };
558
+ const onConfig = (state) => {
559
+ const { error, warnings } = state.config;
560
+ if (error && error !== configError) out(`${paint("err", "\u2716")} ${error} ${KEEPING_LAST_CONFIG}`);
561
+ configError = error;
562
+ if (state.config.reloads === reloads) return;
563
+ reloads = state.config.reloads;
564
+ out(`${paint("signal", "\u21BB")} ${state.source} reloaded`);
565
+ for (const w of warnings) out(paint("warn", w));
566
+ };
567
+ const onState = () => {
568
+ const state = session.getState();
569
+ onTunnels(state);
570
+ onConfig(state);
571
+ };
572
+ const onRecords = () => {
573
+ for (const r of [...session.records.list()].reverse()) {
574
+ if (printed.has(r.id) || r.status === null || r.durationMs === null) continue;
575
+ printed.add(r.id);
576
+ const method = r.websocket ? "WS" : r.method;
577
+ out(
578
+ `${formatTime(r.startedAt)} ${r.tunnel.padEnd(8)} ${method} ${r.path} ${paint(statusColor(r.status), String(r.status))} ${formatDuration(r.durationMs)} ${formatBytes(r.bytes)}${r.redirect ? ` ${paint("signal", `\u21AA Location rewritten to ${r.redirect.to}`)}` : ""}`
579
+ );
580
+ }
581
+ };
582
+ const offState = session.subscribe(onState);
583
+ const offRecords = session.records.subscribe(onRecords);
584
+ onState();
585
+ return () => {
586
+ offState();
587
+ offRecords();
588
+ };
589
+ }
590
+
480
591
  // src/updates.ts
481
592
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
482
593
  import { join as join2 } from "path";
@@ -575,32 +686,12 @@ function bodyNote(r, side) {
575
686
  return null;
576
687
  }
577
688
 
578
- // src/ui/format.ts
579
- function statusColor(status) {
580
- if (status === null) return "muted";
581
- if (status === 101) return "signal";
582
- if (status >= 500) return "err";
583
- if (status >= 400) return "warn";
584
- if (status >= 300) return "muted";
585
- return "ok";
586
- }
587
- function formatDuration(ms) {
588
- return ms < 1e3 ? `${Math.round(ms)}ms` : `${(ms / 1e3).toFixed(1)}s`;
589
- }
590
- function formatBytes(n) {
591
- if (n < 1024) return `${n} B`;
592
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
593
- return `${(n / 1024 / 1024).toFixed(1)} MB`;
594
- }
595
- function formatTime(ms) {
596
- const d = new Date(ms);
597
- return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
598
- }
599
- function localLabel(t) {
600
- return t.host ? `localhost:${t.port} (Host: ${t.host})` : `localhost:${t.port}`;
601
- }
602
-
603
689
  export {
690
+ paint,
691
+ mark,
692
+ ok,
693
+ fail,
694
+ warn,
604
695
  MAX_NAME,
605
696
  normalizeName,
606
697
  validateName,
@@ -628,6 +719,8 @@ export {
628
719
  formatBytes,
629
720
  formatTime,
630
721
  localLabel,
722
+ KEEPING_LAST_CONFIG,
723
+ runPlain,
631
724
  checkForUpdate,
632
725
  updateLine
633
726
  };
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  C,
4
+ KEEPING_LAST_CONFIG,
4
5
  MAX_NAME,
5
6
  TRUNCATED_NOTE,
6
7
  bodyNote,
@@ -15,7 +16,7 @@ import {
15
16
  tunnelColor,
16
17
  updateLine,
17
18
  validateName
18
- } from "./chunk-QVJHHURM.js";
19
+ } from "./chunk-QFM4MUED.js";
19
20
 
20
21
  // src/ui/fullscreen.tsx
21
22
  import { render } from "ink";
@@ -348,6 +349,12 @@ function App({ session, version, system, onExit, inspector = null, inspectorErro
348
349
  const t = setTimeout(() => setMessage(null), 4e3);
349
350
  return () => clearTimeout(t);
350
351
  }, [message]);
352
+ const reloads = state.config.reloads;
353
+ const reloadWarnings = state.config.warnings;
354
+ useEffect(() => {
355
+ if (reloads === 0) return;
356
+ setMessage({ text: [`\u21BB ${state.source} reloaded`, ...reloadWarnings].join(" "), tone: reloadWarnings.length ? "warn" : "signal" });
357
+ }, [reloads]);
351
358
  const inQr = mode.kind === "qr";
352
359
  useEffect(() => {
353
360
  if (!inQr) return;
@@ -467,7 +474,7 @@ function App({ session, version, system, onExit, inspector = null, inspectorErro
467
474
  });
468
475
  const live = state.tunnels.filter((t) => t.status === "live").length;
469
476
  const conn = state.connection === "online" ? { text: "connected \u25CF", color: C.signal } : state.connection === "connecting" ? { text: "connecting\u2026", color: C.muted } : { text: "reconnecting\u2026", color: C.warn };
470
- const listHeight = Math.max(3, rows - state.tunnels.length - (inspector ? 10 : 9) - (update ? 1 : 0));
477
+ const listHeight = Math.max(3, rows - state.tunnels.length - (inspector ? 10 : 9) - (update ? 1 : 0) - (state.config.error ? 1 : 0));
471
478
  const detailRecord = mode.kind === "detail" ? all.find((r) => r.id === mode.id) : void 0;
472
479
  const qrTunnel = mode.kind === "qr" ? state.tunnels.find((t) => t.key === mode.key) : void 0;
473
480
  return /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", width, children: [
@@ -493,6 +500,7 @@ function App({ session, version, system, onExit, inspector = null, inspectorErro
493
500
  /* @__PURE__ */ jsx6(Text6, { color: conn.color, children: conn.text })
494
501
  ] }),
495
502
  update && /* @__PURE__ */ jsx6(Text6, { color: C.warn, children: updateLine(update) }),
503
+ state.config.error && /* @__PURE__ */ jsx6(Text6, { color: C.err, children: `${state.config.error} ${KEEPING_LAST_CONFIG}` }),
496
504
  /* @__PURE__ */ jsx6(Text6, { children: " " }),
497
505
  /* @__PURE__ */ jsx6(TunnelTable, { tunnels: state.tunnels, selected: selTun, focused: focus === "tunnels" }),
498
506
  inspector && /* @__PURE__ */ jsx6(Text6, { color: C.muted, children: `inspector ${inspector.url}` }),
package/dist/index.js CHANGED
@@ -12,52 +12,31 @@ import {
12
12
  checkForUpdate,
13
13
  describeChange,
14
14
  dutoHome,
15
- formatBytes,
16
- formatDuration,
17
- formatTime,
15
+ fail,
18
16
  headerLabel,
19
17
  isCredentialLike,
20
18
  isValidHeaderValue,
21
19
  loadConfig,
22
- localLabel,
20
+ mark,
23
21
  normalizeName,
22
+ ok,
23
+ paint,
24
24
  placeOf,
25
25
  reservedHeaderError,
26
+ runPlain,
26
27
  saveConfig,
27
- statusColor,
28
28
  updateLine,
29
- validateName
30
- } from "./chunk-QVJHHURM.js";
31
-
32
- // src/ui/brand.ts
33
- function colorOn(stream = process.stdout) {
34
- if (process.env.NO_COLOR) return false;
35
- if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
36
- return Boolean(stream.isTTY);
37
- }
38
- var SGR = {
39
- signal: "38;2;45;212;191",
40
- muted: "38;2;138;143;139",
41
- strong: "1",
42
- ok: "38;2;74;222;128",
43
- warn: "38;2;250;204;21",
44
- err: "38;2;248;113;113"
45
- };
46
- function paint(tone, text2, stream) {
47
- return colorOn(stream) ? `\x1B[${SGR[tone]}m${text2}\x1B[0m` : text2;
48
- }
49
- var mark = () => `${paint("signal", "\u25B0")} ${paint("strong", "duto")}`;
50
- var ok = (msg) => `${paint("signal", "\u2714")} ${msg}`;
51
- var fail = (msg) => `${paint("err", "\u2716", process.stderr)} ${msg}`;
52
- var warn = (msg) => `${paint("warn", "\u26A0")} ${msg}`;
29
+ validateName,
30
+ warn
31
+ } from "./chunk-QFM4MUED.js";
53
32
 
54
33
  // src/index.ts
55
34
  import { hostname as hostname2 } from "os";
56
- import { join as join5, relative } from "path";
35
+ import { join as join7, relative } from "path";
57
36
  import cac from "cac";
58
37
 
59
38
  // src/version.ts
60
- var CLI_VERSION = "1.7.0";
39
+ var CLI_VERSION = "1.8.0";
61
40
 
62
41
  // src/api.ts
63
42
  var DutoApiError = class extends Error {
@@ -360,9 +339,29 @@ async function resolveAllow(entries, o = {}) {
360
339
  }
361
340
 
362
341
  // src/session/config.ts
363
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
364
- import { dirname as dirname2, join as join3 } from "path";
342
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
343
+ import { dirname as dirname2, join as join4 } from "path";
365
344
  import { isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
345
+
346
+ // src/session/envFiles.ts
347
+ import { readFileSync as readFileSync2 } from "fs";
348
+ import { join as join3 } from "path";
349
+ import { parse } from "dotenv";
350
+ var ENV_FILES = [".env", ".env.local"];
351
+ function readEnvFile(path) {
352
+ try {
353
+ return parse(readFileSync2(path, "utf8"));
354
+ } catch (err) {
355
+ if (err.code === "ENOENT") return {};
356
+ throw err;
357
+ }
358
+ }
359
+ function configEnv(dir, shell = process.env) {
360
+ const files = ENV_FILES.reduce((env, file) => ({ ...env, ...readEnvFile(join3(dir, file)) }), {});
361
+ return { ...files, ...shell };
362
+ }
363
+
364
+ // src/session/config.ts
366
365
  var ConfigError = class extends Error {
367
366
  constructor(message2, line, file = CONFIG_FILES[0]) {
368
367
  super(!file ? message2 : line ? `${file}, line ${line}: ${message2}` : `${file}: ${message2}`);
@@ -376,6 +375,7 @@ var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
376
375
  var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
377
376
  var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
378
377
  var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "trust_forwarded", "auth", "allow"];
378
+ var SET_IT = `Add it to .env next to ${CONFIG_FILES[0]} or export it in the shell.`;
379
379
  var ENV_REF_RE = /\$\{([A-Z_0-9]+)\}/g;
380
380
  function parseHost(raw) {
381
381
  const host = raw.trim().toLowerCase();
@@ -463,7 +463,7 @@ function parseConfig(text2, file = CONFIG_FILES[0], env = process.env) {
463
463
  if (headers.has(hName.toLowerCase())) throw fail3(`header ${hName} appears twice in "${raw}"`, hLine);
464
464
  const text3 = headerText(h.value);
465
465
  if (text3 === void 0) throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
466
- const value = expandEnv(text3, env, (name2) => fail3(`environment variable ${name2} is not set (used in headers of "${raw}")`, hLine));
466
+ const value = expandEnv(text3, env, (name2) => fail3(`environment variable ${name2} is not set (used in headers of "${raw}"). ${SET_IT}`, hLine));
467
467
  if (!isValidHeaderValue(value)) throw fail3(`invalid character in the value of ${hName} in "${raw}". Use ASCII or Latin-1 text.`, hLine);
468
468
  headers.set(hName.toLowerCase(), value);
469
469
  }
@@ -490,7 +490,7 @@ function parseConfig(text2, file = CONFIG_FILES[0], env = process.env) {
490
490
  const iLine = lineAt(item.range?.[0]) ?? line;
491
491
  const text3 = isScalar(item) && typeof item.value === "string" ? item.value : null;
492
492
  if (text3 === null) throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
493
- const expanded = expandEnv(text3, env, (name2) => fail3(`environment variable ${name2} is not set (used in auth of "${raw}")`, iLine));
493
+ const expanded = expandEnv(text3, env, (name2) => fail3(`environment variable ${name2} is not set (used in auth of "${raw}"). ${SET_IT}`, iLine));
494
494
  const parsed = parseCredential(expanded);
495
495
  if (!parsed.ok) {
496
496
  if (parsed.reason === "format") throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
@@ -525,13 +525,13 @@ function parseConfig(text2, file = CONFIG_FILES[0], env = process.env) {
525
525
  function findConfig(cwd) {
526
526
  let dir = cwd;
527
527
  for (; ; ) {
528
- const present = CONFIG_FILES.filter((f) => existsSync2(join3(dir, f)));
528
+ const present = CONFIG_FILES.filter((f) => existsSync2(join4(dir, f)));
529
529
  if (present.length > 1) throw new ConfigError(`${CONFIG_FILES.join(" and ")} in the same folder. Delete one of them.`, void 0, "");
530
530
  if (present.length === 1) {
531
- const path = join3(dir, present[0]);
532
- return { path, tunnels: parseConfig(readFileSync2(path, "utf8"), present[0]) };
531
+ const path = join4(dir, present[0]);
532
+ return { path, tunnels: parseConfig(readFileSync3(path, "utf8"), present[0], configEnv(dir)) };
533
533
  }
534
- if (existsSync2(join3(dir, ".git"))) return null;
534
+ if (existsSync2(join4(dir, ".git"))) return null;
535
535
  const up = dirname2(dir);
536
536
  if (up === dir) return null;
537
537
  dir = up;
@@ -561,7 +561,7 @@ async function browserLogin(o) {
561
561
  const start = await api.startLogin({
562
562
  deviceId: o.deviceId,
563
563
  deviceName: hostname(),
564
- cliVersion: "1.7.0",
564
+ cliVersion: "1.8.0",
565
565
  nodeVersion: process.version,
566
566
  os: process.platform,
567
567
  arch: process.arch
@@ -2246,7 +2246,6 @@ function sameHostOrigin(h) {
2246
2246
  return host !== "" && bareHost(host) === bareHost(h.host ?? "");
2247
2247
  }
2248
2248
  var TunnelAuth = class {
2249
- qrUser;
2250
2249
  tunnel;
2251
2250
  credentials;
2252
2251
  allow;
@@ -2260,9 +2259,16 @@ var TunnelAuth = class {
2260
2259
  this.allow = o.allow;
2261
2260
  this.now = o.now ?? Date.now;
2262
2261
  this.secret = o.secret ?? randomBytes2(32);
2263
- this.qrUser = o.credentials[0]?.user ?? "";
2264
2262
  this.failures = new FailedAttempts(this.now);
2265
2263
  }
2264
+ get qrUser() {
2265
+ return this.credentials[0]?.user ?? "";
2266
+ }
2267
+ update(o) {
2268
+ if (o.credentials[0]?.user !== this.qrUser) this.qr = null;
2269
+ this.credentials = o.credentials;
2270
+ this.allow = o.allow;
2271
+ }
2266
2272
  access(h) {
2267
2273
  if (this.allow) {
2268
2274
  const ip = first2(h["cf-connecting-ip"]);
@@ -2653,7 +2659,9 @@ async function authRoute(req, res, route, auth) {
2653
2659
  };
2654
2660
  const { pathname } = url;
2655
2661
  if (pathname === LOGIN_PATH && req.method === "GET") {
2656
- page(200, safeNext(url.searchParams.get("next")), null);
2662
+ const next = safeNext(url.searchParams.get("next"));
2663
+ if (auth.access(req.headers).kind !== "denied") go(next);
2664
+ else page(200, next, null);
2657
2665
  return true;
2658
2666
  }
2659
2667
  if (pathname === LOGIN_PATH && req.method === "POST") {
@@ -2983,9 +2991,9 @@ async function startProxy(o) {
2983
2991
 
2984
2992
  // src/session/orphans.ts
2985
2993
  import { spawnSync } from "child_process";
2986
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
2987
- import { join as join4 } from "path";
2988
- var fileOf = (dir, cfPid) => join4(dir, `cloudflared-${cfPid}.json`);
2994
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
2995
+ import { join as join5 } from "path";
2996
+ var fileOf = (dir, cfPid) => join5(dir, `cloudflared-${cfPid}.json`);
2989
2997
  function recordCloudflared(dir, cfPid, sessionPid = process.pid) {
2990
2998
  try {
2991
2999
  mkdirSync3(dir, { recursive: true });
@@ -3007,7 +3015,7 @@ var realProc = {
3007
3015
  },
3008
3016
  isCloudflared(pid) {
3009
3017
  try {
3010
- if (process.platform === "linux") return readFileSync3(`/proc/${pid}/cmdline`, "utf8").includes("cloudflared");
3018
+ if (process.platform === "linux") return readFileSync4(`/proc/${pid}/cmdline`, "utf8").includes("cloudflared");
3011
3019
  if (process.platform === "darwin") return spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }).stdout.includes("cloudflared");
3012
3020
  } catch {
3013
3021
  return false;
@@ -3026,10 +3034,10 @@ function reapOrphans(dir, proc = realProc) {
3026
3034
  let killed = 0;
3027
3035
  for (const name of readdirSync(dir)) {
3028
3036
  if (!/^cloudflared-\d+\.json$/.test(name)) continue;
3029
- const path = join4(dir, name);
3037
+ const path = join5(dir, name);
3030
3038
  let rec;
3031
3039
  try {
3032
- rec = JSON.parse(readFileSync3(path, "utf8"));
3040
+ rec = JSON.parse(readFileSync4(path, "utf8"));
3033
3041
  } catch {
3034
3042
  rmSync2(path, { force: true });
3035
3043
  continue;
@@ -3113,6 +3121,10 @@ var Session = class {
3113
3121
  routes = /* @__PURE__ */ new Map();
3114
3122
  specs = /* @__PURE__ */ new Map();
3115
3123
  persistentAuths = /* @__PURE__ */ new Map();
3124
+ allows = /* @__PURE__ */ new Map();
3125
+ config = { error: null, warnings: [], reloads: 0 };
3126
+ applying = Promise.resolve();
3127
+ background = /* @__PURE__ */ new Set();
3116
3128
  proxy = null;
3117
3129
  child = null;
3118
3130
  token = null;
@@ -3124,7 +3136,7 @@ var Session = class {
3124
3136
  listeners = /* @__PURE__ */ new Set();
3125
3137
  snapshot = null;
3126
3138
  getState() {
3127
- this.snapshot ??= { account: this.account, source: this.o.source, connection: this.connection, tunnels: this.tunnels.map((t) => ({ ...t })) };
3139
+ this.snapshot ??= { account: this.account, source: this.o.source, connection: this.connection, tunnels: this.tunnels.map((t) => ({ ...t })), config: { ...this.config } };
3128
3140
  return this.snapshot;
3129
3141
  }
3130
3142
  subscribe(fn) {
@@ -3136,12 +3148,17 @@ var Session = class {
3136
3148
  this.proxy = await startProxy({ routes: this.routes, store: this.records });
3137
3149
  this.tunnels = specs.map((s) => this.blank(s, false));
3138
3150
  this.emit();
3151
+ const opening = this.openAll();
3152
+ this.applying = opening.catch(() => void 0);
3153
+ await opening;
3154
+ const every = this.o.heartbeatMs ?? HEARTBEAT_MS;
3155
+ this.timer = setInterval(() => void this.beat(), every);
3156
+ }
3157
+ async openAll() {
3139
3158
  const me = await this.o.api.me().catch(() => null);
3140
3159
  if (me) this.account = me.username ?? me.email;
3141
3160
  const existing = await this.o.api.endpoints().catch(() => []);
3142
- for (const t of this.tunnels) await this.open(t, existing);
3143
- const every = this.o.heartbeatMs ?? HEARTBEAT_MS;
3144
- this.timer = setInterval(() => void this.beat(), every);
3161
+ await Promise.all([...this.tunnels].map((t) => this.open(t, existing)));
3145
3162
  }
3146
3163
  async add(spec) {
3147
3164
  if (this.tunnels.some((t2) => t2.key === keyOf(spec))) return;
@@ -3151,6 +3168,106 @@ var Session = class {
3151
3168
  const existing = await this.o.api.endpoints().catch(() => []);
3152
3169
  await this.open(t, existing);
3153
3170
  }
3171
+ apply(specs) {
3172
+ const run = this.applying.then(() => this.reconcile(specs));
3173
+ this.applying = run.catch(() => void 0);
3174
+ return run;
3175
+ }
3176
+ async settled() {
3177
+ await this.applying;
3178
+ while (this.background.size) await Promise.all([...this.background]);
3179
+ }
3180
+ inBackground(work) {
3181
+ const run = work().catch(() => void 0);
3182
+ this.background.add(run);
3183
+ void run.finally(() => this.background.delete(run));
3184
+ }
3185
+ configFailed(error) {
3186
+ this.config = { ...this.config, error };
3187
+ this.emit();
3188
+ }
3189
+ async reconcile(specs) {
3190
+ if (this.stopped) return;
3191
+ const wanted = new Set(specs.map(keyOf));
3192
+ for (const t of this.tunnels.filter((x) => !x.adHoc && !wanted.has(x.key))) this.remove(t);
3193
+ const fresh = [];
3194
+ for (const spec of specs) {
3195
+ if (this.stopped) return;
3196
+ const t = this.tunnels.find((x) => x.key === keyOf(spec));
3197
+ if (t) {
3198
+ await this.update(t, spec);
3199
+ continue;
3200
+ }
3201
+ const added = this.blank(spec, false);
3202
+ this.tunnels.push(added);
3203
+ fresh.push(added);
3204
+ }
3205
+ if (fresh.length) this.inBackground(() => this.openFresh(fresh));
3206
+ this.config = { error: null, warnings: credentialWarnings(specs), reloads: this.config.reloads + 1 };
3207
+ this.emit();
3208
+ }
3209
+ async openFresh(tunnels) {
3210
+ const existing = await this.o.api.endpoints().catch(() => []);
3211
+ await Promise.all(tunnels.filter((t) => !this.gone(t)).map((t) => this.open(t, existing)));
3212
+ }
3213
+ remove(t) {
3214
+ this.tunnels = this.tunnels.filter((x) => x !== t);
3215
+ this.forget(t);
3216
+ this.emit();
3217
+ const endpointId = t.endpointId;
3218
+ if (endpointId) this.inBackground(async () => void await withTimeout(this.o.api.closeTunnel(endpointId).catch(() => void 0), CLOSE_TIMEOUT_MS));
3219
+ }
3220
+ forget(t) {
3221
+ if (t.hostname) this.routes.delete(t.hostname.toLowerCase());
3222
+ this.specs.delete(t.key);
3223
+ this.persistentAuths.delete(t.key);
3224
+ this.allows.delete(t.key);
3225
+ }
3226
+ async update(t, spec) {
3227
+ const before = JSON.stringify(this.specs.get(t.key));
3228
+ const changed = before !== JSON.stringify(spec);
3229
+ const portChanged = t.port !== spec.port;
3230
+ this.specs.set(t.key, spec);
3231
+ const runtime = { url: t.url, hostname: t.hostname, endpointId: t.endpointId, status: t.status, error: t.error, requests: t.requests, errors: t.errors };
3232
+ for (const k of Object.keys(t)) delete t[k];
3233
+ Object.assign(t, this.describe(spec), { adHoc: false }, runtime);
3234
+ const auth = await this.refreshAuth(t.key);
3235
+ if (t.status === "failed") {
3236
+ if (changed) this.inBackground(() => this.open(t, null));
3237
+ return;
3238
+ }
3239
+ if (t.hostname) this.routes.set(t.hostname.toLowerCase(), this.routeOf(t, auth));
3240
+ this.emit();
3241
+ if (portChanged && t.endpointId) this.inBackground(() => this.open(t, null));
3242
+ }
3243
+ routeOf(t, auth) {
3244
+ return { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects, trustForwarded: t.trustForwarded, auth };
3245
+ }
3246
+ async allowFor(key, spec) {
3247
+ if (!spec.allow?.length) {
3248
+ this.allows.delete(key);
3249
+ return void 0;
3250
+ }
3251
+ const entries = JSON.stringify(spec.allow);
3252
+ const cached = this.allows.get(key);
3253
+ if (cached?.entries === entries) return cached.list;
3254
+ const list = await (this.o.resolveAllow ?? resolveAllow)(spec.allow);
3255
+ this.allows.set(key, { entries, list });
3256
+ return list;
3257
+ }
3258
+ async refreshAuth(key) {
3259
+ const spec = this.specs.get(key);
3260
+ const existing = this.persistentAuths.get(key);
3261
+ if (!spec?.auth?.length) {
3262
+ this.persistentAuths.delete(key);
3263
+ this.allows.delete(key);
3264
+ return void 0;
3265
+ }
3266
+ const allow = await this.allowFor(key, spec);
3267
+ if (!existing) return this.authFor(key);
3268
+ existing.update({ credentials: spec.auth, allow });
3269
+ return existing;
3270
+ }
3154
3271
  qrLink(key) {
3155
3272
  const t = this.tunnels.find((x) => x.key === key);
3156
3273
  if (!t?.url) return null;
@@ -3167,7 +3284,7 @@ var Session = class {
3167
3284
  if (existing) return existing;
3168
3285
  const spec = this.specs.get(key);
3169
3286
  if (!spec?.auth?.length) return void 0;
3170
- const allow = spec.allow?.length ? await (this.o.resolveAllow ?? resolveAllow)(spec.allow) : void 0;
3287
+ const allow = await this.allowFor(key, spec);
3171
3288
  const auth = new TunnelAuth({ tunnel: key, credentials: spec.auth, allow });
3172
3289
  this.persistentAuths.set(key, auth);
3173
3290
  return auth;
@@ -3188,7 +3305,13 @@ var Session = class {
3188
3305
  }
3189
3306
  blank(spec, adHoc) {
3190
3307
  this.specs.set(keyOf(spec), spec);
3191
- return { key: keyOf(spec), name: spec.name, port: spec.port, ...spec.host ? { host: spec.host } : {}, ...spec.headers ? { headers: spec.headers } : {}, ...spec.rewriteRedirects === false ? { rewriteRedirects: false } : {}, ...spec.trustForwarded ? { trustForwarded: true } : {}, ...spec.auth?.length ? { auth: true } : {}, url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
3308
+ return { ...this.describe(spec), url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
3309
+ }
3310
+ describe(spec) {
3311
+ return { key: keyOf(spec), name: spec.name, port: spec.port, ...spec.host ? { host: spec.host } : {}, ...spec.headers ? { headers: spec.headers } : {}, ...spec.rewriteRedirects === false ? { rewriteRedirects: false } : {}, ...spec.trustForwarded ? { trustForwarded: true } : {}, ...spec.auth?.length ? { auth: true } : {} };
3312
+ }
3313
+ gone(t) {
3314
+ return this.stopped || !this.tunnels.includes(t);
3192
3315
  }
3193
3316
  async open(t, existing, retrying = false) {
3194
3317
  if (this.stopped) return;
@@ -3204,12 +3327,12 @@ var Session = class {
3204
3327
  ...t.name ? { name: t.name } : {},
3205
3328
  proxyPort: this.proxy.port
3206
3329
  });
3207
- if (this.stopped) {
3330
+ if (this.gone(t)) {
3208
3331
  await withTimeout(this.o.api.closeTunnel(res.endpointId).catch(() => void 0), CLOSE_TIMEOUT_MS);
3209
3332
  return;
3210
3333
  }
3211
3334
  const auth = await this.authFor(t.key);
3212
- if (this.stopped) {
3335
+ if (this.gone(t)) {
3213
3336
  await withTimeout(this.o.api.closeTunnel(res.endpointId).catch(() => void 0), CLOSE_TIMEOUT_MS);
3214
3337
  return;
3215
3338
  }
@@ -3217,7 +3340,7 @@ var Session = class {
3217
3340
  t.hostname = res.hostname;
3218
3341
  t.endpointId = res.endpointId;
3219
3342
  t.error = null;
3220
- this.routes.set(res.hostname.toLowerCase(), { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects, trustForwarded: t.trustForwarded, auth });
3343
+ this.routes.set(res.hostname.toLowerCase(), this.routeOf(t, auth));
3221
3344
  t.status = await isPortListening(t.port) ? "live" : "no-listener";
3222
3345
  this.ensureCloudflared(res.tunnelToken);
3223
3346
  this.emit();
@@ -3334,39 +3457,60 @@ var Session = class {
3334
3457
  }
3335
3458
  };
3336
3459
 
3337
- // src/ui/plain.ts
3338
- function runPlain(session, out) {
3339
- const announced = /* @__PURE__ */ new Set();
3340
- const failed = /* @__PURE__ */ new Set();
3341
- const printed = /* @__PURE__ */ new Set();
3342
- const onState = () => {
3343
- for (const t of session.getState().tunnels) {
3344
- if ((t.status === "live" || t.status === "no-listener") && t.url && !announced.has(t.key)) {
3345
- announced.add(t.key);
3346
- out(`${paint("signal", "\u25CF")} ${t.key} ${t.url}${t.auth ? " \u{1F512}" : ""} \u2192 ${localLabel(t)}`);
3347
- }
3348
- if (t.status === "failed" && t.error && !failed.has(t.key)) {
3349
- failed.add(t.key);
3350
- out(`${paint("err", "\u2716")} ${t.key} ${t.error}`);
3351
- }
3460
+ // src/session/watch.ts
3461
+ import { readFileSync as readFileSync5, watch } from "fs";
3462
+ import { basename, dirname as dirname3, join as join6 } from "path";
3463
+ var RELOAD_DEBOUNCE_MS = 150;
3464
+ function readText(path) {
3465
+ try {
3466
+ return readFileSync5(path, "utf8");
3467
+ } catch (err) {
3468
+ if (err.code === "ENOENT") return null;
3469
+ throw err;
3470
+ }
3471
+ }
3472
+ function watchConfig(o) {
3473
+ const file = basename(o.path);
3474
+ const dir = dirname3(o.path);
3475
+ const watched = /* @__PURE__ */ new Set([file, ...ENV_FILES]);
3476
+ const snapshot = () => [readText(o.path), ...ENV_FILES.map((f) => readText(join6(dir, f)))].join("\0");
3477
+ let lastSeen = snapshot();
3478
+ let timer = null;
3479
+ const reload = () => {
3480
+ timer = null;
3481
+ let text2;
3482
+ let seen;
3483
+ try {
3484
+ text2 = readText(o.path);
3485
+ seen = snapshot();
3486
+ } catch (err) {
3487
+ return o.onError(`${file}: ${err instanceof Error ? err.message : String(err)}`);
3352
3488
  }
3353
- };
3354
- const onRecords = () => {
3355
- for (const r of [...session.records.list()].reverse()) {
3356
- if (printed.has(r.id) || r.status === null || r.durationMs === null) continue;
3357
- printed.add(r.id);
3358
- const method = r.websocket ? "WS" : r.method;
3359
- out(
3360
- `${formatTime(r.startedAt)} ${r.tunnel.padEnd(8)} ${method} ${r.path} ${paint(statusColor(r.status), String(r.status))} ${formatDuration(r.durationMs)} ${formatBytes(r.bytes)}${r.redirect ? ` ${paint("signal", `\u21AA Location rewritten to ${r.redirect.to}`)}` : ""}`
3361
- );
3489
+ if (seen === lastSeen) return;
3490
+ lastSeen = seen;
3491
+ if (text2 === null) return o.onError(`${file} was deleted.`);
3492
+ let specs;
3493
+ try {
3494
+ specs = parseConfig(text2, file, configEnv(dir));
3495
+ } catch (err) {
3496
+ if (err instanceof ConfigError) return o.onError(err.message);
3497
+ return o.onError(`${file}: ${err instanceof Error ? err.message : String(err)}`);
3362
3498
  }
3499
+ o.onChange(specs);
3363
3500
  };
3364
- const offState = session.subscribe(onState);
3365
- const offRecords = session.records.subscribe(onRecords);
3366
- onState();
3367
- return () => {
3368
- offState();
3369
- offRecords();
3501
+ const schedule = () => {
3502
+ if (timer) clearTimeout(timer);
3503
+ timer = setTimeout(reload, o.debounceMs ?? RELOAD_DEBOUNCE_MS);
3504
+ };
3505
+ const watcher = watch(dir, (_event, name) => {
3506
+ if (name === null || watched.has(name)) schedule();
3507
+ });
3508
+ watcher.on("error", () => void 0);
3509
+ return {
3510
+ close() {
3511
+ if (timer) clearTimeout(timer);
3512
+ watcher.close();
3513
+ }
3370
3514
  };
3371
3515
  }
3372
3516
 
@@ -3416,7 +3560,7 @@ function wantsUi(flags) {
3416
3560
  if (process.platform === "win32") return process.argv.includes("--ui");
3417
3561
  return true;
3418
3562
  }
3419
- async function runSession(specs, source, flags) {
3563
+ async function runSession(specs, source, flags, configPath) {
3420
3564
  const config = loadConfig();
3421
3565
  if (!config.authtoken) fail2(LOGIN_HINT);
3422
3566
  const api = createApiClient({ baseUrl: apiUrl(), token: config.authtoken });
@@ -3427,7 +3571,7 @@ async function runSession(specs, source, flags) {
3427
3571
  } catch (err) {
3428
3572
  fail2(err instanceof Error ? err.message : String(err));
3429
3573
  }
3430
- const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname2(), cloudflaredPath, source, runDir: join5(dutoHome(), "run") });
3574
+ const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname2(), cloudflaredPath, source, runDir: join7(dutoHome(), "run") });
3431
3575
  let inspector = null;
3432
3576
  let inspectorError;
3433
3577
  if (flags.inspect !== false) {
@@ -3438,9 +3582,11 @@ async function runSession(specs, source, flags) {
3438
3582
  }
3439
3583
  }
3440
3584
  const warnings = credentialWarnings(specs);
3585
+ const watcher = configPath ? watchConfig({ path: configPath, onChange: (next) => void session.apply(next).catch((err) => session.configFailed(err instanceof Error ? err.message : String(err))), onError: (error) => session.configFailed(error) }) : null;
3441
3586
  if (wantsUi(flags)) {
3442
- const { runFullscreen } = await import("./fullscreen-ZNFUDV2B.js");
3587
+ const { runFullscreen } = await import("./fullscreen-Q3RGW6NJ.js");
3443
3588
  const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update, warnings);
3589
+ watcher?.close();
3444
3590
  await inspector?.close().catch(() => void 0);
3445
3591
  process.exit(code);
3446
3592
  }
@@ -3463,6 +3609,7 @@ async function runSession(specs, source, flags) {
3463
3609
  });
3464
3610
  await Promise.race([session.start(specs), stopped]);
3465
3611
  await stopped;
3612
+ watcher?.close();
3466
3613
  stopLog();
3467
3614
  await session.stop();
3468
3615
  await inspector?.close().catch(() => void 0);
@@ -3490,7 +3637,7 @@ ${paint("signal", "Tip:")} create a duto.yml with your project's tunnels and jus
3490
3637
  `);
3491
3638
  process.exit(0);
3492
3639
  }
3493
- return runSession(found.tunnels, relative(process.cwd(), found.path) || "duto.yml", flags);
3640
+ return runSession(found.tunnels, relative(process.cwd(), found.path) || "duto.yml", flags, found.path);
3494
3641
  }
3495
3642
  const port = parsePort(rawPort);
3496
3643
  const name = parseName(flags.name) ?? null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dutodev/cli",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Expose a local port at https://<name>-<user>.duto.dev with one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,6 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "cac": "^6.7.14",
32
+ "dotenv": "17.4.2",
32
33
  "ink": "^5.2.1",
33
34
  "qrcode-generator": "^1.5.2",
34
35
  "react": "^18.3.1",