@dutodev/cli 0.4.5 → 0.5.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,6 +142,7 @@ 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 |
106
148
  | `tab`, `↑↓` | Alterna entre túneis e requisições e move a seleção |
@@ -126,6 +168,8 @@ Fora de um terminal interativo (pipe, CI) ou com `--no-ui`, a CLI imprime um log
126
168
  | `--name <nome>` | Endereço fixo `<nome>-<usuário>.duto.dev` |
127
169
  | `--host <host>` | Host que o serviço local recebe no lugar do público |
128
170
  | `--no-rewrite-redirects` | Com `--host`, mantém os redirecionamentos como o serviço mandou |
171
+ | `--auth <usuario:senha>` | Pede usuário e senha para abrir o endereço (repetível) |
172
+ | `--permitir <faixa>` | Com `--auth`: IP, faixa ou nome (`stripe`, `github`) que entra sem senha (repetível) |
129
173
  | `--no-ui` / `--ui` | Desliga / força a tela cheia |
130
174
 
131
175
  ## 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";
@@ -107,43 +107,120 @@ function Prompt({ label, value, hint }) {
107
107
  ] });
108
108
  }
109
109
 
110
- // src/ui/RequestList.tsx
110
+ // src/ui/QrView.tsx
111
111
  import { Box as Box2, Text as Text3 } from "ink";
112
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
112
+
113
+ // src/ui/qr.ts
114
+ import qrcode from "qrcode-generator";
115
+ var MARGEM = 2;
116
+ function qrLines(text) {
117
+ const qr = qrcode(0, "L");
118
+ qr.addData(text);
119
+ qr.make();
120
+ const n = qr.getModuleCount();
121
+ const size = n + MARGEM * 2;
122
+ const dark = (r, c) => {
123
+ const rr = r - MARGEM;
124
+ const cc = c - MARGEM;
125
+ return rr >= 0 && cc >= 0 && rr < n && cc < n && qr.isDark(rr, cc);
126
+ };
127
+ const lines = [];
128
+ for (let r = 0; r < size; r += 2) {
129
+ let line = "";
130
+ for (let c = 0; c < size; c++) {
131
+ const top = dark(r, c);
132
+ const bottom = dark(r + 1, c);
133
+ line += top && bottom ? "\u2588" : top ? "\u2580" : bottom ? "\u2584" : " ";
134
+ }
135
+ lines.push(line);
136
+ }
137
+ return lines;
138
+ }
139
+
140
+ // src/ui/QrView.tsx
141
+ import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
142
+ function countdown(ms) {
143
+ const s = Math.ceil(ms / 1e3);
144
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
145
+ }
146
+ function QrView({ tunnel, link, now }) {
147
+ const lines = qrLines(link.url);
148
+ const left = link.expiresAt === null ? null : link.expiresAt - now;
149
+ const expired = left !== null && Math.ceil(left / 1e3) <= 0;
150
+ return /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", children: [
151
+ /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: "Abra no celular" }),
152
+ /* @__PURE__ */ jsxs3(Box2, { children: [
153
+ /* @__PURE__ */ jsx3(Box2, { flexDirection: "column", marginRight: 3, children: lines.map((l, i) => /* @__PURE__ */ jsx3(Text3, { backgroundColor: "white", color: "black", children: l }, i)) }),
154
+ /* @__PURE__ */ jsxs3(Box2, { flexDirection: "column", children: [
155
+ /* @__PURE__ */ jsxs3(Text3, { children: [
156
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: tunnel.key }),
157
+ " ",
158
+ /* @__PURE__ */ jsxs3(Text3, { color: C.muted, children: [
159
+ "\xB7 ",
160
+ tunnel.url
161
+ ] })
162
+ ] }),
163
+ left !== null && /* @__PURE__ */ jsxs3(Fragment2, { children: [
164
+ /* @__PURE__ */ jsx3(Text3, { children: " " }),
165
+ /* @__PURE__ */ jsx3(Text3, { children: "\u{1F512} Este t\xFAnel tem senha." }),
166
+ /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: "O QR entra direto, sem digitar a senha." }),
167
+ /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: "Vale por 5 minutos e s\xF3 para um aparelho." }),
168
+ /* @__PURE__ */ jsx3(Text3, { children: " " }),
169
+ 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)}` })
170
+ ] }),
171
+ /* @__PURE__ */ jsx3(Text3, { children: " " }),
172
+ /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: left !== null ? "g gerar outro \xB7 esc voltar" : "esc voltar" })
173
+ ] })
174
+ ] })
175
+ ] });
176
+ }
177
+
178
+ // src/ui/RequestList.tsx
179
+ import { Box as Box3, Text as Text4 } from "ink";
180
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
181
+ function accessLabel(a) {
182
+ if (!a) return null;
183
+ if (a.kind === "user") return { text: a.user, color: C.muted };
184
+ if (a.kind === "allow") return { text: a.reason, color: C.muted };
185
+ return { text: "sem senha", color: C.warn };
186
+ }
113
187
  function RequestList(props) {
114
188
  const { records, selected, focused, height, width } = props;
115
189
  const start = Math.max(0, Math.min(selected - Math.floor(height / 2), records.length - height));
116
190
  const visible = records.slice(start, start + height);
117
- const pathW = Math.max(10, width - 52);
191
+ const accessW = Math.max(0, ...visible.map((r) => (accessLabel(r.access)?.text.length ?? -1) + 1));
192
+ const pathW = Math.max(10, width - 52 - accessW);
118
193
  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) => {
194
+ return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
195
+ /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: title + "\u2500".repeat(Math.max(0, width - title.length)) }),
196
+ 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
197
  const sel = focused && start + i === selected;
123
198
  const method = r.websocket ? "WS" : r.method;
124
199
  const status = r.status === null ? "\u2026" : String(r.status);
125
200
  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) }),
201
+ const acc = accessLabel(r.access);
202
+ return /* @__PURE__ */ jsxs4(Text4, { inverse: sel, wrap: "truncate-end", children: [
203
+ /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: formatTime(r.startedAt) }),
128
204
  " ",
129
- /* @__PURE__ */ jsx3(Text3, { color: tunnelColor(props.tunnelIndex(r.tunnel)), children: fit(r.tunnel, 9) }),
205
+ /* @__PURE__ */ jsx4(Text4, { color: tunnelColor(props.tunnelIndex(r.tunnel)), children: fit(r.tunnel, 9) }),
130
206
  " ",
131
- /* @__PURE__ */ jsx3(Text3, { bold: true, children: fit(method, 5) }),
207
+ /* @__PURE__ */ jsx4(Text4, { bold: true, children: fit(method, 5) }),
132
208
  fit(r.path, pathW),
133
209
  " ",
134
- /* @__PURE__ */ jsx3(Text3, { color: C[statusColor(r.status)], children: status.padStart(3) }),
210
+ /* @__PURE__ */ jsx4(Text4, { color: C[statusColor(r.status)], children: status.padStart(3) }),
135
211
  " ",
136
- /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: tail.padStart(7) }),
212
+ /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: tail.padStart(7) }),
137
213
  " ",
138
- /* @__PURE__ */ jsx3(Text3, { color: C.muted, children: r.websocket ? "" : formatBytes(r.bytes).padStart(8) })
214
+ /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: r.websocket ? "" : formatBytes(r.bytes).padStart(8) }),
215
+ acc ? /* @__PURE__ */ jsx4(Text4, { color: acc.color, children: ` ${acc.text}` }) : null
139
216
  ] }, r.id);
140
217
  })
141
218
  ] });
142
219
  }
143
220
 
144
221
  // 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";
222
+ import { Box as Box4, Text as Text5 } from "ink";
223
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
147
224
  function note(t) {
148
225
  if (t.status === "failed") return { text: t.error ?? "falhou", color: C.err };
149
226
  if (t.status === "no-listener") return { text: `nada escutando na ${t.port}`, color: C.warn };
@@ -161,12 +238,15 @@ function TunnelTable({ tunnels, selected, focused }) {
161
238
  const nameW = Math.max(6, ...tunnels.map((t) => t.key.length)) + 1;
162
239
  const urlW = Math.max(12, ...tunnels.map((t) => (t.url ?? "").length)) + 1;
163
240
  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: [
241
+ const anyAuth = tunnels.some((t) => t.auth);
242
+ const lock = (t) => !anyAuth ? "" : t.auth ? "\u{1F512}" : " ";
243
+ return /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", children: [
244
+ /* @__PURE__ */ jsxs5(Text5, { color: C.muted, children: [
166
245
  " ",
167
246
  fit("T\xDANEL", nameW),
168
247
  " ",
169
248
  fit("URL", urlW),
249
+ anyAuth ? " " : "",
170
250
  " ",
171
251
  fit("LOCAL", localW),
172
252
  " ",
@@ -178,33 +258,35 @@ function TunnelTable({ tunnels, selected, focused }) {
178
258
  const d = dot(t);
179
259
  const n = note(t);
180
260
  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 }),
261
+ return /* @__PURE__ */ jsxs5(Box4, { flexDirection: "column", children: [
262
+ /* @__PURE__ */ jsxs5(Text5, { inverse: sel, wrap: "truncate-end", children: [
263
+ /* @__PURE__ */ jsx5(Text5, { color: d.color, children: d.char }),
184
264
  " ",
185
- /* @__PURE__ */ jsx4(Text4, { bold: true, children: fit(t.key, nameW) }),
265
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: fit(t.key, nameW) }),
186
266
  " ",
187
267
  fit(t.url ?? "\u2014", urlW),
268
+ lock(t),
188
269
  " ",
189
- /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: fit(localLabel(t), localW) }),
270
+ /* @__PURE__ */ jsx5(Text5, { color: C.muted, children: fit(localLabel(t), localW) }),
190
271
  " ",
191
- /* @__PURE__ */ jsx4(Text4, { bold: true, children: String(t.requests).padStart(4) }),
272
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: String(t.requests).padStart(4) }),
192
273
  " ",
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
274
+ /* @__PURE__ */ jsx5(Text5, { color: t.errors ? C.err : C.muted, children: String(t.errors).padStart(5) }),
275
+ n && t.status !== "failed" ? /* @__PURE__ */ jsx5(Text5, { color: n.color, children: ` ${n.text}` }) : null
195
276
  ] }),
196
- t.status === "failed" && n ? /* @__PURE__ */ jsx4(Text4, { color: n.color, wrap: "wrap", children: ` \u21B3 ${n.text}` }) : null
277
+ t.status === "failed" && n ? /* @__PURE__ */ jsx5(Text5, { color: n.color, wrap: "wrap", children: ` \u21B3 ${n.text}` }) : null
197
278
  ] }, t.key);
198
279
  })
199
280
  ] });
200
281
  }
201
282
 
202
283
  // src/ui/App.tsx
203
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
284
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
204
285
  var KEYS = [
205
286
  ["n", "novo t\xFAnel"],
206
287
  ["c", "copiar URL"],
207
288
  ["o", "abrir no navegador"],
289
+ ["m", "QR no celular"],
208
290
  ["f", "filtrar"],
209
291
  ["\u21B5", "detalhes"],
210
292
  ["tab", "foco"],
@@ -224,6 +306,7 @@ function App({ session, version, system, onExit }) {
224
306
  const [selReq, setSelReq] = useState(0);
225
307
  const [selTun, setSelTun] = useState(0);
226
308
  const [message, setMessage] = useState(null);
309
+ const [now, setNow] = useState(() => Date.now());
227
310
  const quitting = useRef(false);
228
311
  const records = useMemo(() => {
229
312
  const f = filter.trim().toLowerCase();
@@ -238,6 +321,13 @@ function App({ session, version, system, onExit }) {
238
321
  const t = setTimeout(() => setMessage(null), 4e3);
239
322
  return () => clearTimeout(t);
240
323
  }, [message]);
324
+ const inQr = mode.kind === "qr";
325
+ useEffect(() => {
326
+ if (!inQr) return;
327
+ setNow(Date.now());
328
+ const id = setInterval(() => setNow(Date.now()), 1e3);
329
+ return () => clearInterval(id);
330
+ }, [inQr]);
241
331
  const order = state.tunnels.map((t) => t.key);
242
332
  const tunnelIndex = (key) => Math.max(0, order.indexOf(key));
243
333
  function currentTunnel() {
@@ -263,6 +353,13 @@ function App({ session, version, system, onExit }) {
263
353
  setMessage(ok ? { text: `Abrindo ${t.url}`, tone: "signal" } : { text: "N\xE3o consegui abrir o navegador", tone: "warn" });
264
354
  }
265
355
  }
356
+ function showQr() {
357
+ const t = currentTunnel();
358
+ const link = t ? session.qrLink(t.key) : null;
359
+ if (!t || !link) return setMessage({ text: "Esse t\xFAnel ainda n\xE3o tem URL.", tone: "warn" });
360
+ setNow(Date.now());
361
+ setMode({ kind: "qr", key: t.key, link });
362
+ }
266
363
  function submitPort(value) {
267
364
  const port = Number(value.trim());
268
365
  if (!Number.isInteger(port) || port < 1 || port > 65535) return setMessage({ text: `Porta inv\xE1lida: ${value || "(vazia)"} (use 1\u201365535)`, tone: "err" });
@@ -302,6 +399,18 @@ function App({ session, version, system, onExit }) {
302
399
  if (key.escape || key.return || input === "q") setMode({ kind: "list" });
303
400
  return;
304
401
  }
402
+ if (mode.kind === "qr") {
403
+ if (key.escape) return setMode({ kind: "list" });
404
+ if (input === "q") return void quit();
405
+ if (input === "g") {
406
+ const link = session.qrLink(mode.key);
407
+ if (link) {
408
+ setNow(Date.now());
409
+ setMode({ kind: "qr", key: mode.key, link });
410
+ }
411
+ }
412
+ return;
413
+ }
305
414
  if (input === "q") return void quit();
306
415
  if (key.tab) return setFocus((f) => f === "requests" ? "tunnels" : "requests");
307
416
  if (key.upArrow || key.downArrow) {
@@ -314,6 +423,7 @@ function App({ session, version, system, onExit }) {
314
423
  if (key.escape && filter) return setFilter("");
315
424
  if (input === "c") return void act("copy");
316
425
  if (input === "o") return void act("open");
426
+ if (input === "m") return showQr();
317
427
  if (input === "f") return setMode({ kind: "filter", value: filter });
318
428
  if (input === "n") return setMode({ kind: "port", value: "" });
319
429
  });
@@ -321,14 +431,15 @@ function App({ session, version, system, onExit }) {
321
431
  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
432
  const listHeight = Math.max(3, rows - state.tunnels.length - 9);
323
433
  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" }),
434
+ const qrTunnel = mode.kind === "qr" ? state.tunnels.find((t) => t.key === mode.key) : void 0;
435
+ return /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", width, children: [
436
+ /* @__PURE__ */ jsxs6(Box5, { justifyContent: "space-between", children: [
437
+ /* @__PURE__ */ jsxs6(Text6, { children: [
438
+ /* @__PURE__ */ jsx6(Text6, { color: C.signal, children: "\u25B0" }),
328
439
  " ",
329
- /* @__PURE__ */ jsx5(Text5, { bold: true, children: "duto" }),
440
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: "duto" }),
330
441
  " ",
331
- /* @__PURE__ */ jsxs5(Text5, { color: C.muted, children: [
442
+ /* @__PURE__ */ jsxs6(Text6, { color: C.muted, children: [
332
443
  version,
333
444
  " \xB7 ",
334
445
  state.account ?? "\u2026",
@@ -341,20 +452,20 @@ function App({ session, version, system, onExit }) {
341
452
  " no ar"
342
453
  ] })
343
454
  ] }),
344
- /* @__PURE__ */ jsx5(Text5, { color: conn.color, children: conn.text })
455
+ /* @__PURE__ */ jsx6(Text6, { color: conn.color, children: conn.text })
345
456
  ] }),
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} ` })
457
+ /* @__PURE__ */ jsx6(Text6, { children: " " }),
458
+ /* @__PURE__ */ jsx6(TunnelTable, { tunnels: state.tunnels, selected: selTun, focused: focus === "tunnels" }),
459
+ /* @__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 }),
461
+ /* @__PURE__ */ jsx6(Text6, { children: " " }),
462
+ 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
+ mode.kind === "port" && /* @__PURE__ */ jsx6(Prompt, { label: "Novo t\xFAnel \u2014 porta local:", value: mode.value, hint: "\u21B5 continua \xB7 esc cancela" }),
464
+ mode.kind === "name" && /* @__PURE__ */ jsx6(Prompt, { label: `Nome para localhost:${mode.port} (opcional):`, value: mode.value, hint: "\u21B5 abre \xB7 esc cancela" }),
465
+ mode.kind !== "filter" && mode.kind !== "port" && mode.kind !== "name" && /* @__PURE__ */ jsx6(Text6, { color: message ? C[message.tone] : C.muted, children: message?.text ?? " " }),
466
+ /* @__PURE__ */ jsx6(Text6, { wrap: "truncate-end", children: KEYS.map(([k, label]) => /* @__PURE__ */ jsxs6(Text6, { children: [
467
+ /* @__PURE__ */ jsx6(Text6, { inverse: true, color: C.signal, children: ` ${k} ` }),
468
+ /* @__PURE__ */ jsx6(Text6, { color: C.muted, children: ` ${label} ` })
358
469
  ] }, k)) })
359
470
  ] });
360
471
  }
@@ -373,6 +484,7 @@ async function withFullscreen(stdout, run2) {
373
484
  const onSignal = () => ac.abort();
374
485
  process.on("SIGINT", onSignal);
375
486
  process.on("SIGTERM", onSignal);
487
+ process.on("SIGHUP", onSignal);
376
488
  process.once("exit", restore);
377
489
  stdout.write(ENTER_SCREEN);
378
490
  try {
@@ -380,6 +492,7 @@ async function withFullscreen(stdout, run2) {
380
492
  } finally {
381
493
  process.off("SIGINT", onSignal);
382
494
  process.off("SIGTERM", onSignal);
495
+ process.off("SIGHUP", onSignal);
383
496
  process.off("exit", restore);
384
497
  restore();
385
498
  }
@@ -427,7 +540,7 @@ var realSystem = {
427
540
  };
428
541
 
429
542
  // src/ui/fullscreen.tsx
430
- import { jsx as jsx6 } from "react/jsx-runtime";
543
+ import { jsx as jsx7 } from "react/jsx-runtime";
431
544
  function runFullscreen(session, specs, version) {
432
545
  return withFullscreen(
433
546
  process.stdout,
@@ -439,7 +552,7 @@ function runFullscreen(session, specs, version) {
439
552
  app.unmount();
440
553
  resolve(code);
441
554
  };
442
- const app = render(/* @__PURE__ */ jsx6(App, { session, version, system: realSystem, onExit: () => finish(0) }), { exitOnCtrlC: false });
555
+ const app = render(/* @__PURE__ */ jsx7(App, { session, version, system: realSystem, onExit: () => finish(0) }), { exitOnCtrlC: false });
443
556
  signal.addEventListener("abort", () => void session.stop().then(() => finish(0)), { once: true });
444
557
  session.start(specs).catch(() => void 0);
445
558
  })