localhostly 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ // macOS: um LaunchAgent do usuário (~/Library/LaunchAgents/io.iblabs.localhostly.plist, `launchctl`), que sobe no
2
+ // login (RunAtLoad) e é relançado pelo launchd quando cai (KeepAlive).
3
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { ErroServico, executar } from './comum.js';
7
+
8
+ export const ROTULO = 'io.iblabs.localhostly';
9
+
10
+ export function arquivoDoPlist({ casa = homedir() } = {}) {
11
+ return join(casa, 'Library', 'LaunchAgents', `${ROTULO}.plist`);
12
+ }
13
+
14
+ export function arquivoDoLog({ casa = homedir() } = {}) {
15
+ return join(casa, 'Library', 'Logs', 'localhostly.log');
16
+ }
17
+
18
+ export function escaparXml(texto) {
19
+ return String(texto)
20
+ .replaceAll('&', '&')
21
+ .replaceAll('<', '&lt;')
22
+ .replaceAll('>', '&gt;')
23
+ .replaceAll('"', '&quot;')
24
+ .replaceAll("'", '&apos;');
25
+ }
26
+
27
+ export function gerarPlist(comando, { log }) {
28
+ const str = (v) => `<string>${escaparXml(v)}</string>`;
29
+ return [
30
+ '<?xml version="1.0" encoding="UTF-8"?>',
31
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
32
+ '<plist version="1.0">',
33
+ '<dict>',
34
+ ' <key>Label</key>',
35
+ ` ${str(ROTULO)}`,
36
+ ' <key>ProgramArguments</key>',
37
+ ' <array>',
38
+ ...comando.map((a) => ` ${str(a)}`),
39
+ ' </array>',
40
+ ' <key>EnvironmentVariables</key>',
41
+ ' <dict>',
42
+ ' <key>NO_COLOR</key>',
43
+ ' <string>1</string>',
44
+ ' </dict>',
45
+ ' <key>RunAtLoad</key>',
46
+ ' <true/>',
47
+ ' <key>KeepAlive</key>',
48
+ ' <true/>',
49
+ ' <key>ThrottleInterval</key>',
50
+ ' <integer>5</integer>',
51
+ ' <key>ProcessType</key>',
52
+ ' <string>Background</string>',
53
+ ' <key>StandardOutPath</key>',
54
+ ` ${str(log)}`,
55
+ ' <key>StandardErrorPath</key>',
56
+ ` ${str(log)}`,
57
+ '</dict>',
58
+ '</plist>',
59
+ '',
60
+ ].join('\n');
61
+ }
62
+
63
+ function dominio(uid) {
64
+ return `gui/${uid}`;
65
+ }
66
+
67
+ export function instalar({ comando }, { casa, uid = process.getuid?.(), exec = executar } = {}) {
68
+ const arquivo = arquivoDoPlist({ casa });
69
+ const log = arquivoDoLog({ casa });
70
+ mkdirSync(join(arquivo, '..'), { recursive: true });
71
+ mkdirSync(join(log, '..'), { recursive: true });
72
+ // reinstalar troca a configuração: o agente de antes sai do launchd antes do novo entrar
73
+ exec('launchctl', ['bootout', `${dominio(uid)}/${ROTULO}`]);
74
+ writeFileSync(arquivo, gerarPlist(comando, { log }));
75
+ exec('launchctl', ['enable', `${dominio(uid)}/${ROTULO}`]);
76
+ const r = exec('launchctl', ['bootstrap', dominio(uid), arquivo]);
77
+ if (r.rc !== 0) {
78
+ throw new ErroServico(`could not load the LaunchAgent (launchctl bootstrap ${dominio(uid)}: exit ${r.rc})`, r.erro || r.saida);
79
+ }
80
+ return { arquivo, log: `tail -f ${log}`, avisos: [] };
81
+ }
82
+
83
+ export function remover({ casa, uid = process.getuid?.(), exec = executar } = {}) {
84
+ const arquivo = arquivoDoPlist({ casa });
85
+ const existia = existsSync(arquivo);
86
+ exec('launchctl', ['bootout', `${dominio(uid)}/${ROTULO}`]);
87
+ rmSync(arquivo, { force: true });
88
+ return { removido: existia, arquivo };
89
+ }
90
+
91
+ export function status({ casa, uid = process.getuid?.(), exec = executar } = {}) {
92
+ const arquivo = arquivoDoPlist({ casa });
93
+ if (!existsSync(arquivo)) return { instalado: false, arquivo };
94
+ const r = exec('launchctl', ['print', `${dominio(uid)}/${ROTULO}`]);
95
+ const base = { instalado: true, arquivo, sobeSozinho: true, log: `tail -f ${arquivoDoLog({ casa })}` };
96
+ if (r.rc !== 0) return { ...base, rodando: false, estado: 'not loaded', pid: null, sobeSozinho: false };
97
+ const estado = /^\s*state = (.+)$/m.exec(r.saida)?.[1]?.trim() ?? 'unknown';
98
+ const pid = Number(/^\s*pid = (\d+)/m.exec(r.saida)?.[1]) || null;
99
+ return { ...base, rodando: estado === 'running', estado, pid };
100
+ }
@@ -0,0 +1,115 @@
1
+ // Linux: um serviço do usuário no systemd (~/.config/systemd/user/localhostly.service, `systemctl --user`).
2
+ // Sobe com a sessão do usuário; com `loginctl enable-linger`, com o computador, sem sessão aberta.
3
+ import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
4
+ import { homedir, userInfo } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { ErroServico, executar, NOME } from './comum.js';
7
+
8
+ export const UNIDADE = `${NOME}.service`;
9
+
10
+ export function arquivoDaUnidade({ casa = homedir(), env = process.env } = {}) {
11
+ return join(env.XDG_CONFIG_HOME || join(casa, '.config'), 'systemd', 'user', UNIDADE);
12
+ }
13
+
14
+ // Uma palavra do ExecStart: entre aspas quando precisa, com \ e " escapados; % e $ dobrados (o systemd os
15
+ // expande como especificador e variável).
16
+ export function citarSystemd(arg) {
17
+ const s = String(arg);
18
+ if (/[\n\r]/.test(s)) throw new Error('a service argument cannot contain a line break');
19
+ const literal = s.replaceAll('%', '%%').replaceAll('$', '$$$$');
20
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(s)) return literal;
21
+ return `"${literal.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
22
+ }
23
+
24
+ export function gerarUnidade(comando, { porta } = {}) {
25
+ return [
26
+ '[Unit]',
27
+ `Description=localhostly tunnel${porta ? ` to local port ${porta}` : ''}`,
28
+ 'Documentation=https://localhostly.iblabs.io',
29
+ '',
30
+ '[Service]',
31
+ 'Type=simple',
32
+ `ExecStart=${comando.map(citarSystemd).join(' ')}`,
33
+ 'Environment=NO_COLOR=1',
34
+ 'Restart=always',
35
+ 'RestartSec=5',
36
+ '',
37
+ '[Install]',
38
+ 'WantedBy=default.target',
39
+ '',
40
+ ].join('\n');
41
+ }
42
+
43
+ function systemctl(exec, args, mensagem) {
44
+ const r = exec('systemctl', ['--user', ...args]);
45
+ if (r.rc === -1) throw new ErroServico('systemctl not found: this Linux does not run systemd');
46
+ if (r.rc !== 0 && /Failed to connect to bus|No medium found|DBUS_SESSION_BUS_ADDRESS/i.test(r.erro)) {
47
+ throw new ErroServico(
48
+ 'the systemd user manager is not running for this user (no user session bus).\n' +
49
+ `Start it with \`sudo loginctl enable-linger ${userInfo().username}\` (it also keeps the tunnel up without a login) and try again.`,
50
+ r.erro,
51
+ );
52
+ }
53
+ if (mensagem && r.rc !== 0) {
54
+ throw new ErroServico(`${mensagem} (systemctl --user ${args.join(' ')}: exit ${r.rc})`, r.erro || r.saida);
55
+ }
56
+ return r;
57
+ }
58
+
59
+ // Sem linger, o gerenciador do usuário (e o túnel) só vive com uma sessão aberta.
60
+ export function linger(exec = executar) {
61
+ const r = exec('loginctl', ['show-user', userInfo().username, '--property=Linger']);
62
+ if (r.rc !== 0) return null;
63
+ return /Linger=yes/.test(r.saida);
64
+ }
65
+
66
+ export function instalar({ comando, porta }, { casa, env, exec = executar } = {}) {
67
+ const arquivo = arquivoDaUnidade({ casa, env });
68
+ systemctl(exec, ['show-environment'], null); // falha cedo e com a dica se não há gerenciador do usuário
69
+ mkdirSync(join(arquivo, '..'), { recursive: true });
70
+ writeFileSync(arquivo, gerarUnidade(comando, { porta }));
71
+ systemctl(exec, ['daemon-reload'], 'could not reload systemd');
72
+ systemctl(exec, ['enable', UNIDADE], 'could not enable the service');
73
+ systemctl(exec, ['restart', UNIDADE], 'could not start the service');
74
+ const avisos = [];
75
+ if (linger(exec) === false) {
76
+ avisos.push(
77
+ 'The tunnel runs while you are logged in. To keep it up after logout and start it at boot:\n' +
78
+ ` sudo loginctl enable-linger ${userInfo().username}`,
79
+ );
80
+ }
81
+ return { arquivo, log: `journalctl --user -u ${NOME} -f`, avisos };
82
+ }
83
+
84
+ export function remover({ casa, env, exec = executar } = {}) {
85
+ const arquivo = arquivoDaUnidade({ casa, env });
86
+ const existia = existsSync(arquivo);
87
+ if (existia) {
88
+ systemctl(exec, ['disable', '--now', UNIDADE], null);
89
+ rmSync(arquivo, { force: true });
90
+ systemctl(exec, ['daemon-reload'], null);
91
+ }
92
+ return { removido: existia, arquivo };
93
+ }
94
+
95
+ export function status({ casa, env, exec = executar } = {}) {
96
+ const arquivo = arquivoDaUnidade({ casa, env });
97
+ if (!existsSync(arquivo)) return { instalado: false, arquivo };
98
+ const r = systemctl(exec, ['show', UNIDADE, '--property=ActiveState,SubState,MainPID,UnitFileState'], null);
99
+ const campos = Object.fromEntries(
100
+ r.saida
101
+ .split('\n')
102
+ .filter((l) => l.includes('='))
103
+ .map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1).trim()]),
104
+ );
105
+ const pid = Number(campos.MainPID) || null;
106
+ return {
107
+ instalado: true,
108
+ arquivo,
109
+ rodando: campos.ActiveState === 'active' && campos.SubState === 'running',
110
+ estado: campos.ActiveState ? `${campos.ActiveState} (${campos.SubState})` : 'unknown',
111
+ pid,
112
+ sobeSozinho: campos.UnitFileState === 'enabled',
113
+ log: `journalctl --user -u ${NOME} -f`,
114
+ };
115
+ }
@@ -0,0 +1,247 @@
1
+ // Windows: um serviço de verdade (`sc.exe`, início automático, relançado pelo SCM se cair).
2
+ //
3
+ // O node não fala com o gerenciador de serviços (SCM): um node.exe direto no binPath não chama
4
+ // StartServiceCtrlDispatcher e o SCM o derruba em 30 s (erro 1053). Então a instalação compila, com o csc.exe do
5
+ // .NET Framework 4 que todo Windows 10/11 traz, um supervisor mínimo (o C# abaixo, sem dependência): ele é o
6
+ // serviço, roda o agente em `--service` como processo filho, guarda a saída em service.log e o relança 5 s depois
7
+ // de sair; parar o serviço mata o filho.
8
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { ErroServico, executar } from './comum.js';
11
+
12
+ export const SERVICO = 'Localhostly';
13
+
14
+ export const FONTE_SUPERVISOR = String.raw`// localhostly: o supervisor do serviço do Windows (gerado por localhostly service install).
15
+ using System;
16
+ using System.Diagnostics;
17
+ using System.IO;
18
+ using System.ServiceProcess;
19
+ using System.Text;
20
+ using System.Threading;
21
+
22
+ public class LocalhostlyServico : ServiceBase {
23
+ readonly string log;
24
+ readonly string exe;
25
+ readonly string args;
26
+ readonly object trava = new object();
27
+ volatile bool parando;
28
+ Process filho;
29
+
30
+ // args: <log> <exe> <args do agente...>
31
+ public LocalhostlyServico(string[] a) {
32
+ ServiceName = "Localhostly";
33
+ CanStop = true;
34
+ CanShutdown = true;
35
+ log = a[0];
36
+ exe = a[1];
37
+ var resto = new StringBuilder();
38
+ for (int i = 2; i < a.Length; i++) { if (i > 2) resto.Append(' '); resto.Append(Citar(a[i])); }
39
+ args = resto.ToString();
40
+ }
41
+
42
+ // As regras do CommandLineToArgvW: aspas em volta, \ antes de " dobradas.
43
+ public static string Citar(string s) {
44
+ if (s.Length > 0 && s.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) return s;
45
+ var b = new StringBuilder("\"");
46
+ int barras = 0;
47
+ foreach (char c in s) {
48
+ if (c == '\\') { barras++; continue; }
49
+ if (c == '"') { b.Append('\\', barras * 2 + 1); b.Append('"'); }
50
+ else { b.Append('\\', barras); b.Append(c); }
51
+ barras = 0;
52
+ }
53
+ b.Append('\\', barras * 2);
54
+ b.Append('"');
55
+ return b.ToString();
56
+ }
57
+
58
+ void Escrever(string linha) {
59
+ if (linha == null) return;
60
+ lock (trava) {
61
+ try {
62
+ var info = new FileInfo(log);
63
+ if (info.Exists && info.Length > 10L * 1024 * 1024) {
64
+ File.Copy(log, log + ".1", true);
65
+ File.WriteAllText(log, "");
66
+ }
67
+ File.AppendAllText(log, linha + Environment.NewLine);
68
+ } catch { }
69
+ }
70
+ }
71
+
72
+ public void Rodar() {
73
+ while (!parando) {
74
+ try {
75
+ var psi = new ProcessStartInfo(exe, args);
76
+ psi.UseShellExecute = false;
77
+ psi.CreateNoWindow = true;
78
+ psi.RedirectStandardOutput = true;
79
+ psi.RedirectStandardError = true;
80
+ psi.EnvironmentVariables["NO_COLOR"] = "1";
81
+ var p = new Process();
82
+ p.StartInfo = psi;
83
+ p.OutputDataReceived += (s, e) => Escrever(e.Data);
84
+ p.ErrorDataReceived += (s, e) => Escrever(e.Data);
85
+ p.Start();
86
+ filho = p;
87
+ p.BeginOutputReadLine();
88
+ p.BeginErrorReadLine();
89
+ p.WaitForExit();
90
+ if (!parando) Escrever(DateTime.UtcNow.ToString("o") + " agent exited with code " + p.ExitCode + "; restarting in 5s");
91
+ } catch (Exception e) {
92
+ Escrever(DateTime.UtcNow.ToString("o") + " could not start the agent: " + e.Message);
93
+ }
94
+ for (int i = 0; i < 50 && !parando; i++) Thread.Sleep(100);
95
+ }
96
+ }
97
+
98
+ protected override void OnStart(string[] ignorados) {
99
+ var t = new Thread(Rodar);
100
+ t.IsBackground = true;
101
+ t.Start();
102
+ }
103
+
104
+ protected override void OnStop() {
105
+ parando = true;
106
+ try { if (filho != null && !filho.HasExited) filho.Kill(); } catch { }
107
+ }
108
+
109
+ protected override void OnShutdown() { OnStop(); }
110
+
111
+ // --console: o mesmo laço em primeiro plano, para testar sem instalar o serviço.
112
+ public static void Main(string[] a) {
113
+ if (a.Length > 0 && a[0] == "--console") {
114
+ var resto = new string[a.Length - 1];
115
+ Array.Copy(a, 1, resto, 0, resto.Length);
116
+ new LocalhostlyServico(resto).Rodar();
117
+ return;
118
+ }
119
+ ServiceBase.Run(new LocalhostlyServico(a));
120
+ }
121
+ }
122
+ `;
123
+
124
+ // Uma palavra da linha de comando do Windows (as regras do CommandLineToArgvW, as mesmas do Citar do supervisor).
125
+ export function citarWindows(arg) {
126
+ const s = String(arg);
127
+ if (s.length > 0 && !/[\s"]/.test(s)) return s;
128
+ let saida = '"';
129
+ let barras = 0;
130
+ for (const c of s) {
131
+ if (c === '\\') {
132
+ barras++;
133
+ continue;
134
+ }
135
+ if (c === '"') saida += `${'\\'.repeat(barras * 2 + 1)}"`;
136
+ else saida += `${'\\'.repeat(barras)}${c}`;
137
+ barras = 0;
138
+ }
139
+ return `${saida}${'\\'.repeat(barras * 2)}"`;
140
+ }
141
+
142
+ export function caminhos(home) {
143
+ const pasta = join(home, 'service');
144
+ return {
145
+ pasta,
146
+ fonte: join(pasta, 'localhostly-service.cs'),
147
+ supervisor: join(pasta, 'localhostly-service.exe'),
148
+ log: join(home, 'service.log'),
149
+ };
150
+ }
151
+
152
+ // O binPath do serviço: o supervisor, o log e o comando do agente, cada um citado.
153
+ export function binPath(supervisor, log, comando) {
154
+ return [supervisor, log, ...comando].map(citarWindows).join(' ');
155
+ }
156
+
157
+ // Os argumentos do sc.exe (cada item é um argumento; o node os cita ao chamar, e o sc.exe recebe o binPath com
158
+ // as aspas de dentro intactas). `criar`: create na primeira vez, config quando o serviço já existe.
159
+ export function argumentosSc(binPathTexto, { criar = true } = {}) {
160
+ return [
161
+ criar ? 'create' : 'config',
162
+ SERVICO,
163
+ 'binPath=',
164
+ binPathTexto,
165
+ 'start=',
166
+ 'auto',
167
+ 'DisplayName=',
168
+ 'localhostly tunnel',
169
+ ];
170
+ }
171
+
172
+ function cscDoSistema(env) {
173
+ const windir = env.WINDIR || env.SystemRoot || 'C:\\Windows';
174
+ for (const fw of ['Framework64', 'Framework']) {
175
+ const csc = join(windir, 'Microsoft.NET', fw, 'v4.0.30319', 'csc.exe');
176
+ if (existsSync(csc)) return csc;
177
+ }
178
+ return null;
179
+ }
180
+
181
+ export function compilarSupervisor(home, { env = process.env, exec = executar, csc = cscDoSistema(env) } = {}) {
182
+ const c = caminhos(home);
183
+ if (!csc) throw new ErroServico('csc.exe (.NET Framework 4) not found: cannot build the service host');
184
+ mkdirSync(c.pasta, { recursive: true });
185
+ writeFileSync(c.fonte, FONTE_SUPERVISOR);
186
+ const r = exec(csc, ['/nologo', '/target:winexe', `/out:${c.supervisor}`, '/reference:System.ServiceProcess.dll', c.fonte]);
187
+ if (r.rc !== 0) throw new ErroServico(`could not build the service host (csc.exe: exit ${r.rc})`, r.saida || r.erro);
188
+ return c;
189
+ }
190
+
191
+ const NAO_EXISTE = /\b1060\b/;
192
+ const SEM_ADMIN = /\b5\b.*(denied|negado)|Access is denied|Acesso negado|OpenSCManager FAILED 5/i;
193
+
194
+ function sc(exec, args) {
195
+ return exec('sc.exe', args);
196
+ }
197
+
198
+ function exigirSc(exec, args, mensagem) {
199
+ const r = sc(exec, args);
200
+ if (r.rc === 0) return r;
201
+ const texto = `${r.saida}\n${r.erro}`;
202
+ if (SEM_ADMIN.test(texto)) {
203
+ throw new ErroServico('Windows services need an elevated terminal: open it with "Run as administrator" and try again.');
204
+ }
205
+ throw new ErroServico(`${mensagem} (sc.exe ${args[0]}: exit ${r.rc})`, texto);
206
+ }
207
+
208
+ export function instalar({ comando, home }, { env = process.env, exec = executar, cscPadrao } = {}) {
209
+ const c = compilarSupervisor(home, { env, exec, csc: cscPadrao ?? cscDoSistema(env) });
210
+ const existe = !NAO_EXISTE.test(`${sc(exec, ['query', SERVICO]).saida}`);
211
+ if (existe) sc(exec, ['stop', SERVICO]);
212
+ exigirSc(exec, argumentosSc(binPath(c.supervisor, c.log, comando), { criar: !existe }), 'could not create the service');
213
+ sc(exec, ['description', SERVICO, 'Keeps the localhostly tunnel up (https://localhostly.iblabs.io).']);
214
+ sc(exec, ['failure', SERVICO, 'reset=', '86400', 'actions=', 'restart/5000/restart/5000/restart/5000']);
215
+ exigirSc(exec, ['start', SERVICO], 'could not start the service');
216
+ return { arquivo: c.supervisor, log: c.log, avisos: [] };
217
+ }
218
+
219
+ export function remover({ home, exec = executar } = {}) {
220
+ const q = sc(exec, ['query', SERVICO]);
221
+ const existia = !NAO_EXISTE.test(`${q.saida}\n${q.erro}`) && q.rc !== -1;
222
+ if (existia) {
223
+ sc(exec, ['stop', SERVICO]);
224
+ exigirSc(exec, ['delete', SERVICO], 'could not remove the service');
225
+ }
226
+ if (home) rmSync(caminhos(home).pasta, { recursive: true, force: true });
227
+ return { removido: existia, arquivo: SERVICO };
228
+ }
229
+
230
+ const ESTADOS = { 1: 'stopped', 2: 'starting', 3: 'stopping', 4: 'running', 5: 'resuming', 6: 'pausing', 7: 'paused' };
231
+
232
+ export function status({ home, exec = executar } = {}) {
233
+ const r = sc(exec, ['queryex', SERVICO]);
234
+ if (r.rc === -1 || NAO_EXISTE.test(`${r.saida}\n${r.erro}`)) return { instalado: false, arquivo: SERVICO };
235
+ const codigo = Number(/STATE\s*:\s*(\d+)/.exec(r.saida)?.[1]);
236
+ const pid = Number(/PID\s*:\s*(\d+)/.exec(r.saida)?.[1]) || null;
237
+ const config = sc(exec, ['qc', SERVICO]).saida;
238
+ return {
239
+ instalado: true,
240
+ arquivo: SERVICO,
241
+ rodando: codigo === 4,
242
+ estado: ESTADOS[codigo] ?? 'unknown',
243
+ pid,
244
+ sobeSozinho: /AUTO_START/.test(config),
245
+ log: home ? caminhos(home).log : null,
246
+ };
247
+ }
package/agente/tela.js ADDED
@@ -0,0 +1,72 @@
1
+ // A tela do agente: a URL e uma linha por requisição, sem dependência.
2
+ const CORES = { verde: 32, amarelo: 33, vermelho: 31, ciano: 36, cinza: 90, negrito: 1 };
3
+
4
+ function hora(d = new Date()) {
5
+ return d.toTimeString().slice(0, 8);
6
+ }
7
+
8
+ export function criarTela(saida = process.stdout, { cor = Boolean(saida.isTTY) && !process.env.NO_COLOR } = {}) {
9
+ const pintar = (nome, texto) => (cor ? `\x1b[${CORES[nome]}m${texto}\x1b[0m` : texto);
10
+ const linha = (texto) => saida.write(`${texto}\n`);
11
+
12
+ function corDoStatus(status) {
13
+ if (status >= 500 || status === 0) return 'vermelho';
14
+ if (status >= 400) return 'amarelo';
15
+ if (status >= 300) return 'ciano';
16
+ return 'verde';
17
+ }
18
+
19
+ return {
20
+ conectando(servidor) {
21
+ linha(pintar('cinza', `connecting to ${servidor}…`));
22
+ },
23
+ // senha: o texto (a gerada, que só esta tela mostra), true (a que o usuário escolheu) ou nada.
24
+ // permitidos: a lista do --allow (só esses endereços entram) ou nada.
25
+ pronto({ url, alvo, senha = null, permitidos = null }) {
26
+ linha('');
27
+ linha(`${pintar('negrito', url)} → http://${alvo}`);
28
+ if (permitidos?.length) linha(`Allowed: ${pintar('negrito', permitidos.join(', '))} ${pintar('cinza', '(other addresses get 403)')}`);
29
+ if (typeof senha === 'string') linha(`Password: ${pintar('negrito', senha)} ${pintar('cinza', '(any user name)')}`);
30
+ else if (senha) linha(pintar('cinza', 'Password protected (any user name).'));
31
+ linha(pintar('cinza', 'Ctrl+C to stop. Requests:'));
32
+ linha('');
33
+ },
34
+ requisicao({ metodo, caminho, status, ms }, quando = new Date()) {
35
+ const c = caminho.length > 60 ? `${caminho.slice(0, 59)}…` : caminho;
36
+ linha(
37
+ `${pintar('cinza', hora(quando))} ${metodo.padEnd(7)} ${c.padEnd(40)} ` +
38
+ `${pintar(corDoStatus(status), String(status || '---'))} ${pintar('cinza', `${ms}ms`)}`,
39
+ );
40
+ },
41
+ aviso(texto) {
42
+ linha(pintar('amarelo', texto));
43
+ },
44
+ erro(texto) {
45
+ linha(pintar('vermelho', texto));
46
+ },
47
+ };
48
+ }
49
+
50
+ // O modo serviço (--service): sem tabela ao vivo nem cor; uma linha por evento, com a hora, no stdout que o
51
+ // systemd (journal), o launchd e o serviço do Windows guardam.
52
+ export function criarLog(saida = process.stdout, { relogio = () => new Date() } = {}) {
53
+ const linha = (texto) => saida.write(`${relogio().toISOString()} ${texto}\n`);
54
+ return {
55
+ conectando(servidor) {
56
+ linha(`connecting to ${servidor}`);
57
+ },
58
+ pronto({ url, alvo, senha = null, retomado = false, permitidos = null }) {
59
+ linha(
60
+ `online ${url} -> http://${alvo}${retomado ? ' (same name)' : ''}${senha ? ' (password protected)' : ''}` +
61
+ `${permitidos?.length ? ` (allowed: ${permitidos.join(',')})` : ''}`,
62
+ );
63
+ },
64
+ requisicao() {},
65
+ aviso(texto) {
66
+ linha(texto);
67
+ },
68
+ erro(texto) {
69
+ linha(`error: ${texto}`);
70
+ },
71
+ };
72
+ }