@dutodev/cli 0.4.6 → 0.6.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
@@ -93,6 +93,47 @@ Em todos os casos, cadastre `https://<nome>-<usuário>.duto.dev/<caminho de reto
93
93
 
94
94
  **Sobre o `X-Forwarded-*`.** `X-Forwarded-Host`, `X-Forwarded-Proto` e `X-Forwarded-For` são uma convenção antiga entre proxies reversos para contar ao app o que o cliente pediu de verdade. A versão padronizada é o cabeçalho `Forwarded` (RFC 7239), menos usado na prática. Frameworks costumam ler esses cabeçalhos só quando o proxy é marcado como confiável (`trusted proxies`, `trust proxy`, `USE_X_FORWARDED_HOST`), então o efeito de `headers:` depende de como o app está configurado. Por outro lado, se um app lê o `X-Forwarded-Host` sem essa checagem, qualquer cliente pode forjá-lo em produção. Vale garantir que o proxy da frente sobrescreva ou remova esse cabeçalho.
95
95
 
96
+ ## Senha no túnel
97
+
98
+ Sem senha, qualquer pessoa com a URL acessa o seu ambiente de desenvolvimento. Para proteger um túnel, coloque `auth` (e, se precisar, `permitir`) no `duto.yml`:
99
+
100
+ ```yaml
101
+ tunnels:
102
+ backend:
103
+ port: 3000
104
+ auth:
105
+ - cliente:${SENHA_CLIENTE}
106
+ - ana:${SENHA_ANA}
107
+ permitir: [stripe, github, 203.0.113.0/24]
108
+ ```
109
+
110
+ - `auth` é uma lista de `usuario:senha`. Use um par por pessoa. O primeiro `:` separa o usuário da senha; a senha tem de 8 a 128 caracteres.
111
+ - `${VAR}` lê o valor de uma variável de ambiente. Use sempre: assim a senha não vai para o git junto com o `duto.yml`. Se a variável não existe, a CLI para com o número da linha. O nome só pode ter letras maiúsculas, dígitos e `_` (`${SENHA_ANA}`); qualquer outra coisa, como `${senha}`, fica no texto sem trocar e vira parte da senha.
112
+ - Na lista de uma linha, entre colchetes, o `${VAR}` precisa de aspas (`auth: ["cliente:${SENHA_CLIENTE}"]`), porque `{` é um caractere especial do YAML. Na lista em bloco, como acima, não precisa.
113
+ - Na linha de comando, para um túnel só: `duto 3000 --auth cliente:minhasenha1 --permitir stripe`. As duas opções se repetem. Elas só valem com a porta; junto com um `duto.yml`, coloque-as no arquivo.
114
+ - Cuidado: a senha passada em `--auth` fica no histórico do shell e aparece para quem rodar `ps` na máquina. Para algo além de um teste rápido, prefira o `duto.yml` com `${VAR}`.
115
+
116
+ Quem usa o túnel entra de um destes jeitos:
117
+
118
+ | Quem | Como |
119
+ |---|---|
120
+ | Pessoa no navegador | Abre a URL, vê a página "Este túnel tem senha" e entra com usuário e senha |
121
+ | Celular | Escaneia o QR code (tecla `m`, veja abaixo) e entra sem digitar |
122
+ | `curl`, Postman, scripts | `curl -u cliente:senha https://backend-<usuário>.duto.dev/` (`Authorization: Basic`) |
123
+ | API que já usa `Authorization: Bearer` | Cabeçalho `X-Duto-Auth: cliente:senha`, e o `Bearer` segue intacto até o app |
124
+
125
+ Sem credencial, um navegador é levado à página de entrada e os outros clientes recebem `401`. O app local nunca recebe a credencial do Duto: o `Authorization: Basic` que confere, o `X-Duto-Auth` e o cookie do túnel são removidos antes de repassar a requisição (os outros cookies chegam). Um `Authorization: Bearer` do próprio app passa sem mudança.
126
+
127
+ **Sair e sessão.** No navegador, o login vira um cookie que vale só para aquele túnel. Para sair, abra `/__duto/sair`: ele apaga o cookie e volta para a página de entrada. O cookie dura até o navegador fechar ou a CLI parar. Reiniciar a CLI desconecta todo mundo, porque a chave que assina os cookies é nova a cada sessão. O cookie só vale em requisições do próprio túnel: uma página de outra origem (outro túnel, outro site) que chame o túnel precisa mandar `X-Duto-Auth` ou `Authorization: Basic`. Por isso, abrir o túnel a partir de um link em outro endereço `*.duto.dev` (o painel ou outro túnel) pede a senha de novo; um link vindo de fora (e-mail, chat) ou a URL digitada entra direto.
128
+
129
+ **QR code.** Com a tela cheia aberta, a tecla `m` mostra o QR do túnel selecionado no lugar da lista de requisições. Num túnel sem senha, o QR leva a URL do túnel. Num túnel com senha, leva um link que entra direto, sem digitar nada, como o primeiro usuário da lista `auth`. Esse link vale por 5 minutos e só para um aparelho: depois de aberto uma vez, ou passado o prazo, cai na página de entrada. Na tela do QR, `g` gera um link novo (e invalida o anterior) e `esc` volta para a lista.
130
+
131
+ **CORS.** Um frontend em outro domínio continua funcionando. O preflight (`OPTIONS` com `Origin` e `Access-Control-Request-Method`, sem corpo) passa sem senha, mas dele só voltam o status e os cabeçalhos de CORS da resposta do app. Quando o app responde com CORS, o Duto acrescenta `X-Duto-Auth` ao `Access-Control-Allow-Headers`. Você não muda nada no app. A resposta `401` também leva os cabeçalhos de CORS, para o frontend ler o erro em vez de ver uma falha de rede.
132
+
133
+ **Webhooks.** `permitir` libera, sem senha, quem chega de certas origens: os nomes `stripe` e `github` (a CLI baixa a lista oficial de IPs deles, guarda por um dia em `~/.duto/` e usa uma cópia embutida se a rede falhar) e faixas de IP, IPv4 ou IPv6, como `203.0.113.0/24`. `permitir` só vale junto com `auth`.
134
+
135
+ Na tela, os túneis com senha aparecem com um cadeado (🔒) ao lado da URL. A lista de requisições mostra o usuário que entrou e o motivo das liberadas por `permitir`; as barradas com `401` levam "sem senha", e os navegadores barrados aparecem com o redirecionamento `303` para a página de entrada. Um túnel aberto com `n` na tela cheia não tem senha.
136
+
96
137
  ## A tela
97
138
 
98
139
  Com os túneis no ar, o terminal mostra cada túnel (URL, destino local, requisições e erros) e as requisições chegando ao vivo: método, caminho, status, tempo e tamanho. WebSocket funciona normalmente e aparece como `WS 101`.
@@ -101,11 +142,17 @@ Com os túneis no ar, o terminal mostra cada túnel (URL, destino local, requisi
101
142
  |---|---|
102
143
  | `n` | Abre mais um túnel (só nesta sessão) |
103
144
  | `c` / `o` | Copia / abre no navegador a URL do túnel selecionado |
145
+ | `m` | Mostra o QR code do túnel selecionado, para abrir no celular |
104
146
  | `f` | Filtra por túnel, método, caminho ou status |
105
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 |
106
149
  | `tab`, `↑↓` | Alterna entre túneis e requisições e move a seleção |
107
150
  | `q` ou Ctrl+C | Fecha os túneis e sai |
108
151
 
152
+ **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.
153
+
154
+ 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).
155
+
109
156
  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.
110
157
 
111
158
  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.
@@ -126,6 +173,8 @@ Fora de um terminal interativo (pipe, CI) ou com `--no-ui`, a CLI imprime um log
126
173
  | `--name <nome>` | Endereço fixo `<nome>-<usuário>.duto.dev` |
127
174
  | `--host <host>` | Host que o serviço local recebe no lugar do público |
128
175
  | `--no-rewrite-redirects` | Com `--host`, mantém os redirecionamentos como o serviço mandou |
176
+ | `--auth <usuario:senha>` | Pede usuário e senha para abrir o endereço (repetível) |
177
+ | `--permitir <faixa>` | Com `--auth`: IP, faixa ou nome (`stripe`, `github`) que entra sem senha (repetível) |
129
178
  | `--no-ui` / `--ui` | Desliga / força a tela cheia |
130
179
 
131
180
  ## Configuração
@@ -14,7 +14,7 @@ import { render } from "ink";
14
14
 
15
15
  // src/ui/App.tsx
16
16
  import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
17
- import { Box as Box4, Text as Text5, useInput, useStdout } from "ink";
17
+ import { Box as Box5, Text as Text6, useInput, useStdout } from "ink";
18
18
 
19
19
  // src/ui/Detail.tsx
20
20
  import { Box, Text } from "ink";
@@ -84,12 +84,16 @@ function Detail({ record }) {
84
84
  record.tunnel
85
85
  ] })
86
86
  ] }),
87
+ record.replayOf !== void 0 && /* @__PURE__ */ jsxs(Text, { color: C.muted, children: [
88
+ "\u21BB reenvio de #",
89
+ record.replayOf
90
+ ] }),
87
91
  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 }),
92
+ /* @__PURE__ */ jsx(Section, { title: "Requisi\xE7\xE3o", headers: record.reqHeaders, body: record.reqBody, truncated: record.reqTruncated }),
93
+ /* @__PURE__ */ jsx(Section, { title: "Resposta", headers: record.resHeaders, body: record.resBody, truncated: record.resTruncated }),
90
94
  /* @__PURE__ */ jsxs(Text, { color: C.muted, children: [
91
95
  "\n",
92
- "esc volta para a lista"
96
+ "r reenvia \xB7 esc volta para a lista"
93
97
  ] })
94
98
  ] });
95
99
  }
@@ -107,43 +111,121 @@ function Prompt({ label, value, hint }) {
107
111
  ] });
108
112
  }
109
113
 
110
- // src/ui/RequestList.tsx
114
+ // src/ui/QrView.tsx
111
115
  import { Box as Box2, Text as Text3 } from "ink";
112
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
116
+
117
+ // src/ui/qr.ts
118
+ import qrcode from "qrcode-generator";
119
+ var MARGEM = 2;
120
+ function qrLines(text) {
121
+ const qr = qrcode(0, "L");
122
+ qr.addData(text);
123
+ qr.make();
124
+ const n = qr.getModuleCount();
125
+ const size = n + MARGEM * 2;
126
+ const dark = (r, c) => {
127
+ const rr = r - MARGEM;
128
+ const cc = c - MARGEM;
129
+ return rr >= 0 && cc >= 0 && rr < n && cc < n && qr.isDark(rr, cc);
130
+ };
131
+ const lines = [];
132
+ for (let r = 0; r < size; r += 2) {
133
+ let line = "";
134
+ for (let c = 0; c < size; c++) {
135
+ const top = dark(r, c);
136
+ const bottom = dark(r + 1, c);
137
+ line += top && bottom ? "\u2588" : top ? "\u2580" : bottom ? "\u2584" : " ";
138
+ }
139
+ lines.push(line);
140
+ }
141
+ return lines;
142
+ }
143
+
144
+ // src/ui/QrView.tsx
145
+ import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
146
+ function countdown(ms) {
147
+ const s = Math.ceil(ms / 1e3);
148
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
149
+ }
150
+ function QrView({ tunnel, link, now }) {
151
+ const lines = qrLines(link.url);
152
+ const left = link.expiresAt === null ? null : link.expiresAt - now;
153
+ const expired = left !== null && Math.ceil(left / 1e3) <= 0;
154
+ return /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", children: [
155
+ /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: "Abra no celular" }),
156
+ /* @__PURE__ */ jsxs3(Box2, { children: [
157
+ /* @__PURE__ */ jsx3(Box2, { flexDirection: "column", marginRight: 3, children: lines.map((l, i) => /* @__PURE__ */ jsx3(Text3, { backgroundColor: "white", color: "black", children: l }, i)) }),
158
+ /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", children: [
159
+ /* @__PURE__ */ jsxs3(Text3, { children: [
160
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: tunnel.key }),
161
+ " ",
162
+ /* @__PURE__ */ jsxs3(Text3, { color: C.muted, children: [
163
+ "\xB7 ",
164
+ tunnel.url
165
+ ] })
166
+ ] }),
167
+ left !== null && /* @__PURE__ */ jsxs3(Fragment2, { children: [
168
+ /* @__PURE__ */ jsx3(Text3, { children: " " }),
169
+ /* @__PURE__ */ jsx3(Text3, { children: "\u{1F512} Este t\xFAnel tem senha." }),
170
+ /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: "O QR entra direto, sem digitar a senha." }),
171
+ /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: "Vale por 5 minutos e s\xF3 para um aparelho." }),
172
+ /* @__PURE__ */ jsx3(Text3, { children: " " }),
173
+ expired ? /* @__PURE__ */ jsx3(Text3, { color: C.warn, children: "Expirou. Aperte g para gerar outro." }) : /* @__PURE__ */ jsx3(Text3, { color: C.signal, children: `Expira em ${countdown(left)}` })
174
+ ] }),
175
+ /* @__PURE__ */ jsx3(Text3, { children: " " }),
176
+ /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: left !== null ? "g gerar outro \xB7 esc voltar" : "esc voltar" })
177
+ ] })
178
+ ] })
179
+ ] });
180
+ }
181
+
182
+ // src/ui/RequestList.tsx
183
+ import { Box as Box3, Text as Text4 } from "ink";
184
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
185
+ function accessLabel(a) {
186
+ if (!a) return null;
187
+ if (a.kind === "user") return { text: a.user, color: C.muted };
188
+ if (a.kind === "allow") return { text: a.reason, color: C.muted };
189
+ return { text: "sem senha", color: C.warn };
190
+ }
113
191
  function RequestList(props) {
114
192
  const { records, selected, focused, height, width } = props;
115
193
  const start = Math.max(0, Math.min(selected - Math.floor(height / 2), records.length - height));
116
194
  const visible = records.slice(start, start + height);
117
- const pathW = Math.max(10, width - 52);
195
+ const accessW = Math.max(0, ...visible.map((r) => (accessLabel(r.access)?.text.length ?? -1) + 1));
196
+ const pathW = Math.max(10, width - 54 - accessW);
118
197
  const title = props.filter ? `REQUISI\xC7\xD5ES \xB7 filtro: ${props.filter} ` : "REQUISI\xC7\xD5ES ";
119
- return /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", children: [
120
- /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: title + "\u2500".repeat(Math.max(0, width - title.length)) }),
121
- records.length === 0 ? /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: props.filter ? "Nenhuma requisi\xE7\xE3o com esse filtro." : "Nenhuma requisi\xE7\xE3o ainda. Elas aparecem aqui assim que chegarem." }) : visible.map((r, i) => {
198
+ return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
199
+ /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: title + "\u2500".repeat(Math.max(0, width - title.length)) }),
200
+ records.length === 0 ? /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: props.filter ? "Nenhuma requisi\xE7\xE3o com esse filtro." : "Nenhuma requisi\xE7\xE3o ainda. Elas aparecem aqui assim que chegarem." }) : visible.map((r, i) => {
122
201
  const sel = focused && start + i === selected;
123
202
  const method = r.websocket ? "WS" : r.method;
124
203
  const status = r.status === null ? "\u2026" : String(r.status);
125
204
  const tail = r.websocket && r.status === 101 ? "aberto" : r.durationMs === null ? "" : formatDuration(r.durationMs);
126
- return /* @__PURE__ */ jsxs3(Text3, { inverse: sel, wrap: "truncate-end", children: [
127
- /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: formatTime(r.startedAt) }),
205
+ const acc = accessLabel(r.access);
206
+ return /* @__PURE__ */ jsxs4(Text4, { inverse: sel, wrap: "truncate-end", children: [
207
+ /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: formatTime(r.startedAt) }),
128
208
  " ",
129
- /* @__PURE__ */ jsx3(Text3, { color: tunnelColor(props.tunnelIndex(r.tunnel)), children: fit(r.tunnel, 9) }),
209
+ /* @__PURE__ */ jsx4(Text4, { color: tunnelColor(props.tunnelIndex(r.tunnel)), children: fit(r.tunnel, 9) }),
130
210
  " ",
131
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: fit(method, 5) }),
211
+ /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: r.replayOf !== void 0 ? "\u21BB " : " " }),
212
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: fit(method, 5) }),
132
213
  fit(r.path, pathW),
133
214
  " ",
134
- /* @__PURE__ */ jsx3(Text3, { color: C[statusColor(r.status)], children: status.padStart(3) }),
215
+ /* @__PURE__ */ jsx4(Text4, { color: C[statusColor(r.status)], children: status.padStart(3) }),
135
216
  " ",
136
- /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: tail.padStart(7) }),
217
+ /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: tail.padStart(7) }),
137
218
  " ",
138
- /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: r.websocket ? "" : formatBytes(r.bytes).padStart(8) })
219
+ /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: r.websocket ? "" : formatBytes(r.bytes).padStart(8) }),
220
+ acc ? /* @__PURE__ */ jsx4(Text4, { color: acc.color, children: ` ${acc.text}` }) : null
139
221
  ] }, r.id);
140
222
  })
141
223
  ] });
142
224
  }
143
225
 
144
226
  // src/ui/TunnelTable.tsx
145
- import { Box as Box3, Text as Text4 } from "ink";
146
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
227
+ import { Box as Box4, Text as Text5 } from "ink";
228
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
147
229
  function note(t) {
148
230
  if (t.status === "failed") return { text: t.error ?? "falhou", color: C.err };
149
231
  if (t.status === "no-listener") return { text: `nada escutando na ${t.port}`, color: C.warn };
@@ -161,12 +243,15 @@ function TunnelTable({ tunnels, selected, focused }) {
161
243
  const nameW = Math.max(6, ...tunnels.map((t) => t.key.length)) + 1;
162
244
  const urlW = Math.max(12, ...tunnels.map((t) => (t.url ?? "").length)) + 1;
163
245
  const localW = Math.max(15, ...tunnels.map((t) => localLabel(t).length));
164
- return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
165
- /* @__PURE__ */ jsxs4(Text4, { color: C.muted, children: [
246
+ const anyAuth = tunnels.some((t) => t.auth);
247
+ const lock = (t) => !anyAuth ? "" : t.auth ? "\u{1F512}" : " ";
248
+ return /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", children: [
249
+ /* @__PURE__ */ jsxs5(Text5, { color: C.muted, children: [
166
250
  " ",
167
251
  fit("T\xDANEL", nameW),
168
252
  " ",
169
253
  fit("URL", urlW),
254
+ anyAuth ? " " : "",
170
255
  " ",
171
256
  fit("LOCAL", localW),
172
257
  " ",
@@ -178,33 +263,35 @@ function TunnelTable({ tunnels, selected, focused }) {
178
263
  const d = dot(t);
179
264
  const n = note(t);
180
265
  const sel = focused && i === selected;
181
- return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
182
- /* @__PURE__ */ jsxs4(Text4, { inverse: sel, wrap: "truncate-end", children: [
183
- /* @__PURE__ */ jsx4(Text4, { color: d.color, children: d.char }),
266
+ return /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", children: [
267
+ /* @__PURE__ */ jsxs5(Text5, { inverse: sel, wrap: "truncate-end", children: [
268
+ /* @__PURE__ */ jsx5(Text5, { color: d.color, children: d.char }),
184
269
  " ",
185
- /* @__PURE__ */ jsx4(Text4, { bold: true, children: fit(t.key, nameW) }),
270
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: fit(t.key, nameW) }),
186
271
  " ",
187
272
  fit(t.url ?? "\u2014", urlW),
273
+ lock(t),
188
274
  " ",
189
- /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: fit(localLabel(t), localW) }),
275
+ /* @__PURE__ */ jsx5(Text5, { color: C.muted, children: fit(localLabel(t), localW) }),
190
276
  " ",
191
- /* @__PURE__ */ jsx4(Text4, { bold: true, children: String(t.requests).padStart(4) }),
277
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: String(t.requests).padStart(4) }),
192
278
  " ",
193
- /* @__PURE__ */ jsx4(Text4, { color: t.errors ? C.err : C.muted, children: String(t.errors).padStart(5) }),
194
- n && t.status !== "failed" ? /* @__PURE__ */ jsx4(Text4, { color: n.color, children: ` ${n.text}` }) : null
279
+ /* @__PURE__ */ jsx5(Text5, { color: t.errors ? C.err : C.muted, children: String(t.errors).padStart(5) }),
280
+ n && t.status !== "failed" ? /* @__PURE__ */ jsx5(Text5, { color: n.color, children: ` ${n.text}` }) : null
195
281
  ] }),
196
- t.status === "failed" && n ? /* @__PURE__ */ jsx4(Text4, { color: n.color, wrap: "wrap", children: ` \u21B3 ${n.text}` }) : null
282
+ t.status === "failed" && n ? /* @__PURE__ */ jsx5(Text5, { color: n.color, wrap: "wrap", children: ` \u21B3 ${n.text}` }) : null
197
283
  ] }, t.key);
198
284
  })
199
285
  ] });
200
286
  }
201
287
 
202
288
  // src/ui/App.tsx
203
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
289
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
204
290
  var KEYS = [
205
291
  ["n", "novo t\xFAnel"],
206
292
  ["c", "copiar URL"],
207
293
  ["o", "abrir no navegador"],
294
+ ["m", "QR no celular"],
208
295
  ["f", "filtrar"],
209
296
  ["\u21B5", "detalhes"],
210
297
  ["tab", "foco"],
@@ -224,6 +311,7 @@ function App({ session, version, system, onExit }) {
224
311
  const [selReq, setSelReq] = useState(0);
225
312
  const [selTun, setSelTun] = useState(0);
226
313
  const [message, setMessage] = useState(null);
314
+ const [now, setNow] = useState(() => Date.now());
227
315
  const quitting = useRef(false);
228
316
  const records = useMemo(() => {
229
317
  const f = filter.trim().toLowerCase();
@@ -233,11 +321,21 @@ function App({ session, version, system, onExit }) {
233
321
  useEffect(() => {
234
322
  if (selReq >= records.length) setSelReq(Math.max(0, records.length - 1));
235
323
  }, [records.length, selReq]);
324
+ useEffect(() => {
325
+ if (mode.kind === "detail" && !all.some((r) => r.id === mode.id)) setMode({ kind: "list" });
326
+ }, [mode, all]);
236
327
  useEffect(() => {
237
328
  if (!message) return;
238
329
  const t = setTimeout(() => setMessage(null), 4e3);
239
330
  return () => clearTimeout(t);
240
331
  }, [message]);
332
+ const inQr = mode.kind === "qr";
333
+ useEffect(() => {
334
+ if (!inQr) return;
335
+ setNow(Date.now());
336
+ const id = setInterval(() => setNow(Date.now()), 1e3);
337
+ return () => clearInterval(id);
338
+ }, [inQr]);
241
339
  const order = state.tunnels.map((t) => t.key);
242
340
  const tunnelIndex = (key) => Math.max(0, order.indexOf(key));
243
341
  function currentTunnel() {
@@ -263,6 +361,13 @@ function App({ session, version, system, onExit }) {
263
361
  setMessage(ok ? { text: `Abrindo ${t.url}`, tone: "signal" } : { text: "N\xE3o consegui abrir o navegador", tone: "warn" });
264
362
  }
265
363
  }
364
+ function showQr() {
365
+ const t = currentTunnel();
366
+ const link = t ? session.qrLink(t.key) : null;
367
+ if (!t || !link) return setMessage({ text: "Esse t\xFAnel ainda n\xE3o tem URL.", tone: "warn" });
368
+ setNow(Date.now());
369
+ setMode({ kind: "qr", key: t.key, link });
370
+ }
266
371
  function submitPort(value) {
267
372
  const port = Number(value.trim());
268
373
  if (!Number.isInteger(port) || port < 1 || port > 65535) return setMessage({ text: `Porta inv\xE1lida: ${value || "(vazia)"} (use 1\u201365535)`, tone: "err" });
@@ -299,7 +404,24 @@ function App({ session, version, system, onExit }) {
299
404
  return;
300
405
  }
301
406
  if (mode.kind === "detail") {
302
- if (key.escape || key.return || input === "q") setMode({ kind: "list" });
407
+ if (key.escape || key.return || input === "q") return setMode({ kind: "list" });
408
+ if (input === "r") {
409
+ const res = session.replay(mode.id);
410
+ if (res.ok) setMode({ kind: "detail", id: res.id });
411
+ else setMessage({ text: res.reason, tone: "warn" });
412
+ }
413
+ return;
414
+ }
415
+ if (mode.kind === "qr") {
416
+ if (key.escape) return setMode({ kind: "list" });
417
+ if (input === "q") return void quit();
418
+ if (input === "g") {
419
+ const link = session.qrLink(mode.key);
420
+ if (link) {
421
+ setNow(Date.now());
422
+ setMode({ kind: "qr", key: mode.key, link });
423
+ }
424
+ }
303
425
  return;
304
426
  }
305
427
  if (input === "q") return void quit();
@@ -310,25 +432,27 @@ function App({ session, version, system, onExit }) {
310
432
  else setSelReq((i) => Math.min(Math.max(0, i + d), Math.max(0, records.length - 1)));
311
433
  return;
312
434
  }
313
- if (key.return && focus === "requests" && records[selReq]) return setMode({ kind: "detail" });
435
+ if (key.return && focus === "requests" && records[selReq]) return setMode({ kind: "detail", id: records[selReq].id });
314
436
  if (key.escape && filter) return setFilter("");
315
437
  if (input === "c") return void act("copy");
316
438
  if (input === "o") return void act("open");
439
+ if (input === "m") return showQr();
317
440
  if (input === "f") return setMode({ kind: "filter", value: filter });
318
441
  if (input === "n") return setMode({ kind: "port", value: "" });
319
442
  });
320
443
  const live = state.tunnels.filter((t) => t.status === "live").length;
321
444
  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 };
322
445
  const listHeight = Math.max(3, rows - state.tunnels.length - 9);
323
- const selected = records[selReq];
324
- return /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", width, children: [
325
- /* @__PURE__ */ jsxs5(Box4, { justifyContent: "space-between", children: [
326
- /* @__PURE__ */ jsxs5(Text5, { children: [
327
- /* @__PURE__ */ jsx5(Text5, { color: C.signal, children: "\u25B0" }),
446
+ const detailRecord = mode.kind === "detail" ? all.find((r) => r.id === mode.id) : void 0;
447
+ const qrTunnel = mode.kind === "qr" ? state.tunnels.find((t) => t.key === mode.key) : void 0;
448
+ return /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", width, children: [
449
+ /* @__PURE__ */ jsxs6(Box5, { justifyContent: "space-between", children: [
450
+ /* @__PURE__ */ jsxs6(Text6, { children: [
451
+ /* @__PURE__ */ jsx6(Text6, { color: C.signal, children: "\u25B0" }),
328
452
  " ",
329
- /* @__PURE__ */ jsx5(Text5, { bold: true, children: "duto" }),
453
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: "duto" }),
330
454
  " ",
331
- /* @__PURE__ */ jsxs5(Text5, { color: C.muted, children: [
455
+ /* @__PURE__ */ jsxs6(Text6, { color: C.muted, children: [
332
456
  version,
333
457
  " \xB7 ",
334
458
  state.account ?? "\u2026",
@@ -341,20 +465,20 @@ function App({ session, version, system, onExit }) {
341
465
  " no ar"
342
466
  ] })
343
467
  ] }),
344
- /* @__PURE__ */ jsx5(Text5, { color: conn.color, children: conn.text })
468
+ /* @__PURE__ */ jsx6(Text6, { color: conn.color, children: conn.text })
345
469
  ] }),
346
- /* @__PURE__ */ jsx5(Text5, { children: " " }),
347
- /* @__PURE__ */ jsx5(TunnelTable, { tunnels: state.tunnels, selected: selTun, focused: focus === "tunnels" }),
348
- /* @__PURE__ */ jsx5(Text5, { children: " " }),
349
- mode.kind === "detail" && selected ? /* @__PURE__ */ jsx5(Detail, { record: selected }) : /* @__PURE__ */ jsx5(RequestList, { records, tunnelIndex, selected: selReq, focused: focus === "requests", height: listHeight, width, filter }),
350
- /* @__PURE__ */ jsx5(Text5, { children: " " }),
351
- mode.kind === "filter" && /* @__PURE__ */ jsx5(Prompt, { label: "Filtrar (t\xFAnel, m\xE9todo, caminho ou status):", value: mode.value, hint: "\u21B5 aplica \xB7 esc cancela" }),
352
- mode.kind === "port" && /* @__PURE__ */ jsx5(Prompt, { label: "Novo t\xFAnel \u2014 porta local:", value: mode.value, hint: "\u21B5 continua \xB7 esc cancela" }),
353
- mode.kind === "name" && /* @__PURE__ */ jsx5(Prompt, { label: `Nome para localhost:${mode.port} (opcional):`, value: mode.value, hint: "\u21B5 abre \xB7 esc cancela" }),
354
- mode.kind !== "filter" && mode.kind !== "port" && mode.kind !== "name" && /* @__PURE__ */ jsx5(Text5, { color: message ? C[message.tone] : C.muted, children: message?.text ?? " " }),
355
- /* @__PURE__ */ jsx5(Text5, { wrap: "truncate-end", children: KEYS.map(([k, label]) => /* @__PURE__ */ jsxs5(Text5, { children: [
356
- /* @__PURE__ */ jsx5(Text5, { inverse: true, color: C.signal, children: ` ${k} ` }),
357
- /* @__PURE__ */ jsx5(Text5, { color: C.muted, children: ` ${label} ` })
470
+ /* @__PURE__ */ jsx6(Text6, { children: " " }),
471
+ /* @__PURE__ */ jsx6(TunnelTable, { tunnels: state.tunnels, selected: selTun, focused: focus === "tunnels" }),
472
+ /* @__PURE__ */ jsx6(Text6, { children: " " }),
473
+ 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 }),
474
+ /* @__PURE__ */ jsx6(Text6, { children: " " }),
475
+ mode.kind === "filter" && /* @__PURE__ */ jsx6(Prompt, { label: "Filtrar (t\xFAnel, m\xE9todo, caminho ou status):", value: mode.value, hint: "\u21B5 aplica \xB7 esc cancela" }),
476
+ mode.kind === "port" && /* @__PURE__ */ jsx6(Prompt, { label: "Novo t\xFAnel \u2014 porta local:", value: mode.value, hint: "\u21B5 continua \xB7 esc cancela" }),
477
+ mode.kind === "name" && /* @__PURE__ */ jsx6(Prompt, { label: `Nome para localhost:${mode.port} (opcional):`, value: mode.value, hint: "\u21B5 abre \xB7 esc cancela" }),
478
+ mode.kind !== "filter" && mode.kind !== "port" && mode.kind !== "name" && /* @__PURE__ */ jsx6(Text6, { color: message ? C[message.tone] : C.muted, children: message?.text ?? " " }),
479
+ /* @__PURE__ */ jsx6(Text6, { wrap: "truncate-end", children: KEYS.map(([k, label]) => /* @__PURE__ */ jsxs6(Text6, { children: [
480
+ /* @__PURE__ */ jsx6(Text6, { inverse: true, color: C.signal, children: ` ${k} ` }),
481
+ /* @__PURE__ */ jsx6(Text6, { color: C.muted, children: ` ${label} ` })
358
482
  ] }, k)) })
359
483
  ] });
360
484
  }
@@ -429,7 +553,7 @@ var realSystem = {
429
553
  };
430
554
 
431
555
  // src/ui/fullscreen.tsx
432
- import { jsx as jsx6 } from "react/jsx-runtime";
556
+ import { jsx as jsx7 } from "react/jsx-runtime";
433
557
  function runFullscreen(session, specs, version) {
434
558
  return withFullscreen(
435
559
  process.stdout,
@@ -441,7 +565,7 @@ function runFullscreen(session, specs, version) {
441
565
  app.unmount();
442
566
  resolve(code);
443
567
  };
444
- const app = render(/* @__PURE__ */ jsx6(App, { session, version, system: realSystem, onExit: () => finish(0) }), { exitOnCtrlC: false });
568
+ const app = render(/* @__PURE__ */ jsx7(App, { session, version, system: realSystem, onExit: () => finish(0) }), { exitOnCtrlC: false });
445
569
  signal.addEventListener("abort", () => void session.stop().then(() => finish(0)), { once: true });
446
570
  session.start(specs).catch(() => void 0);
447
571
  })