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.
@@ -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
+ }