bridgeaibrasil 0.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.
- package/README.md +153 -0
- package/bin/bridgeai.js +105 -0
- package/package.json +39 -0
- package/scripts/abrir.js +98 -0
- package/scripts/login.js +155 -0
- package/scripts/portas.js +398 -0
- package/scripts/tunnel.js +500 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Registro das portas locais que um projeto da BridgeAI deixou abertas.
|
|
3
|
+
//
|
|
4
|
+
// ## O problema que ele resolve
|
|
5
|
+
//
|
|
6
|
+
// A pessoa começa uma funcionalidade, sobe o servidor local e o túnel, e vai
|
|
7
|
+
// embora sem parar nada. Na sessão seguinte — outra janela, outro dia, às vezes
|
|
8
|
+
// outro projeto — a porta está ocupada, e o caminho fácil é abrir outra: o app
|
|
9
|
+
// passa a responder na 3001 enquanto ela olha a 3000 e conclui que a mudança não
|
|
10
|
+
// funcionou.
|
|
11
|
+
//
|
|
12
|
+
// Com o túnel é pior, e o `CLAUDE.md` da plataforma já escreve por quê: **se ele
|
|
13
|
+
// cair e outro Postgres tomar a 55432, a próxima conexão vai para o banco errado
|
|
14
|
+
// sem erro nenhum**, e a migration seguinte vai junto.
|
|
15
|
+
//
|
|
16
|
+
// ## Porta é recurso da MÁQUINA, não do projeto
|
|
17
|
+
//
|
|
18
|
+
// Por isso o registro é UM, em `~/.bridgeai/portas.json`, ao lado do
|
|
19
|
+
// `profile.json`, e a chave é a PORTA. Um arquivo dentro de cada projeto não
|
|
20
|
+
// enxergaria o conflito que mais dói, que é entre projetos — e o caso já está
|
|
21
|
+
// documentado: o túnel é **um só para todos os projetos** (`--dev`), e o
|
|
22
|
+
// `/bridgeai:comecar` manda "se já tem um túnel aberto de outro projeto, pule
|
|
23
|
+
// este passo" sem dar ao Claude nenhum jeito de saber disso.
|
|
24
|
+
//
|
|
25
|
+
// ## As três regras, e as três são sobre não mentir
|
|
26
|
+
//
|
|
27
|
+
// 1. **O arquivo é uma AFIRMAÇÃO, não um fato.** A máquina reinicia, o terminal
|
|
28
|
+
// fecha, o processo morre — e ninguém escreve "fechei". Toda leitura mede a
|
|
29
|
+
// porta na hora; o registro só diz de quem ela era e desde quando.
|
|
30
|
+
// 2. **"Fechado" nunca sai da ausência de registro.** Sai de ninguém estar
|
|
31
|
+
// escutando. Mesma disciplina do `probed: false` do `status` e do `measured`
|
|
32
|
+
// da vigia de disco: não saber é um estado, e ele não pode ser confundido
|
|
33
|
+
// com "está tudo bem".
|
|
34
|
+
// 3. **Não existe comando de matar aqui, e é de propósito.** PID é reciclado
|
|
35
|
+
// pelo sistema: matar o 12345 de ontem é matar um processo qualquer de hoje.
|
|
36
|
+
// O que este arquivo entrega é IDENTIDADE — quem escuta a porta agora, e com
|
|
37
|
+
// que nome — para quem for encerrar conferir antes, e com o usuário.
|
|
38
|
+
//
|
|
39
|
+
// Zero dependência, como todo script deste plugin: ele roda na máquina de quem
|
|
40
|
+
// pode não ter `node_modules` nenhum.
|
|
41
|
+
|
|
42
|
+
const fs = require('fs');
|
|
43
|
+
const path = require('path');
|
|
44
|
+
const os = require('os');
|
|
45
|
+
const net = require('net');
|
|
46
|
+
const { execFileSync } = require('child_process');
|
|
47
|
+
|
|
48
|
+
const PASTA = path.join(os.homedir(), '.bridgeai');
|
|
49
|
+
const REGISTRO = path.join(PASTA, 'portas.json');
|
|
50
|
+
const VERSAO = 1;
|
|
51
|
+
|
|
52
|
+
// Uma conexão que ninguém atende falha em milissegundos; o teto existe para o
|
|
53
|
+
// caso de um firewall que engole o pacote em vez de recusar.
|
|
54
|
+
const TIMEOUT_MS = 800;
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// O arquivo
|
|
58
|
+
|
|
59
|
+
function ler(arquivo = REGISTRO) {
|
|
60
|
+
try {
|
|
61
|
+
const dados = JSON.parse(fs.readFileSync(arquivo, 'utf8'));
|
|
62
|
+
if (!dados || typeof dados !== 'object' || typeof dados.portas !== 'object') return vazio();
|
|
63
|
+
return { versao: dados.versao || VERSAO, portas: dados.portas || {} };
|
|
64
|
+
} catch {
|
|
65
|
+
// Sem arquivo, ilegível, ou escrito por uma versão futura: começa vazio. Um
|
|
66
|
+
// registro corrompido não pode impedir alguém de desenvolver.
|
|
67
|
+
return vazio();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function vazio() {
|
|
72
|
+
return { versao: VERSAO, portas: {} };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Escreve por temporário + rename: duas sessões anotando ao mesmo tempo é raro,
|
|
76
|
+
// mas um arquivo cortado no meio faria toda sessão seguinte começar do zero.
|
|
77
|
+
function gravar(dados, arquivo = REGISTRO) {
|
|
78
|
+
fs.mkdirSync(path.dirname(arquivo), { recursive: true });
|
|
79
|
+
const tmp = `${arquivo}.${process.pid}.tmp`;
|
|
80
|
+
fs.writeFileSync(tmp, `${JSON.stringify(dados, null, 2)}\n`, 'utf8');
|
|
81
|
+
fs.renameSync(tmp, arquivo);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Anota que uma porta foi aberta.
|
|
86
|
+
*
|
|
87
|
+
* `pid` é opcional e vale ouro quando existe: é o que separa "o processo que eu
|
|
88
|
+
* conheço continua de pé" de "alguém TROCOU o dono desta porta", que é o caso
|
|
89
|
+
* perigoso do túnel.
|
|
90
|
+
*/
|
|
91
|
+
function anotarAbertura(
|
|
92
|
+
{ porta, oQue, projeto, comando, pid } = {},
|
|
93
|
+
arquivo = REGISTRO,
|
|
94
|
+
) {
|
|
95
|
+
const p = Number(porta);
|
|
96
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error(`Porta inválida: ${porta}`);
|
|
97
|
+
|
|
98
|
+
const dados = ler(arquivo);
|
|
99
|
+
dados.portas[String(p)] = {
|
|
100
|
+
oQue: oQue || 'processo local',
|
|
101
|
+
projeto: projeto || process.cwd(),
|
|
102
|
+
comando: comando || null,
|
|
103
|
+
pid: Number.isInteger(Number(pid)) && Number(pid) > 0 ? Number(pid) : null,
|
|
104
|
+
desde: new Date().toISOString(),
|
|
105
|
+
};
|
|
106
|
+
gravar(dados, arquivo);
|
|
107
|
+
return dados.portas[String(p)];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Anota que uma porta foi fechada. Some do registro — o histórico não serve a ninguém. */
|
|
111
|
+
function anotarFechamento(porta, arquivo = REGISTRO) {
|
|
112
|
+
const dados = ler(arquivo);
|
|
113
|
+
const chave = String(Number(porta));
|
|
114
|
+
if (!(chave in dados.portas)) return false;
|
|
115
|
+
delete dados.portas[chave];
|
|
116
|
+
gravar(dados, arquivo);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// A medição — a parte que decide se o arquivo acima é verdade
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Alguém atende em 127.0.0.1:porta?
|
|
125
|
+
*
|
|
126
|
+
* Perguntando CONECTANDO, que é o mesmo caminho do `ocupada()` do `tunnel.js` e
|
|
127
|
+
* pela mesma razão: no Windows, ligar em `127.0.0.1:<p>` com um `0.0.0.0:<p>` já
|
|
128
|
+
* de pé é permitido, e nada reclama.
|
|
129
|
+
*/
|
|
130
|
+
function escutando(porta, timeout = TIMEOUT_MS) {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
const s = net.connect({ host: '127.0.0.1', port: Number(porta) });
|
|
133
|
+
let respondido = false;
|
|
134
|
+
const fim = (r) => {
|
|
135
|
+
if (respondido) return;
|
|
136
|
+
respondido = true;
|
|
137
|
+
s.destroy();
|
|
138
|
+
resolve(r);
|
|
139
|
+
};
|
|
140
|
+
s.setTimeout(timeout);
|
|
141
|
+
s.once('connect', () => fim(true));
|
|
142
|
+
s.once('error', () => fim(false));
|
|
143
|
+
s.once('timeout', () => fim(false));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function rodar(cmd, args) {
|
|
148
|
+
try {
|
|
149
|
+
return execFileSync(cmd, args, {
|
|
150
|
+
encoding: 'utf8',
|
|
151
|
+
timeout: 4000,
|
|
152
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
153
|
+
windowsHide: true,
|
|
154
|
+
});
|
|
155
|
+
} catch {
|
|
156
|
+
// Ferramenta ausente, sem permissão, ou saída diferente da esperada. `null`
|
|
157
|
+
// é "não sei", e quem lê precisa poder dizer isso em vez de inventar dono.
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Quem escuta a porta AGORA, medido no sistema. `null` quando não deu para saber. */
|
|
163
|
+
function donoDaPorta(porta) {
|
|
164
|
+
const p = String(Number(porta));
|
|
165
|
+
|
|
166
|
+
if (process.platform === 'win32') {
|
|
167
|
+
const saida = rodar('netstat', ['-ano', '-p', 'tcp']);
|
|
168
|
+
if (!saida) return null;
|
|
169
|
+
for (const linha of saida.split(/\r?\n/)) {
|
|
170
|
+
const campos = linha.trim().split(/\s+/);
|
|
171
|
+
if (campos.length < 5) continue;
|
|
172
|
+
const [, local, , estado, pid] = campos;
|
|
173
|
+
if (estado !== 'LISTENING') continue;
|
|
174
|
+
// `local` é `127.0.0.1:3000` ou `[::]:3000`; a porta é o que vem depois
|
|
175
|
+
// do último dois-pontos, senão o IPv6 quebraria a leitura.
|
|
176
|
+
if (local.slice(local.lastIndexOf(':') + 1) !== p) continue;
|
|
177
|
+
const n = Number(pid);
|
|
178
|
+
if (Number.isInteger(n) && n > 0) return n;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const saida = rodar('lsof', ['-nP', `-iTCP:${p}`, '-sTCP:LISTEN', '-t']);
|
|
184
|
+
if (!saida) return null;
|
|
185
|
+
const n = Number(saida.trim().split(/\s+/)[0]);
|
|
186
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** O nome do processo, para a frase dizer "node.exe" e não só um número. */
|
|
190
|
+
function nomeDoProcesso(pid) {
|
|
191
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
192
|
+
|
|
193
|
+
if (process.platform === 'win32') {
|
|
194
|
+
const saida = rodar('tasklist', ['/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV']);
|
|
195
|
+
const m = saida && saida.match(/^"([^"]+)"/m);
|
|
196
|
+
return m ? m[1] : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const saida = rodar('ps', ['-o', 'comm=', '-p', String(pid)]);
|
|
200
|
+
const nome = saida && saida.trim().split(/\r?\n/)[0];
|
|
201
|
+
return nome || null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function vivo(pid) {
|
|
205
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
206
|
+
try {
|
|
207
|
+
process.kill(pid, 0);
|
|
208
|
+
return true;
|
|
209
|
+
} catch (e) {
|
|
210
|
+
// EPERM quer dizer que o processo existe e é de outro usuário — vivo, e não
|
|
211
|
+
// ausente. Só ESRCH prova que ele se foi.
|
|
212
|
+
return e && e.code === 'EPERM';
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* O estado real de cada porta registrada, e a limpeza do que já não existe.
|
|
218
|
+
*
|
|
219
|
+
* Três estados, e a diferença entre os dois primeiros é o que salva alguém:
|
|
220
|
+
*
|
|
221
|
+
* - `de-pe` — alguém atende, e é o processo que anotamos (ou não sabemos
|
|
222
|
+
* o dono, e então não afirmamos nada além de "está ocupada").
|
|
223
|
+
* - `outro-dono` — alguém atende, e é OUTRO processo. No túnel, este é o estado
|
|
224
|
+
* em que o `.env` do projeto aponta para um banco alheio.
|
|
225
|
+
* - `fechado` — ninguém atende. O registro é apagado aqui, e não por um
|
|
226
|
+
* comando que alguém talvez nunca rode.
|
|
227
|
+
*/
|
|
228
|
+
async function reconciliar({ arquivo = REGISTRO, limpar = true } = {}) {
|
|
229
|
+
const dados = ler(arquivo);
|
|
230
|
+
const linhas = [];
|
|
231
|
+
let mudou = false;
|
|
232
|
+
|
|
233
|
+
for (const [chave, reg] of Object.entries(dados.portas)) {
|
|
234
|
+
const porta = Number(chave);
|
|
235
|
+
const ocupada = await escutando(porta);
|
|
236
|
+
|
|
237
|
+
if (!ocupada) {
|
|
238
|
+
linhas.push({ porta, ...reg, estado: 'fechado', donoAgora: null, nomeAgora: null });
|
|
239
|
+
delete dados.portas[chave];
|
|
240
|
+
mudou = true;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const donoAgora = donoDaPorta(porta);
|
|
245
|
+
const trocou = reg.pid != null && donoAgora != null && donoAgora !== reg.pid;
|
|
246
|
+
linhas.push({
|
|
247
|
+
porta,
|
|
248
|
+
...reg,
|
|
249
|
+
estado: trocou ? 'outro-dono' : 'de-pe',
|
|
250
|
+
donoAgora,
|
|
251
|
+
nomeAgora: nomeDoProcesso(donoAgora),
|
|
252
|
+
pidVivo: vivo(reg.pid),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (mudou && limpar) {
|
|
257
|
+
try {
|
|
258
|
+
gravar(dados, arquivo);
|
|
259
|
+
} catch {
|
|
260
|
+
// Registro que não dá para gravar não pode impedir a leitura de responder.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return linhas.sort((a, b) => a.porta - b.porta);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// O texto
|
|
269
|
+
|
|
270
|
+
function desdeQuando(iso) {
|
|
271
|
+
const t = Date.parse(iso);
|
|
272
|
+
if (!Number.isFinite(t)) return 'há tempo indeterminado';
|
|
273
|
+
const min = Math.round((Date.now() - t) / 60000);
|
|
274
|
+
if (min < 1) return 'agora há pouco';
|
|
275
|
+
if (min < 60) return `há ${min} min`;
|
|
276
|
+
const h = Math.round(min / 60);
|
|
277
|
+
if (h < 48) return `há ${h} h`;
|
|
278
|
+
return `há ${Math.round(h / 24)} dias`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function mesmoProjeto(reg, cwd) {
|
|
282
|
+
if (!reg.projeto) return false;
|
|
283
|
+
return path.resolve(reg.projeto).toLowerCase() === path.resolve(cwd).toLowerCase();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* As linhas em português, para o hook de sessão e para o comando `listar`.
|
|
288
|
+
*
|
|
289
|
+
* Só o que está DE PÉ vira texto: uma porta que fechou não é notícia, e um
|
|
290
|
+
* relatório que lista o que já não existe é o que se aprende a ignorar.
|
|
291
|
+
*/
|
|
292
|
+
function resumo(linhas, cwd = process.cwd()) {
|
|
293
|
+
const vivas = linhas.filter((l) => l.estado !== 'fechado');
|
|
294
|
+
if (vivas.length === 0) return '';
|
|
295
|
+
|
|
296
|
+
const texto = vivas.map((l) => {
|
|
297
|
+
const dono = mesmoProjeto(l, cwd) ? 'deste projeto' : `do projeto ${path.basename(l.projeto || '?')}`;
|
|
298
|
+
const quem =
|
|
299
|
+
l.donoAgora != null
|
|
300
|
+
? ` — quem atende agora é o PID ${l.donoAgora}${l.nomeAgora ? ` (${l.nomeAgora})` : ''}`
|
|
301
|
+
: ' — não consegui descobrir qual processo atende';
|
|
302
|
+
|
|
303
|
+
if (l.estado === 'outro-dono') {
|
|
304
|
+
return (
|
|
305
|
+
`- **${l.porta}** está ocupada, mas NÃO pelo processo anotado ` +
|
|
306
|
+
`(era o PID ${l.pid}, ${l.oQue} ${dono})${quem}. ` +
|
|
307
|
+
'Trate como porta de estranho: se este projeto aponta para ela, o que ele encontrar não é o que a plataforma abriu.'
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return (
|
|
311
|
+
`- **${l.porta}** — ${l.oQue} ${dono}, aberta ${desdeQuando(l.desde)}${quem}.` +
|
|
312
|
+
(l.comando ? ` Foi aberta com: \`${l.comando}\`` : '')
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return [
|
|
317
|
+
'Portas locais que a BridgeAI anotou nesta máquina e continuam de pé:',
|
|
318
|
+
'',
|
|
319
|
+
...texto,
|
|
320
|
+
'',
|
|
321
|
+
'Antes de subir servidor ou túnel, use o que já está aqui em vez de abrir outra porta. ' +
|
|
322
|
+
'Para encerrar alguma, confirme com o usuário e confira o PID que ESTÁ atendendo — ' +
|
|
323
|
+
'nunca o anotado: o sistema recicla PID, e matar o número de ontem é matar um processo qualquer de hoje.',
|
|
324
|
+
].join('\n');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// CLI
|
|
329
|
+
|
|
330
|
+
function opcoes(argv) {
|
|
331
|
+
const o = {};
|
|
332
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
333
|
+
const a = argv[i];
|
|
334
|
+
if (!a.startsWith('--')) continue;
|
|
335
|
+
const [chave, valor] = a.includes('=') ? [a.slice(2, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a.slice(2), argv[++i]];
|
|
336
|
+
o[chave] = valor;
|
|
337
|
+
}
|
|
338
|
+
return o;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function cli(argv) {
|
|
342
|
+
const comando = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'listar';
|
|
343
|
+
const o = opcoes(argv);
|
|
344
|
+
|
|
345
|
+
if (comando === 'abrir') {
|
|
346
|
+
const reg = anotarAbertura({
|
|
347
|
+
porta: o.porta,
|
|
348
|
+
oQue: o['o-que'],
|
|
349
|
+
projeto: o.projeto,
|
|
350
|
+
comando: o.comando,
|
|
351
|
+
pid: o.pid,
|
|
352
|
+
});
|
|
353
|
+
console.log(`Anotado: porta ${o.porta} — ${reg.oQue} (${reg.projeto}).`);
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (comando === 'fechar') {
|
|
358
|
+
const tinha = anotarFechamento(o.porta);
|
|
359
|
+
console.log(
|
|
360
|
+
tinha
|
|
361
|
+
? `Anotado: porta ${o.porta} liberada.`
|
|
362
|
+
: `A porta ${o.porta} não estava anotada — nada a fazer.`,
|
|
363
|
+
);
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const linhas = await reconciliar();
|
|
368
|
+
if (o.json !== undefined) {
|
|
369
|
+
console.log(JSON.stringify(linhas, null, 2));
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
const texto = resumo(linhas);
|
|
373
|
+
console.log(texto || 'Nenhuma porta anotada está de pé nesta máquina.');
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = {
|
|
378
|
+
REGISTRO,
|
|
379
|
+
PASTA,
|
|
380
|
+
ler,
|
|
381
|
+
anotarAbertura,
|
|
382
|
+
anotarFechamento,
|
|
383
|
+
escutando,
|
|
384
|
+
donoDaPorta,
|
|
385
|
+
nomeDoProcesso,
|
|
386
|
+
reconciliar,
|
|
387
|
+
resumo,
|
|
388
|
+
// Exportado para o `bin/bridgeai.js` poder chamá-lo: daquele arquivo, quem é
|
|
389
|
+
// o módulo principal é ele, então a guarda `require.main === module` logo
|
|
390
|
+
// abaixo não dispara e o `require` sozinho não rodaria nada.
|
|
391
|
+
cli,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
if (require.main === module) {
|
|
395
|
+
cli(process.argv.slice(2))
|
|
396
|
+
.then((c) => process.exit(c))
|
|
397
|
+
.catch(() => process.exit(0)); // falha aqui nunca pode atrapalhar quem está desenvolvendo
|
|
398
|
+
}
|