@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
|
-
**
|
|
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
|
|
|
@@ -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-
|
|
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" ? `
|
|
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-
|
|
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: "
|
|
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" ? `
|
|
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
|
|
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() ===
|
|
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 =
|
|
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
|
|
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" ? `
|
|
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:
|
|
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-
|
|
2589
|
-
const code = await runFullscreen(session, specs, "1.
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
|
|
2662
|
-
const
|
|
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
|
-
|
|
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.
|
|
2815
|
+
cli.version("1.1.0");
|
|
2685
2816
|
try {
|
|
2686
2817
|
cli.parse(process.argv, { run: false });
|
|
2687
2818
|
await cli.runMatchedCommand();
|