@dutodev/cli 0.7.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/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-74IJFYGT.js";
14
+ } from "./chunk-76YCOMXH.js";
14
15
 
15
16
  // src/ui/brand.ts
16
17
  function colorOn(stream = process.stdout) {
@@ -20,7 +21,6 @@ function colorOn(stream = process.stdout) {
20
21
  }
21
22
  var SGR = {
22
23
  signal: "38;2;45;212;191",
23
- // #2dd4bf
24
24
  muted: "38;2;138;143;139",
25
25
  strong: "1",
26
26
  ok: "38;2;74;222;128",
@@ -36,7 +36,7 @@ var fail = (msg) => `${paint("err", "\u2716", process.stderr)} ${msg}`;
36
36
  var warn = (msg) => `${paint("warn", "\u26A0")} ${msg}`;
37
37
 
38
38
  // src/index.ts
39
- import { hostname } from "os";
39
+ import { hostname as hostname2 } from "os";
40
40
  import { join as join6, relative } from "path";
41
41
  import cac from "cac";
42
42
 
@@ -52,9 +52,10 @@ var DutoApiError = class extends Error {
52
52
  code;
53
53
  };
54
54
  var MESSAGES = {
55
- unauthorized: "Authtoken inv\xE1lido. Gere um novo em https://duto.dev e rode: duto login <token>",
56
- suspended: "Sua conta est\xE1 suspensa.",
57
- network: "N\xE3o consegui falar com duto.dev. Verifique sua conex\xE3o."
55
+ unauthorized: "This machine is not logged in. Run: duto login",
56
+ suspended: "Your account is suspended.",
57
+ network: "Could not reach duto.dev. Check your connection.",
58
+ loginExpired: "The login code expired. Run duto login again."
58
59
  };
59
60
  function createApiClient(o) {
60
61
  const doFetch = o.fetchImpl ?? fetch;
@@ -64,7 +65,7 @@ function createApiClient(o) {
64
65
  res = await doFetch(`${o.baseUrl}${path}`, {
65
66
  method,
66
67
  headers: {
67
- authorization: `Bearer ${o.token}`,
68
+ ...o.token === void 0 ? {} : { authorization: `Bearer ${o.token}` },
68
69
  ...body === void 0 ? {} : { "content-type": "application/json" },
69
70
  "user-agent": "duto-cli"
70
71
  },
@@ -80,7 +81,7 @@ function createApiClient(o) {
80
81
  const code = data.error ?? "http_error";
81
82
  if (res.status === 401) throw new DutoApiError(MESSAGES.unauthorized, 401, code);
82
83
  if (res.status === 403 && code === "suspended") throw new DutoApiError(MESSAGES.suspended, 403, code);
83
- throw new DutoApiError(data.message ?? `Erro ${res.status} ao falar com duto.dev.`, res.status, code);
84
+ throw new DutoApiError(data.message ?? `duto.dev returned HTTP ${res.status}.`, res.status, code);
84
85
  }
85
86
  async function json(method, path, body) {
86
87
  const res = await call(method, path, body);
@@ -99,6 +100,12 @@ function createApiClient(o) {
99
100
  const res = await call("DELETE", `/api/tunnels/${encodeURIComponent(id)}`);
100
101
  if (!res.ok && res.status !== 404) await fail3(res);
101
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
+ },
102
109
  me: () => json("GET", "/api/me"),
103
110
  endpoints: () => json("GET", "/api/endpoints")
104
111
  };
@@ -169,7 +176,7 @@ function assetFor(platform, arch) {
169
176
  case "win32/x64":
170
177
  return { asset: "cloudflared-windows-amd64.exe", kind: "exe" };
171
178
  default:
172
- throw new Error(`Plataforma n\xE3o suportada: ${key}`);
179
+ throw new Error(`Unsupported platform: ${key}`);
173
180
  }
174
181
  }
175
182
  function downloadUrl(asset, version = CLOUDFLARED_VERSION) {
@@ -184,13 +191,13 @@ async function ensureCloudflared(opts = {}) {
184
191
  opts.onDownload?.();
185
192
  mkdirSync2(binDir, { recursive: true });
186
193
  const res = await (opts.fetchImpl ?? fetch)(downloadUrl(asset));
187
- if (!res.ok) throw new Error(`N\xE3o consegui baixar o cloudflared (HTTP ${res.status}).`);
194
+ if (!res.ok) throw new Error(`Could not download cloudflared (HTTP ${res.status}).`);
188
195
  const bytes = Buffer.from(await res.arrayBuffer());
189
196
  const expected = (opts.checksums ?? CHECKSUMS)[asset];
190
197
  const actual = createHash("sha256").update(bytes).digest("hex");
191
198
  if (!expected || actual !== expected) {
192
199
  rmSync(target, { force: true });
193
- throw new Error("Falha na verifica\xE7\xE3o do cloudflared");
200
+ throw new Error("cloudflared checksum mismatch. Try again; if it keeps failing, update duto.");
194
201
  }
195
202
  const tmp = mkdtempSync(join2(binDir, ".download-"));
196
203
  try {
@@ -217,13 +224,13 @@ async function ensureCloudflaredWithProgress(write, opts = {}) {
217
224
  ...opts,
218
225
  onDownload: () => {
219
226
  downloading = true;
220
- write("\u2193 Baixando cloudflared (s\xF3 na primeira vez)... ");
227
+ write("\u2193 Downloading cloudflared (first run only)... ");
221
228
  }
222
229
  });
223
230
  if (downloading) write("ok\n");
224
231
  return path;
225
232
  } catch (err) {
226
- if (downloading) write("falhou\n");
233
+ if (downloading) write("failed\n");
227
234
  throw err;
228
235
  }
229
236
  }
@@ -274,8 +281,8 @@ var SOURCES = {
274
281
  github: { url: "https://api.github.com/meta", pick: (j) => j?.hooks }
275
282
  };
276
283
  function splitCidr(raw) {
277
- const [ip, p, ...resto] = raw.split("/");
278
- if (!ip || resto.length) return null;
284
+ const [ip, p, ...extra] = raw.split("/");
285
+ if (!ip || extra.length) return null;
279
286
  const v = isIP(ip);
280
287
  if (!v) return null;
281
288
  const max = v === 4 ? 32 : 128;
@@ -311,9 +318,9 @@ async function fetchRanges(name, f) {
311
318
  const res = await f(src.url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: "application/json" } });
312
319
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
313
320
  const list = src.pick(await res.json());
314
- if (!Array.isArray(list)) throw new Error("formato inesperado");
321
+ if (!Array.isArray(list)) throw new Error("unexpected format");
315
322
  const out = list.filter((x) => typeof x === "string" && splitCidr(x) !== null);
316
- if (!out.length) throw new Error("lista vazia");
323
+ if (!out.length) throw new Error("empty list");
317
324
  return out.map((x) => x.includes("/") ? x : `${x}/${splitCidr(x).prefix}`);
318
325
  }
319
326
  async function resolveAllow(entries, o = {}) {
@@ -325,7 +332,8 @@ async function resolveAllow(entries, o = {}) {
325
332
  const cached = readCache(cacheFile);
326
333
  const fresh = cached && now - cached.fetchedAt >= 0 && now - cached.fetchedAt < CACHE_MS ? cached : null;
327
334
  const lists = {};
328
- if (fresh && names.every((n) => Array.isArray(fresh.lists[n]) && fresh.lists[n].length)) {
335
+ const coversEveryName = fresh && names.every((n) => Array.isArray(fresh.lists[n]) && fresh.lists[n].length);
336
+ if (fresh && coversEveryName) {
329
337
  for (const n of names) lists[n] = fresh.lists[n];
330
338
  } else {
331
339
  const net = {};
@@ -336,7 +344,8 @@ async function resolveAllow(entries, o = {}) {
336
344
  lists[name] = ALLOW_SNAPSHOT[name];
337
345
  }
338
346
  }
339
- if (names.length && Object.keys(net).length === names.length) {
347
+ const everyListFromNetwork = names.length > 0 && Object.keys(net).length === names.length;
348
+ if (everyListFromNetwork) {
340
349
  try {
341
350
  mkdirSync3(dirname(cacheFile), { recursive: true });
342
351
  writeFileSync3(cacheFile, JSON.stringify({ fetchedAt: now, lists: net }));
@@ -366,7 +375,7 @@ import { dirname as dirname2, join as join4 } from "path";
366
375
  import { isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
367
376
  var ConfigError = class extends Error {
368
377
  constructor(message2, line, file = CONFIG_FILES[0]) {
369
- super(!file ? message2 : line ? `${file}, linha ${line}: ${message2}` : `${file}: ${message2}`);
378
+ super(!file ? message2 : line ? `${file}, line ${line}: ${message2}` : `${file}: ${message2}`);
370
379
  this.line = line;
371
380
  this.name = "ConfigError";
372
381
  }
@@ -376,7 +385,7 @@ var CONFIG_FILES = ["duto.yml", "duto.yaml"];
376
385
  var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
377
386
  var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
378
387
  var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
379
- var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "permitir"];
388
+ var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "allow"];
380
389
  var ENV_REF_RE = /\$\{([A-Z_0-9]+)\}/g;
381
390
  function parseHost(raw) {
382
391
  const host = raw.trim().toLowerCase();
@@ -402,11 +411,11 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
402
411
  const lineAt = (offset) => offset === void 0 ? void 0 : lc.linePos(offset).line;
403
412
  if (doc.errors.length) {
404
413
  const e = doc.errors[0];
405
- throw fail3("YAML inv\xE1lido. Confira a indenta\xE7\xE3o e os dois-pontos.", lineAt(e.pos[0]));
414
+ throw fail3("invalid YAML. Check the indentation and the colons.", lineAt(e.pos[0]));
406
415
  }
407
416
  const tunnels = isMap(doc.contents) ? doc.contents.get("tunnels", true) : void 0;
408
417
  if (!isMap(tunnels) || tunnels.items.length === 0) {
409
- throw fail3('declare os t\xFAneis em "tunnels:", um por linha (ex.: web: 3000).');
418
+ throw fail3('declare the tunnels under "tunnels:", one per line (e.g. web: 3000)');
410
419
  }
411
420
  const specs = [];
412
421
  for (const pair of tunnels.items) {
@@ -416,40 +425,40 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
416
425
  const v = validateName(name);
417
426
  if (!v.ok) {
418
427
  throw fail3(
419
- v.reason === "reserved" ? `nome reservado: "${raw}". Escolha outro.` : `nome inv\xE1lido: "${raw}" (use s\xF3 letras min\xFAsculas e n\xFAmeros, sem h\xEDfen)`,
428
+ v.reason === "reserved" ? `"${raw}" is reserved. Pick another name.` : `invalid name: "${raw}" (use only lowercase letters and digits, no hyphen)`,
420
429
  line
421
430
  );
422
431
  }
423
432
  const port = portOf(pair.value);
424
- if (port === null) throw fail3(`porta inv\xE1lida para "${raw}" (use 1\u201365535)`, line);
433
+ if (port === null) throw fail3(`invalid port for "${raw}" (use 1\u201365535)`, line);
425
434
  const spec = { name, port };
426
435
  if (isMap(pair.value)) {
427
436
  const body = pair.value;
428
437
  for (const item of body.items) {
429
438
  const key = isScalar(item.key) ? String(item.key.value) : "";
430
- if (!TUNNEL_KEYS.includes(key)) throw fail3(`chave desconhecida em "${raw}": ${key} (use port, host, headers, rewrite_redirects, auth ou permitir)`, line);
439
+ if (!TUNNEL_KEYS.includes(key)) throw fail3(`unknown key in "${raw}": ${key} (use port, host, headers, rewrite_redirects, auth or allow)`, line);
431
440
  }
432
441
  const hostNode = body.get("host", true);
433
442
  if (hostNode !== void 0) {
434
443
  const host = isScalar(hostNode) && typeof hostNode.value === "string" ? parseHost(hostNode.value) : null;
435
- if (!host) throw fail3(`host inv\xE1lido para "${raw}": use s\xF3 o dom\xEDnio, como meuapp.test`, line);
444
+ if (!host) throw fail3(`invalid host for "${raw}": use only the domain, like myapp.test`, line);
436
445
  spec.host = host;
437
446
  }
438
447
  const headersNode = body.get("headers", true);
439
448
  if (headersNode !== void 0) {
440
- if (!isMap(headersNode)) throw fail3(`headers de "${raw}" deve ser uma lista de Nome: valor`, line);
449
+ if (!isMap(headersNode)) throw fail3(`headers in "${raw}" must be a list of Name: value`, line);
441
450
  const headers = {};
442
451
  for (const h of headersNode.items) {
443
452
  const hName = isScalar(h.key) ? String(h.key.value) : "";
444
453
  const hLine = lineAt(h.key?.range?.[0]);
445
- if (!HEADER_NAME_RE.test(hName)) throw fail3(`cabe\xE7alho inv\xE1lido em "${raw}": "${hName}"`, hLine);
446
- if (hName.toLowerCase() === "host") throw fail3(`para trocar o Host de "${raw}", use host: em vez de headers`, hLine);
454
+ if (!HEADER_NAME_RE.test(hName)) throw fail3(`invalid header in "${raw}": "${hName}"`, hLine);
455
+ if (hName.toLowerCase() === "host") throw fail3(`to change the Host of "${raw}", use host: instead of headers`, hLine);
447
456
  const value = h.value === null ? null : isScalar(h.value) ? h.value.value : void 0;
448
457
  if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
449
- throw fail3(`valor inv\xE1lido para o cabe\xE7alho "${hName}" em "${raw}"`, hLine);
458
+ throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
450
459
  }
451
460
  const str = value === null ? "" : String(value);
452
- if (/[\r\n]/.test(str)) throw fail3(`valor inv\xE1lido para o cabe\xE7alho "${hName}" em "${raw}"`, hLine);
461
+ if (/[\r\n]/.test(str)) throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
453
462
  headers[hName.toLowerCase()] = str;
454
463
  }
455
464
  spec.headers = headers;
@@ -457,50 +466,50 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
457
466
  const rewriteNode = body.get("rewrite_redirects", true);
458
467
  if (rewriteNode !== void 0) {
459
468
  const value = isScalar(rewriteNode) ? rewriteNode.value : void 0;
460
- if (typeof value !== "boolean") throw fail3(`rewrite_redirects de "${raw}" deve ser true ou false`, line);
461
- if (!spec.host) throw fail3(`rewrite_redirects de "${raw}" s\xF3 vale junto com host:`, line);
469
+ if (typeof value !== "boolean") throw fail3(`rewrite_redirects in "${raw}" must be true or false`, line);
470
+ if (!spec.host) throw fail3(`rewrite_redirects in "${raw}" only works together with host:`, line);
462
471
  if (value === false) spec.rewriteRedirects = false;
463
472
  }
464
473
  const authNode = body.get("auth", true);
465
474
  if (authNode !== void 0) {
466
- if (!isSeq(authNode)) throw fail3(`auth de "${raw}" deve ser uma lista de usuario:senha`, line);
475
+ if (!isSeq(authNode)) throw fail3(`auth in "${raw}" must be a list of user:password`, line);
467
476
  const auth = [];
468
477
  for (const item of authNode.items) {
469
478
  const iLine = lineAt(item.range?.[0]) ?? line;
470
479
  const text2 = isScalar(item) && typeof item.value === "string" ? item.value : null;
471
- if (text2 === null) throw fail3(`credencial inv\xE1lida em "${raw}": use usuario:senha`, iLine);
480
+ if (text2 === null) throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
472
481
  const expanded = text2.replace(ENV_REF_RE, (_m, name2) => {
473
482
  const val = env[name2];
474
- if (val === void 0) throw fail3(`vari\xE1vel de ambiente ${name2} n\xE3o definida (usada em auth de "${raw}")`, iLine);
483
+ if (val === void 0) throw fail3(`environment variable ${name2} is not set (used in auth of "${raw}")`, iLine);
475
484
  return val;
476
485
  });
477
486
  const parsed = parseCredential(expanded);
478
487
  if (!parsed.ok) {
479
- if (parsed.reason === "format") throw fail3(`credencial inv\xE1lida em "${raw}": use usuario:senha`, iLine);
480
- if (parsed.reason === "user") throw fail3(`usu\xE1rio inv\xE1lido em "${raw}": de 1 a 128 caracteres, sem espa\xE7o`, iLine);
481
- throw fail3(`senha de "${expanded.slice(0, expanded.indexOf(":"))}" em "${raw}" precisa ter de 8 a 128 caracteres`, iLine);
488
+ if (parsed.reason === "format") throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
489
+ if (parsed.reason === "user") throw fail3(`invalid user in "${raw}": 1 to 128 characters, no spaces`, iLine);
490
+ throw fail3(`password for "${expanded.slice(0, expanded.indexOf(":"))}" in "${raw}" must be 8 to 128 characters`, iLine);
482
491
  }
483
- if (auth.some((c) => c.user === parsed.cred.user)) throw fail3(`usu\xE1rio "${parsed.cred.user}" aparece duas vezes em "${raw}"`, iLine);
492
+ if (auth.some((c) => c.user === parsed.cred.user)) throw fail3(`user "${parsed.cred.user}" appears twice in "${raw}"`, iLine);
484
493
  auth.push(parsed.cred);
485
494
  }
486
- if (!auth.length) throw fail3(`auth de "${raw}" n\xE3o pode ficar vazia`, line);
495
+ if (!auth.length) throw fail3(`auth in "${raw}" cannot be empty`, line);
487
496
  spec.auth = auth;
488
497
  }
489
- const allowNode = body.get("permitir", true);
498
+ const allowNode = body.get("allow", true);
490
499
  if (allowNode !== void 0) {
491
- if (!isSeq(allowNode)) throw fail3(`permitir de "${raw}" deve ser uma lista`, line);
492
- if (!spec.auth) throw fail3(`permitir de "${raw}" s\xF3 vale junto com auth:`, line);
500
+ if (!isSeq(allowNode)) throw fail3(`allow in "${raw}" must be a list`, line);
501
+ if (!spec.auth) throw fail3(`allow in "${raw}" only works together with auth:`, line);
493
502
  const allow = [];
494
503
  for (const item of allowNode.items) {
495
504
  const iLine = lineAt(item.range?.[0]) ?? line;
496
505
  const value = isScalar(item) ? String(item.value) : "";
497
- if (!parseAllowEntry(value)) throw fail3(`permitir de "${raw}": "${value}" n\xE3o \xE9 um nome conhecido (stripe, github) nem uma faixa de IP`, iLine);
506
+ if (!parseAllowEntry(value)) throw fail3(`allow in "${raw}": "${value}" is not a known name (stripe, github) or an IP range`, iLine);
498
507
  allow.push(value);
499
508
  }
500
509
  if (allow.length) spec.allow = allow;
501
510
  }
502
511
  }
503
- if (specs.some((s) => s.name === name)) throw fail3(`o nome "${raw}" aparece duas vezes`, line);
512
+ if (specs.some((s) => s.name === name)) throw fail3(`the name "${raw}" appears twice`, line);
504
513
  specs.push(spec);
505
514
  }
506
515
  return specs;
@@ -509,7 +518,7 @@ function findConfig(cwd) {
509
518
  let dir = cwd;
510
519
  for (; ; ) {
511
520
  const present = CONFIG_FILES.filter((f) => existsSync3(join4(dir, f)));
512
- if (present.length > 1) throw new ConfigError(`${CONFIG_FILES.join(" e ")} na mesma pasta. Apague um dos dois.`, void 0, "");
521
+ if (present.length > 1) throw new ConfigError(`${CONFIG_FILES.join(" and ")} in the same folder. Delete one of them.`, void 0, "");
513
522
  if (present.length === 1) {
514
523
  const path = join4(dir, present[0]);
515
524
  return { path, tunnels: parseConfig(readFileSync3(path, "utf8"), present[0]) };
@@ -521,7 +530,96 @@ function findConfig(cwd) {
521
530
  }
522
531
  }
523
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
+
524
619
  // src/inspector/jsonView.ts
620
+ function countLabel(n, one, many) {
621
+ return n + " " + (n === 1 ? one : many);
622
+ }
525
623
  function jsonSyntaxError(text) {
526
624
  try {
527
625
  JSON.parse(text);
@@ -697,14 +795,15 @@ th { color: ${JSON_KEY}; font-weight: 400; white-space: nowrap; }
697
795
  }
698
796
  `;
699
797
  var SCRIPT = String.raw`"use strict";
798
+ var countLabel = (${countLabel.toString()});
700
799
  var jsonSyntaxError = (${jsonSyntaxError.toString()});
701
800
  var jsonTokens = (${jsonTokens.toString()});
702
801
  var jsonIndent = (${jsonIndent.toString()});
703
802
  var COLORS = ${JSON.stringify(C)};
704
803
  var TOKEN = new URLSearchParams(location.search).get("t") || "";
705
- var TABS = ["Corpo", "Cabeçalhos", "Resposta", "Raw"];
804
+ var TABS = ["Body", "Headers", "Response", "Raw"];
706
805
 
707
- var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Corpo", ended: false };
806
+ var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Body", ended: false };
708
807
  var listSeq = 0, detailSeq = 0, msgTimer = null;
709
808
  var es = null, lost = false, replaying = false;
710
809
  function fail(e) { if (!state.ended) console.error(e); }
@@ -725,12 +824,12 @@ function banner(text) {
725
824
  }
726
825
  function ended() {
727
826
  state.ended = true;
728
- banner("Sessão encerrada; abra o inspetor de novo pela tecla i.");
827
+ banner("Session ended. Press i in duto to open the inspector again.");
729
828
  if (es) es.close();
730
829
  }
731
830
 
732
831
  function api(path, method) {
733
- return fetch(path, { method: method || "GET", headers: { "X-Duto-Inspetor": TOKEN }, cache: "no-store" }).then(function (r) {
832
+ return fetch(path, { method: method || "GET", headers: { "X-Duto-Inspector": TOKEN }, cache: "no-store" }).then(function (r) {
734
833
  if (r.status === 403) { ended(); throw new Error("403"); }
735
834
  return r.json().then(function (body) { return { status: r.status, body: body }; });
736
835
  });
@@ -742,22 +841,20 @@ function statusInfo(it) {
742
841
  var color = s >= 500 ? COLORS.err : s >= 400 ? COLORS.warn : s >= 300 ? COLORS.signal : COLORS.ok;
743
842
  return { text: String(s), color: color };
744
843
  }
745
- if (it.error) return { text: "erro", color: COLORS.err };
844
+ if (it.error) return { text: "error", color: COLORS.err };
746
845
  return { text: "…", color: COLORS.muted };
747
846
  }
748
847
  function duration(it) { return it.durationMs === null || it.durationMs === undefined ? "" : it.durationMs + " ms"; }
749
848
  function size(n) {
750
849
  if (n < 1024) return n + " B";
751
- if (n < 1024 * 1024) return (n / 1024).toFixed(1).replace(".", ",") + " KB";
752
- return (n / 1024 / 1024).toFixed(1).replace(".", ",") + " MB";
850
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
851
+ return (n / 1024 / 1024).toFixed(1) + " MB";
753
852
  }
754
853
  function methodOf(it) { return it.websocket ? "WS" : it.method; }
755
854
  function header(h, name) {
756
855
  for (var k in h) if (k.toLowerCase() === name) return h[k];
757
856
  return "";
758
857
  }
759
-
760
- // Mesma regra do filtro da tela do terminal.
761
858
  function visible() {
762
859
  var f = $("filter").value.trim().toLowerCase();
763
860
  if (!f) return state.items;
@@ -768,13 +865,13 @@ function visible() {
768
865
 
769
866
  function renderTop() {
770
867
  var n = state.items.length;
771
- $("count").textContent = n + (n === 1 ? " requisição" : " requisições");
868
+ $("count").textContent = countLabel(n, "request", "requests");
772
869
  var box = $("tunnels");
773
870
  clear(box);
774
871
  state.tunnels.forEach(function (t) {
775
872
  var s = el("span");
776
873
  s.appendChild(el("span", "k", t.key));
777
- s.appendChild(document.createTextNode(" " + (t.url || "sem endereço ainda")));
874
+ s.appendChild(document.createTextNode(" " + (t.url || "no URL yet")));
778
875
  box.appendChild(s);
779
876
  });
780
877
  }
@@ -786,7 +883,7 @@ function renderList() {
786
883
  clear(list);
787
884
  var items = visible();
788
885
  if (items.length === 0) {
789
- list.appendChild(el("div", "empty", state.items.length === 0 ? "Nenhuma requisição ainda." : "Nada casa com o filtro."));
886
+ list.appendChild(el("div", "empty", state.items.length === 0 ? "No requests yet." : "No requests match the filter."));
790
887
  return;
791
888
  }
792
889
  items.forEach(function (it) {
@@ -812,7 +909,7 @@ function renderList() {
812
909
 
813
910
  function loadList() {
814
911
  var seq = ++listSeq;
815
- return Promise.all([api("/api/requisicoes"), api("/api/sessao")]).then(function (rs) {
912
+ return Promise.all([api("/api/requests"), api("/api/session")]).then(function (rs) {
816
913
  if (seq !== listSeq) return;
817
914
  state.items = rs[0].body.items || [];
818
915
  state.tunnels = rs[1].body.tunnels || [];
@@ -837,13 +934,13 @@ function select(id) {
837
934
 
838
935
  function loadDetail(id) {
839
936
  var seq = ++detailSeq;
840
- return api("/api/requisicoes/" + id).then(function (r) {
937
+ return api("/api/requests/" + id).then(function (r) {
841
938
  if (seq !== detailSeq || state.selected !== id) return;
842
939
  var key = JSON.stringify(r.body);
843
- if (key === state.detailKey) return; // nada mudou: mantém dobras e rolagem
940
+ if (key === state.detailKey) return;
844
941
  state.detailKey = key;
845
942
  state.detail = r.status === 200 ? r.body : null;
846
- if (r.status !== 200) renderGone(r.body && r.body.error ? r.body.error : "Essa requisição já saiu da lista.");
943
+ if (r.status !== 200) renderGone(r.body && r.body.error ? r.body.error : "That request is no longer in the list.");
847
944
  else renderDetail();
848
945
  }).catch(fail);
849
946
  }
@@ -872,7 +969,7 @@ function renderDetail() {
872
969
  if (duration(d)) parts.push(duration(d));
873
970
  parts.push(size(d.bytes));
874
971
  parts.push(d.tunnel);
875
- if (d.replayOf !== undefined) parts.push("↻ reenvio de #" + d.replayOf);
972
+ if (d.replayOf !== undefined) parts.push("↻ replay of #" + d.replayOf);
876
973
  head.appendChild(el("span", "mut", " · " + parts.join(" · ")));
877
974
  if (d.error) head.appendChild(el("div", "msg err", d.error));
878
975
 
@@ -891,12 +988,12 @@ function renderContent() {
891
988
  var box = $("content");
892
989
  var top = box.scrollTop;
893
990
  clear(box);
894
- if (state.tab === "Corpo") renderBody(box, d.reqBody, d.reqNote, d.reqTruncatedNote, d.reqHeaders);
895
- else if (state.tab === "Cabeçalhos") box.appendChild(table(d.reqHeaders));
896
- else if (state.tab === "Resposta") {
897
- box.appendChild(el("div", "sect", "Cabeçalhos"));
991
+ if (state.tab === "Body") renderBody(box, d.reqBody, d.reqNote, d.reqTruncatedNote, d.reqHeaders);
992
+ else if (state.tab === "Headers") box.appendChild(table(d.reqHeaders));
993
+ else if (state.tab === "Response") {
994
+ box.appendChild(el("div", "sect", "Headers"));
898
995
  box.appendChild(table(d.resHeaders));
899
- box.appendChild(el("div", "sect", "Corpo"));
996
+ box.appendChild(el("div", "sect", "Body"));
900
997
  renderBody(box, d.resBody, d.resNote, d.resTruncatedNote, d.resHeaders);
901
998
  } else {
902
999
  var lines = [d.method + " " + d.path + " HTTP/1.1"];
@@ -910,7 +1007,7 @@ function renderContent() {
910
1007
 
911
1008
  function table(h) {
912
1009
  var keys = Object.keys(h || {});
913
- if (keys.length === 0) return el("div", "note", "Sem cabeçalhos.");
1010
+ if (keys.length === 0) return el("div", "note", "No headers.");
914
1011
  var t = el("table");
915
1012
  keys.forEach(function (k) {
916
1013
  var tr = el("tr");
@@ -927,7 +1024,7 @@ function renderBody(box, body, note, truncNote, headers) {
927
1024
  return;
928
1025
  }
929
1026
  if (body === null || body === undefined) {
930
- box.appendChild(el("div", "note", "Sem corpo."));
1027
+ box.appendChild(el("div", "note", "No body."));
931
1028
  } else if (header(headers, "content-type").toLowerCase().indexOf("json") >= 0) {
932
1029
  var err = jsonSyntaxError(body);
933
1030
  if (err === null) box.appendChild(jsonTree(jsonIndent(body)));
@@ -947,8 +1044,6 @@ function renderBody(box, body, note, truncNote, headers) {
947
1044
  }
948
1045
 
949
1046
  var KIND = { key: "k", string: "s", number: "n", literal: "n", punct: "", space: "" };
950
-
951
- // JSON colorido; clicar no { ou [ recolhe para "{ ▸ N campos }".
952
1047
  function jsonTree(text) {
953
1048
  var pre = el("pre");
954
1049
  var stack = [{ node: pre }];
@@ -973,8 +1068,8 @@ function jsonTree(text) {
973
1068
  var n = c.has ? c.count + 1 : 0;
974
1069
  if (n > 0) {
975
1070
  c.wrap.dataset.n = String(n);
976
- c.open.title = "recolher ou abrir";
977
- c.sum.textContent = " ▸ " + n + (c.obj ? (n === 1 ? " campo " : " campos ") : n === 1 ? " item " : " itens ");
1071
+ c.open.title = "collapse or expand";
1072
+ c.sum.textContent = " ▸ " + (c.obj ? countLabel(n, "field", "fields") : countLabel(n, "item", "items")) + " ";
978
1073
  } else c.open.className = "";
979
1074
  c.wrap.appendChild(document.createTextNode(t.text));
980
1075
  return;
@@ -1006,13 +1101,11 @@ function renderButtons() {
1006
1101
  var notes = [];
1007
1102
  if (d.curl !== null && d.curlNote) notes.push(d.curlNote);
1008
1103
  var tunnel = state.tunnels.filter(function (t) { return t.key === d.tunnel; })[0];
1009
- if (d.curl !== null && tunnel && tunnel.auth) notes.push("Troque SENHA pela senha do túnel.");
1104
+ if (d.curl !== null && tunnel && tunnel.auth) notes.push("Replace PASSWORD with the tunnel password.");
1010
1105
  $("curlNote").textContent = notes.join(" ");
1011
1106
  }
1012
-
1013
- // "Copiar corpo" copia o corpo da aba ativa: a resposta na aba Resposta, senão a requisição.
1014
1107
  function copyable(d) {
1015
- return state.tab === "Resposta" ? { text: d.resBody, note: d.resNote } : { text: d.reqBody, note: d.reqNote };
1108
+ return state.tab === "Response" ? { text: d.resBody, note: d.resNote } : { text: d.reqBody, note: d.reqNote };
1016
1109
  }
1017
1110
 
1018
1111
  function showMsg(text, isErr) {
@@ -1031,10 +1124,10 @@ function hideMsg() {
1031
1124
 
1032
1125
  function copy(text) {
1033
1126
  navigator.clipboard.writeText(text).then(function () {
1034
- showMsg("Copiado.", false);
1127
+ showMsg("Copied.", false);
1035
1128
  msgTimer = setTimeout(hideMsg, 2000);
1036
1129
  }, function () {
1037
- showMsg("Não deu para copiar.", true);
1130
+ showMsg("Could not copy.", true);
1038
1131
  });
1039
1132
  }
1040
1133
 
@@ -1045,14 +1138,14 @@ function replay() {
1045
1138
  if (replaying) return;
1046
1139
  replaying = true;
1047
1140
  $("replay").disabled = true;
1048
- api("/api/requisicoes/" + d.id + "/reenviar", "POST").then(function (r) {
1141
+ api("/api/requests/" + d.id + "/replay", "POST").then(function (r) {
1049
1142
  replaying = false;
1050
1143
  if (state.detail) renderButtons();
1051
1144
  if (r.body && r.body.ok) {
1052
1145
  hideMsg();
1053
1146
  return loadList().then(function () { return select(r.body.id); });
1054
1147
  }
1055
- showMsg(r.body && r.body.reason ? r.body.reason : "Não deu para reenviar.", true);
1148
+ showMsg(r.body && r.body.reason ? r.body.reason : "Could not replay.", true);
1056
1149
  }).catch(function (e) {
1057
1150
  replaying = false;
1058
1151
  if (state.detail) renderButtons();
@@ -1071,11 +1164,10 @@ function move(delta) {
1071
1164
 
1072
1165
  function connect() {
1073
1166
  if (state.ended) return;
1074
- es = new EventSource("/api/eventos?t=" + encodeURIComponent(TOKEN));
1167
+ es = new EventSource("/api/events?t=" + encodeURIComponent(TOKEN));
1075
1168
  es.addEventListener("open", function () {
1076
1169
  if (state.ended) return;
1077
1170
  banner("");
1078
- // Reconectou: o que mudou no meio do caminho não chegou por evento.
1079
1171
  if (lost) {
1080
1172
  lost = false;
1081
1173
  loadList();
@@ -1085,22 +1177,18 @@ function connect() {
1085
1177
  es.addEventListener("error", function () {
1086
1178
  if (state.ended) return;
1087
1179
  lost = true;
1088
- banner("Desconectado da sessão.");
1089
- // Resposta não 200 (ex.: 403 de outra sessão) fecha o EventSource de vez.
1180
+ banner("Disconnected from the session.");
1090
1181
  if (es.readyState === 2) probe();
1091
1182
  });
1092
- es.addEventListener("mudou", function (ev) {
1183
+ es.addEventListener("changed", function (ev) {
1093
1184
  var ids = [];
1094
1185
  try { ids = JSON.parse(ev.data).ids || []; } catch (e) {}
1095
1186
  loadList();
1096
- // Lista vazia de ids: algo mudou fora da assinatura; busca o detalhe de novo também.
1097
1187
  if (state.selected !== null && (ids.length === 0 || ids.indexOf(state.selected) >= 0)) loadDetail(state.selected);
1098
1188
  });
1099
1189
  }
1100
-
1101
- // EventSource fechado: a API diz se a sessão acabou (403 → ended) ou se dá para reconectar.
1102
1190
  function probe() {
1103
- api("/api/sessao").then(function () {
1191
+ api("/api/session").then(function () {
1104
1192
  if (!state.ended) setTimeout(connect, 3000);
1105
1193
  }, function () {
1106
1194
  if (!state.ended) setTimeout(connect, 3000);
@@ -1150,35 +1238,35 @@ function init() {
1150
1238
  init();
1151
1239
  `;
1152
1240
  function inspectorPage(nonce) {
1153
- const tabs = ["Corpo", "Cabe\xE7alhos", "Resposta", "Raw"].map((t, i) => `<button type="button" class="tab${i === 0 ? " on" : ""}" role="tab" data-tab="${t}">${t}</button>`).join("");
1241
+ const tabs = ["Body", "Headers", "Response", "Raw"].map((t, i) => `<button type="button" class="tab${i === 0 ? " on" : ""}" role="tab" data-tab="${t}">${t}</button>`).join("");
1154
1242
  return `<!doctype html>
1155
- <html lang="pt-BR">
1243
+ <html lang="en">
1156
1244
  <head>
1157
1245
  <meta charset="utf-8">
1158
1246
  <meta name="viewport" content="width=device-width, initial-scale=1">
1159
- <title>duto \xB7 inspetor</title>
1247
+ <title>duto \xB7 inspector</title>
1160
1248
  <link rel="icon" href="data:,">
1161
1249
  <style nonce="${nonce}">${STYLE}</style>
1162
1250
  </head>
1163
1251
  <body>
1164
1252
  <div class="app">
1165
- <div class="top"><span><b>\u25B0 duto</b> \xB7 inspetor \xB7 <span id="count">0 requisi\xE7\xF5es</span></span><span class="tunnels" id="tunnels"></span></div>
1253
+ <div class="top"><span><b>\u25B0 duto</b> \xB7 inspector \xB7 <span id="count">0 requests</span></span><span class="tunnels" id="tunnels"></span></div>
1166
1254
  <div class="banner" id="banner" role="status" hidden></div>
1167
1255
  <div class="main">
1168
1256
  <div class="side">
1169
- <input class="search" id="filter" type="search" placeholder="filtrar\u2026" aria-label="Filtrar requisi\xE7\xF5es" autocomplete="off" spellcheck="false">
1170
- <div class="list" id="list" role="listbox" aria-label="Requisi\xE7\xF5es"></div>
1257
+ <input class="search" id="filter" type="search" placeholder="filter\u2026" aria-label="Filter requests" autocomplete="off" spellcheck="false">
1258
+ <div class="list" id="list" role="listbox" aria-label="Requests"></div>
1171
1259
  </div>
1172
1260
  <div class="detail">
1173
- <div class="empty" id="none">Escolha uma requisi\xE7\xE3o na lista.</div>
1261
+ <div class="empty" id="none">Select a request from the list.</div>
1174
1262
  <div class="detail pane" id="detail" hidden>
1175
1263
  <div class="head" id="head"></div>
1176
1264
  <div class="tabs" role="tablist">${tabs}</div>
1177
1265
  <div class="content" id="content"></div>
1178
1266
  <div class="btns">
1179
- <span><button type="button" class="btn pri" id="replay">\u21BB Reenviar</button></span>
1180
- <span><button type="button" class="btn" id="copyBody">Copiar corpo</button></span>
1181
- <span><button type="button" class="btn" id="copyCurl">Copiar como curl</button></span>
1267
+ <span><button type="button" class="btn pri" id="replay">\u21BB Replay</button></span>
1268
+ <span><button type="button" class="btn" id="copyBody">Copy body</button></span>
1269
+ <span><button type="button" class="btn" id="copyCurl">Copy as curl</button></span>
1182
1270
  <span class="msg" id="curlNote"></span>
1183
1271
  <span class="msg" id="msg" role="status" hidden></span>
1184
1272
  </div>
@@ -1197,21 +1285,21 @@ import { createHash as createHash2, randomBytes, timingSafeEqual } from "crypto"
1197
1285
  import { createServer } from "http";
1198
1286
 
1199
1287
  // src/session/replay.ts
1200
- var tunnelClosedReason = (tunnel2) => `O t\xFAnel ${tunnel2} n\xE3o est\xE1 aberto nesta sess\xE3o.`;
1288
+ var tunnelClosedReason = (tunnel2) => `Tunnel ${tunnel2} is not open in this session.`;
1201
1289
  function replayBlock(r) {
1202
- if (!r) return "Essa requisi\xE7\xE3o j\xE1 saiu da lista.";
1203
- if (r.websocket) return "WebSocket n\xE3o d\xE1 para reenviar.";
1290
+ if (!r) return "That request is no longer in the list.";
1291
+ if (r.websocket) return "WebSocket requests cannot be replayed.";
1204
1292
  if (r.noReplay) return r.noReplay;
1205
- if (r.access?.kind === "denied") return "Barrada pela senha do t\xFAnel; n\xE3o d\xE1 para reenviar.";
1206
- if (!r.reqComplete) return "A requisi\xE7\xE3o ainda n\xE3o chegou inteira.";
1207
- if (r.reqTruncated) return "Corpo acima de 64 KB; n\xE3o d\xE1 para reenviar.";
1208
- if (r.reqBodyBytes > 0 && r.reqRaw === null) return "Corpo bin\xE1rio n\xE3o \xE9 guardado; n\xE3o d\xE1 para reenviar.";
1293
+ if (r.access?.kind === "denied") return "Blocked by the tunnel password; cannot replay.";
1294
+ if (!r.reqComplete) return "The request has not fully arrived yet.";
1295
+ if (r.reqTruncated) return "Body over 64 KB; cannot replay.";
1296
+ if (r.reqBodyBytes > 0 && r.reqRaw === null) return "Binary body is not stored; cannot replay.";
1209
1297
  return null;
1210
1298
  }
1211
- var DROP = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "expect", "content-length", "upgrade", "te", "trailer", "proxy-connection"]);
1299
+ var HOP_BY_HOP_AND_BODY_HEADERS = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "expect", "content-length", "upgrade", "te", "trailer", "proxy-connection"]);
1212
1300
  function replayHeaders(r) {
1213
1301
  const out = {};
1214
- for (const [k, v] of Object.entries(r.reqHeaders)) if (!DROP.has(k.toLowerCase())) out[k] = v;
1302
+ for (const [k, v] of Object.entries(r.reqHeaders)) if (!HOP_BY_HOP_AND_BODY_HEADERS.has(k.toLowerCase())) out[k] = v;
1215
1303
  const hadLength = Object.keys(r.reqHeaders).some((k) => k.toLowerCase() === "content-length");
1216
1304
  if (r.reqBodyBytes > 0 || hadLength) out["content-length"] = String(r.reqRaw?.length ?? 0);
1217
1305
  return out;
@@ -1232,7 +1320,7 @@ function toCurl(r, o) {
1232
1320
  for (const [k, v] of Object.entries(r.reqHeaders)) {
1233
1321
  if (!skip(k)) parts.push(`-H ${shellQuote(`${k}: ${v}`)}`);
1234
1322
  }
1235
- if (o.authUser !== null) parts.push(`-H ${shellQuote(`X-Duto-Auth: ${o.authUser}:SENHA`)}`);
1323
+ if (o.authUser !== null) parts.push(`-H ${shellQuote(`X-Duto-Auth: ${o.authUser}:PASSWORD`)}`);
1236
1324
  if (r.reqBody !== null) parts.push(`--data-binary ${shellQuote(r.reqBody)}`);
1237
1325
  return parts.join(" \\\n ");
1238
1326
  }
@@ -1240,12 +1328,12 @@ function toCurl(r, o) {
1240
1328
  // src/inspector/server.ts
1241
1329
  var DEFAULT_PORT = 4141;
1242
1330
  var PORT_TRIES = 20;
1243
- var NO_TOKEN = "Abra o inspetor pela tecla i da tela do duto ou pelo endere\xE7o impresso no terminal.";
1244
- var GONE = "Essa requisi\xE7\xE3o j\xE1 saiu da lista.";
1245
- var NO_URL = "O t\xFAnel ainda n\xE3o tem endere\xE7o p\xFAblico.";
1246
- var CURL_BYTES = "O corpo do curl pode n\xE3o ser id\xEAntico ao recebido (bytes fora de UTF-8).";
1331
+ var NO_TOKEN = "Open the inspector with the i key in the duto screen, or with the address printed in the terminal.";
1332
+ var GONE = "That request is no longer in the list.";
1333
+ var NO_URL = "The tunnel has no public URL yet.";
1334
+ var CURL_BYTES = "The curl body may differ from the one received (non-UTF-8 bytes).";
1247
1335
  var sha = (s) => createHash2("sha256").update(s).digest();
1248
- var same = (a, b) => timingSafeEqual(sha(a), sha(b));
1336
+ var constantTimeEqual = (a, b) => timingSafeEqual(sha(a), sha(b));
1249
1337
  var first = (v) => Array.isArray(v) ? v[0] : v;
1250
1338
  function listItem(r) {
1251
1339
  return {
@@ -1308,7 +1396,7 @@ async function startInspector(o) {
1308
1396
  if (seen.get(r.id) !== s) ids.push(r.id);
1309
1397
  }
1310
1398
  seen = next;
1311
- const msg = `event: mudou
1399
+ const msg = `event: changed
1312
1400
  data: ${JSON.stringify({ ids })}
1313
1401
 
1314
1402
  `;
@@ -1339,19 +1427,19 @@ data: ${JSON.stringify({ ids })}
1339
1427
  const handle = (req, res) => {
1340
1428
  base(res);
1341
1429
  const host = first(req.headers.host)?.toLowerCase();
1342
- if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text(res, 403, "Host n\xE3o permitido.");
1430
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text(res, 403, "Host not allowed.");
1343
1431
  let url2;
1344
1432
  try {
1345
1433
  url2 = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
1346
1434
  } catch {
1347
- return text(res, 404, "N\xE3o encontrado.");
1435
+ return text(res, 404, "Not found.");
1348
1436
  }
1349
1437
  const path = url2.pathname;
1350
1438
  const method = req.method ?? "GET";
1351
1439
  const queryToken = url2.searchParams.get("t") ?? "";
1352
- const headerToken = first(req.headers["x-duto-inspetor"]) ?? "";
1440
+ const headerToken = first(req.headers["x-duto-inspector"]) ?? "";
1353
1441
  if (method === "GET" && path === "/") {
1354
- if (!same(queryToken, token)) return text(res, 403, NO_TOKEN);
1442
+ if (!constantTimeEqual(queryToken, token)) return text(res, 403, NO_TOKEN);
1355
1443
  const nonce = randomBytes(16).toString("base64url");
1356
1444
  res.writeHead(200, {
1357
1445
  "Content-Type": "text/html; charset=utf-8",
@@ -1359,11 +1447,11 @@ data: ${JSON.stringify({ ids })}
1359
1447
  });
1360
1448
  return void res.end(o.page(nonce));
1361
1449
  }
1362
- const isEvents = method === "GET" && path === "/api/eventos";
1363
- const detailId = /^\/api\/requisicoes\/([1-9]\d{0,15})(\/reenviar)?$/.exec(path);
1364
- const known = method === "GET" && (path === "/api/sessao" || path === "/api/requisicoes" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
1365
- if (!known) return text(res, 404, "N\xE3o encontrado.");
1366
- if (!same(headerToken, token) && !(isEvents && same(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
1450
+ const isEvents = method === "GET" && path === "/api/events";
1451
+ const detailId = /^\/api\/requests\/([1-9]\d{0,15})(\/replay)?$/.exec(path);
1452
+ const known = method === "GET" && (path === "/api/session" || path === "/api/requests" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
1453
+ if (!known) return text(res, 404, "Not found.");
1454
+ if (!constantTimeEqual(headerToken, token) && !(isEvents && constantTimeEqual(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
1367
1455
  if (isEvents) {
1368
1456
  res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", Connection: "keep-alive" });
1369
1457
  res.write(": duto\n\n");
@@ -1372,10 +1460,10 @@ data: ${JSON.stringify({ ids })}
1372
1460
  res.on("close", () => clients.delete(res));
1373
1461
  return;
1374
1462
  }
1375
- if (path === "/api/sessao") {
1463
+ if (path === "/api/session") {
1376
1464
  return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth })) });
1377
1465
  }
1378
- if (path === "/api/requisicoes") return json(res, 200, { items: session.records.list().map(listItem) });
1466
+ if (path === "/api/requests") return json(res, 200, { items: session.records.list().map(listItem) });
1379
1467
  const id = Number(detailId[1]);
1380
1468
  if (!Number.isSafeInteger(id)) return json(res, 404, { error: GONE });
1381
1469
  if (method === "POST") return json(res, 200, session.replay(id));
@@ -1387,7 +1475,7 @@ data: ${JSON.stringify({ ids })}
1387
1475
  try {
1388
1476
  handle(req, res);
1389
1477
  } catch {
1390
- if (!res.headersSent) text(res, 500, "Erro interno.");
1478
+ if (!res.headersSent) text(res, 500, "Internal error.");
1391
1479
  else res.end();
1392
1480
  }
1393
1481
  });
@@ -1437,7 +1525,7 @@ data: ${JSON.stringify({ ids })}
1437
1525
  }
1438
1526
 
1439
1527
  // src/session/session.ts
1440
- import { spawn } from "child_process";
1528
+ import { spawn as spawn2 } from "child_process";
1441
1529
 
1442
1530
  // src/portcheck.ts
1443
1531
  import { connect } from "net";
@@ -1460,10 +1548,11 @@ async function isPortListening(port, timeoutMs = 500) {
1460
1548
 
1461
1549
  // src/session/auth.ts
1462
1550
  import { createHash as createHash3, createHmac, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1463
- var TUNNEL_COOKIE = "__Host-duto_tunel";
1551
+ var TUNNEL_COOKIE = "__Host-duto_tunnel";
1552
+ var SEC_FETCH_SITE_SAME_SITE = "same-site";
1464
1553
  var QR_TTL_MS = 5 * 6e4;
1465
1554
  var sha2 = (s) => createHash3("sha256").update(s).digest();
1466
- var same2 = (a, b) => timingSafeEqual2(sha2(a), sha2(b));
1555
+ var constantTimeEqual2 = (a, b) => timingSafeEqual2(sha2(a), sha2(b));
1467
1556
  function splitPair(raw) {
1468
1557
  const i = raw.indexOf(":");
1469
1558
  return i < 0 ? null : { user: raw.slice(0, i), password: raw.slice(i + 1) };
@@ -1475,7 +1564,7 @@ function parseBasic(value) {
1475
1564
  var first2 = (v) => Array.isArray(v) ? v[0] : v;
1476
1565
  var bareHost = (s) => s.replace(/:\d+$/, "").toLowerCase();
1477
1566
  function sameHostOrigin(h) {
1478
- if (first2(h["sec-fetch-site"])?.toLowerCase() === "same-site") return false;
1567
+ if (first2(h["sec-fetch-site"])?.toLowerCase() === SEC_FETCH_SITE_SAME_SITE) return false;
1479
1568
  const origin = first2(h.origin);
1480
1569
  if (origin === void 0) return true;
1481
1570
  let host;
@@ -1510,16 +1599,16 @@ var TunnelAuth = class {
1510
1599
  }
1511
1600
  const cookieUser = this.cookieUser(h.cookie);
1512
1601
  if (cookieUser && sameHostOrigin(h)) return { kind: "user", user: cookieUser };
1513
- const duto = first2(h["x-duto-auth"]);
1514
- const cand = duto !== void 0 ? splitPair(duto) : parseBasic(first2(h.authorization));
1515
- if (cand && this.checkPassword(cand.user, cand.password)) return { kind: "user", user: cand.user };
1602
+ const dutoAuthHeader = first2(h["x-duto-auth"]);
1603
+ const candidate = dutoAuthHeader !== void 0 ? splitPair(dutoAuthHeader) : parseBasic(first2(h.authorization));
1604
+ if (candidate && this.checkPassword(candidate.user, candidate.password)) return { kind: "user", user: candidate.user };
1516
1605
  return { kind: "denied" };
1517
1606
  }
1518
1607
  checkPassword(user, password) {
1519
1608
  let ok2 = false;
1520
1609
  for (const c of this.credentials) {
1521
- const userOk = same2(c.user, user);
1522
- const passOk = same2(c.password, password);
1610
+ const userOk = constantTimeEqual2(c.user, user);
1611
+ const passOk = constantTimeEqual2(c.password, password);
1523
1612
  if (userOk && passOk) ok2 = true;
1524
1613
  }
1525
1614
  return ok2;
@@ -1536,7 +1625,7 @@ var TunnelAuth = class {
1536
1625
  }
1537
1626
  consumeQrToken(token) {
1538
1627
  const qr = this.qr;
1539
- if (!qr || !same2(qr.token, token)) return false;
1628
+ if (!qr || !constantTimeEqual2(qr.token, token)) return false;
1540
1629
  this.qr = null;
1541
1630
  return this.now() <= qr.expiresAt;
1542
1631
  }
@@ -1554,7 +1643,6 @@ var TunnelAuth = class {
1554
1643
  }
1555
1644
  return out;
1556
1645
  }
1557
- // Valor: usuário em base64url + "." + HMAC(túnel, usuário).
1558
1646
  cookieValue(user) {
1559
1647
  return `${Buffer.from(user).toString("base64url")}.${this.sign(user)}`;
1560
1648
  }
@@ -1567,10 +1655,10 @@ var TunnelAuth = class {
1567
1655
  for (const p of list) {
1568
1656
  const item = p.trim();
1569
1657
  if (!item.startsWith(`${TUNNEL_COOKIE}=`)) continue;
1570
- const [b64, sig2, ...resto] = item.slice(TUNNEL_COOKIE.length + 1).split(".");
1571
- if (!b64 || !sig2 || resto.length) continue;
1658
+ const [b64, sig2, ...extra] = item.slice(TUNNEL_COOKIE.length + 1).split(".");
1659
+ if (!b64 || !sig2 || extra.length) continue;
1572
1660
  const user = Buffer.from(b64, "base64url").toString("utf8");
1573
- if (same2(sig2, this.sign(user)) && this.credentials.some((c) => c.user === user)) return user;
1661
+ if (constantTimeEqual2(sig2, this.sign(user)) && this.credentials.some((c) => c.user === user)) return user;
1574
1662
  }
1575
1663
  return null;
1576
1664
  }
@@ -1622,14 +1710,14 @@ var WAIT_SCRIPT = `
1622
1710
  }
1623
1711
  function again() { delay = Math.min(delay * 2, 30000); poll(); }
1624
1712
  if (!window.EventSource) return poll();
1625
- var fails = 0, es = new EventSource("/__duto/espera");
1713
+ var fails = 0, es = new EventSource("/__duto/wait");
1626
1714
  es.addEventListener("up", function () { es.close(); location.reload(); });
1627
1715
  es.onerror = function () { if (++fails >= 3) { es.close(); poll(); } };
1628
1716
  })();
1629
1717
  </script>`;
1630
1718
  function pageShell(title, body, css = "") {
1631
1719
  return `<!doctype html>
1632
- <html lang="pt-BR">
1720
+ <html lang="en">
1633
1721
  <head>
1634
1722
  <meta charset="utf-8">
1635
1723
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -1661,13 +1749,13 @@ function unavailablePage(o) {
1661
1749
  const tunnel2 = esc(o.tunnel);
1662
1750
  const local = `localhost:${o.port}`;
1663
1751
  return pageShell(
1664
- `Nada respondendo em ${local} \xB7 duto`,
1752
+ `Nothing responding on ${local} \xB7 duto`,
1665
1753
  `<main>
1666
1754
  <div class="mark"><span class="dot"></span>duto \xB7 ${tunnel2}</div>
1667
- <h1>Nada respondendo em <code>${local}</code></h1>
1668
- <p>O t\xFAnel <code>${tunnel2}</code> est\xE1 no ar, mas o servi\xE7o local n\xE3o respondeu. Suba o servidor na m\xE1quina que abriu o t\xFAnel.</p>
1669
- <p>Se voc\xEA s\xF3 recebeu este link, avise quem o enviou.</p>
1670
- <div class="retry">${o.retry ? `<b>Aguardando o servi\xE7o.</b> A p\xE1gina volta sozinha quando ele responder.<noscript> Recarregue quando o servi\xE7o estiver no ar.</noscript>` : "Recarregue a p\xE1gina quando o servi\xE7o estiver no ar."}</div>
1755
+ <h1>Nothing is responding on <code>${local}</code></h1>
1756
+ <p>The tunnel <code>${tunnel2}</code> is live, but your local service did not respond. Start the server on the machine that opened the tunnel.</p>
1757
+ <p>If you were only sent this link, let whoever shared it know.</p>
1758
+ <div class="retry">${o.retry ? `<b>Waiting for the service.</b> This page reloads when it responds.<noscript> Reload once the service is up.</noscript>` : "Reload the page once the service is up."}</div>
1671
1759
  </main>${o.retry ? WAIT_SCRIPT : ""}`
1672
1760
  );
1673
1761
  }
@@ -1680,35 +1768,35 @@ button{margin-top:18px;width:100%;padding:10px;border:0;border-radius:.3rem;back
1680
1768
  .error{margin:14px 0 0;color:#b42318;font-weight:600}
1681
1769
  @media (prefers-color-scheme:dark){.error{color:#f97066}}
1682
1770
  `;
1683
- function safeVolta(raw) {
1771
+ function safeNext(raw) {
1684
1772
  if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) return "/";
1685
1773
  return /^[\x21-\x7e]+$/.test(raw) ? raw : "/";
1686
1774
  }
1687
1775
  function loginPage(o) {
1688
1776
  return pageShell(
1689
- "Entrar \xB7 duto",
1777
+ "Sign in \xB7 duto",
1690
1778
  `<main>
1691
1779
  <div class="mark"><span class="dot"></span>duto \xB7 ${esc(o.tunnel)}</div>
1692
- <h1>Este t\xFAnel tem senha</h1>
1693
- <p>Pe\xE7a o acesso a quem enviou o link.</p>
1694
- <form method="post" action="/__duto/entrar">
1695
- <input type="hidden" name="volta" value="${esc(o.volta)}">
1696
- <label for="usuario">Usu\xE1rio</label>
1697
- <input id="usuario" name="usuario" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
1698
- <label for="senha">Senha</label>
1699
- <input id="senha" name="senha" type="password" autocomplete="current-password" required>
1700
- ${o.error ? `<p class="error" role="alert">Usu\xE1rio ou senha incorretos.</p>
1701
- ` : ""}<button type="submit">Entrar</button>
1780
+ <h1>This tunnel is password protected</h1>
1781
+ <p>Ask whoever sent you the link for access.</p>
1782
+ <form method="post" action="/__duto/login">
1783
+ <input type="hidden" name="next" value="${esc(o.next)}">
1784
+ <label for="username">Username</label>
1785
+ <input id="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
1786
+ <label for="password">Password</label>
1787
+ <input id="password" name="password" type="password" autocomplete="current-password" required>
1788
+ ${o.error ? `<p class="error" role="alert">Wrong username or password.</p>
1789
+ ` : ""}<button type="submit">Sign in</button>
1702
1790
  </form>
1703
- <div class="retry">Voc\xEA continua conectado at\xE9 o t\xFAnel fechar.</div>
1791
+ <div class="retry">You stay signed in until you close the browser or the tunnel closes.</div>
1704
1792
  </main>`,
1705
1793
  CSS
1706
1794
  );
1707
1795
  }
1708
1796
 
1709
1797
  // src/session/proxy.ts
1710
- var DENIED_NO_REPLAY = "Barrada pela senha do t\xFAnel; n\xE3o d\xE1 para reenviar.";
1711
- var PREFLIGHT_NO_REPLAY = "Preflight de CORS n\xE3o d\xE1 para reenviar.";
1798
+ var DENIED_NO_REPLAY = "Blocked by the tunnel password; cannot replay.";
1799
+ var PREFLIGHT_NO_REPLAY = "CORS preflight cannot be replayed.";
1712
1800
  var TEXT2 = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql)|[^;]*\+(json|xml))/i;
1713
1801
  var MAX_BODY = 64 * 1024;
1714
1802
  var LOCAL_AGENT = new Agent({ keepAlive: false });
@@ -1726,7 +1814,6 @@ var Capture = class {
1726
1814
  max;
1727
1815
  chunks = [];
1728
1816
  size = 0;
1729
- /** Bytes vistos, capturados ou não. */
1730
1817
  total = 0;
1731
1818
  truncated = false;
1732
1819
  push(c) {
@@ -1749,6 +1836,7 @@ var Capture = class {
1749
1836
  return this.enabled && this.size > 0 ? Buffer.concat(this.chunks).toString("utf8") : null;
1750
1837
  }
1751
1838
  };
1839
+ var HOST_CACHE_MS = 5e3;
1752
1840
  var hostCache = /* @__PURE__ */ new Map();
1753
1841
  function probe(host, port) {
1754
1842
  return new Promise((resolve) => {
@@ -1764,7 +1852,7 @@ function probe(host, port) {
1764
1852
  }
1765
1853
  async function localHost(port) {
1766
1854
  const hit = hostCache.get(port);
1767
- if (hit && Date.now() - hit.at < 5e3) return hit.host;
1855
+ if (hit && Date.now() - hit.at < HOST_CACHE_MS) return hit.host;
1768
1856
  const host = await probe("127.0.0.1", port) ? "127.0.0.1" : await probe("::1", port) ? "::1" : null;
1769
1857
  if (!host) return "127.0.0.1";
1770
1858
  hostCache.set(port, { host, at: Date.now() });
@@ -1793,13 +1881,16 @@ function hostOf(req) {
1793
1881
  return (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
1794
1882
  }
1795
1883
  var DUTO_PREFIX = "/__duto/";
1796
- var WAIT_PATH = "/__duto/espera";
1884
+ var WAIT_PATH = "/__duto/wait";
1797
1885
  var WAIT_MAX_MS = 10 * 6e4;
1798
- var LOGIN_PATH = "/__duto/entrar";
1799
- var LOGOUT_PATH = "/__duto/sair";
1886
+ var DEFAULT_ROUTE_WAIT_MS = 3e3;
1887
+ var DEFAULT_WAIT_CHECK_MS = 1e3;
1888
+ var SSE_KEEPALIVE_MS = 25e3;
1889
+ var LOGIN_PATH = "/__duto/login";
1890
+ var LOGOUT_PATH = "/__duto/logout";
1800
1891
  var QR_PREFIX = "/__duto/qr/";
1801
1892
  var MAX_FORM = 8 * 1024;
1802
- var DENIED_TEXT = "Este t\xFAnel tem senha. Envie Authorization: Basic ou X-Duto-Auth: usuario:senha.";
1893
+ var DENIED_TEXT = "This tunnel is password protected. Send Authorization: Basic or X-Duto-Auth: user:password.";
1803
1894
  var WS_DENIED = 'HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="duto"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n';
1804
1895
  function readForm(req, max) {
1805
1896
  return new Promise((resolve) => {
@@ -1822,9 +1913,9 @@ function readForm(req, max) {
1822
1913
  }
1823
1914
  async function authRoute(req, res, route, auth) {
1824
1915
  const url = new URL(req.url ?? "/", "http://duto");
1825
- const page = (status, volta, error) => {
1916
+ const page = (status, next, error) => {
1826
1917
  res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1827
- res.end(loginPage({ tunnel: route.tunnel, volta, error }));
1918
+ res.end(loginPage({ tunnel: route.tunnel, next, error }));
1828
1919
  };
1829
1920
  const go = (location, cookie) => {
1830
1921
  res.writeHead(303, { location, "cache-control": "no-store", ...cookie && { "set-cookie": cookie } });
@@ -1832,21 +1923,21 @@ async function authRoute(req, res, route, auth) {
1832
1923
  };
1833
1924
  const { pathname } = url;
1834
1925
  if (pathname === LOGIN_PATH && req.method === "GET") {
1835
- page(200, safeVolta(url.searchParams.get("volta")), false);
1926
+ page(200, safeNext(url.searchParams.get("next")), false);
1836
1927
  return true;
1837
1928
  }
1838
1929
  if (pathname === LOGIN_PATH && req.method === "POST") {
1839
1930
  const raw = await readForm(req, MAX_FORM);
1840
1931
  if (raw === null) {
1841
1932
  res.writeHead(413, { "content-type": "text/plain; charset=utf-8", connection: "close" });
1842
- res.end("Formul\xE1rio grande demais");
1933
+ res.end("Form too large");
1843
1934
  return true;
1844
1935
  }
1845
1936
  const form = new URLSearchParams(raw);
1846
- const user = form.get("usuario") ?? "";
1847
- const volta = safeVolta(form.get("volta"));
1848
- if (auth.checkPassword(user, form.get("senha") ?? "")) go(volta, auth.loginCookie(user));
1849
- else page(401, volta, true);
1937
+ const user = form.get("username") ?? "";
1938
+ const next = safeNext(form.get("next"));
1939
+ if (auth.checkPassword(user, form.get("password") ?? "")) go(next, auth.loginCookie(user));
1940
+ else page(401, next, true);
1850
1941
  return true;
1851
1942
  }
1852
1943
  if (pathname === LOGOUT_PATH && req.method === "GET") {
@@ -1862,7 +1953,7 @@ async function authRoute(req, res, route, auth) {
1862
1953
  }
1863
1954
  function deny(req, res, html) {
1864
1955
  const status = html ? 303 : 401;
1865
- const out = html ? { location: `${LOGIN_PATH}?volta=${encodeURIComponent(req.url ?? "/")}`, "cache-control": "no-store" } : { "www-authenticate": 'Basic realm="duto"', "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", ...cors401(req.headers.origin) };
1956
+ const out = html ? { location: `${LOGIN_PATH}?next=${encodeURIComponent(req.url ?? "/")}`, "cache-control": "no-store" } : { "www-authenticate": 'Basic realm="duto"', "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", ...cors401(req.headers.origin) };
1866
1957
  const body = html || req.method === "HEAD" ? "" : DENIED_TEXT;
1867
1958
  res.writeHead(status, out);
1868
1959
  res.end(body);
@@ -1904,7 +1995,7 @@ function waitForService(res, port, checkMs, pingMs) {
1904
1995
  async function startProxy(o) {
1905
1996
  const max = o.maxBody ?? MAX_BODY;
1906
1997
  let nextId = 1;
1907
- const routeWaitMs = o.routeWaitMs ?? 3e3;
1998
+ const routeWaitMs = o.routeWaitMs ?? DEFAULT_ROUTE_WAIT_MS;
1908
1999
  async function routeFor(req) {
1909
2000
  const host = hostOf(req);
1910
2001
  for (let waited = 0; ; waited += 50) {
@@ -1965,7 +2056,7 @@ async function startProxy(o) {
1965
2056
  function replay(id) {
1966
2057
  const r = o.store.get(id);
1967
2058
  const blocked = replayBlock(r);
1968
- if (blocked || !r) return { ok: false, reason: blocked ?? "Essa requisi\xE7\xE3o j\xE1 saiu da lista." };
2059
+ if (blocked || !r) return { ok: false, reason: blocked ?? "That request is no longer in the list." };
1969
2060
  const route = [...o.routes.values()].find((x) => x.tunnel === r.tunnel);
1970
2061
  if (!route) return { ok: false, reason: tunnelClosedReason(r.tunnel) };
1971
2062
  const newId = nextId++;
@@ -2006,17 +2097,17 @@ async function startProxy(o) {
2006
2097
  const route = await routeFor(req);
2007
2098
  if (!route) {
2008
2099
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
2009
- res.end("T\xFAnel n\xE3o encontrado nesta sess\xE3o");
2100
+ res.end("Tunnel not found in this session");
2010
2101
  return;
2011
2102
  }
2012
2103
  if ((req.url ?? "").startsWith(DUTO_PREFIX)) {
2013
2104
  if (req.url === WAIT_PATH && req.method === "GET") {
2014
2105
  if (route.auth?.access(req.headers).kind === "denied") return void deny(req, res, false);
2015
- return waitForService(res, route.port, o.waitCheckMs ?? 1e3, o.waitPingMs ?? 25e3);
2106
+ return waitForService(res, route.port, o.waitCheckMs ?? DEFAULT_WAIT_CHECK_MS, o.waitPingMs ?? SSE_KEEPALIVE_MS);
2016
2107
  }
2017
2108
  if (route.auth && await authRoute(req, res, route, route.auth)) return;
2018
2109
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
2019
- res.end("Rota reservada do Duto");
2110
+ res.end("Reserved Duto route");
2020
2111
  return;
2021
2112
  }
2022
2113
  const preflight = !!route.auth && isPreflight(req.method, req.headers);
@@ -2059,7 +2150,7 @@ async function startProxy(o) {
2059
2150
  req.resume();
2060
2151
  if (res.destroyed && res.writableFinished === false && !res.headersSent) return;
2061
2152
  const html = wantsHtml(req.headers.accept);
2062
- const msg = html ? unavailablePage({ tunnel: route.tunnel, port: route.port, retry: req.method === "GET" }) : `Nada escutando em localhost:${route.port}. Suba o servidor e tente de novo.`;
2153
+ const msg = html ? unavailablePage({ tunnel: route.tunnel, port: route.port, retry: req.method === "GET" }) : `Nothing is listening on localhost:${route.port}. Start the server and try again.`;
2063
2154
  if (!res.headersSent) {
2064
2155
  res.writeHead(503, { "content-type": html ? "text/html; charset=utf-8" : "text/plain; charset=utf-8", "cache-control": "no-store", "retry-after": "3" });
2065
2156
  res.end(req.method === "HEAD" ? void 0 : msg);
@@ -2223,7 +2314,6 @@ var RecordStore = class {
2223
2314
  get(id) {
2224
2315
  return this.items.find((x) => x.id === id);
2225
2316
  }
2226
- /** Mais recentes primeiro. Mesma referência até a próxima mudança (serve de snapshot para a tela). */
2227
2317
  list() {
2228
2318
  this.snapshot ??= [...this.items].reverse();
2229
2319
  return this.snapshot;
@@ -2243,8 +2333,8 @@ var READY = "Registered tunnel connection";
2243
2333
  var CLOSE_TIMEOUT_MS = 3e3;
2244
2334
  var HEARTBEAT_MS = 3e4;
2245
2335
  var RESTART_CF_MS = 2e3;
2246
- var ALREADY_OPEN = "J\xE1 aberto em outra sess\xE3o nesta m\xE1quina";
2247
- var STALE_MS = 75e3;
2336
+ var ALREADY_OPEN = "Already open in another session on this machine";
2337
+ var STALE_AFTER_MISSED_HEARTBEATS_MS = 75e3;
2248
2338
  function message(err) {
2249
2339
  return err instanceof Error ? err.message : String(err);
2250
2340
  }
@@ -2252,7 +2342,7 @@ function withTimeout(p, ms) {
2252
2342
  return Promise.race([p, new Promise((r) => setTimeout(() => r(void 0), ms).unref())]);
2253
2343
  }
2254
2344
  function keyOf(spec) {
2255
- return spec.name ?? "(padr\xE3o)";
2345
+ return spec.name ?? "(default)";
2256
2346
  }
2257
2347
  var Session = class {
2258
2348
  constructor(o) {
@@ -2264,8 +2354,7 @@ var Session = class {
2264
2354
  tunnels = [];
2265
2355
  routes = /* @__PURE__ */ new Map();
2266
2356
  specs = /* @__PURE__ */ new Map();
2267
- /** Um TunnelAuth por túnel, reaproveitado nas reaberturas para não desconectar quem já entrou. */
2268
- auths = /* @__PURE__ */ new Map();
2357
+ persistentAuths = /* @__PURE__ */ new Map();
2269
2358
  proxy = null;
2270
2359
  child = null;
2271
2360
  token = null;
@@ -2276,7 +2365,6 @@ var Session = class {
2276
2365
  connection = "connecting";
2277
2366
  listeners = /* @__PURE__ */ new Set();
2278
2367
  snapshot = null;
2279
- /** Mesma referência até a próxima mudança (serve de snapshot para a tela). */
2280
2368
  getState() {
2281
2369
  this.snapshot ??= { account: this.account, source: this.o.source, connection: this.connection, tunnels: this.tunnels.map((t) => ({ ...t })) };
2282
2370
  return this.snapshot;
@@ -2285,23 +2373,18 @@ var Session = class {
2285
2373
  this.listeners.add(fn);
2286
2374
  return () => this.listeners.delete(fn);
2287
2375
  }
2288
- /** Abre os túneis em sequência; falhas ficam no estado e não interrompem os outros. */
2289
2376
  async start(specs) {
2290
2377
  if (this.o.runDir) reapOrphans(this.o.runDir);
2291
2378
  this.proxy = await startProxy({ routes: this.routes, store: this.records });
2292
2379
  this.tunnels = specs.map((s) => this.blank(s, false));
2293
2380
  this.emit();
2294
- try {
2295
- const me = await this.o.api.me();
2296
- this.account = me.username ?? me.email;
2297
- } catch {
2298
- }
2381
+ const me = await this.o.api.me().catch(() => null);
2382
+ if (me) this.account = me.username ?? me.email;
2299
2383
  const existing = await this.o.api.endpoints().catch(() => []);
2300
2384
  for (const t of this.tunnels) await this.open(t, existing);
2301
2385
  const every = this.o.heartbeatMs ?? HEARTBEAT_MS;
2302
2386
  this.timer = setInterval(() => void this.beat(), every);
2303
2387
  }
2304
- /** Túnel avulso, só nesta sessão. */
2305
2388
  async add(spec) {
2306
2389
  if (this.tunnels.some((t2) => t2.key === keyOf(spec))) return;
2307
2390
  const t = this.blank(spec, true);
@@ -2310,33 +2393,29 @@ var Session = class {
2310
2393
  const existing = await this.o.api.endpoints().catch(() => []);
2311
2394
  await this.open(t, existing);
2312
2395
  }
2313
- /** Link para o QR: com senha, um token de uso único; sem senha, só a URL. */
2314
2396
  qrLink(key) {
2315
2397
  const t = this.tunnels.find((x) => x.key === key);
2316
2398
  if (!t?.url) return null;
2317
- const auth = this.auths.get(key);
2399
+ const auth = this.persistentAuths.get(key);
2318
2400
  if (!auth) return { url: t.url, expiresAt: null };
2319
2401
  const { token, expiresAt } = auth.issueQrToken();
2320
2402
  return { url: `${t.url}/__duto/qr/${token}`, expiresAt };
2321
2403
  }
2322
- /** Primeiro usuário configurado do túnel, ou null quando ele não tem senha. */
2323
2404
  authUser(key) {
2324
- return this.auths.get(key)?.qrUser ?? null;
2405
+ return this.persistentAuths.get(key)?.qrUser ?? null;
2325
2406
  }
2326
- /** Cria o TunnelAuth na primeira abertura (precisa do allow resolvido) e o mantém depois. */
2327
2407
  async authFor(key) {
2328
- const existing = this.auths.get(key);
2408
+ const existing = this.persistentAuths.get(key);
2329
2409
  if (existing) return existing;
2330
2410
  const spec = this.specs.get(key);
2331
2411
  if (!spec?.auth?.length) return void 0;
2332
2412
  const allow = spec.allow?.length ? await (this.o.resolveAllow ?? resolveAllow)(spec.allow) : void 0;
2333
2413
  const auth = new TunnelAuth({ tunnel: key, credentials: spec.auth, allow });
2334
- this.auths.set(key, auth);
2414
+ this.persistentAuths.set(key, auth);
2335
2415
  return auth;
2336
2416
  }
2337
- /** Reenvia uma requisição da lista ao serviço local. */
2338
2417
  replay(id) {
2339
- return this.proxy ? this.proxy.replay(id) : { ok: false, reason: "Sess\xE3o ainda n\xE3o come\xE7ou." };
2418
+ return this.proxy ? this.proxy.replay(id) : { ok: false, reason: "Session has not started yet." };
2340
2419
  }
2341
2420
  async stop() {
2342
2421
  if (this.stopped) return;
@@ -2355,10 +2434,10 @@ var Session = class {
2355
2434
  }
2356
2435
  async open(t, existing, retrying = false) {
2357
2436
  if (this.stopped) return;
2358
- const taken = existing?.some(
2359
- (e) => e.name === t.name && e.connected && e.deviceId === this.o.deviceId && e.lastUsedAt !== null && Date.now() - e.lastUsedAt < STALE_MS
2437
+ const takenByLiveSession = existing?.some(
2438
+ (e) => e.name === t.name && e.connected && e.deviceId === this.o.deviceId && e.lastUsedAt !== null && Date.now() - e.lastUsedAt < STALE_AFTER_MISSED_HEARTBEATS_MS
2360
2439
  );
2361
- if (taken) return this.fail(t, ALREADY_OPEN);
2440
+ if (takenByLiveSession) return this.fail(t, ALREADY_OPEN);
2362
2441
  try {
2363
2442
  const res = await this.o.api.openTunnel({
2364
2443
  deviceId: this.o.deviceId,
@@ -2405,7 +2484,7 @@ var Session = class {
2405
2484
  if (this.child && this.token === token) return;
2406
2485
  if (this.child) this.child.kill("SIGTERM");
2407
2486
  this.token = token;
2408
- const c = spawn(this.o.cloudflaredPath, ["tunnel", "--no-autoupdate", "run"], {
2487
+ const c = spawn2(this.o.cloudflaredPath, ["tunnel", "--no-autoupdate", "run"], {
2409
2488
  env: { ...process.env, TUNNEL_TOKEN: token },
2410
2489
  stdio: ["ignore", "ignore", "pipe"]
2411
2490
  });
@@ -2534,7 +2613,7 @@ function runPlain(session, out) {
2534
2613
  }
2535
2614
 
2536
2615
  // src/index.ts
2537
- var LOGIN_HINT = "Fa\xE7a login primeiro: duto login <token> (pegue o token em https://duto.dev)";
2616
+ var LOGIN_HINT = "Log in first: duto login";
2538
2617
  function fail2(message2) {
2539
2618
  console.error(fail(message2));
2540
2619
  process.exit(1);
@@ -2542,19 +2621,19 @@ function fail2(message2) {
2542
2621
  function parsePort(raw) {
2543
2622
  const s = String(raw ?? "");
2544
2623
  const n = Number(s);
2545
- if (!/^\d+$/.test(s) || n < 1 || n > 65535) fail2(`Porta inv\xE1lida: ${s} (use 1\u201365535)`);
2624
+ if (!/^\d+$/.test(s) || n < 1 || n > 65535) fail2(`Invalid port: ${s} (use 1\u201365535)`);
2546
2625
  return n;
2547
2626
  }
2548
2627
  function parseName(raw) {
2549
2628
  if (raw === void 0 || raw === true || raw === "") return void 0;
2550
2629
  const name = normalizeName(String(raw));
2551
2630
  const v = validateName(name);
2552
- if (!v.ok) fail2(v.reason === "reserved" ? `Nome reservado: "${name}". Escolha outro.` : "Nome inv\xE1lido: use s\xF3 letras min\xFAsculas e n\xFAmeros (sem h\xEDfen)");
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.`);
2553
2632
  return name;
2554
2633
  }
2555
2634
  function parseHostFlag(raw) {
2556
2635
  if (raw === void 0 || raw === true || raw === "") return void 0;
2557
- return parseHost(String(raw)) ?? fail2("Host inv\xE1lido: use s\xF3 o dom\xEDnio, como meuapp.test");
2636
+ return parseHost(String(raw)) ?? fail2("Invalid host: use the domain only, like myapp.test");
2558
2637
  }
2559
2638
  function listFlag(raw) {
2560
2639
  if (raw === void 0) return [];
@@ -2564,14 +2643,14 @@ function parseAuthFlags(raw) {
2564
2643
  const out = [];
2565
2644
  for (const entry of listFlag(raw)) {
2566
2645
  const r = parseCredential(entry);
2567
- if (!r.ok) fail2(r.reason === "password" ? "Senha precisa ter de 8 a 128 caracteres" : r.reason === "user" ? "Usu\xE1rio inv\xE1lido: de 1 a 128 caracteres, sem espa\xE7o" : "Credencial inv\xE1lida: use usuario:senha");
2568
- if (out.some((c) => c.user === r.cred.user)) fail2(`Usu\xE1rio "${r.cred.user}" aparece duas vezes`);
2646
+ if (!r.ok) fail2(r.reason === "password" ? "Password must be 8 to 128 characters" : r.reason === "user" ? "Invalid user: 1 to 128 characters, no spaces" : "Invalid credential: use user:password");
2647
+ if (out.some((c) => c.user === r.cred.user)) fail2(`Duplicate user "${r.cred.user}"`);
2569
2648
  out.push(r.cred);
2570
2649
  }
2571
2650
  return out;
2572
2651
  }
2573
2652
  function parseAllowFlags(raw) {
2574
- return listFlag(raw).map((v) => parseAllowEntry(v) ? v : fail2(`"${v}" n\xE3o \xE9 um nome conhecido (stripe, github) nem uma faixa de IP`));
2653
+ return listFlag(raw).map((v) => parseAllowEntry(v) ? v : fail2(`"${v}" is not a known name (stripe, github) or an IP range`));
2575
2654
  }
2576
2655
  function wantsUi(flags) {
2577
2656
  if (flags.ui === false) return false;
@@ -2589,7 +2668,7 @@ async function runSession(specs, source, flags) {
2589
2668
  } catch (err) {
2590
2669
  fail2(err instanceof Error ? err.message : String(err));
2591
2670
  }
2592
- 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") });
2593
2672
  let inspector = null;
2594
2673
  let inspectorError;
2595
2674
  if (flags.inspect !== false) {
@@ -2600,14 +2679,14 @@ async function runSession(specs, source, flags) {
2600
2679
  }
2601
2680
  }
2602
2681
  if (wantsUi(flags)) {
2603
- const { runFullscreen } = await import("./fullscreen-YK365XSO.js");
2604
- const code = await runFullscreen(session, specs, "0.7.0", inspector, inspectorError);
2682
+ const { runFullscreen } = await import("./fullscreen-ECEH3R2V.js");
2683
+ const code = await runFullscreen(session, specs, "1.1.0", inspector, inspectorError);
2605
2684
  await inspector?.close().catch(() => void 0);
2606
2685
  process.exit(code);
2607
2686
  }
2608
- console.log(`${mark()} ${paint("muted", `${"0.7.0"} \xB7 ${source} \xB7 Ctrl+C para encerrar`)}`);
2609
- if (inspector) console.log(paint("muted", `inspetor: ${inspector.openUrl}`));
2610
- if (inspectorError) console.log(warn(`inspetor n\xE3o subiu: ${inspectorError}`));
2687
+ console.log(`${mark()} ${paint("muted", `${"1.1.0"} \xB7 ${source} \xB7 Ctrl+C to stop`)}`);
2688
+ if (inspector) console.log(paint("muted", `inspector: ${inspector.openUrl}`));
2689
+ if (inspectorError) console.log(warn(`inspector failed to start: ${inspectorError}`));
2611
2690
  const stopLog = runPlain(session, (l) => console.log(l));
2612
2691
  const stopped = new Promise((resolve) => {
2613
2692
  const onSignal = () => {
@@ -2629,9 +2708,9 @@ async function runSession(specs, source, flags) {
2629
2708
  }
2630
2709
  async function tunnel(rawPort, flags) {
2631
2710
  if (rawPort === void 0) {
2632
- if (flags.host !== void 0) fail2("Use --host junto com a porta (duto 8080 --host meuapp.test) ou host: no duto.yml");
2633
- if (flags.auth !== void 0) fail2("Use --auth junto com a porta (duto 3000 --auth ana:senha) ou auth: no duto.yml");
2634
- if (flags.permitir !== void 0) fail2("Use --permitir junto com a porta (duto 3000 --permitir stripe) ou permitir: no duto.yml");
2711
+ if (flags.host !== void 0) fail2("Use --host with a port (duto 8080 --host myapp.test) or host: in duto.yml");
2712
+ if (flags.auth !== void 0) fail2("Use --auth with a port (duto 3000 --auth ana:secret) or auth: in duto.yml");
2713
+ if (flags.allow !== void 0) fail2("Use --allow with a port (duto 3000 --allow stripe) or allow: in duto.yml");
2635
2714
  let found;
2636
2715
  try {
2637
2716
  found = findConfig(process.cwd());
@@ -2641,7 +2720,7 @@ async function tunnel(rawPort, flags) {
2641
2720
  if (!found) {
2642
2721
  cli.outputHelp();
2643
2722
  console.log(`
2644
- ${paint("signal", "Dica:")} crie um duto.yml com os t\xFAneis do projeto e rode s\xF3 ${paint("strong", "duto")}:
2723
+ ${paint("signal", "Tip:")} create a duto.yml with your project's tunnels and just run ${paint("strong", "duto")}:
2645
2724
 
2646
2725
  tunnels:
2647
2726
  web: 3000
@@ -2655,30 +2734,67 @@ ${paint("signal", "Dica:")} crie um duto.yml com os t\xFAneis do projeto e rode
2655
2734
  const name = parseName(flags.name) ?? null;
2656
2735
  const host = parseHostFlag(flags.host);
2657
2736
  const keepRedirects = flags.rewriteRedirects === false;
2658
- if (keepRedirects && !host) fail2("--no-rewrite-redirects s\xF3 vale junto com --host");
2737
+ if (keepRedirects && !host) fail2("--no-rewrite-redirects only works with --host");
2659
2738
  const auth = parseAuthFlags(flags.auth);
2660
- const allow = parseAllowFlags(flags.permitir);
2661
- if (allow.length && !auth.length) fail2("--permitir s\xF3 vale junto com --auth");
2662
- return runSession([{ name, port, ...host ? { host } : {}, ...keepRedirects ? { rewriteRedirects: false } : {}, ...auth.length ? { auth } : {}, ...allow.length ? { allow } : {} }], "linha de comando", flags);
2739
+ const allow = parseAllowFlags(flags.allow);
2740
+ if (allow.length && !auth.length) fail2("--allow only works with --auth");
2741
+ return runSession([{ name, port, ...host ? { host } : {}, ...keepRedirects ? { rewriteRedirects: false } : {}, ...auth.length ? { auth } : {}, ...allow.length ? { allow } : {} }], "command line", flags);
2663
2742
  }
2664
2743
  var cli = cac("duto");
2665
- cli.command("[port]", "Sem porta: abre os t\xFAneis do duto.yml. Com porta: abre um t\xFAnel para ela").option("--name <nome>", "Nome do endere\xE7o fixo (ex.: web \u2192 https://web-<usu\xE1rio>.duto.dev)").option("--host <host>", "Host que o servi\xE7o local recebe no lugar do p\xFAblico (ex.: meuapp.test)").option("--no-rewrite-redirects", "Com --host: n\xE3o troca o host local pelo p\xFAblico nos redirecionamentos").option("--auth <usuario:senha>", "Pede usu\xE1rio e senha para abrir o endere\xE7o (repet\xEDvel)").option("--permitir <faixa>", "Com --auth: IP, faixa (CIDR) ou nome (stripe, github) que entra sem senha (repet\xEDvel)").option("--no-ui", "Sem tela cheia: s\xF3 um log das requisi\xE7\xF5es (\xFAtil em scripts)").option("--no-inspect", "N\xE3o sobe o inspetor no navegador (127.0.0.1:4141)").option("--ui", "For\xE7a a tela cheia (no Windows ela vem desligada)").action((port, opts) => tunnel(port, opts));
2666
- cli.command("http <port>", "O mesmo que duto <porta> (para quem vem do ngrok)").option("--name <nome>", "Nome do endere\xE7o fixo").option("--host <host>", "Host que o servi\xE7o local recebe no lugar do p\xFAblico").option("--no-rewrite-redirects", "Com --host: mant\xE9m os redirecionamentos como o servi\xE7o mandou").option("--auth <usuario:senha>", "Pede usu\xE1rio e senha para abrir o endere\xE7o (repet\xEDvel)").option("--permitir <faixa>", "Com --auth: IP, faixa (CIDR) ou nome (stripe, github) que entra sem senha (repet\xEDvel)").option("--no-ui", "Sem tela cheia").option("--no-inspect", "N\xE3o sobe o inspetor no navegador (127.0.0.1:4141)").action((port, opts) => tunnel(port, opts));
2667
- cli.command("login <token>", "Salva o authtoken (pegue em https://duto.dev)").action(async (token) => {
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));
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));
2746
+ cli.command("login [token]", "Log in through the browser, or save an authtoken").action(async (token) => {
2668
2747
  try {
2748
+ if (token === void 0) return await loginWithBrowser();
2669
2749
  const me = await createApiClient({ baseUrl: apiUrl(), token: String(token) }).me();
2670
2750
  saveConfig({ ...loadConfig(), authtoken: String(token) });
2671
- console.log(ok(`Conectado como ${paint("strong", me.username ?? me.email)}`));
2751
+ console.log(loggedInLine(me.username ?? me.email));
2672
2752
  } catch (err) {
2673
2753
  fail2(err instanceof Error ? err.message : String(err));
2674
2754
  }
2675
2755
  });
2676
- cli.command("logout", "Remove o authtoken desta m\xE1quina").action(() => {
2677
- 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;
2678
2782
  saveConfig(rest);
2679
- console.log(ok("Authtoken removido"));
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
+ );
2680
2796
  });
2681
- cli.command("status", "Mostra a conta e os t\xFAneis no ar").action(async () => {
2797
+ cli.command("status", "Show your account and live tunnels").action(async () => {
2682
2798
  const config = loadConfig();
2683
2799
  if (!config.authtoken) fail2(LOGIN_HINT);
2684
2800
  try {
@@ -2686,23 +2802,17 @@ cli.command("status", "Mostra a conta e os t\xFAneis no ar").action(async () =>
2686
2802
  const [me, endpoints] = await Promise.all([api.me(), api.endpoints()]);
2687
2803
  console.log(`${mark()} ${paint("muted", `${me.username} \xB7 ${me.email}`)}`);
2688
2804
  const live = endpoints.filter((e) => e.connected);
2689
- if (!live.length) console.log(paint("muted", "Nenhum t\xFAnel no ar."));
2805
+ if (!live.length) console.log(paint("muted", "No live tunnels."));
2690
2806
  for (const e of live)
2691
2807
  console.log(
2692
- `${paint("signal", "\u25CF")} https://${e.hostname} ${paint("muted", `\u2192 localhost:${e.port}${e.deviceId === config.deviceId ? "" : ` \xB7 ${e.deviceName ?? "outra m\xE1quina"}`}`)}`
2808
+ `${paint("signal", "\u25CF")} https://${e.hostname} ${paint("muted", `\u2192 localhost:${e.port}${e.deviceId === config.deviceId ? "" : ` \xB7 ${e.deviceName ?? "another machine"}`}`)}`
2693
2809
  );
2694
2810
  } catch (err) {
2695
2811
  fail2(err instanceof DutoApiError ? err.message : String(err));
2696
2812
  }
2697
2813
  });
2698
- cli.help(
2699
- (sections) => sections.map((s) => ({
2700
- ...s,
2701
- title: s.title === "Usage" ? "Uso" : s.title === "Commands" ? "Comandos" : s.title === "Options" ? "Op\xE7\xF5es" : s.title,
2702
- body: s.body.replace(/^\s*For more info, run any command with the `--help` flag:/m, " Mais detalhes de um comando: duto <comando> --help").replace("Display this message", "Mostra esta ajuda").replace("Display version number", "Mostra a vers\xE3o")
2703
- }))
2704
- );
2705
- cli.version("0.7.0");
2814
+ cli.help();
2815
+ cli.version("1.1.0");
2706
2816
  try {
2707
2817
  cli.parse(process.argv, { run: false });
2708
2818
  await cli.runMatchedCommand();