@danhachuel/pandanews 0.1.1 → 0.1.3

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 CHANGED
@@ -2,6 +2,20 @@
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.1.03 (2026-10-23)
6
+
7
+ - Runtime movido para um diretório **estável por usuário** (`~/.pandanews`, sobrescrevível via `PANDANEWS_HOME`): upgrades de versão do pacote não reinstalam mais Python/dependências — o instalador detecta o que já existe e só instala o que falta
8
+ - **Migração automática**: runtimes de instalações anteriores (cache do npx ou layout antigo na pasta do pacote) são localizados e reaproveitados em vez de baixados de novo (`PANDANEWS_SKIP_MIGRATE=1` força instalação limpa)
9
+ - **Fingerprint de dependências** (requirements.txt + versão do Python): o pip só roda quando os requisitos mudam
10
+ - Reinstalação do Python apenas quando a versão do runtime muda (limpeza automática do antigo)
11
+ - Workflow: passo `npm ci` antes do smoke test — o checkout do CI não tem `node_modules` e o launcher (que importa `fs-extra`) falhava com ERR_MODULE_NOT_FOUND
12
+
13
+ ## 0.1.02 (2026-10-23)
14
+
15
+ - Porta padrão da UI alterada de 3030 para **4030** (sobrescrevível via `PANDANEWS_UI_PORT`)
16
+ - Fallback automático de porta: se 4030 estiver ocupada, o PandaNews tenta a seguinte (4031, 4032, ...) em até 20 tentativas, com mensagem clara se nenhuma estiver livre
17
+ - CORS do backend passa a aceitar localhost/127.0.0.1 em qualquer porta, acompanhando a porta dinâmica da UI
18
+
5
19
  ## 0.1.01 (2026-10-23)
6
20
 
7
21
  - README reescrito para o fluxo de um único comando via npx (padrão Thunderbolt): `npx @danhachuel/pandanews` instala tudo junto e sobe o app; removidas as instruções manuais de desenvolvimento (pip, uvicorn, npm)
package/README.md CHANGED
@@ -32,11 +32,11 @@ npx.cmd --yes --prefer-online @danhachuel/pandanews install
32
32
  npx --yes @danhachuel/pandanews install
33
33
  ```
34
34
 
35
- O runtime fica em `.runtime/` na raiz do pacote (sobrescreva com a variável de ambiente `PANDANEWS_HOME`).
35
+ O runtime fica em `~/.pandanews` (sobrescreva com a variável de ambiente `PANDANEWS_HOME`) — **upgrades de versão não reinstalam** o que já existe: o instalador detecta Python, dependências e dados já presentes e só instala o que falta.
36
36
 
37
- ## Status (versão 0.1.01)
37
+ ## Status (versão 0.1.03)
38
38
 
39
- - `npx @danhachuel/pandanews` instala tudo junto (Python portátil + dependências + banco local) e sobe o backend em 127.0.0.1:8000 com página em 127.0.0.1:3030
39
+ - `npx @danhachuel/pandanews` instala tudo junto (Python portátil + dependências + banco local) e sobe o backend em 127.0.0.1:8000 com página em 127.0.0.1:4030 — se a porta estiver ocupada, usa a seguinte automaticamente (4031, 4032, ...)
40
40
  - A UI React completa (Dashboard, Blogs, Notícias, Imagens, Automação, Configurações) e o pipeline de notícias chegam nas próximas versões (Fases 1 a 3 do plano — ver `CHANGELOG.md`)
41
41
 
42
42
  ## Regras do projeto
@@ -1,16 +1,17 @@
1
1
  """PandaNews API — FastAPI.
2
2
 
3
3
  Fase 0/1: aplicação mínima com endpoint de health e CORS liberado para a UI
4
- local (127.0.0.1:3030). CRUD de blogs e configurações chegam na Fase 1.
4
+ local (localhost/127.0.0.1 em qualquer porta — a UI usa porta dinâmica a partir
5
+ de 4030). CRUD de blogs e configurações chegam na Fase 1.
5
6
  """
6
7
  from fastapi import FastAPI
7
8
  from fastapi.middleware.cors import CORSMiddleware
8
9
 
9
- app = FastAPI(title="PandaNews API", version="0.1.1")
10
+ app = FastAPI(title="PandaNews API", version="0.1.3")
10
11
 
11
12
  app.add_middleware(
12
13
  CORSMiddleware,
13
- allow_origins=["http://localhost:3030", "http://127.0.0.1:3030"],
14
+ allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
14
15
  allow_methods=["*"],
15
16
  allow_headers=["*"],
16
17
  )
@@ -5,4 +5,4 @@ UI do PandaNews (React + Vite + Tailwind), inicializada na Fase 2 do plano de im
5
5
  - Páginas: Dashboard, Blogs, Notícias, Imagens, Automação, Configurações, Logs
6
6
  - Sidebar + topbar no padrão Leadmaster
7
7
  - 7 leis de usabilidade aplicadas (Fitts, Hick, Zeigarnik, Jakob, Gradiente de Meta, von Restorff, Miller)
8
- - Build: `npm run build` → `dist/` servido em 127.0.0.1:3030
8
+ - Build: `npm run build` → `dist/` servido em 127.0.0.1:4030 (com fallback para 4031, 4032, ... se ocupada)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/pandanews",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
@@ -1,15 +1,22 @@
1
1
  // Instalador do runtime PandaNews.
2
2
  //
3
- // Provisiona tudo em um único comando (padrão Thunderbolt):
4
- // 1. estrutura de pastas (data/, .runtime/)
5
- // 2. Python 3.12.7 portátil via python-build-standalone (sem Python pré-instalado)
6
- // 3. virtualenv em .runtime/venv
7
- // 4. dependências do backend (backend/requirements.txt)
8
- // 5. marcadores de estado (.runtime/.ready, .runtime/.python-ready)
9
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ // O runtime vive em um diretório ESTÁVEL por usuário (~/.pandanews, ou
4
+ // PANDANEWS_HOME), separado da pasta do pacote npm: upgrades de versão não
5
+ // reinstalam Python/dependências. O instalador detecta o que já existe e só
6
+ // executa o que falta:
7
+ // 1. estrutura de pastas (data/, .runtime/)
8
+ // 2. Python 3.12.7 portátil (python-build-standalone)
9
+ // 3. virtualenv (.runtime/venv)
10
+ // 4. dependências do backend — com fingerprint de requirements.txt + versão
11
+ // do Python: pip só roda quando mudam
12
+ // 5. migração de runtimes legados (pasta do pacote / cache do npx)
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
10
14
  import { spawnSync } from 'node:child_process';
11
- import { join, resolve } from 'node:path';
15
+ import { createHash } from 'node:crypto';
16
+ import { dirname, join, resolve } from 'node:path';
12
17
  import { fileURLToPath } from 'node:url';
18
+ import os from 'node:os';
19
+ import fse from 'fs-extra';
13
20
 
14
21
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
15
22
  export const PKG_ROOT = join(__dirname, '..');
@@ -17,9 +24,10 @@ export const PKG_ROOT = join(__dirname, '..');
17
24
  const PYTHON_VERSION = '3.12.7';
18
25
  const PYTHON_RELEASE = '20241016';
19
26
 
20
- // Raiz de instalação: PANDANEWS_HOME sobrepõe o padrão (raiz do pacote instalado).
27
+ // Raiz de instalação estável: PANDANEWS_HOME sobrepõe; padrão ~/.pandanews.
21
28
  export function resolveHome() {
22
- return process.env.PANDANEWS_HOME ? resolve(process.env.PANDANEWS_HOME) : PKG_ROOT;
29
+ if (process.env.PANDANEWS_HOME) return resolve(process.env.PANDANEWS_HOME);
30
+ return join(os.homedir(), '.pandanews');
23
31
  }
24
32
 
25
33
  // Detecta plataforma e asset de python-build-standalone.
@@ -51,6 +59,68 @@ function venvBin(home) {
51
59
  : join(home, '.runtime', 'venv', 'bin', 'python');
52
60
  }
53
61
 
62
+ function pythonSpec() {
63
+ return `cpython-${PYTHON_VERSION}+${PYTHON_RELEASE}`;
64
+ }
65
+
66
+ // Fingerprint das dependências: muda quando o requirements.txt ou a versão do
67
+ // Python mudam — só então o pip roda de novo.
68
+ function depsFingerprint() {
69
+ const reqPath = join(PKG_ROOT, 'backend', 'requirements.txt');
70
+ const req = existsSync(reqPath) ? readFileSync(reqPath, 'utf8') : '';
71
+ const hash = createHash('sha256').update(req).digest('hex').slice(0, 16);
72
+ return `${pythonSpec()};req=${hash}`;
73
+ }
74
+
75
+ // Procura runtimes legados: na própria pasta do pacote (layout antigo) e nos
76
+ // buckets do cache do npx (versões anteriores do pacote). Best effort.
77
+ function findLegacyRuntime() {
78
+ const candidates = [];
79
+ const marker = '.python-ready';
80
+ const sameDir = join(PKG_ROOT, '.runtime');
81
+ if (existsSync(join(sameDir, marker))) candidates.push(sameDir);
82
+ try {
83
+ const cacheBase = process.env.npm_config_cache
84
+ ? process.env.npm_config_cache
85
+ : join(os.homedir(), process.platform === 'win32' ? join('AppData', 'Local', 'npm-cache') : '.npm');
86
+ const npxRoot = join(cacheBase, '_npx');
87
+ if (existsSync(npxRoot)) {
88
+ for (const entry of readdirSync(npxRoot)) {
89
+ const rt = join(npxRoot, entry, 'node_modules', '@danhachuel', 'pandanews', '.runtime');
90
+ if (existsSync(join(rt, marker))) candidates.push(rt);
91
+ }
92
+ }
93
+ } catch { /* best effort */ }
94
+ if (candidates.length === 0) return null;
95
+ candidates.sort((a, b) => statSync(join(b, marker)).mtimeMs - statSync(join(a, marker)).mtimeMs);
96
+ return candidates[0];
97
+ }
98
+
99
+ // Reaproveita um runtime legado movendo-o para o home estável. Pula se o home
100
+ // já tiver um runtime válido, ou se PANDANEWS_SKIP_MIGRATE estiver definido.
101
+ function migrateLegacyRuntime(home) {
102
+ if (process.env.PANDANEWS_SKIP_MIGRATE) return;
103
+ const targetRuntime = join(home, '.runtime');
104
+ if (existsSync(join(targetRuntime, '.python-ready'))) return;
105
+ const legacy = findLegacyRuntime();
106
+ if (!legacy) return;
107
+ console.log(`Runtime já instalado encontrado em ${legacy} — reaproveitando (movendo para ${home})...`);
108
+ try {
109
+ // Remove stubs vazios criados antes da migração: rename de diretório no
110
+ // Windows falha (EPERM) quando o destino já existe.
111
+ rmSync(targetRuntime, { recursive: true, force: true });
112
+ fse.moveSync(legacy, targetRuntime);
113
+ const legacyData = join(dirname(legacy), 'data');
114
+ if (existsSync(legacyData)) {
115
+ fse.copySync(legacyData, join(home, 'data'));
116
+ rmSync(legacyData, { recursive: true, force: true });
117
+ }
118
+ console.log('Migração concluída.');
119
+ } catch (err) {
120
+ console.log(`Migração falhou (${err && err.message}) — seguindo com instalação nova.`);
121
+ }
122
+ }
123
+
54
124
  async function downloadPython(home, platform) {
55
125
  const url = pythonArchiveUrl(platform);
56
126
  const runtimeDir = join(home, '.runtime');
@@ -108,9 +178,19 @@ export async function runInstall({ skipPythonDeps = false } = {}) {
108
178
 
109
179
  if (skipPythonDeps) {
110
180
  console.log('--skip-python-deps: instalação estrutural (uso em CI).');
111
- } else if (existsSync(join(home, '.runtime', '.python-ready'))) {
112
- console.log('Runtime Python já provisionado — nada a fazer.');
113
181
  } else {
182
+ migrateLegacyRuntime(home);
183
+
184
+ // Versão do Python mudou? Limpa o runtime antigo antes de reinstalar.
185
+ const specFile = join(home, '.runtime', '.python-spec');
186
+ const wantSpec = pythonSpec();
187
+ const haveSpec = existsSync(specFile) ? readFileSync(specFile, 'utf8').trim() : '';
188
+ if (haveSpec && haveSpec !== wantSpec) {
189
+ console.log('Versão do Python do runtime mudou — limpando instalação antiga...');
190
+ rmSync(join(home, '.runtime', 'python'), { recursive: true, force: true });
191
+ rmSync(join(home, '.runtime', 'venv'), { recursive: true, force: true });
192
+ }
193
+
114
194
  if (!existsSync(pythonBin(home))) {
115
195
  await downloadPython(home, platform);
116
196
  } else {
@@ -121,9 +201,21 @@ export async function runInstall({ skipPythonDeps = false } = {}) {
121
201
  } else {
122
202
  console.log('Virtualenv já presente.');
123
203
  }
124
- installDeps(home);
204
+
205
+ // Dependências: só roda o pip quando o fingerprint muda.
206
+ const fpFile = join(home, '.runtime', '.deps-fingerprint');
207
+ const haveFp = existsSync(fpFile) ? readFileSync(fpFile, 'utf8').trim() : '';
208
+ const wantFp = depsFingerprint();
209
+ if (haveFp !== wantFp) {
210
+ installDeps(home);
211
+ writeFileSync(fpFile, wantFp);
212
+ console.log('Dependências em dia.');
213
+ } else {
214
+ console.log('Dependências já instaladas — nada a fazer.');
215
+ }
216
+
217
+ writeFileSync(specFile, wantSpec);
125
218
  writeFileSync(join(home, '.runtime', '.python-ready'), new Date().toISOString());
126
- console.log('Dependências instaladas. Runtime Python pronto.');
127
219
  }
128
220
 
129
221
  writeFileSync(join(home, '.runtime', '.ready'), new Date().toISOString());
package/scripts/start.js CHANGED
@@ -1,8 +1,9 @@
1
- // Inicia o PandaNews: backend FastAPI (127.0.0.1:8000) + UI (127.0.0.1:3030).
1
+ // Inicia o PandaNews: backend FastAPI (127.0.0.1:8000) + UI (127.0.0.1:4030+).
2
2
  //
3
3
  // Fluxo de um único comando (padrão Thunderbolt): se o runtime não estiver
4
4
  // provisionado, executa o install completo (Python portátil + dependências)
5
- // antes de subir os servidores. Ao final, abre o navegador.
5
+ // antes de subir os servidores. A UI começa em PANDANEWS_UI_PORT (padrão 4030)
6
+ // e, se a porta estiver ocupada, tenta automaticamente a seguinte (4031, ...).
6
7
  import { existsSync, readFileSync } from 'node:fs';
7
8
  import { spawn } from 'node:child_process';
8
9
  import { createServer } from 'node:http';
@@ -10,7 +11,8 @@ import { join, extname } from 'node:path';
10
11
  import { resolveHome, runInstall, PKG_ROOT } from './install.js';
11
12
 
12
13
  const BACKEND_PORT = Number(process.env.PANDANEWS_API_PORT || 8000);
13
- const UI_PORT = Number(process.env.PANDANEWS_UI_PORT || 3030);
14
+ const UI_PORT_BASE = Number(process.env.PANDANEWS_UI_PORT || 4030);
15
+ const UI_PORT_MAX_TRIES = 20;
14
16
 
15
17
  function venvBin(home) {
16
18
  return process.platform === 'win32'
@@ -43,9 +45,11 @@ function placeholderPage() {
43
45
  </html>`;
44
46
  }
45
47
 
46
- function serveUi(onReady) {
48
+ let uiServer = null;
49
+
50
+ function createUiServer() {
47
51
  const distDir = join(PKG_ROOT, 'frontend', 'dist');
48
- const server = createServer((req, res) => {
52
+ return createServer((req, res) => {
49
53
  const url = req.url.split('?')[0];
50
54
  const filePath = join(distDir, url === '/' ? 'index.html' : url.slice(1));
51
55
  if (!existsSync(filePath)) {
@@ -56,16 +60,30 @@ function serveUi(onReady) {
56
60
  res.writeHead(200, { 'Content-Type': MIME[extname(filePath)] || 'application/octet-stream' });
57
61
  res.end(readFileSync(filePath));
58
62
  });
59
- server.on('error', (err) => {
63
+ }
64
+
65
+ function listenUi(port, attempt) {
66
+ if (attempt > UI_PORT_MAX_TRIES) {
67
+ console.error(`Nenhuma porta livre entre ${UI_PORT_BASE} e ${UI_PORT_BASE + UI_PORT_MAX_TRIES - 1}.`);
68
+ console.error('Encerre algum processo que as ocupe ou defina PANDANEWS_UI_PORT.');
69
+ process.exit(1);
70
+ }
71
+ const server = createUiServer();
72
+ server.once('error', (err) => {
60
73
  if (err && err.code === 'EADDRINUSE') {
61
- console.error(`A porta ${UI_PORT} já está em uso. Encerre o processo que a ocupa ou defina PANDANEWS_UI_PORT.`);
74
+ console.log(`Porta ${port} ocupada — tentando ${port + 1}...`);
75
+ listenUi(port + 1, attempt + 1);
62
76
  } else {
63
77
  console.error(`Erro no servidor de UI: ${err && err.message}`);
78
+ process.exit(1);
64
79
  }
65
- process.exit(1);
66
80
  });
67
- server.listen(UI_PORT, '127.0.0.1', onReady);
68
- return server;
81
+ server.listen(port, '127.0.0.1', () => {
82
+ uiServer = server;
83
+ console.log(`PandaNews ativo em http://127.0.0.1:${port}`);
84
+ console.log('Pressione Ctrl+C para encerrar.');
85
+ openBrowser(`http://127.0.0.1:${port}`);
86
+ });
69
87
  }
70
88
 
71
89
  async function openBrowser(url) {
@@ -103,14 +121,10 @@ export async function runStart() {
103
121
  }
104
122
  });
105
123
 
106
- const server = serveUi(() => {
107
- console.log(`PandaNews ativo em http://127.0.0.1:${UI_PORT}`);
108
- console.log('Pressione Ctrl+C para encerrar.');
109
- openBrowser(`http://127.0.0.1:${UI_PORT}`);
110
- });
124
+ listenUi(UI_PORT_BASE, 1);
111
125
 
112
126
  process.on('exit', () => {
113
- server.close();
127
+ if (uiServer) uiServer.close();
114
128
  uvicorn.kill();
115
129
  });
116
130
  process.on('SIGINT', () => process.exit(0));