@danhachuel/pandanews 0.4.2 → 0.4.4
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/CHANGELOG.md +18 -0
- package/backend/app/main.py +1 -1
- package/backend/core/proc.py +89 -0
- package/package.json +1 -1
- package/scripts/build.js +8 -2
- package/scripts/install.js +20 -3
- package/scripts/start.js +1 -1
- package/scripts/test-start-isolation.mjs +195 -17
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
Todas as versões publicadas são registradas aqui antes do publish (regra em AGENTS.md). A etiqueta visual usa PATCH de dois dígitos (ex.: 0.1.00); o registry npm normaliza para a versão canônica (ex.: 0.1.0).
|
|
4
4
|
|
|
5
|
+
## 0.4.04 (2026-11-08)
|
|
6
|
+
|
|
7
|
+
- **HOTFIX real — fim das janelas de CMD no instalador**: o 0.4.03 blindou apenas o launcher (`start.js`) e deixou o **caminho de instalação** descoberto. No Windows, `tar` (extração do Python portátil), `python -m venv` e `pip install -r requirements.txt` eram lançados **sem `windowsHide`** — era aí que as múltiplas janelas de terminal apareciam. Os três `spawnSync` de `scripts/install.js` agora nascem com `windowsHide: true` (`CREATE_NO_WINDOW`)
|
|
8
|
+
- `scripts/build.js` blindado também: `npm install` e `npm run build` do frontend são chamados com `shell: true` (o wrapper `cmd.exe`/`npm.cmd` entra na conta) — ambos agora com `windowsHide: true`, sem piscar tela preta
|
|
9
|
+
- Saída preservada: o instalador mantém `stdio: 'inherit'`, então o progresso continua visível **no console que o usuário já abriu** — só a janela extra deixa de existir (verificado empiricamente no Windows)
|
|
10
|
+
- **Helper Python único e obrigatório**: `backend/core/proc.py` expõe `run_silent_process` / `run_silent` / `spawn_silent` / `call_silent` / `check_output_silent` / `system_silent`, aplicando `CREATE_NO_WINDOW = 0x08000000` via `creationflags` no Windows. O pipeline e o APScheduler rodam em memória (threads do `BackgroundScheduler`), sem processos do SO; o helper é o ponto único para qualquer subprocesso futuro
|
|
11
|
+
- **Teste de regressão ampliado e validado**: `scripts/test-start-isolation.mjs` deixa de olhar só o `start.js` e passa a varrer **todos** os pontos de entrada — `start.js`, `install.js`, `build.js` e `bin/cli.js` — exigindo `windowsHide: true` em cada `spawn`/`spawnSync`/`exec`/`execSync` (inclusive quando as opções vêm de constante compartilhada, ex. `...SILENT_WINDOWS`)
|
|
12
|
+
- O teste também trava regressões do backend Python: qualquer `subprocess.run/Popen/call/check_output/check_call` ou `os.system`/`os.popen` direto em `backend/**/*.py` falha o build (precisa passar por `backend.core.proc`)
|
|
13
|
+
- E ainda barra `uvicorn` com `--reload`/`--workers` no launcher — cada worker é um processo filho extra que, no Windows, pode alocar console próprio e ignorar as flags do pai
|
|
14
|
+
- **Guard verificado por mutação**: com a flag removida do `pip`, com um `subprocess.run` direto no backend e com `--reload` no uvicorn, o teste falha em todos os casos (exit 1) — não é carimbo de borracha
|
|
15
|
+
- Nota de transparência: a colisão de versão com o 0.4.03 já publicado no registry foi resolvida por renumeração, conforme a regra de sessões simultâneas do AGENTS.md
|
|
16
|
+
|
|
17
|
+
## 0.4.03 (2026-11-08)
|
|
18
|
+
|
|
19
|
+
- **Regra permanente no AGENTS.md**: é PROIBIDO criar/publicar versões que abram janelas de terminal (CMD/PowerShell/console Python) no computador do usuário — todo processo filho deve nascer com `windowsHide: true` + grupo isolado, rodar em background com saída apenas em log e ser encerrado por código; versão que exiba janela de console é inválida (hotfix + CHANGELOG)
|
|
20
|
+
- Teste de regressão reforçado: varre **todas** as chamadas `spawn`/`spawnSync` do launcher e falha se qualquer uma perder `windowsHide`/`detached` — o CI passa a barrar isso automaticamente
|
|
21
|
+
- `taskkill` do encerramento gracioso também com `detached: true` (uniformidade da regra)
|
|
22
|
+
|
|
5
23
|
## 0.4.02 (2026-11-08)
|
|
6
24
|
|
|
7
25
|
- **Seletor dinâmico de modelos NVIDIA NIM**: novo endpoint proxy `GET /api/v1/configuracoes/llm/nvidia/models` que consulta a NVIDIA em tempo real com a chave do usuário — a API key nunca é exposta ao frontend/Network tab
|
package/backend/app/main.py
CHANGED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Processos filhos silenciosos (regra permanente do AGENTS.md).
|
|
2
|
+
|
|
3
|
+
O PandaNews NUNCA pode abrir janelas de terminal (CMD, PowerShell, console
|
|
4
|
+
Python) no computador do usuário. No Windows, todo processo filho precisa da
|
|
5
|
+
flag ``CREATE_NO_WINDOW`` (0x08000000); sem ela, qualquer ``subprocess`` ou
|
|
6
|
+
gerador de console aloca uma tela preta.
|
|
7
|
+
|
|
8
|
+
Este módulo é o ÚNICO ponto de entrada autorizado para subprocessos no
|
|
9
|
+
backend. Nunca chame ``subprocess.run``/``subprocess.Popen``/``subprocess.call``
|
|
10
|
+
nem ``os.system`` diretamente — use ``run_silent_process``/``run_silent``/
|
|
11
|
+
``spawn_silent``. O teste ``scripts/test-start-isolation.mjs`` (rodado no CI)
|
|
12
|
+
falha o build se encontrar uma chamada direta em ``backend/**/*.py``.
|
|
13
|
+
|
|
14
|
+
Exemplos::
|
|
15
|
+
|
|
16
|
+
from backend.core.proc import run_silent_process, spawn_silent
|
|
17
|
+
|
|
18
|
+
# execução síncrona
|
|
19
|
+
run_silent_process(["ffmpeg", "-version"], check=True, capture_output=True)
|
|
20
|
+
|
|
21
|
+
# execução assíncrona (não bloqueia o event loop)
|
|
22
|
+
proc = spawn_silent(["pandoc", "--version"])
|
|
23
|
+
proc.wait()
|
|
24
|
+
|
|
25
|
+
O pipeline de notícias e o APScheduler rodam em memória (threads do
|
|
26
|
+
``BackgroundScheduler``), sem processos do sistema operacional — este módulo
|
|
27
|
+
só é necessário quando uma ferramenta externa realmente precisa ser chamada.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
# Flag do Windows: o processo nasce sem console, portanto sem janela visível.
|
|
35
|
+
# Ver https://learn.microsoft.com/windows/win32/procthread/process-creation-flags
|
|
36
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def silent_kwargs(kwargs: dict | None = None) -> dict:
|
|
40
|
+
"""Devolve as opções de subprocesso com a flag de ocultação aplicada.
|
|
41
|
+
|
|
42
|
+
Em Windows adiciona ``creationflags=CREATE_NO_WINDOW``; nas demais
|
|
43
|
+
plataformas não há console novo a ocultar, então as opções passam
|
|
44
|
+
intactas. Nunca use ``shell=True`` junto a este helper.
|
|
45
|
+
"""
|
|
46
|
+
opts = dict(kwargs or {})
|
|
47
|
+
if sys.platform == "win32":
|
|
48
|
+
opts["creationflags"] = CREATE_NO_WINDOW
|
|
49
|
+
return opts
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_silent_process(cmd_args, **kwargs):
|
|
53
|
+
"""Substitui ``subprocess.run`` — sem janela de console no Windows."""
|
|
54
|
+
return subprocess.run(cmd_args, **silent_kwargs(kwargs))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Alias: mesma semântica, nome curto para uso interno.
|
|
58
|
+
run_silent = run_silent_process
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def spawn_silent(cmd_args, **kwargs):
|
|
62
|
+
"""Substitui ``subprocess.Popen`` — sem janela de console no Windows."""
|
|
63
|
+
return subprocess.Popen(cmd_args, **silent_kwargs(kwargs))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def call_silent(cmd_args, **kwargs):
|
|
67
|
+
"""Substitui ``subprocess.call`` — sem janela de console no Windows."""
|
|
68
|
+
return subprocess.call(cmd_args, **silent_kwargs(kwargs))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_output_silent(cmd_args, **kwargs):
|
|
72
|
+
"""Substitui ``subprocess.check_output`` — sem janela de console no Windows."""
|
|
73
|
+
return subprocess.check_output(cmd_args, **silent_kwargs(kwargs))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def system_silent(cmd: str) -> int:
|
|
77
|
+
"""Substitui ``os.system`` sem abrir shell visível.
|
|
78
|
+
|
|
79
|
+
``os.system`` sempre passa por ``cmd.exe``/``/bin/sh`` e herda o console;
|
|
80
|
+
quando não há alternativa, a chamada é feita pelo helper silencioso.
|
|
81
|
+
"""
|
|
82
|
+
if sys.platform == "win32":
|
|
83
|
+
proc = subprocess.run(
|
|
84
|
+
cmd,
|
|
85
|
+
shell=True,
|
|
86
|
+
creationflags=CREATE_NO_WINDOW,
|
|
87
|
+
)
|
|
88
|
+
return proc.returncode
|
|
89
|
+
return subprocess.call(cmd, shell=True)
|
package/package.json
CHANGED
package/scripts/build.js
CHANGED
|
@@ -15,12 +15,18 @@ export async function runBuild() {
|
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
17
|
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
18
|
+
// Regra permanente (AGENTS.md): NENHUM spawn pode abrir janela de console
|
|
19
|
+
// no computador do usuário. No Windows o filho nasce com CREATE_NO_WINDOW
|
|
20
|
+
// (windowsHide: true) — com `shell: true` o wrapper cmd.exe também nasce
|
|
21
|
+
// oculto, sem piscar tela preta. O stdio segue 'inherit': a saída fica no
|
|
22
|
+
// console que o usuário já abriu.
|
|
23
|
+
const SILENT_WINDOWS = { windowsHide: true };
|
|
18
24
|
console.log('Instalando dependências do frontend...');
|
|
19
25
|
// shell: true — obrigatório para .cmd no Windows (Node >= 18)
|
|
20
|
-
const install = spawnSync(npmCmd, ['install'], { cwd: frontendDir, stdio: 'inherit', shell: true });
|
|
26
|
+
const install = spawnSync(npmCmd, ['install'], { cwd: frontendDir, stdio: 'inherit', shell: true, ...SILENT_WINDOWS });
|
|
21
27
|
if (install.status !== 0) throw new Error('Falha ao instalar dependências do frontend.');
|
|
22
28
|
console.log('Compilando o frontend...');
|
|
23
|
-
const build = spawnSync(npmCmd, ['run', 'build'], { cwd: frontendDir, stdio: 'inherit', shell: true });
|
|
29
|
+
const build = spawnSync(npmCmd, ['run', 'build'], { cwd: frontendDir, stdio: 'inherit', shell: true, ...SILENT_WINDOWS });
|
|
24
30
|
if (build.status !== 0) throw new Error('Falha ao compilar o frontend.');
|
|
25
31
|
console.log('Frontend compilado em frontend/dist.');
|
|
26
32
|
}
|
package/scripts/install.js
CHANGED
|
@@ -21,6 +21,20 @@ import fse from 'fs-extra';
|
|
|
21
21
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
22
22
|
export const PKG_ROOT = join(__dirname, '..');
|
|
23
23
|
|
|
24
|
+
// ------------------------------------------------------------------------
|
|
25
|
+
// Regra permanente (AGENTS.md): é PROIBIDO abrir janelas de terminal (CMD,
|
|
26
|
+
// PowerShell, console Python) no computador do usuário. Todo processo filho
|
|
27
|
+
// lançado pelo instalador nasce no Windows com `windowsHide: true`, que aplica
|
|
28
|
+
// a flag CREATE_NO_WINDOW — nenhuma tela preta aparece.
|
|
29
|
+
//
|
|
30
|
+
// `detached: true` NÃO é usado aqui de propósito: essas chamadas são curtas
|
|
31
|
+
// (tar/venv/pip) e o `stdio: 'inherit'` mantém o progresso no console que o
|
|
32
|
+
// usuário JÁ abriu. Com CREATE_NO_WINDOW o filho escreve nos handles herdados
|
|
33
|
+
// sem alocar uma janela extra. O teste scripts/test-start-isolation.mjs varre
|
|
34
|
+
// este arquivo e falha o build se qualquer spawn perder o windowsHide.
|
|
35
|
+
// ------------------------------------------------------------------------
|
|
36
|
+
const SILENT_WINDOWS = { windowsHide: true };
|
|
37
|
+
|
|
24
38
|
const PYTHON_VERSION = '3.12.7';
|
|
25
39
|
const PYTHON_RELEASE = '20241016';
|
|
26
40
|
|
|
@@ -152,7 +166,7 @@ async function downloadPython(home, platform) {
|
|
|
152
166
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
153
167
|
writeFileSync(archive, buf);
|
|
154
168
|
console.log(`Download concluído (${(buf.length / 1024 / 1024).toFixed(1)} MB). Extraindo...`);
|
|
155
|
-
const tar = spawnSync('tar', ['-xzf', archive, '-C', runtimeDir]);
|
|
169
|
+
const tar = spawnSync('tar', ['-xzf', archive, '-C', runtimeDir], SILENT_WINDOWS);
|
|
156
170
|
if (tar.status !== 0) throw new Error('Falha ao extrair o Python portátil (tar).');
|
|
157
171
|
if (!existsSync(pythonBin(home))) {
|
|
158
172
|
throw new Error('Extração do Python concluída, mas o binário não foi encontrado.');
|
|
@@ -162,7 +176,10 @@ async function downloadPython(home, platform) {
|
|
|
162
176
|
|
|
163
177
|
function createVenv(home) {
|
|
164
178
|
console.log('Criando virtualenv...');
|
|
165
|
-
const venv = spawnSync(pythonBin(home), ['-m', 'venv', join(home, '.runtime', 'venv')], {
|
|
179
|
+
const venv = spawnSync(pythonBin(home), ['-m', 'venv', join(home, '.runtime', 'venv')], {
|
|
180
|
+
stdio: 'inherit',
|
|
181
|
+
...SILENT_WINDOWS,
|
|
182
|
+
});
|
|
166
183
|
if (venv.status !== 0) throw new Error('Falha ao criar o virtualenv.');
|
|
167
184
|
if (!existsSync(venvBin(home))) throw new Error('Virtualenv criado, mas o binário não foi encontrado.');
|
|
168
185
|
}
|
|
@@ -173,7 +190,7 @@ function installDeps(home) {
|
|
|
173
190
|
const pip = spawnSync(
|
|
174
191
|
venvBin(home),
|
|
175
192
|
['-m', 'pip', 'install', '--disable-pip-version-check', '-r', req],
|
|
176
|
-
{ stdio: 'inherit' },
|
|
193
|
+
{ stdio: 'inherit', ...SILENT_WINDOWS },
|
|
177
194
|
);
|
|
178
195
|
if (pip.status !== 0) throw new Error('Falha ao instalar as dependências do backend.');
|
|
179
196
|
}
|
package/scripts/start.js
CHANGED
|
@@ -87,7 +87,7 @@ async function findFreePort(base, maxTries) {
|
|
|
87
87
|
function encerrarPorPid(pid, motivo) {
|
|
88
88
|
try {
|
|
89
89
|
if (process.platform === 'win32') {
|
|
90
|
-
spawnSync('taskkill', ['/pid', String(pid), '/f', '/t'], { windowsHide: true });
|
|
90
|
+
spawnSync('taskkill', ['/pid', String(pid), '/f', '/t'], { windowsHide: true, detached: true });
|
|
91
91
|
} else {
|
|
92
92
|
process.kill(pid, 'SIGTERM');
|
|
93
93
|
}
|
|
@@ -1,20 +1,134 @@
|
|
|
1
|
-
// Teste de regressão do isolamento de processos do
|
|
1
|
+
// Teste de regressão do isolamento de processos (regra permanente do AGENTS.md):
|
|
2
|
+
// NENHUM spawn/exec do launcher/instalador pode abrir janela de terminal (CMD,
|
|
3
|
+
// PowerShell, console Python) no computador do usuário.
|
|
2
4
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// Cobre TODOS os pontos de entrada:
|
|
6
|
+
// scripts/start.js — backend uvicorn + taskkill
|
|
7
|
+
// scripts/install.js — tar / venv / pip
|
|
8
|
+
// scripts/build.js — npm.cmd install e run build (shell: true)
|
|
9
|
+
// bin/cli.js — despacho de comandos
|
|
10
|
+
// backend/**/*.py — nenhuma chamada direta a subprocess/os.system
|
|
7
11
|
//
|
|
8
12
|
// Roda em qualquer plataforma, inclusive no CI (Linux/ubuntu) — sem exigir o
|
|
9
13
|
// runtime Python completo.
|
|
10
|
-
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
11
15
|
import { join, dirname } from 'node:path';
|
|
12
16
|
import { fileURLToPath } from 'node:url';
|
|
13
17
|
|
|
14
18
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
-
const src = readFileSync(join(root, 'scripts', 'start.js'), 'utf8');
|
|
16
19
|
|
|
17
|
-
const
|
|
20
|
+
const falhas = [];
|
|
21
|
+
|
|
22
|
+
function assert(condicao, mensagem) {
|
|
23
|
+
console.log(`${condicao ? 'PASS' : 'FAIL'} — ${mensagem}`);
|
|
24
|
+
if (!condicao) falhas.push(mensagem);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------- utilidades
|
|
28
|
+
|
|
29
|
+
// Extrai o conteúdo entre delimitadores balanceados, começando em `inicio`
|
|
30
|
+
// (índice do delimitador de abertura). Ignora delimitadores dentro de strings.
|
|
31
|
+
function ateOFechamento(src, inicio, abre, fecha) {
|
|
32
|
+
let profundidade = 1;
|
|
33
|
+
let aspa = null;
|
|
34
|
+
let escapando = false;
|
|
35
|
+
for (let i = inicio + 1; i < src.length; i++) {
|
|
36
|
+
const c = src[i];
|
|
37
|
+
if (escapando) {
|
|
38
|
+
escapando = false;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (c === '\\') {
|
|
42
|
+
escapando = true;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (aspa) {
|
|
46
|
+
if (c === aspa) aspa = null;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
50
|
+
aspa = c;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (c === abre) profundidade += 1;
|
|
54
|
+
else if (c === fecha) {
|
|
55
|
+
profundidade -= 1;
|
|
56
|
+
if (profundidade === 0) return src.slice(inicio + 1, i);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return src.slice(inicio + 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Texto completo dos argumentos de uma chamada, a partir do '(' encontrado.
|
|
63
|
+
function argumentosDaChamada(src, indiceAbre) {
|
|
64
|
+
return ateOFechamento(src, indiceAbre, '(', ')');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Objetos literais de nível de módulo (const NOME = { ... }) — servem para
|
|
68
|
+
// resolver spreads como `...SILENT_WINDOWS` dentro das opções de spawn.
|
|
69
|
+
function constantesDeModulo(src) {
|
|
70
|
+
const mapa = new Map();
|
|
71
|
+
const regex = /const\s+([A-Za-z_$][\w$]*)\s*=\s*\{/g;
|
|
72
|
+
let m;
|
|
73
|
+
while ((m = regex.exec(src)) !== null) {
|
|
74
|
+
const inicioChaves = m.index + m[0].length - 1;
|
|
75
|
+
mapa.set(m[1], ateOFechamento(src, inicioChaves, '{', '}'));
|
|
76
|
+
}
|
|
77
|
+
return mapa;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Uma referência a constante (solta ou via spread `...NOME`) é silenciosa
|
|
81
|
+
// apenas se a constante NOME trouxer windowsHide.
|
|
82
|
+
function referenciaConstanteSilenciosa(argTexto, constantes) {
|
|
83
|
+
const regex = /([A-Za-z_$][\w$]*)/g;
|
|
84
|
+
let m;
|
|
85
|
+
while ((m = regex.exec(argTexto)) !== null) {
|
|
86
|
+
const definicao = constantes.get(m[1]);
|
|
87
|
+
if (definicao && /windowsHide:\s*true/.test(definicao)) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ------------------------------------------------------------ varredura JS
|
|
93
|
+
|
|
94
|
+
const PONTOS_DE_ENTRADA_JS = ['scripts/start.js', 'scripts/install.js', 'scripts/build.js', 'bin/cli.js'];
|
|
95
|
+
|
|
96
|
+
for (const relativo of PONTOS_DE_ENTRADA_JS) {
|
|
97
|
+
const caminho = join(root, relativo);
|
|
98
|
+
if (!existsSync(caminho)) {
|
|
99
|
+
assert(false, `${relativo} — arquivo ausente`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const src = readFileSync(caminho, 'utf8');
|
|
103
|
+
const constantes = constantesDeModulo(src);
|
|
104
|
+
|
|
105
|
+
const chamadas = [...src.matchAll(/\b(spawn|spawnSync|exec|execSync|execFile|execFileSync)\s*\(/g)];
|
|
106
|
+
let silenciosas = 0;
|
|
107
|
+
for (const chamada of chamadas) {
|
|
108
|
+
const indiceAbre = chamada.index + chamada[0].length - 1;
|
|
109
|
+
const texto = argumentosDaChamada(src, indiceAbre);
|
|
110
|
+
if (/windowsHide:\s*true/.test(texto) || referenciaConstanteSilenciosa(texto, constantes)) {
|
|
111
|
+
silenciosas += 1;
|
|
112
|
+
} else {
|
|
113
|
+
const marca = `${chamada[1]}(${texto.slice(0, 60).replace(/\s+/g, ' ')}…)`;
|
|
114
|
+
assert(false, `${relativo} — ${marca} sem windowsHide: true (abre janela de console no usuário)`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (chamadas.length === 0) {
|
|
118
|
+
assert(true, `${relativo} — nenhuma chamada de processo direta (sem risco de janela de console)`);
|
|
119
|
+
} else {
|
|
120
|
+
assert(
|
|
121
|
+
silenciosas === chamadas.length,
|
|
122
|
+
`${relativo} — ${silenciosas}/${chamadas.length} chamadas de processo com windowsHide: true`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ------------------------------------------- isolamento do backend (start.js)
|
|
128
|
+
|
|
129
|
+
const startSrc = readFileSync(join(root, 'scripts', 'start.js'), 'utf8');
|
|
130
|
+
|
|
131
|
+
for (const [regex, descricao] of [
|
|
18
132
|
[/detached:\s*true/, 'detached: true — grupo de processos próprio (CREATE_NEW_PROCESS_GROUP no Windows)'],
|
|
19
133
|
[/windowsHide:\s*true/, 'windowsHide: true — sem janela de console fantasma'],
|
|
20
134
|
[/stdio:\s*\['ignore',\s*logFd,\s*logFd\]/, "stdio: ['ignore', logFd, logFd] — saída do backend no arquivo (fd)"],
|
|
@@ -24,17 +138,81 @@ const verificacoes = [
|
|
|
24
138
|
[/\.unref\(\)/, 'unref() — launcher não acopla o ciclo de vida ao backend'],
|
|
25
139
|
[/taskkill/, 'encerramento no Windows via taskkill /PID /T (árvore de processos)'],
|
|
26
140
|
[/process\.kill/, 'encerramento no Unix via signal no grupo de processos'],
|
|
141
|
+
]) {
|
|
142
|
+
assert(regex.test(startSrc), descricao);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Uvicorn em produção JAMAIS com --reload/--workers: cada worker é um processo
|
|
146
|
+
// filho extra, que no Windows pode alocar console próprio e ignorar as flags
|
|
147
|
+
// do processo pai.
|
|
148
|
+
const uvicornExtras = startSrc.match(/['"]--(?:reload|workers)['"]/g);
|
|
149
|
+
assert(
|
|
150
|
+
!uvicornExtras,
|
|
151
|
+
`uvicorn sem --reload/--workers (nenhum processo filho extra no Windows)${
|
|
152
|
+
uvicornExtras ? ` — encontrado: ${uvicornExtras.join(', ')}` : ''
|
|
153
|
+
}`,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// ------------------------------------------------ varredura Python (backend)
|
|
157
|
+
|
|
158
|
+
const PROC_HELPER = join(root, 'backend', 'core', 'proc.py');
|
|
159
|
+
assert(existsSync(PROC_HELPER), 'backend/core/proc.py — helper silencioso existe (ponto único de subprocesso)');
|
|
160
|
+
if (existsSync(PROC_HELPER)) {
|
|
161
|
+
const procSrc = readFileSync(PROC_HELPER, 'utf8');
|
|
162
|
+
assert(/CREATE_NO_WINDOW\s*=\s*0x08000000/.test(procSrc), 'CREATE_NO_WINDOW = 0x08000000 definido no helper');
|
|
163
|
+
assert(/def run_silent_process/.test(procSrc), 'run_silent_process disponível no helper');
|
|
164
|
+
assert(/creationflags/.test(procSrc), 'helper aplica creationflags no Windows');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Lista recursiva dos .py do backend (sem __pycache__).
|
|
168
|
+
function listarPy(diretorio, acumulador = []) {
|
|
169
|
+
for (const entrada of readdirSync(diretorio, { withFileTypes: true })) {
|
|
170
|
+
if (entrada.name === '__pycache__') continue;
|
|
171
|
+
const caminho = join(diretorio, entrada.name);
|
|
172
|
+
if (entrada.isDirectory()) listarPy(caminho, acumulador);
|
|
173
|
+
else if (entrada.name.endsWith('.py')) acumulador.push(caminho);
|
|
174
|
+
}
|
|
175
|
+
return acumulador;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const PADROES_PROIBIDOS = [
|
|
179
|
+
[/subprocess\.run\(/, 'subprocess.run('],
|
|
180
|
+
[/subprocess\.Popen\(/, 'subprocess.Popen('],
|
|
181
|
+
[/subprocess\.call\(/, 'subprocess.call('],
|
|
182
|
+
[/subprocess\.check_output\(/, 'subprocess.check_output('],
|
|
183
|
+
[/subprocess\.check_call\(/, 'subprocess.check_call('],
|
|
184
|
+
[/os\.system\(/, 'os.system('],
|
|
185
|
+
[/os\.popen\(/, 'os.popen('],
|
|
27
186
|
];
|
|
28
187
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
188
|
+
const backendDir = join(root, 'backend');
|
|
189
|
+
let arquivosPy = 0;
|
|
190
|
+
let diretasProibidas = 0;
|
|
191
|
+
if (existsSync(backendDir)) {
|
|
192
|
+
for (const arquivo of listarPy(backendDir)) {
|
|
193
|
+
if (arquivo.endsWith(join('core', 'proc.py'))) continue; // helper autorizado
|
|
194
|
+
arquivosPy += 1;
|
|
195
|
+
const conteudo = readFileSync(arquivo, 'utf8');
|
|
196
|
+
const relativo = arquivo.slice(root.length + 1);
|
|
197
|
+
for (const [regex, rotulo] of PADROES_PROIBIDOS) {
|
|
198
|
+
if (regex.test(conteudo)) {
|
|
199
|
+
diretasProibidas += 1;
|
|
200
|
+
assert(false, `${relativo} — uso direto de ${rotulo} sem CREATE_NO_WINDOW (use backend.core.proc)`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
34
204
|
}
|
|
205
|
+
assert(arquivosPy > 0, `backend/ — ${arquivosPy} arquivos .py varridos`);
|
|
206
|
+
assert(
|
|
207
|
+
diretasProibidas === 0,
|
|
208
|
+
'nenhuma chamada direta a subprocess/os.system no backend (todas via helper silencioso)',
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// -------------------------------------------------------------- veredicto
|
|
35
212
|
|
|
36
|
-
if (falhas
|
|
37
|
-
console.
|
|
38
|
-
process.exit(
|
|
213
|
+
if (falhas.length === 0) {
|
|
214
|
+
console.log('Isolamento de processos: todas as verificações passaram.');
|
|
215
|
+
process.exit(0);
|
|
39
216
|
}
|
|
40
|
-
console.
|
|
217
|
+
console.error(`Isolamento de processos: ${falhas.length} verificação(ões) falharam.`);
|
|
218
|
+
process.exit(1);
|