@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 +49 -0
- package/dist/{fullscreen-EPWB53EP.js → fullscreen-7P5GBCQX.js} +179 -55
- package/dist/index.js +694 -97
- package/package.json +2 -1
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
|
|
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.
|
|
89
|
-
/* @__PURE__ */ jsx(Section, { title: "Resposta", headers: record.resHeaders, body: record.resBody, truncated: record.
|
|
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/
|
|
114
|
+
// src/ui/QrView.tsx
|
|
111
115
|
import { Box as Box2, Text as Text3 } from "ink";
|
|
112
|
-
|
|
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
|
|
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__ */
|
|
120
|
-
/* @__PURE__ */
|
|
121
|
-
records.length === 0 ? /* @__PURE__ */
|
|
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
|
-
|
|
127
|
-
|
|
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__ */
|
|
209
|
+
/* @__PURE__ */ jsx4(Text4, { color: tunnelColor(props.tunnelIndex(r.tunnel)), children: fit(r.tunnel, 9) }),
|
|
130
210
|
" ",
|
|
131
|
-
/* @__PURE__ */
|
|
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__ */
|
|
215
|
+
/* @__PURE__ */ jsx4(Text4, { color: C[statusColor(r.status)], children: status.padStart(3) }),
|
|
135
216
|
" ",
|
|
136
|
-
/* @__PURE__ */
|
|
217
|
+
/* @__PURE__ */ jsx4(Text4, { color: C.muted, children: tail.padStart(7) }),
|
|
137
218
|
" ",
|
|
138
|
-
/* @__PURE__ */
|
|
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
|
|
146
|
-
import { jsx as
|
|
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
|
-
|
|
165
|
-
|
|
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__ */
|
|
182
|
-
/* @__PURE__ */
|
|
183
|
-
/* @__PURE__ */
|
|
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__ */
|
|
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__ */
|
|
275
|
+
/* @__PURE__ */ jsx5(Text5, { color: C.muted, children: fit(localLabel(t), localW) }),
|
|
190
276
|
" ",
|
|
191
|
-
/* @__PURE__ */
|
|
277
|
+
/* @__PURE__ */ jsx5(Text5, { bold: true, children: String(t.requests).padStart(4) }),
|
|
192
278
|
" ",
|
|
193
|
-
/* @__PURE__ */
|
|
194
|
-
n && t.status !== "failed" ? /* @__PURE__ */
|
|
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__ */
|
|
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
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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__ */
|
|
453
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, children: "duto" }),
|
|
330
454
|
" ",
|
|
331
|
-
/* @__PURE__ */
|
|
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__ */
|
|
468
|
+
/* @__PURE__ */ jsx6(Text6, { color: conn.color, children: conn.text })
|
|
345
469
|
] }),
|
|
346
|
-
/* @__PURE__ */
|
|
347
|
-
/* @__PURE__ */
|
|
348
|
-
/* @__PURE__ */
|
|
349
|
-
mode.kind === "detail" &&
|
|
350
|
-
/* @__PURE__ */
|
|
351
|
-
mode.kind === "filter" && /* @__PURE__ */
|
|
352
|
-
mode.kind === "port" && /* @__PURE__ */
|
|
353
|
-
mode.kind === "name" && /* @__PURE__ */
|
|
354
|
-
mode.kind !== "filter" && mode.kind !== "port" && mode.kind !== "name" && /* @__PURE__ */
|
|
355
|
-
/* @__PURE__ */
|
|
356
|
-
/* @__PURE__ */
|
|
357
|
-
/* @__PURE__ */
|
|
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
|
|
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__ */
|
|
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
|
})
|