@dutodev/cli 1.0.0 → 1.1.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
@@ -124,7 +124,7 @@ People get in one of these ways:
124
124
 
125
125
  Without credentials, a browser is sent to the sign-in page and other clients get `401`. Your local app never receives Duto's credentials: the `Authorization: Basic` that matched, `X-Duto-Auth` and the tunnel cookie are stripped before the request is forwarded (other cookies pass through). An `Authorization: Bearer` from your own app passes unchanged.
126
126
 
127
- **Logging out and sessions.** In a browser, signing in sets a cookie that is valid for that tunnel only. To log out, open `/__duto/logout`: it clears the cookie and returns to the sign-in page. The cookie lasts until the browser closes or the CLI stops. Restarting the CLI signs everyone out, because the key that signs the cookies is new every session. The cookie only counts on requests to the tunnel itself: a page on another origin (another tunnel, another site) that calls the tunnel has to send `X-Duto-Auth` or `Authorization: Basic`. So opening the tunnel from a link on another `*.duto.dev` address (the dashboard or another tunnel) asks for the password again; a link from outside (email, chat) or a typed URL gets straight in.
127
+ **Signing out and sessions.** In a browser, signing in sets a cookie that is valid for that tunnel only. To sign out, open `/__duto/logout`: it clears the cookie and returns to the sign-in page. The cookie lasts until the browser closes or the CLI stops. Restarting the CLI signs everyone out, because the key that signs the cookies is new every session. The cookie only counts on requests to the tunnel itself: a page on another origin (another tunnel, another site) that calls the tunnel has to send `X-Duto-Auth` or `Authorization: Basic`. So opening the tunnel from a link on another `*.duto.dev` address (the dashboard or another tunnel) asks for the password again; a link from outside (email, chat) or a typed URL gets straight in.
128
128
 
129
129
  **QR code.** With the full screen open, key `m` shows the QR of the selected tunnel in place of the request list. On a tunnel without a password, the QR points to the tunnel URL. On a tunnel with a password, it points to a link that signs in directly, as the first user in the `auth` list. That link is valid for 5 minutes and for one device only: once opened, or after it expires, it falls back to the sign-in page. On the QR screen, `g` generates a new link (and invalidates the previous one) and `esc` goes back to the list.
130
130
 
@@ -95,6 +95,7 @@ function localLabel(t) {
95
95
  }
96
96
 
97
97
  export {
98
+ MAX_NAME,
98
99
  normalizeName,
99
100
  validateName,
100
101
  C,
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  C,
4
+ MAX_NAME,
4
5
  TRUNCATED_NOTE,
5
6
  bodyNote,
6
7
  fit,
@@ -12,7 +13,7 @@ import {
12
13
  statusColor,
13
14
  tunnelColor,
14
15
  validateName
15
- } from "./chunk-BPOXISIM.js";
16
+ } from "./chunk-76YCOMXH.js";
16
17
 
17
18
  // src/ui/fullscreen.tsx
18
19
  import { render } from "ink";
@@ -369,7 +370,7 @@ function App({ session, version, system, onExit, inspector = null, inspectorErro
369
370
  const name = raw ? normalizeName(raw) : null;
370
371
  if (name !== null) {
371
372
  const v = validateName(name);
372
- if (!v.ok) return setMessage({ text: v.reason === "reserved" ? `Reserved name: "${raw}"` : "Invalid name: use lowercase letters and numbers only (no hyphens)", tone: "err" });
373
+ if (!v.ok) return setMessage({ text: v.reason === "reserved" ? `"${raw}" is reserved. Pick another name.` : `Invalid name. Use only lowercase letters and digits (no hyphens), up to ${MAX_NAME} characters.`, tone: "err" });
373
374
  }
374
375
  setMode({ kind: "list" });
375
376
  void session.add({ name, port });
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  C,
4
+ MAX_NAME,
4
5
  TRUNCATED_NOTE,
5
6
  bodyNote,
6
7
  formatBytes,
@@ -10,7 +11,7 @@ import {
10
11
  normalizeName,
11
12
  statusColor,
12
13
  validateName
13
- } from "./chunk-BPOXISIM.js";
14
+ } from "./chunk-76YCOMXH.js";
14
15
 
15
16
  // src/ui/brand.ts
16
17
  function colorOn(stream = process.stdout) {
@@ -35,7 +36,7 @@ var fail = (msg) => `${paint("err", "\u2716", process.stderr)} ${msg}`;
35
36
  var warn = (msg) => `${paint("warn", "\u26A0")} ${msg}`;
36
37
 
37
38
  // src/index.ts
38
- import { hostname } from "os";
39
+ import { hostname as hostname2 } from "os";
39
40
  import { join as join6, relative } from "path";
40
41
  import cac from "cac";
41
42
 
@@ -51,9 +52,10 @@ var DutoApiError = class extends Error {
51
52
  code;
52
53
  };
53
54
  var MESSAGES = {
54
- unauthorized: "Invalid authtoken. Generate a new one at https://duto.dev and run: duto login <token>",
55
+ unauthorized: "This machine is not logged in. Run: duto login",
55
56
  suspended: "Your account is suspended.",
56
- network: "Could not reach duto.dev. Check your connection."
57
+ network: "Could not reach duto.dev. Check your connection.",
58
+ loginExpired: "The login code expired. Run duto login again."
57
59
  };
58
60
  function createApiClient(o) {
59
61
  const doFetch = o.fetchImpl ?? fetch;
@@ -63,7 +65,7 @@ function createApiClient(o) {
63
65
  res = await doFetch(`${o.baseUrl}${path}`, {
64
66
  method,
65
67
  headers: {
66
- authorization: `Bearer ${o.token}`,
68
+ ...o.token === void 0 ? {} : { authorization: `Bearer ${o.token}` },
67
69
  ...body === void 0 ? {} : { "content-type": "application/json" },
68
70
  "user-agent": "duto-cli"
69
71
  },
@@ -98,6 +100,12 @@ function createApiClient(o) {
98
100
  const res = await call("DELETE", `/api/tunnels/${encodeURIComponent(id)}`);
99
101
  if (!res.ok && res.status !== 404) await fail3(res);
100
102
  },
103
+ startLogin: (r) => json("POST", "/api/cli/login", r),
104
+ pollLogin: (deviceCode) => json("POST", "/api/cli/login/poll", { deviceCode }),
105
+ async revokeSelf() {
106
+ const res = await call("DELETE", "/api/cli/token");
107
+ if (!res.ok && res.status !== 401 && res.status !== 404) await fail3(res);
108
+ },
101
109
  me: () => json("GET", "/api/me"),
102
110
  endpoints: () => json("GET", "/api/endpoints")
103
111
  };
@@ -417,7 +425,7 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
417
425
  const v = validateName(name);
418
426
  if (!v.ok) {
419
427
  throw fail3(
420
- v.reason === "reserved" ? `reserved name: "${raw}". Choose another.` : `invalid name: "${raw}" (use only lowercase letters and digits, no hyphen)`,
428
+ v.reason === "reserved" ? `"${raw}" is reserved. Pick another name.` : `invalid name: "${raw}" (use only lowercase letters and digits, no hyphen)`,
421
429
  line
422
430
  );
423
431
  }
@@ -522,6 +530,92 @@ function findConfig(cwd) {
522
530
  }
523
531
  }
524
532
 
533
+ // src/login.ts
534
+ import { spawn } from "child_process";
535
+ import { hostname } from "os";
536
+ var SLOW_DOWN_MS = 5e3;
537
+ var MAX_NETWORK_ERRORS_IN_A_ROW = 3;
538
+ async function browserLogin(o) {
539
+ const api = createApiClient({ baseUrl: o.baseUrl, fetchImpl: o.fetchImpl });
540
+ const start = await api.startLogin({
541
+ deviceId: o.deviceId,
542
+ deviceName: hostname(),
543
+ cliVersion: "1.1.0",
544
+ nodeVersion: process.version,
545
+ os: process.platform,
546
+ arch: process.arch
547
+ });
548
+ o.out(` Visit ${start.verificationUrl}`);
549
+ tryOpen(o.open, start.verificationUrl);
550
+ let waitMs = start.interval * 1e3;
551
+ let networkErrors = 0;
552
+ for (; ; ) {
553
+ await o.sleep(waitMs);
554
+ let poll;
555
+ try {
556
+ poll = await api.pollLogin(start.deviceCode);
557
+ networkErrors = 0;
558
+ } catch (err) {
559
+ const isNetwork = err instanceof DutoApiError && err.code === "network";
560
+ if (!isNetwork || ++networkErrors > MAX_NETWORK_ERRORS_IN_A_ROW) throw err;
561
+ continue;
562
+ }
563
+ if (poll.status === "approved") return { token: poll.token, username: poll.username };
564
+ if (poll.status === "expired") throw new DutoApiError(MESSAGES.loginExpired, 410, "expired");
565
+ if (poll.status === "slow_down") waitMs += SLOW_DOWN_MS;
566
+ }
567
+ }
568
+ function tryOpen(open, url) {
569
+ try {
570
+ open(url);
571
+ } catch {
572
+ return;
573
+ }
574
+ }
575
+ function openCommand(url) {
576
+ if (process.platform === "darwin") return { command: "open", args: [url] };
577
+ if (process.platform === "win32") return { command: "cmd", args: ["/c", "start", '""', url] };
578
+ return { command: "xdg-open", args: [url] };
579
+ }
580
+ function loggedInLine(username) {
581
+ return ok(`Logged in as ${username}.`);
582
+ }
583
+ function loginSuccessLines(username) {
584
+ return [
585
+ loggedInLine(username),
586
+ "",
587
+ "To open a tunnel, run `duto 3000`, or `duto` in a folder with a duto.yml."
588
+ ];
589
+ }
590
+ function openBrowser(url) {
591
+ try {
592
+ const { command, args } = openCommand(url);
593
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
594
+ child.on("error", () => {
595
+ });
596
+ child.unref();
597
+ } catch {
598
+ return;
599
+ }
600
+ }
601
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
602
+ function startSpinner(label, stream = process.stderr) {
603
+ if (!stream.isTTY) {
604
+ stream.write(`${label}
605
+ `);
606
+ return () => {
607
+ };
608
+ }
609
+ let frame = 0;
610
+ const draw = () => stream.write(`\r${FRAMES[frame++ % FRAMES.length]} ${label}`);
611
+ draw();
612
+ const timer = setInterval(draw, 80);
613
+ return () => {
614
+ clearInterval(timer);
615
+ stream.write("\r\x1B[K");
616
+ };
617
+ }
618
+
525
619
  // src/inspector/jsonView.ts
526
620
  function countLabel(n, one, many) {
527
621
  return n + " " + (n === 1 ? one : many);
@@ -1431,7 +1525,7 @@ data: ${JSON.stringify({ ids })}
1431
1525
  }
1432
1526
 
1433
1527
  // src/session/session.ts
1434
- import { spawn } from "child_process";
1528
+ import { spawn as spawn2 } from "child_process";
1435
1529
 
1436
1530
  // src/portcheck.ts
1437
1531
  import { connect } from "net";
@@ -1455,7 +1549,7 @@ async function isPortListening(port, timeoutMs = 500) {
1455
1549
  // src/session/auth.ts
1456
1550
  import { createHash as createHash3, createHmac, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1457
1551
  var TUNNEL_COOKIE = "__Host-duto_tunnel";
1458
- var SAME_SITE_SIBLING_TUNNEL_COOKIE_REJECTED = "same-site";
1552
+ var SEC_FETCH_SITE_SAME_SITE = "same-site";
1459
1553
  var QR_TTL_MS = 5 * 6e4;
1460
1554
  var sha2 = (s) => createHash3("sha256").update(s).digest();
1461
1555
  var constantTimeEqual2 = (a, b) => timingSafeEqual2(sha2(a), sha2(b));
@@ -1470,7 +1564,7 @@ function parseBasic(value) {
1470
1564
  var first2 = (v) => Array.isArray(v) ? v[0] : v;
1471
1565
  var bareHost = (s) => s.replace(/:\d+$/, "").toLowerCase();
1472
1566
  function sameHostOrigin(h) {
1473
- if (first2(h["sec-fetch-site"])?.toLowerCase() === SAME_SITE_SIBLING_TUNNEL_COOKIE_REJECTED) return false;
1567
+ if (first2(h["sec-fetch-site"])?.toLowerCase() === SEC_FETCH_SITE_SAME_SITE) return false;
1474
1568
  const origin = first2(h.origin);
1475
1569
  if (origin === void 0) return true;
1476
1570
  let host;
@@ -1694,7 +1788,7 @@ function loginPage(o) {
1694
1788
  ${o.error ? `<p class="error" role="alert">Wrong username or password.</p>
1695
1789
  ` : ""}<button type="submit">Sign in</button>
1696
1790
  </form>
1697
- <div class="retry">You stay signed in until the tunnel closes.</div>
1791
+ <div class="retry">You stay signed in until you close the browser or the tunnel closes.</div>
1698
1792
  </main>`,
1699
1793
  CSS
1700
1794
  );
@@ -2390,7 +2484,7 @@ var Session = class {
2390
2484
  if (this.child && this.token === token) return;
2391
2485
  if (this.child) this.child.kill("SIGTERM");
2392
2486
  this.token = token;
2393
- const c = spawn(this.o.cloudflaredPath, ["tunnel", "--no-autoupdate", "run"], {
2487
+ const c = spawn2(this.o.cloudflaredPath, ["tunnel", "--no-autoupdate", "run"], {
2394
2488
  env: { ...process.env, TUNNEL_TOKEN: token },
2395
2489
  stdio: ["ignore", "ignore", "pipe"]
2396
2490
  });
@@ -2519,7 +2613,7 @@ function runPlain(session, out) {
2519
2613
  }
2520
2614
 
2521
2615
  // src/index.ts
2522
- var LOGIN_HINT = "Log in first: duto login <token> (get your token at https://duto.dev)";
2616
+ var LOGIN_HINT = "Log in first: duto login";
2523
2617
  function fail2(message2) {
2524
2618
  console.error(fail(message2));
2525
2619
  process.exit(1);
@@ -2534,7 +2628,7 @@ function parseName(raw) {
2534
2628
  if (raw === void 0 || raw === true || raw === "") return void 0;
2535
2629
  const name = normalizeName(String(raw));
2536
2630
  const v = validateName(name);
2537
- if (!v.ok) fail2(v.reason === "reserved" ? `Reserved name: "${name}". Pick another.` : "Invalid name: use lowercase letters and numbers only (no hyphens)");
2631
+ if (!v.ok) fail2(v.reason === "reserved" ? `"${name}" is reserved. Pick another name.` : `Invalid name. Use only lowercase letters and digits (no hyphens), up to ${MAX_NAME} characters.`);
2538
2632
  return name;
2539
2633
  }
2540
2634
  function parseHostFlag(raw) {
@@ -2574,7 +2668,7 @@ async function runSession(specs, source, flags) {
2574
2668
  } catch (err) {
2575
2669
  fail2(err instanceof Error ? err.message : String(err));
2576
2670
  }
2577
- const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname(), cloudflaredPath, source, runDir: join6(dutoHome(), "run") });
2671
+ const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname2(), cloudflaredPath, source, runDir: join6(dutoHome(), "run") });
2578
2672
  let inspector = null;
2579
2673
  let inspectorError;
2580
2674
  if (flags.inspect !== false) {
@@ -2585,12 +2679,12 @@ async function runSession(specs, source, flags) {
2585
2679
  }
2586
2680
  }
2587
2681
  if (wantsUi(flags)) {
2588
- const { runFullscreen } = await import("./fullscreen-IWZCH3IP.js");
2589
- const code = await runFullscreen(session, specs, "1.0.0", inspector, inspectorError);
2682
+ const { runFullscreen } = await import("./fullscreen-ECEH3R2V.js");
2683
+ const code = await runFullscreen(session, specs, "1.1.0", inspector, inspectorError);
2590
2684
  await inspector?.close().catch(() => void 0);
2591
2685
  process.exit(code);
2592
2686
  }
2593
- console.log(`${mark()} ${paint("muted", `${"1.0.0"} \xB7 ${source} \xB7 Ctrl+C to stop`)}`);
2687
+ console.log(`${mark()} ${paint("muted", `${"1.1.0"} \xB7 ${source} \xB7 Ctrl+C to stop`)}`);
2594
2688
  if (inspector) console.log(paint("muted", `inspector: ${inspector.openUrl}`));
2595
2689
  if (inspectorError) console.log(warn(`inspector failed to start: ${inspectorError}`));
2596
2690
  const stopLog = runPlain(session, (l) => console.log(l));
@@ -2649,19 +2743,56 @@ ${paint("signal", "Tip:")} create a duto.yml with your project's tunnels and jus
2649
2743
  var cli = cac("duto");
2650
2744
  cli.command("[port]", "No port: open the tunnels in duto.yml. With a port: open a tunnel to it").option("--name <name>", "Fixed address name (e.g. web \u2192 https://web-<user>.duto.dev)").option("--host <host>", "Host the local service receives instead of the public one (e.g. myapp.test)").option("--no-rewrite-redirects", "With --host: do not swap the local host for the public one in redirects").option("--auth <user:password>", "Ask for a username and password to open the address (repeatable)").option("--allow <range>", "With --auth: IP, CIDR range or name (stripe, github) that gets in without a password (repeatable)").option("--no-ui", "No full screen: just a request log (useful in scripts)").option("--no-inspect", "Do not start the browser inspector (127.0.0.1:4141)").option("--ui", "Force full screen (off by default on Windows)").action((port, opts) => tunnel(port, opts));
2651
2745
  cli.command("http <port>", "Same as duto <port> (for people coming from ngrok)").option("--name <name>", "Fixed address name").option("--host <host>", "Host the local service receives instead of the public one").option("--no-rewrite-redirects", "With --host: keep redirects as the service sent them").option("--auth <user:password>", "Ask for a username and password to open the address (repeatable)").option("--allow <range>", "With --auth: IP, CIDR range or name (stripe, github) that gets in without a password (repeatable)").option("--no-ui", "No full screen").option("--no-inspect", "Do not start the browser inspector (127.0.0.1:4141)").action((port, opts) => tunnel(port, opts));
2652
- cli.command("login <token>", "Save your authtoken (get it at https://duto.dev)").action(async (token) => {
2746
+ cli.command("login [token]", "Log in through the browser, or save an authtoken").action(async (token) => {
2653
2747
  try {
2748
+ if (token === void 0) return await loginWithBrowser();
2654
2749
  const me = await createApiClient({ baseUrl: apiUrl(), token: String(token) }).me();
2655
2750
  saveConfig({ ...loadConfig(), authtoken: String(token) });
2656
- console.log(ok(`Logged in as ${paint("strong", me.username ?? me.email)}`));
2751
+ console.log(loggedInLine(me.username ?? me.email));
2657
2752
  } catch (err) {
2658
2753
  fail2(err instanceof Error ? err.message : String(err));
2659
2754
  }
2660
2755
  });
2661
- cli.command("logout", "Remove the authtoken from this machine").action(() => {
2662
- const { authtoken: _removed, ...rest } = loadConfig();
2756
+ async function loginWithBrowser() {
2757
+ const config = loadConfig();
2758
+ let stopSpinner = () => {
2759
+ };
2760
+ try {
2761
+ const { token, username } = await browserLogin({
2762
+ baseUrl: apiUrl(),
2763
+ deviceId: config.deviceId,
2764
+ open: openBrowser,
2765
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
2766
+ out: (line) => {
2767
+ console.log(line);
2768
+ stopSpinner = startSpinner("Waiting for authentication...");
2769
+ }
2770
+ });
2771
+ stopSpinner();
2772
+ saveConfig({ ...config, authtoken: token });
2773
+ for (const line of loginSuccessLines(username)) console.log(line);
2774
+ } catch (err) {
2775
+ stopSpinner();
2776
+ throw err;
2777
+ }
2778
+ }
2779
+ cli.command("logout", "Remove the authtoken from this machine").action(async () => {
2780
+ const { authtoken, ...rest } = loadConfig();
2781
+ let reachable = true;
2663
2782
  saveConfig(rest);
2664
- console.log(ok("Authtoken removed"));
2783
+ if (authtoken) {
2784
+ try {
2785
+ await createApiClient({ baseUrl: apiUrl(), token: authtoken }).revokeSelf();
2786
+ } catch (err) {
2787
+ if (!(err instanceof DutoApiError) || err.code !== "network") fail2(err instanceof Error ? err.message : String(err));
2788
+ reachable = false;
2789
+ }
2790
+ }
2791
+ console.log(
2792
+ ok(
2793
+ reachable ? "Authtoken removed" : "Authtoken removed from this machine. Could not reach duto.dev; revoke it on the Machines page."
2794
+ )
2795
+ );
2665
2796
  });
2666
2797
  cli.command("status", "Show your account and live tunnels").action(async () => {
2667
2798
  const config = loadConfig();
@@ -2681,7 +2812,7 @@ cli.command("status", "Show your account and live tunnels").action(async () => {
2681
2812
  }
2682
2813
  });
2683
2814
  cli.help();
2684
- cli.version("1.0.0");
2815
+ cli.version("1.1.0");
2685
2816
  try {
2686
2817
  cli.parse(process.argv, { run: false });
2687
2818
  await cli.runMatchedCommand();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dutodev/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",