@dutodev/cli 1.8.0 → 1.9.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
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Put a local service on the internet at a stable HTTPS URL, with one command:
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
duto 3000 --name web # https://web-<user>.duto.
|
|
6
|
+
duto 3000 --name web # https://web-<user>.duto.page → localhost:3000
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
The URL stays the same from one session to the next. That makes it good for webhooks, login callbacks, testing on your phone, or showing someone your work without deploying.
|
|
@@ -20,8 +20,13 @@ Requires Node 18 or later. On first run, the CLI downloads a pinned release of t
|
|
|
20
20
|
|
|
21
21
|
## Get started
|
|
22
22
|
|
|
23
|
-
1.
|
|
24
|
-
|
|
23
|
+
1. Connect the CLI to your account (once per machine). It opens your browser so you can approve it:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
duto login
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
2. On CI and servers with no browser, create a token on the **Machines** page at [duto.dev](https://duto.dev) and pass it:
|
|
25
30
|
|
|
26
31
|
```
|
|
27
32
|
duto login <token>
|
|
@@ -30,8 +35,8 @@ Requires Node 18 or later. On first run, the CLI downloads a pinned release of t
|
|
|
30
35
|
3. Open a tunnel:
|
|
31
36
|
|
|
32
37
|
```
|
|
33
|
-
duto 3000 # https://<user>.duto.
|
|
34
|
-
duto 3000 --name web # https://web-<user>.duto.
|
|
38
|
+
duto 3000 # https://<user>.duto.page
|
|
39
|
+
duto 3000 --name web # https://web-<user>.duto.page
|
|
35
40
|
```
|
|
36
41
|
|
|
37
42
|
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.
|
|
@@ -47,13 +52,13 @@ tunnels:
|
|
|
47
52
|
webhook: 4000
|
|
48
53
|
```
|
|
49
54
|
|
|
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.
|
|
55
|
+
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.page`) from the same file.
|
|
51
56
|
|
|
52
57
|
**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
58
|
|
|
54
59
|
### Host and headers
|
|
55
60
|
|
|
56
|
-
By default, your local service gets the request as it arrived from the internet: `Host: web-<user>.duto.
|
|
61
|
+
By default, your local service gets the request as it arrived from the internet: `Host: web-<user>.duto.page`, `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:
|
|
57
62
|
|
|
58
63
|
```yaml
|
|
59
64
|
tunnels:
|
|
@@ -79,7 +84,7 @@ tunnels:
|
|
|
79
84
|
**What `host:` does:**
|
|
80
85
|
|
|
81
86
|
- Your service receives the Host you set, and `X-Forwarded-Host` is not sent. To send it anyway, put it in `headers:`.
|
|
82
|
-
- Redirects come back with the public address. A `Location: http://admin.myapp.test:8081/login` reaches the browser as `https://admin-<user>.duto.
|
|
87
|
+
- Redirects come back with the public address. A `Location: http://admin.myapp.test:8081/login` reaches the browser as `https://admin-<user>.duto.page/login`. This also applies inside encoded parameters of the `Location`. To turn it off, set `rewrite_redirects: false` on the tunnel.
|
|
83
88
|
- Links in response bodies (HTML, JSON) are not rewritten. URLs your app builds from the Host only open on your machine.
|
|
84
89
|
- On the command line: `duto 8081 --host admin.myapp.test`, and `--no-rewrite-redirects` to turn the swap off.
|
|
85
90
|
|
|
@@ -91,7 +96,7 @@ Ways out, from simplest to most work:
|
|
|
91
96
|
2. If the app needs the local domain for other decisions and reads `X-Forwarded-Host`, pass the domain through `headers:` and keep the public Host.
|
|
92
97
|
3. Set the public callback URL in the app, if it lets you.
|
|
93
98
|
|
|
94
|
-
In every case, register `https://<name>-<user>.duto.
|
|
99
|
+
In every case, register `https://<name>-<user>.duto.page/<callback path>` as the callback URL at the provider. The comparison is usually exact, with no wildcards: each person on the team has their own address.
|
|
95
100
|
|
|
96
101
|
**What you cannot set.** Some headers belong to Cloudflare, to Duto or to the HTTP connection itself. `duto.yml` refuses them, with the line and what to do:
|
|
97
102
|
|
|
@@ -153,12 +158,12 @@ People get in one of these ways:
|
|
|
153
158
|
|---|---|
|
|
154
159
|
| Person in a browser | Opens the URL, sees the "This tunnel is password protected" page and signs in with user and password |
|
|
155
160
|
| Phone | Scans the QR code (key `m`, see below) and gets in without typing |
|
|
156
|
-
| `curl`, Postman, scripts | `curl -u client:password https://backend-<user>.duto.
|
|
161
|
+
| `curl`, Postman, scripts | `curl -u client:password https://backend-<user>.duto.page/` (`Authorization: Basic`) |
|
|
157
162
|
| An API that already uses `Authorization: Bearer` | Send `X-Duto-Auth: client:password`; the `Bearer` goes through to the app untouched |
|
|
158
163
|
|
|
159
164
|
Without credentials, a browser is sent to the sign-in page and other clients get `401`. After 10 wrong passwords from one address in 5 minutes, that address is locked out for the rest of the window (the sign-in page answers `429` with `Retry-After`, other clients keep getting `401`). After 100 wrong passwords from all addresses, every password attempt is locked out the same way. A browser that already signed in keeps working. 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.
|
|
160
165
|
|
|
161
|
-
**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.
|
|
166
|
+
**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.page` address (the dashboard or another tunnel) asks for the password again; a link from outside (email, chat) or a typed URL gets straight in.
|
|
162
167
|
|
|
163
168
|
**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.
|
|
164
169
|
|
|
@@ -231,7 +236,7 @@ The page has the list on the left and the details on the right, with the tabs **
|
|
|
231
236
|
|
|
232
237
|
| Option | What it does |
|
|
233
238
|
|---|---|
|
|
234
|
-
| `--name <name>` | Fixed address `<name>-<user>.duto.
|
|
239
|
+
| `--name <name>` | Fixed address `<name>-<user>.duto.page` |
|
|
235
240
|
| `--host <host>` | Host the local service receives instead of the public one |
|
|
236
241
|
| `--no-rewrite-redirects` | With `--host`, keep redirects as the service sent them |
|
|
237
242
|
| `--auth <user:password>` | Ask for a username and password to open the address (repeatable) |
|
|
@@ -644,6 +644,8 @@ function reasonOf(c) {
|
|
|
644
644
|
if (c.source === "headers") return c.kind === "remove" ? 'headers: ""' : "headers:";
|
|
645
645
|
if (c.source === "host") return "host:";
|
|
646
646
|
if (c.source === "visitor") return "from the visitor; set trust_forwarded: true to keep it";
|
|
647
|
+
if (c.source === "isolation" && c.name === "set-cookie") return "Domain=duto.page reaches every user's tunnel; kept on this address";
|
|
648
|
+
if (c.source === "isolation") return "from another user's tunnel; only SameSite=None cookies go through";
|
|
647
649
|
return "Duto credential";
|
|
648
650
|
}
|
|
649
651
|
function valueOf(c) {
|
package/dist/index.js
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
updateLine,
|
|
29
29
|
validateName,
|
|
30
30
|
warn
|
|
31
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-QMQWGCIE.js";
|
|
32
32
|
|
|
33
33
|
// src/index.ts
|
|
34
34
|
import { hostname as hostname2 } from "os";
|
|
@@ -36,7 +36,7 @@ import { join as join7, relative } from "path";
|
|
|
36
36
|
import cac from "cac";
|
|
37
37
|
|
|
38
38
|
// src/version.ts
|
|
39
|
-
var CLI_VERSION = "1.
|
|
39
|
+
var CLI_VERSION = "1.9.0";
|
|
40
40
|
|
|
41
41
|
// src/api.ts
|
|
42
42
|
var DutoApiError = class extends Error {
|
|
@@ -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.
|
|
564
|
+
cliVersion: "1.9.0",
|
|
565
565
|
nodeVersion: process.version,
|
|
566
566
|
os: process.platform,
|
|
567
567
|
arch: process.arch
|
|
@@ -1729,7 +1729,7 @@ function redirectIssues(r, ctx) {
|
|
|
1729
1729
|
{
|
|
1730
1730
|
level: "warn",
|
|
1731
1731
|
title: "Redirect drops to http",
|
|
1732
|
-
detail: `Location is ${location}.
|
|
1732
|
+
detail: `Location is ${location}. Duto tunnels only serve https, so the browser takes an extra hop and loses POST bodies. Have the app trust X-Forwarded-Proto.`
|
|
1733
1733
|
}
|
|
1734
1734
|
];
|
|
1735
1735
|
}
|
|
@@ -2436,6 +2436,89 @@ function deliver(h, route) {
|
|
|
2436
2436
|
return changes.length ? { headers: out, changes } : { headers: h, changes };
|
|
2437
2437
|
}
|
|
2438
2438
|
|
|
2439
|
+
// src/session/siteIsolation.ts
|
|
2440
|
+
function firstLabel(host) {
|
|
2441
|
+
return host.split(".")[0] ?? "";
|
|
2442
|
+
}
|
|
2443
|
+
function parentDomain(host) {
|
|
2444
|
+
return host.slice(host.indexOf(".") + 1).toLowerCase();
|
|
2445
|
+
}
|
|
2446
|
+
function tunnelOwner(host) {
|
|
2447
|
+
const label = firstLabel(host.toLowerCase());
|
|
2448
|
+
return label.slice(label.lastIndexOf("-") + 1);
|
|
2449
|
+
}
|
|
2450
|
+
function originHost(value) {
|
|
2451
|
+
if (!value) return null;
|
|
2452
|
+
try {
|
|
2453
|
+
const url = new URL(value);
|
|
2454
|
+
return { origin: url.origin, host: url.hostname.toLowerCase() };
|
|
2455
|
+
} catch {
|
|
2456
|
+
return null;
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
function isSiblingTunnel(host, publicHost) {
|
|
2460
|
+
const parent = parentDomain(publicHost);
|
|
2461
|
+
if (!host.endsWith(`.${parent}`)) return false;
|
|
2462
|
+
return !host.slice(0, -parent.length - 1).includes(".");
|
|
2463
|
+
}
|
|
2464
|
+
function crossUserOrigin(h, publicHost) {
|
|
2465
|
+
const site = h["sec-fetch-site"];
|
|
2466
|
+
if (site !== void 0 && site !== "same-site") return null;
|
|
2467
|
+
const from = originHost(h.origin) ?? originHost(h.referer);
|
|
2468
|
+
if (!from || !isSiblingTunnel(from.host, publicHost)) return null;
|
|
2469
|
+
return tunnelOwner(from.host) === tunnelOwner(publicHost) ? null : from.origin;
|
|
2470
|
+
}
|
|
2471
|
+
function scopeSetCookies(lines, publicHost) {
|
|
2472
|
+
const parent = parentDomain(publicHost);
|
|
2473
|
+
const changes = [];
|
|
2474
|
+
const scoped = lines.map((line) => {
|
|
2475
|
+
const parts = line.split(";");
|
|
2476
|
+
const kept = parts.filter((part, i) => {
|
|
2477
|
+
if (i === 0) return true;
|
|
2478
|
+
const [key = "", value = ""] = part.split("=");
|
|
2479
|
+
const shared = key.trim().toLowerCase() === "domain" && value.trim().replace(/^\./, "").toLowerCase() === parent;
|
|
2480
|
+
if (shared) changes.push({ kind: "remove", name: "set-cookie", from: `${parseSetCookie(line).name}: ${part.trim()}`, source: "isolation" });
|
|
2481
|
+
return !shared;
|
|
2482
|
+
});
|
|
2483
|
+
return kept.length === parts.length ? line : kept.join(";");
|
|
2484
|
+
});
|
|
2485
|
+
return { lines: scoped, changes };
|
|
2486
|
+
}
|
|
2487
|
+
var SameSiteMemory = class {
|
|
2488
|
+
byName = /* @__PURE__ */ new Map();
|
|
2489
|
+
learn(setCookies) {
|
|
2490
|
+
for (const line of setCookies) {
|
|
2491
|
+
const c = parseSetCookie(line);
|
|
2492
|
+
const value = typeof c.attrs.samesite === "string" ? c.attrs.samesite.toLowerCase() : "lax";
|
|
2493
|
+
this.byName.set(c.name, value === "none" || value === "strict" ? value : "lax");
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
sentCrossSite(name, navigation) {
|
|
2497
|
+
const sameSite = this.byName.get(name) ?? "lax";
|
|
2498
|
+
return sameSite === "none" || navigation && sameSite === "lax";
|
|
2499
|
+
}
|
|
2500
|
+
};
|
|
2501
|
+
function isNavigation(h, method) {
|
|
2502
|
+
if (method !== "GET" && method !== "HEAD") return false;
|
|
2503
|
+
const mode = h["sec-fetch-mode"];
|
|
2504
|
+
return mode === "navigate" || mode === void 0 && h.origin === void 0;
|
|
2505
|
+
}
|
|
2506
|
+
function isolateCookies(h, publicHost, memory, method = "GET") {
|
|
2507
|
+
const cookie = h.cookie;
|
|
2508
|
+
const origin = cookie ? crossUserOrigin(h, publicHost) : null;
|
|
2509
|
+
if (!cookie || !origin) return { headers: h, changes: [] };
|
|
2510
|
+
const navigation = isNavigation(h, method);
|
|
2511
|
+
const pairs = cookie.split(";").map((p) => p.trim()).filter(Boolean);
|
|
2512
|
+
const nameOf = (pair2) => pair2.split("=")[0].trim();
|
|
2513
|
+
const kept = pairs.filter((p) => memory.sentCrossSite(nameOf(p), navigation));
|
|
2514
|
+
const removed = pairs.filter((p) => !kept.includes(p)).map(nameOf);
|
|
2515
|
+
if (removed.length === 0) return { headers: h, changes: [] };
|
|
2516
|
+
const headers = { ...h };
|
|
2517
|
+
if (kept.length) headers.cookie = kept.join("; ");
|
|
2518
|
+
else delete headers.cookie;
|
|
2519
|
+
return { headers, changes: [{ kind: "remove", name: "cookie", from: `${removed.join(", ")} (from ${origin})`, source: "isolation" }] };
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2439
2522
|
// src/session/errorPage.ts
|
|
2440
2523
|
var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
2441
2524
|
function wantsHtml(accept) {
|
|
@@ -2731,6 +2814,26 @@ function waitForService(res, port, checkMs, pingMs) {
|
|
|
2731
2814
|
async function startProxy(o) {
|
|
2732
2815
|
const max = o.maxBody ?? MAX_BODY;
|
|
2733
2816
|
let nextId = 1;
|
|
2817
|
+
const sameSite = /* @__PURE__ */ new Map();
|
|
2818
|
+
function memoryOf(route) {
|
|
2819
|
+
let memory = sameSite.get(route.tunnel);
|
|
2820
|
+
if (!memory) sameSite.set(route.tunnel, memory = new SameSiteMemory());
|
|
2821
|
+
return memory;
|
|
2822
|
+
}
|
|
2823
|
+
function deliverFor(req, route) {
|
|
2824
|
+
const delivered = deliver(req.headers, route);
|
|
2825
|
+
const isolated = isolateCookies(delivered.headers, hostOf(req), memoryOf(route), req.method ?? "GET");
|
|
2826
|
+
return isolated.changes.length ? { headers: isolated.headers, changes: [...delivered.changes, ...isolated.changes] } : delivered;
|
|
2827
|
+
}
|
|
2828
|
+
function scopeResponseCookies(id, req, route, up) {
|
|
2829
|
+
const lines = up.headers["set-cookie"];
|
|
2830
|
+
if (!lines?.length) return;
|
|
2831
|
+
memoryOf(route).learn(lines);
|
|
2832
|
+
const scoped = scopeSetCookies(lines, hostOf(req));
|
|
2833
|
+
if (!scoped.changes.length) return;
|
|
2834
|
+
up.headers["set-cookie"] = scoped.lines;
|
|
2835
|
+
o.store.update(id, { headerChanges: [...o.store.get(id)?.headerChanges ?? [], ...scoped.changes] });
|
|
2836
|
+
}
|
|
2734
2837
|
const routeWaitMs = o.routeWaitMs ?? DEFAULT_ROUTE_WAIT_MS;
|
|
2735
2838
|
async function routeFor(req) {
|
|
2736
2839
|
const host = hostOf(req);
|
|
@@ -2860,7 +2963,7 @@ async function startProxy(o) {
|
|
|
2860
2963
|
}
|
|
2861
2964
|
const preflight = !!route.auth && isPreflight(req.method, req.headers);
|
|
2862
2965
|
const access = route.auth && !preflight ? route.auth.access(req.headers) : void 0;
|
|
2863
|
-
const { headers, changes } =
|
|
2966
|
+
const { headers, changes } = deliverFor(req, route);
|
|
2864
2967
|
if (access?.kind === "denied") {
|
|
2865
2968
|
const html = wantsHtml(req.headers.accept);
|
|
2866
2969
|
const { id: id2, started: started2 } = record(req, route, headers, changes, false, html ? void 0 : access, DENIED_NO_REPLAY);
|
|
@@ -2894,6 +2997,7 @@ async function startProxy(o) {
|
|
|
2894
2997
|
o.store.update(id, { redirect: { from, to } });
|
|
2895
2998
|
}
|
|
2896
2999
|
}
|
|
3000
|
+
scopeResponseCookies(id, req, route, up);
|
|
2897
3001
|
res.writeHead(up.statusCode ?? 502, up.statusMessage, up.headers);
|
|
2898
3002
|
trackResponse(id, started, up, reqCap);
|
|
2899
3003
|
up.pipe(res);
|
|
@@ -2937,7 +3041,7 @@ async function startProxy(o) {
|
|
|
2937
3041
|
const route = await routeFor(req);
|
|
2938
3042
|
if (!route) return void client.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
2939
3043
|
const access = route.auth?.access(req.headers);
|
|
2940
|
-
const { headers, changes } =
|
|
3044
|
+
const { headers, changes } = deliverFor(req, route);
|
|
2941
3045
|
const { id, started } = record(req, route, headers, changes, true, access, access?.kind === "denied" ? DENIED_NO_REPLAY : void 0);
|
|
2942
3046
|
if (access?.kind === "denied") {
|
|
2943
3047
|
client.end(WS_DENIED);
|
|
@@ -3584,7 +3688,7 @@ async function runSession(specs, source, flags, configPath) {
|
|
|
3584
3688
|
const warnings = credentialWarnings(specs);
|
|
3585
3689
|
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;
|
|
3586
3690
|
if (wantsUi(flags)) {
|
|
3587
|
-
const { runFullscreen } = await import("./fullscreen-
|
|
3691
|
+
const { runFullscreen } = await import("./fullscreen-GM4ZV6FW.js");
|
|
3588
3692
|
const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update, warnings);
|
|
3589
3693
|
watcher?.close();
|
|
3590
3694
|
await inspector?.close().catch(() => void 0);
|
|
@@ -3650,7 +3754,7 @@ ${paint("signal", "Tip:")} create a duto.yml with your project's tunnels and jus
|
|
|
3650
3754
|
return runSession([{ name, port, ...host ? { host } : {}, ...keepRedirects ? { rewriteRedirects: false } : {}, ...auth.length ? { auth } : {}, ...allow.length ? { allow } : {} }], "command line", flags);
|
|
3651
3755
|
}
|
|
3652
3756
|
var cli = cac("duto");
|
|
3653
|
-
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.
|
|
3757
|
+
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.page)").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));
|
|
3654
3758
|
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));
|
|
3655
3759
|
cli.command("login [token]", "Log in through the browser, or save an authtoken").action(async (token) => {
|
|
3656
3760
|
try {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dutodev/cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Expose a local port at https://<name>-<user>.duto.
|
|
3
|
+
"version": "1.9.0",
|
|
4
|
+
"description": "Expose a local port at https://<name>-<user>.duto.page with one command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|