@danhachuel/pandanews 0.4.0 → 0.4.2

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.
@@ -8,7 +8,7 @@
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
10
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
11
- <script type="module" crossorigin src="/assets/index-WDOmiBk4.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-BLjBdAFC.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-CUCWGfn9.css">
13
13
  </head>
14
14
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/pandanews",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "SaaS local de criação e automação de múltiplos blogs de notícias com IA (FastAPI + React + Tailwind).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,8 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "scripts": {
23
- "build:frontend": "node scripts/build.js"
23
+ "build:frontend": "node scripts/build.js",
24
+ "test": "node scripts/test-start-isolation.mjs"
24
25
  },
25
26
  "repository": {
26
27
  "type": "git",
package/scripts/start.js CHANGED
@@ -3,14 +3,18 @@
3
3
  // Fluxo de um único comando: se o runtime não estiver provisionado, executa o
4
4
  // install completo (Python portátil + dependências) antes de subir os
5
5
  // servidores. Ambas as portas têm fallback automático (API: 8000, 8001, ...;
6
- // UI: 4030, 4031, ...). O sistema NÃO abre o navegador: o endereço é exibido
7
- // no terminal e o usuário decide quando abrir.
6
+ // UI: 4030, 4031, ...). O sistema NÃO abre o navegador.
8
7
  //
9
- // Robustez: o output do backend é gravado em <home>/data/backend.log (completo)
10
- // e, se o processo morrer de forma anormal, o backend reinicia sozinho (até 5
11
- // vezes) — a UI não cai junto. Logs de acesso não poluem o console.
12
- import { createWriteStream, existsSync, readFileSync } from 'node:fs';
13
- import { spawn } from 'node:child_process';
8
+ // ISOLAMENTO DE PROCESSOS (Windows/MobaXterm): o backend Python roda em grupo
9
+ // de processos próprio (detached + windowsHide) com stdio direto no arquivo
10
+ // <home>/data/backend.log — não herda o console do launcher. Um Ctrl+C ou
11
+ // perda do terminal derruba apenas o launcher Node; o backend e as gerações
12
+ // em andamento sobrevivem. O encerramento do backend é SEMPRE por código
13
+ // (SIGTERM + taskkill /PID /T), nunca por propagação de sinal do terminal.
14
+ // Se uma instância anterior morreu deixando backend vivo, o launcher detecta
15
+ // (backend.pid + health) e reaproveita em vez de subir uma segunda.
16
+ import { appendFileSync, closeSync, existsSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
17
+ import { spawn, spawnSync } from 'node:child_process';
14
18
  import { createServer, request as httpRequest } from 'node:http';
15
19
  import net from 'node:net';
16
20
  import { join, extname } from 'node:path';
@@ -19,14 +23,16 @@ import { resolveHome, runInstall, PKG_ROOT } from './install.js';
19
23
  const API_PORT_BASE = Number(process.env.PANDANEWS_API_PORT || 8000);
20
24
  const UI_PORT_BASE = Number(process.env.PANDANEWS_UI_PORT || 4030);
21
25
  const PORT_MAX_TRIES = 20;
22
- const BACKEND_MAX_RESTARTS = 5;
23
- const BACKEND_RESTART_DELAY_MS = 3000;
24
- const BACKEND_HEALTHY_UPTIME_MS = 60000;
26
+ const BACKEND_POLL_ATTEMPTS = 60; // até 30s para o backend iniciar
27
+ const BACKEND_POLL_MS = 500;
28
+ const UI_POLL_ATTEMPTS = 20; // até 10s para a UI antiga responder
25
29
 
26
30
  let activeBackendPort = API_PORT_BASE;
27
31
 
28
32
  const FAVICON_URI = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%230b1220'/%3E%3Ccircle cx='25' cy='28' r='13' fill='%23e6edf3'/%3E%3Ccircle cx='75' cy='28' r='13' fill='%23e6edf3'/%3E%3Ccircle cx='50' cy='52' r='30' fill='%23e6edf3'/%3E%3Cellipse cx='37' cy='48' rx='9' ry='11' fill='%230b1220' transform='rotate(-15 37 48)'/%3E%3Cellipse cx='63' cy='48' rx='9' ry='11' fill='%230b1220' transform='rotate(15 63 48)'/%3E%3Ccircle cx='38' cy='49' r='3' fill='%2322c55e'/%3E%3Ccircle cx='62' cy='49' r='3' fill='%2322c55e'/%3E%3Cellipse cx='50' cy='62' rx='5' ry='4' fill='%230b1220'/%3E%3C/svg%3E";
29
33
 
34
+ // ---------------------------------------------------------------- utilidades
35
+
30
36
  function venvBin(home) {
31
37
  return process.platform === 'win32'
32
38
  ? join(home, '.runtime', 'venv', 'Scripts', 'python.exe')
@@ -44,25 +50,23 @@ const MIME = {
44
50
  '.ico': 'image/x-icon',
45
51
  };
46
52
 
47
- function placeholderPage() {
48
- return `<!DOCTYPE html>
49
- <html lang="pt-BR">
50
- <head>
51
- <meta charset="utf-8">
52
- <title>PandaNews</title>
53
- <link rel="icon" href="${FAVICON_URI}">
54
- </head>
55
- <body style="font-family: Inter, system-ui, sans-serif; background: #0b1220; color: #e6edf3; display: flex; min-height: 100vh; align-items: center; justify-content: center;">
56
- <div style="text-align: center;">
57
- <h1 style="font-size: 3rem; margin: 0;">🐼 PandaNews</h1>
58
- <p style="color: #9aa7b5;">Backend ativo em <a style="color: #22c55e;" href="/api/v1/health">/api/v1/health</a></p>
59
- <p style="color: #9aa7b5;">A UI React completa chega na versão 0.2.00.</p>
60
- </div>
61
- </body>
62
- </html>`;
53
+ // Testa se uma porta aceita conexão TCP (uvicorn ouvindo).
54
+ function portaAberta(port) {
55
+ return new Promise((resolve) => {
56
+ const socket = net.connect({ host: '127.0.0.1', port });
57
+ socket.once('connect', () => {
58
+ socket.destroy();
59
+ resolve(true);
60
+ });
61
+ socket.once('error', () => resolve(false));
62
+ socket.setTimeout(500, () => {
63
+ socket.destroy();
64
+ resolve(false);
65
+ });
66
+ });
63
67
  }
64
68
 
65
- // Testa se uma porta pode ser vinculada (probe real de bind).
69
+ // Encontra a primeira porta livre a partir de base (probe real de bind).
66
70
  function probePort(port) {
67
71
  return new Promise((resolve) => {
68
72
  const srv = net.createServer();
@@ -79,6 +83,40 @@ async function findFreePort(base, maxTries) {
79
83
  throw new Error(`Nenhuma porta livre entre ${base} e ${base + maxTries - 1}`);
80
84
  }
81
85
 
86
+ // Encerra o processo por PID e (no Windows) a árvore de processos /T.
87
+ function encerrarPorPid(pid, motivo) {
88
+ try {
89
+ if (process.platform === 'win32') {
90
+ spawnSync('taskkill', ['/pid', String(pid), '/f', '/t'], { windowsHide: true });
91
+ } else {
92
+ process.kill(pid, 'SIGTERM');
93
+ }
94
+ } catch {
95
+ /* processo já não existia */
96
+ }
97
+ console.log(motivo);
98
+ }
99
+
100
+ // ---------------------------------------------------------- UI estática/proxy
101
+
102
+ function placeholderPage() {
103
+ return `<!DOCTYPE html>
104
+ <html lang="pt-BR">
105
+ <head>
106
+ <meta charset="utf-8">
107
+ <title>PandaNews</title>
108
+ <link rel="icon" href="${FAVICON_URI}">
109
+ </head>
110
+ <body style="font-family: Inter, system-ui, sans-serif; background: #0b1220; color: #e6edf3; display: flex; min-height: 100vh; align-items: center; justify-content: center;">
111
+ <div style="text-align: center;">
112
+ <h1 style="font-size: 3rem; margin: 0;">🐼 PandaNews</h1>
113
+ <p style="color: #9aa7b5;">Backend ativo em <a style="color: #22c55e;" href="/api/v1/health">/api/v1/health</a></p>
114
+ <p style="color: #9aa7b5;">A UI React completa chega na versão 0.2.00.</p>
115
+ </div>
116
+ </body>
117
+ </html>`;
118
+ }
119
+
82
120
  // Encaminha /api/* para o backend FastAPI (na porta ativa).
83
121
  function proxyApi(req, res) {
84
122
  const options = {
@@ -154,62 +192,103 @@ function listenUi(port, attempt) {
154
192
  server.listen(port, '127.0.0.1', () => {
155
193
  uiServer = server;
156
194
  console.log(`PandaNews ativo em http://127.0.0.1:${port} — abra no seu navegador.`);
157
- console.log('Pressione Ctrl+C para encerrar.');
195
+ console.log('Pressione Ctrl+C para encerrar o launcher.');
158
196
  });
159
197
  }
160
198
 
161
- let backendProc = null;
162
- let backendRestarts = 0;
199
+ // ------------------------------------------------------- backend destacado
200
+
201
+ let backendPid = null; // PID do backend que ESTA sessão iniciou (null = reaproveitado)
202
+
203
+ // Descobre um backend já em execução: arquivo backend.pid + porta respondendo.
204
+ async function backendJaRodando(home) {
205
+ const pidFile = join(home, 'data', 'backend.pid');
206
+ if (!existsSync(pidFile)) return null;
207
+ const pid = Number(readFileSync(pidFile, 'utf8').trim());
208
+ if (!Number.isInteger(pid) || pid <= 0) return null;
209
+ // procura o backend em qualquer porta candidata (a que gravou o pid)
210
+ for (let port = API_PORT_BASE; port < API_PORT_BASE + PORT_MAX_TRIES; port++) {
211
+ if (await portaAberta(port)) return { pid, port };
212
+ }
213
+ return null;
214
+ }
163
215
 
164
- // Sobe o backend com saída completa em <home>/data/backend.log; o console
165
- // mantém apenas stderr (banner, erros, tracebacks). Se o processo morrer de
166
- // forma anormal, reinicia sozinho (até 5 tentativas).
167
- function startBackend(home) {
168
- console.log(`Iniciando backend FastAPI em http://127.0.0.1:${activeBackendPort} ...`);
169
- const uvicorn = spawn(
216
+ async function esperarBackend(port) {
217
+ for (let i = 0; i < BACKEND_POLL_ATTEMPTS; i++) {
218
+ if (await portaAberta(port)) return true;
219
+ await new Promise((r) => setTimeout(r, BACKEND_POLL_MS));
220
+ }
221
+ return false;
222
+ }
223
+
224
+ // Sobe o uvicorn em grupo de processos próprio: detached (novo grupo no
225
+ // Windows), windowsHide (sem janela preta) e stdio direto no arquivo de log.
226
+ async function startBackend(home) {
227
+ const port = await findFreePort(API_PORT_BASE, PORT_MAX_TRIES);
228
+ if (port !== API_PORT_BASE) {
229
+ console.log(`Porta ${API_PORT_BASE} ocupada — backend usando ${port}.`);
230
+ }
231
+ activeBackendPort = port;
232
+
233
+ const logPath = join(home, 'data', 'backend.log');
234
+ const bootstrapLine = `--- ${new Date().toISOString()} — iniciando backend (porta ${port}) ---\n`;
235
+ appendFileSync(logPath, bootstrapLine);
236
+ // stdio por FD (obrigatório com detached no Windows: async forks não aceitam path)
237
+ const logFd = openSync(logPath, 'a');
238
+
239
+ console.log(`Iniciando backend FastAPI em http://127.0.0.1:${port} ... (log: ${logPath})`);
240
+ const python = spawn(
170
241
  venvBin(home),
171
- ['-m', 'uvicorn', 'backend.app.main:app', '--host', '127.0.0.1', '--port', String(activeBackendPort)],
242
+ ['-m', 'uvicorn', 'backend.app.main:app', '--host', '127.0.0.1', '--port', String(port)],
172
243
  {
173
244
  cwd: PKG_ROOT,
174
- stdio: ['inherit', 'pipe', 'pipe'],
175
- env: { ...process.env, PANDANEWS_HOME: home },
245
+ detached: true, // CREATE_NEW_PROCESS_GROUP no Windows — imune a Ctrl+C do console
246
+ windowsHide: true, // sem janela de console fantasma
247
+ stdio: ['ignore', logFd, logFd], // saída direto no arquivo (append) — não herda console
248
+ env: { ...process.env, PANDANEWS_HOME: home, PYTHONUNBUFFERED: '1' },
176
249
  },
177
250
  );
178
- backendProc = uvicorn;
251
+ closeSync(logFd); // o filho tem sua própria cópia herdada
252
+ python.unref(); // o launcher não morre com o backend e vice-versa
253
+ backendPid = python.pid ?? null;
254
+ if (backendPid) {
255
+ try {
256
+ writeFileSync(join(home, 'data', 'backend.pid'), String(backendPid));
257
+ } catch {
258
+ /* melhor esforço */
259
+ }
260
+ }
179
261
 
180
- const logPath = join(home, 'data', 'backend.log');
181
- const logStream = createWriteStream(logPath, { flags: 'a' });
182
- logStream.write(`\n--- ${new Date().toISOString()} — iniciando backend (pid ${uvicorn.pid}, porta ${activeBackendPort}) ---\n`);
183
- // Logs de acesso do uvicorn (stdout) vão SOMENTE para o arquivo: o terminal
184
- // fica limpo. O stderr (banner, WARNING/ERROR e tracebacks) é visível.
185
- uvicorn.stdout.on('data', (chunk) => {
186
- logStream.write(chunk);
187
- });
188
- uvicorn.stderr.on('data', (chunk) => {
189
- process.stderr.write(chunk);
190
- logStream.write(chunk);
191
- });
262
+ if (await esperarBackend(port)) {
263
+ console.log(`Backend no ar em http://127.0.0.1:${port} (sobrevive a Ctrl+C — encerrado apenas por este launcher).`);
264
+ return true;
265
+ }
266
+ console.error('Backend não respondeu a tempo — verifique o log completo em:');
267
+ console.error(` ${logPath}`);
268
+ return false;
269
+ }
192
270
 
193
- const startedAt = Date.now();
194
- uvicorn.on('exit', (code) => {
195
- logStream.end(`--- backend encerrou com código ${code} ---\n`);
196
- if (code === 0 || code === null) {
271
+ // Watchdog de saúde: spawn destacado não emite eventos de exit confiáveis —
272
+ // a porta é polidada a cada 10s e 3 falhas consecutivas reerguem o backend.
273
+ let falhasSaude = 0;
274
+ function iniciarWatchdog(home) {
275
+ setInterval(async () => {
276
+ if (await portaAberta(activeBackendPort)) {
277
+ falhasSaude = 0;
197
278
  return;
198
279
  }
199
- if (Date.now() - startedAt > BACKEND_HEALTHY_UPTIME_MS) {
200
- backendRestarts = 0; // rodou saudável por tempo suficiente: zera o contador
280
+ falhasSaude += 1;
281
+ if (falhasSaude >= 3) {
282
+ console.error('Backend não responde — reiniciando...');
283
+ falhasSaude = 0;
284
+ backendPid = null;
285
+ await startBackend(home);
201
286
  }
202
- backendRestarts += 1;
203
- if (backendRestarts > BACKEND_MAX_RESTARTS) {
204
- console.error(`Backend caiu ${backendRestarts - 1}x seguidas — desistindo.`);
205
- console.error(`Log completo do backend: ${logPath}`);
206
- process.exit(1);
207
- }
208
- console.error(`Backend encerrou com código ${code} — reiniciando em 3s (tentativa ${backendRestarts}/${BACKEND_MAX_RESTARTS})...`);
209
- setTimeout(() => startBackend(home), BACKEND_RESTART_DELAY_MS);
210
- });
287
+ }, 10000).unref();
211
288
  }
212
289
 
290
+ // ---------------------------------------------------------------- execução
291
+
213
292
  export async function runStart() {
214
293
  const home = resolveHome();
215
294
  if (!existsSync(join(home, '.runtime', '.ready'))) {
@@ -223,19 +302,49 @@ export async function runStart() {
223
302
  return;
224
303
  }
225
304
 
226
- // Fallback de portas: API em 8000, 8001, ...; UI em 4030, 4031, ...
227
- activeBackendPort = await findFreePort(API_PORT_BASE, PORT_MAX_TRIES);
228
- if (activeBackendPort !== API_PORT_BASE) {
229
- console.log(`Porta ${API_PORT_BASE} ocupada — backend usando ${activeBackendPort}.`);
305
+ // 1) reaproveita backend de sessão anterior (launcher morto por Ctrl+C),
306
+ // eliminando o processo órfão perceptível na porta 8000
307
+ const existente = await backendJaRodando(home);
308
+ let backendOk;
309
+ if (existente && existente.port === Number(process.env.PANDANEWS_API_PORT || API_PORT_BASE)) {
310
+ console.log(`Backend já em execução (PID ${existente.pid}) — reaproveitando em http://127.0.0.1:${existente.port}.`);
311
+ activeBackendPort = existente.port;
312
+ backendOk = true;
313
+ } else {
314
+ if (existente) {
315
+ // backend rodando em outra porta: mantemos e deixamos nossa UI usá-la
316
+ console.log(`Backend já em execução (PID ${existente.pid}) na porta ${existente.port} — reaproveitando.`);
317
+ activeBackendPort = existente.port;
318
+ backendOk = true;
319
+ } else {
320
+ backendOk = await startBackend(home);
321
+ }
230
322
  }
231
323
 
232
- startBackend(home);
324
+ // 2) interface
233
325
  listenUi(UI_PORT_BASE, 1);
234
326
 
235
- process.on('exit', () => {
236
- if (uiServer) uiServer.close();
237
- if (backendProc) backendProc.kill();
327
+ // watchdog de saúde do backend (reergue em quedas silenciosas)
328
+ iniciarWatchdog(home);
329
+
330
+ // 3) sinais: encerramento SEMPRE por código, nunca por propagação do terminal
331
+ process.on('SIGINT', () => {
332
+ console.log('\nEncerrando PandaNews...');
333
+ if (backendPid) {
334
+ encerrarPorPid(backendPid, `Backend (PID ${backendPid}) encerrado — log: ${join(home, 'data', 'backend.log')}`);
335
+ try {
336
+ unlinkSync(join(home, 'data', 'backend.pid'));
337
+ } catch {
338
+ /* melhor esforço */
339
+ }
340
+ } else {
341
+ console.log('Backend reaproveitado de sessão anterior — segue rodando em background.');
342
+ }
343
+ process.exit(0);
238
344
  });
239
- process.on('SIGINT', () => process.exit(0));
240
345
  process.on('SIGTERM', () => process.exit(0));
346
+
347
+ if (!backendOk) {
348
+ process.exitCode = 1;
349
+ }
241
350
  }
@@ -0,0 +1,40 @@
1
+ // Teste de regressão do isolamento de processos do launcher (scripts/start.js).
2
+ //
3
+ // Falha se as flags de isolamento forem removidas: detached (novo grupo de
4
+ // processos — imune a Ctrl+C do console no Windows), windowsHide (sem janela
5
+ // fantasma), stdio no arquivo (backend sobrevive ao terminal), unref e o
6
+ // arquivo backend.pid (encerramento gracioso por código).
7
+ //
8
+ // Roda em qualquer plataforma, inclusive no CI (Linux/ubuntu) — sem exigir o
9
+ // runtime Python completo.
10
+ import { readFileSync } from 'node:fs';
11
+ import { join, dirname } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
15
+ const src = readFileSync(join(root, 'scripts', 'start.js'), 'utf8');
16
+
17
+ const verificacoes = [
18
+ [/detached:\s*true/, 'detached: true — grupo de processos próprio (CREATE_NEW_PROCESS_GROUP no Windows)'],
19
+ [/windowsHide:\s*true/, 'windowsHide: true — sem janela de console fantasma'],
20
+ [/stdio:\s*\['ignore',\s*logFd,\s*logFd\]/, "stdio: ['ignore', logFd, logFd] — saída do backend no arquivo (fd)"],
21
+ [/openSync\(logPath, 'a'\)/, 'openSync do log em append (fd reaproveitado pelo filho)'],
22
+ [/PYTHONUNBUFFERED/, 'PYTHONUNBUFFERED=1 — logs do backend sem buffer'],
23
+ [/backend\.pid/, 'backend.pid — PID gravado para encerramento gracioso'],
24
+ [/\.unref\(\)/, 'unref() — launcher não acopla o ciclo de vida ao backend'],
25
+ [/taskkill/, 'encerramento no Windows via taskkill /PID /T (árvore de processos)'],
26
+ [/process\.kill/, 'encerramento no Unix via signal no grupo de processos'],
27
+ ];
28
+
29
+ let falhas = 0;
30
+ for (const [regex, descricao] of verificacoes) {
31
+ const ok = regex.test(src);
32
+ console.log(`${ok ? 'PASS' : 'FAIL'} — ${descricao}`);
33
+ if (!ok) falhas += 1;
34
+ }
35
+
36
+ if (falhas > 0) {
37
+ console.error(`Isolamento de processos: ${falhas} verificação(ões) falharam.`);
38
+ process.exit(1);
39
+ }
40
+ console.log('Isolamento de processos: todas as verificações passaram.');