@dutodev/cli 0.5.0 → 0.7.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
@@ -145,13 +145,39 @@ Com os túneis no ar, o terminal mostra cada túnel (URL, destino local, requisi
145
145
  | `m` | Mostra o QR code do túnel selecionado, para abrir no celular |
146
146
  | `f` | Filtra por túnel, método, caminho ou status |
147
147
  | `↵` | Detalhes da requisição: cabeçalhos e o começo dos corpos |
148
+ | `r` | No detalhe, reenvia a requisição ao serviço local |
149
+ | `i` | Abre o inspetor no navegador |
148
150
  | `tab`, `↑↓` | Alterna entre túneis e requisições e move a seleção |
149
151
  | `q` ou Ctrl+C | Fecha os túneis e sai |
150
152
 
153
+ **Reenviar uma requisição.** No detalhe (`↵`), a tecla `r` manda de novo ao serviço local o mesmo método, caminho, cabeçalhos e corpo que o app recebeu, já com as regras `host:` e `headers:` do túnel aplicadas e sem as credenciais do próprio Duto. O reenvio entra na lista marcado com `↻`, a tela pula para o detalhe dele (`↻ reenvio de #N`) e `esc` volta. Serve para depurar webhooks sem esperar o serviço externo mandar de novo; em especial, uma requisição que ficou com `503` porque o serviço local estava fora do ar pode ser reenviada assim que ele subir. Como os bytes são idênticos, assinaturas de webhook continuam válidas. Já apps que recusam eventos antigos (o Stripe tolera 5 minutos por padrão) vão rejeitar o reenvio depois dessa janela.
154
+
155
+ Não dá para reenviar WebSocket, requisições barradas pela senha do túnel, corpos binários, corpos acima de 64 KB, requisições cujo corpo não terminou de chegar ou quando o túnel já não está aberto; nesses casos uma linha explica o motivo. O reenvio não existe no log simples (sem terminal interativo).
156
+
151
157
  Se o túnel está no ar mas nada responde na porta local, quem abrir o endereço no navegador vê uma página do Duto que volta sozinha quando o serviço sobe. Ela espera por uma conexão só (SSE): a CLI checa a porta na sua máquina e avisa a página assim que o serviço responde, sem recarregar de tempos em tempos. Clientes que não são navegador (`curl`, APIs) recebem a mesma resposta em texto. Em ambos os casos o status é `503` com `Retry-After: 3`. Os caminhos `/__duto/…` são reservados para isso e nunca chegam ao seu app.
152
158
 
153
159
  Fora de um terminal interativo (pipe, CI) ou com `--no-ui`, a CLI imprime um log simples. No Windows a tela cheia vem desligada; use `--ui` para tentar.
154
160
 
161
+ ## Inspetor no navegador
162
+
163
+ Junto com a sessão, a CLI sobe um inspetor local: uma página no navegador com as requisições chegando ao vivo, o JSON formatado e botões para copiar e reenviar. Ele é mais confortável que o terminal para corpos grandes.
164
+
165
+ - **Como abrir.** Na tela cheia, a tecla `i` abre o navegador já com o endereço certo, e a tela mostra `inspetor http://127.0.0.1:<porta>`. No modo `plain` (pipe, CI, `--no-ui`), a CLI imprime `inspetor: <url>?t=<token>`; copie esse endereço inteiro.
166
+ - **Porta.** O padrão é `127.0.0.1:4141`. Se estiver ocupada, a CLI tenta as seguintes até a 4161 e, por fim, qualquer porta livre. O inspetor escuta só em `127.0.0.1`: não é acessível por outras máquinas da rede.
167
+ - **Token.** O endereço leva um token da sessão (`?t=...`). Ele existe para que outros sites abertos no seu navegador não consigam ler nem reenviar as suas requisições. Por isso a tela cheia não exibe o token; use a tecla `i`.
168
+ - **Desligar.** `--no-inspect` não sobe o inspetor. No modo `plain` o endereço com token vai para a saída; se ela vai para um log compartilhado, use `--no-inspect`.
169
+ - **Nada vai para o disco.** As requisições ficam só na memória da sessão e somem quando ela termina. Funciona sem internet.
170
+
171
+ A página tem a lista à esquerda e o detalhe à direita, com as abas **Corpo**, **Cabeçalhos**, **Resposta** e **Raw**. O JSON aparece reindentado, exatamente como chegou, com os erros de sintaxe destacados.
172
+
173
+ | Ação | Como |
174
+ |---|---|
175
+ | Copiar o corpo | **Copiar corpo** copia o corpo da aba ativa |
176
+ | Copiar como `curl` | **Copiar como curl**; em túnel com senha, o comando leva `-H 'X-Duto-Auth: <usuário>:SENHA'` e você troca `SENHA` pela senha |
177
+ | Reenviar | **↻ Reenviar** ou a tecla `r`, com as mesmas regras do terminal |
178
+ | Filtrar | `/` foca o filtro |
179
+ | Navegar | `↑` e `↓` movem a seleção |
180
+
155
181
  ## Comandos
156
182
 
157
183
  | Comando | O que faz |
@@ -171,6 +197,7 @@ Fora de um terminal interativo (pipe, CI) ou com `--no-ui`, a CLI imprime um log
171
197
  | `--auth <usuario:senha>` | Pede usuário e senha para abrir o endereço (repetível) |
172
198
  | `--permitir <faixa>` | Com `--auth`: IP, faixa ou nome (`stripe`, `github`) que entra sem senha (repetível) |
173
199
  | `--no-ui` / `--ui` | Desliga / força a tela cheia |
200
+ | `--no-inspect` | Não sobe o inspetor no navegador (a tecla `i` passa a mostrar "Inspetor desligado (--no-inspect).") |
174
201
 
175
202
  ## Configuração
176
203
 
@@ -38,6 +38,37 @@ function validateName(name, max = MAX_NAME) {
38
38
  return { ok: true };
39
39
  }
40
40
 
41
+ // src/ui/theme.ts
42
+ var C = {
43
+ signal: "#2dd4bf",
44
+ muted: "#8a8f8b",
45
+ strong: "#f3f4f1",
46
+ ok: "#4ade80",
47
+ warn: "#facc15",
48
+ err: "#f87171"
49
+ };
50
+ var TUNNEL_COLORS = ["#93c5fd", "#c4b5fd", "#facc15", "#4ade80", "#f9a8d4", "#fdba74", "#67e8f9"];
51
+ function tunnelColor(index) {
52
+ return TUNNEL_COLORS[index % TUNNEL_COLORS.length];
53
+ }
54
+ function fit(text, width) {
55
+ if (width <= 0) return "";
56
+ return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}\u2026` : text.padEnd(width);
57
+ }
58
+
59
+ // src/session/bodyNote.ts
60
+ var TRUNCATED_NOTE = "Corpo cortado em 64 KB.";
61
+ function bodyNote(r, side) {
62
+ if (side === "req") {
63
+ if (r.reqBodyBytes === 0) return "Sem corpo.";
64
+ if (r.reqBody === null && !r.reqTruncated) return "Corpo bin\xE1rio; n\xE3o \xE9 guardado.";
65
+ return null;
66
+ }
67
+ if (r.bytes === 0) return "Sem corpo.";
68
+ if (r.resBody === null) return "Corpo bin\xE1rio; n\xE3o \xE9 guardado.";
69
+ return null;
70
+ }
71
+
41
72
  // src/ui/format.ts
42
73
  function statusColor(status) {
43
74
  if (status === null) return "muted";
@@ -66,6 +97,11 @@ function localLabel(t) {
66
97
  export {
67
98
  normalizeName,
68
99
  validateName,
100
+ C,
101
+ tunnelColor,
102
+ fit,
103
+ TRUNCATED_NOTE,
104
+ bodyNote,
69
105
  statusColor,
70
106
  formatDuration,
71
107
  formatBytes,
@@ -1,13 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ C,
4
+ TRUNCATED_NOTE,
5
+ bodyNote,
6
+ fit,
3
7
  formatBytes,
4
8
  formatDuration,
5
9
  formatTime,
6
10
  localLabel,
7
11
  normalizeName,
8
12
  statusColor,
13
+ tunnelColor,
9
14
  validateName
10
- } from "./chunk-26EQC6HB.js";
15
+ } from "./chunk-74IJFYGT.js";
11
16
 
12
17
  // src/ui/fullscreen.tsx
13
18
  import { render } from "ink";
@@ -18,30 +23,10 @@ import { Box as Box5, Text as Text6, useInput, useStdout } from "ink";
18
23
 
19
24
  // src/ui/Detail.tsx
20
25
  import { Box, Text } from "ink";
21
-
22
- // src/ui/theme.ts
23
- var C = {
24
- signal: "#2dd4bf",
25
- muted: "#8a8f8b",
26
- strong: "#f3f4f1",
27
- ok: "#4ade80",
28
- warn: "#facc15",
29
- err: "#f87171"
30
- };
31
- var TUNNEL_COLORS = ["#93c5fd", "#c4b5fd", "#facc15", "#4ade80", "#f9a8d4", "#fdba74", "#67e8f9"];
32
- function tunnelColor(index) {
33
- return TUNNEL_COLORS[index % TUNNEL_COLORS.length];
34
- }
35
- function fit(text, width) {
36
- if (width <= 0) return "";
37
- return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}\u2026` : text.padEnd(width);
38
- }
39
-
40
- // src/ui/Detail.tsx
41
26
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
42
27
  var MAX_HEADERS = 12;
43
28
  var MAX_BODY_LINES = 8;
44
- function Section({ title, headers, body, truncated }) {
29
+ function Section({ title, headers, body, truncated, note: note2 }) {
45
30
  const entries = Object.entries(headers);
46
31
  const lines = body ? body.split("\n") : [];
47
32
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
@@ -59,9 +44,9 @@ function Section({ title, headers, body, truncated }) {
59
44
  entries.length - MAX_HEADERS,
60
45
  " cabe\xE7alhos"
61
46
  ] }),
62
- body === null ? /* @__PURE__ */ jsx(Text, { color: C.muted, children: "(corpo bin\xE1rio ou n\xE3o capturado)" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
47
+ body === null ? /* @__PURE__ */ jsx(Text, { color: C.muted, children: note2 ?? "" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
63
48
  lines.slice(0, MAX_BODY_LINES).map((l, i) => /* @__PURE__ */ jsx(Text, { wrap: "truncate-end", children: l }, i)),
64
- (lines.length > MAX_BODY_LINES || truncated) && /* @__PURE__ */ jsx(Text, { color: C.muted, children: truncated ? "\u2026 cortado em 64 KB" : "\u2026" })
49
+ (lines.length > MAX_BODY_LINES || truncated) && /* @__PURE__ */ jsx(Text, { color: C.muted, children: truncated ? TRUNCATED_NOTE : "\u2026" })
65
50
  ] })
66
51
  ] });
67
52
  }
@@ -84,12 +69,16 @@ function Detail({ record }) {
84
69
  record.tunnel
85
70
  ] })
86
71
  ] }),
72
+ record.replayOf !== void 0 && /* @__PURE__ */ jsxs(Text, { color: C.muted, children: [
73
+ "\u21BB reenvio de #",
74
+ record.replayOf
75
+ ] }),
87
76
  record.error && /* @__PURE__ */ jsx(Text, { color: C.err, children: record.error }),
88
- /* @__PURE__ */ jsx(Section, { title: "Requisi\xE7\xE3o", headers: record.reqHeaders, body: record.reqBody, truncated: record.truncated }),
89
- /* @__PURE__ */ jsx(Section, { title: "Resposta", headers: record.resHeaders, body: record.resBody, truncated: record.truncated }),
77
+ /* @__PURE__ */ jsx(Section, { title: "Requisi\xE7\xE3o", headers: record.reqHeaders, body: record.reqBody, truncated: record.reqTruncated, note: bodyNote(record, "req") }),
78
+ /* @__PURE__ */ jsx(Section, { title: "Resposta", headers: record.resHeaders, body: record.resBody, truncated: record.resTruncated, note: bodyNote(record, "res") }),
90
79
  /* @__PURE__ */ jsxs(Text, { color: C.muted, children: [
91
80
  "\n",
92
- "esc volta para a lista"
81
+ "r reenvia \xB7 esc volta para a lista"
93
82
  ] })
94
83
  ] });
95
84
  }
@@ -189,7 +178,7 @@ function RequestList(props) {
189
178
  const start = Math.max(0, Math.min(selected - Math.floor(height / 2), records.length - height));
190
179
  const visible = records.slice(start, start + height);
191
180
  const accessW = Math.max(0, ...visible.map((r) => (accessLabel(r.access)?.text.length ?? -1) + 1));
192
- const pathW = Math.max(10, width - 52 - accessW);
181
+ const pathW = Math.max(10, width - 54 - accessW);
193
182
  const title = props.filter ? `REQUISI\xC7\xD5ES \xB7 filtro: ${props.filter} ` : "REQUISI\xC7\xD5ES ";
194
183
  return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
195
184
  /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: title + "\u2500".repeat(Math.max(0, width - title.length)) }),
@@ -204,6 +193,7 @@ function RequestList(props) {
204
193
  " ",
205
194
  /* @__PURE__ */ jsx4(Text4, { color: tunnelColor(props.tunnelIndex(r.tunnel)), children: fit(r.tunnel, 9) }),
206
195
  " ",
196
+ /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: r.replayOf !== void 0 ? "\u21BB " : " " }),
207
197
  /* @__PURE__ */ jsx4(Text4, { bold: true, children: fit(method, 5) }),
208
198
  fit(r.path, pathW),
209
199
  " ",
@@ -286,13 +276,14 @@ var KEYS = [
286
276
  ["n", "novo t\xFAnel"],
287
277
  ["c", "copiar URL"],
288
278
  ["o", "abrir no navegador"],
279
+ ["i", "inspetor"],
289
280
  ["m", "QR no celular"],
290
281
  ["f", "filtrar"],
291
282
  ["\u21B5", "detalhes"],
292
283
  ["tab", "foco"],
293
284
  ["q", "sair"]
294
285
  ];
295
- function App({ session, version, system, onExit }) {
286
+ function App({ session, version, system, onExit, inspector = null, inspectorError }) {
296
287
  const subState = useCallback((fn) => session.subscribe(fn), [session]);
297
288
  const subRecords = useCallback((fn) => session.records.subscribe(fn), [session]);
298
289
  const state = useSyncExternalStore(subState, () => session.getState());
@@ -305,7 +296,7 @@ function App({ session, version, system, onExit }) {
305
296
  const [focus, setFocus] = useState("requests");
306
297
  const [selReq, setSelReq] = useState(0);
307
298
  const [selTun, setSelTun] = useState(0);
308
- const [message, setMessage] = useState(null);
299
+ const [message, setMessage] = useState(inspectorError ? { text: `Inspetor n\xE3o subiu: ${inspectorError}`, tone: "warn" } : null);
309
300
  const [now, setNow] = useState(() => Date.now());
310
301
  const quitting = useRef(false);
311
302
  const records = useMemo(() => {
@@ -316,6 +307,9 @@ function App({ session, version, system, onExit }) {
316
307
  useEffect(() => {
317
308
  if (selReq >= records.length) setSelReq(Math.max(0, records.length - 1));
318
309
  }, [records.length, selReq]);
310
+ useEffect(() => {
311
+ if (mode.kind === "detail" && !all.some((r) => r.id === mode.id)) setMode({ kind: "list" });
312
+ }, [mode, all]);
319
313
  useEffect(() => {
320
314
  if (!message) return;
321
315
  const t = setTimeout(() => setMessage(null), 4e3);
@@ -342,6 +336,11 @@ function App({ session, version, system, onExit }) {
342
336
  await session.stop();
343
337
  onExit();
344
338
  }
339
+ async function openInspector() {
340
+ if (!inspector) return setMessage({ text: "Inspetor desligado (--no-inspect).", tone: "warn" });
341
+ const ok = await system.open(inspector.openUrl);
342
+ setMessage(ok ? { text: "Abrindo o inspetor", tone: "signal" } : { text: "N\xE3o consegui abrir o navegador.", tone: "warn" });
343
+ }
345
344
  async function act(kind) {
346
345
  const t = currentTunnel();
347
346
  if (!t?.url) return setMessage({ text: "Esse t\xFAnel ainda n\xE3o tem URL.", tone: "warn" });
@@ -396,7 +395,12 @@ function App({ session, version, system, onExit }) {
396
395
  return;
397
396
  }
398
397
  if (mode.kind === "detail") {
399
- if (key.escape || key.return || input === "q") setMode({ kind: "list" });
398
+ if (key.escape || key.return || input === "q") return setMode({ kind: "list" });
399
+ if (input === "r") {
400
+ const res = session.replay(mode.id);
401
+ if (res.ok) setMode({ kind: "detail", id: res.id });
402
+ else setMessage({ text: res.reason, tone: "warn" });
403
+ }
400
404
  return;
401
405
  }
402
406
  if (mode.kind === "qr") {
@@ -419,18 +423,19 @@ function App({ session, version, system, onExit }) {
419
423
  else setSelReq((i) => Math.min(Math.max(0, i + d), Math.max(0, records.length - 1)));
420
424
  return;
421
425
  }
422
- if (key.return && focus === "requests" && records[selReq]) return setMode({ kind: "detail" });
426
+ if (key.return && focus === "requests" && records[selReq]) return setMode({ kind: "detail", id: records[selReq].id });
423
427
  if (key.escape && filter) return setFilter("");
424
428
  if (input === "c") return void act("copy");
425
429
  if (input === "o") return void act("open");
430
+ if (input === "i") return void openInspector();
426
431
  if (input === "m") return showQr();
427
432
  if (input === "f") return setMode({ kind: "filter", value: filter });
428
433
  if (input === "n") return setMode({ kind: "port", value: "" });
429
434
  });
430
435
  const live = state.tunnels.filter((t) => t.status === "live").length;
431
436
  const conn = state.connection === "online" ? { text: "conectado \u25CF", color: C.signal } : state.connection === "connecting" ? { text: "conectando\u2026", color: C.muted } : { text: "reconectando\u2026", color: C.warn };
432
- const listHeight = Math.max(3, rows - state.tunnels.length - 9);
433
- const selected = records[selReq];
437
+ const listHeight = Math.max(3, rows - state.tunnels.length - (inspector ? 10 : 9));
438
+ const detailRecord = mode.kind === "detail" ? all.find((r) => r.id === mode.id) : void 0;
434
439
  const qrTunnel = mode.kind === "qr" ? state.tunnels.find((t) => t.key === mode.key) : void 0;
435
440
  return /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", width, children: [
436
441
  /* @__PURE__ */ jsxs6(Box5, { justifyContent: "space-between", children: [
@@ -456,8 +461,9 @@ function App({ session, version, system, onExit }) {
456
461
  ] }),
457
462
  /* @__PURE__ */ jsx6(Text6, { children: " " }),
458
463
  /* @__PURE__ */ jsx6(TunnelTable, { tunnels: state.tunnels, selected: selTun, focused: focus === "tunnels" }),
464
+ inspector && /* @__PURE__ */ jsx6(Text6, { color: C.muted, children: `inspetor ${inspector.url}` }),
459
465
  /* @__PURE__ */ jsx6(Text6, { children: " " }),
460
- mode.kind === "detail" && selected ? /* @__PURE__ */ jsx6(Detail, { record: selected }) : mode.kind === "qr" && qrTunnel ? /* @__PURE__ */ jsx6(QrView, { tunnel: qrTunnel, link: mode.link, now }) : /* @__PURE__ */ jsx6(RequestList, { records, tunnelIndex, selected: selReq, focused: focus === "requests", height: listHeight, width, filter }),
466
+ mode.kind === "detail" && detailRecord ? /* @__PURE__ */ jsx6(Detail, { record: detailRecord }) : mode.kind === "qr" && qrTunnel ? /* @__PURE__ */ jsx6(QrView, { tunnel: qrTunnel, link: mode.link, now }) : /* @__PURE__ */ jsx6(RequestList, { records, tunnelIndex, selected: selReq, focused: focus === "requests", height: listHeight, width, filter }),
461
467
  /* @__PURE__ */ jsx6(Text6, { children: " " }),
462
468
  mode.kind === "filter" && /* @__PURE__ */ jsx6(Prompt, { label: "Filtrar (t\xFAnel, m\xE9todo, caminho ou status):", value: mode.value, hint: "\u21B5 aplica \xB7 esc cancela" }),
463
469
  mode.kind === "port" && /* @__PURE__ */ jsx6(Prompt, { label: "Novo t\xFAnel \u2014 porta local:", value: mode.value, hint: "\u21B5 continua \xB7 esc cancela" }),
@@ -541,7 +547,7 @@ var realSystem = {
541
547
 
542
548
  // src/ui/fullscreen.tsx
543
549
  import { jsx as jsx7 } from "react/jsx-runtime";
544
- function runFullscreen(session, specs, version) {
550
+ function runFullscreen(session, specs, version, inspector, inspectorError) {
545
551
  return withFullscreen(
546
552
  process.stdout,
547
553
  (signal) => new Promise((resolve) => {
@@ -552,7 +558,7 @@ function runFullscreen(session, specs, version) {
552
558
  app.unmount();
553
559
  resolve(code);
554
560
  };
555
- const app = render(/* @__PURE__ */ jsx7(App, { session, version, system: realSystem, onExit: () => finish(0) }), { exitOnCtrlC: false });
561
+ const app = render(/* @__PURE__ */ jsx7(App, { session, version, system: realSystem, inspector, inspectorError, onExit: () => finish(0) }), { exitOnCtrlC: false });
556
562
  signal.addEventListener("abort", () => void session.stop().then(() => finish(0)), { once: true });
557
563
  session.start(specs).catch(() => void 0);
558
564
  })