primocode 10.7.0 → 10.8.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/lib/conta.js CHANGED
@@ -1,32 +1,30 @@
1
1
  /**
2
- * conta.js — entrar na conta Conecta Primo, de dentro do terminal.
2
+ * conta.js — a conta do PrimoCode, no Firebase do próprio PrimoCode.
3
3
  *
4
- * "Com /login você só entra na conta de novo, não tem mais ligar terminal
5
- * com o navegador." (23/09/2026)
4
+ * "não vai mais precisar conecta … conta no Primo Code … Firebase …
5
+ * redefinir senha … plano obrigatoriamente" (Joel, 12/10/2026)
6
6
  *
7
- * ── SEM NAVEGADOR, MESMO PROTOCOLO ───────────────────────────────────────
8
- * O login era um pareamento: o terminal pedia um código, abria o navegador, a
9
- * pessoa aprovava lá e o terminal esperava. Agora o terminal pergunta e-mail
10
- * e senha e faz, ele mesmo, os três passos que a página fazia:
7
+ * ── O LOGIN ──────────────────────────────────────────────────────────────
8
+ * `/login` abre o navegador em primocode.web.app/entrar. Lá a pessoa cria a
9
+ * conta, entra ou redefine a senha. A página devolve o login para o terminal
10
+ * por um formulário enviado a http://127.0.0.1:<porta>/pronto (uma navegação,
11
+ * não um fetch: o navegador não barra página pública falando com o
12
+ * localhost por navegação). O `estado` aleatório impede outra página de
13
+ * empurrar um login que o terminal não pediu.
11
14
  *
12
- * 1. entra no Firebase com e-mail e senha (a mesma conta do app);
13
- * 2. abre um pedido de dispositivo (`/api/dispositivo/novo`);
14
- * 3. aprova esse pedido com o token do Firebase e recolhe o token `pc_`.
15
+ * Sem navegador (SSH, servidor): `/login senha` pergunta e-mail e senha aqui.
15
16
  *
16
- * O backend (backend/dispositivos.py do conecta-primo-ai) não muda nada: para
17
- * ele, é uma aprovação como qualquer outra, e o `pc_` continua sendo a única
18
- * credencial guardada. A senha e o token do Firebase não saem da memória.
17
+ * ── A CREDENCIAL ─────────────────────────────────────────────────────────
18
+ * Fica o refresh token do Firebase e o token de acesso da hora (1h). O de
19
+ * acesso vai ao servidor do PrimoCode no `x-primocode-token`, que confere a
20
+ * assinatura com as chaves do Google, sem perguntar a ninguém. Mora em
21
+ * `~/.primocode/conta.json` (600), NUNCA no config.json: aquele é o arquivo
22
+ * que alguém cola num chamado de suporte.
19
23
  *
20
- * Conta criada só com o Google não tem senha, e não entra por aqui.
21
- *
22
- * ── O ARQUIVO DA CREDENCIAL ──────────────────────────────────────────────
23
- * Ele NÃO entra no config.json. Aquele arquivo guarda preferência — modo,
24
- * esforço, mascote — e é o que alguém abre para conferir um ajuste, cola num
25
- * chamado de suporte ou copia para outra máquina. Credencial junto de
26
- * preferência é credencial que vaza por hábito, não por ataque.
27
- *
28
- * Mora em `~/.primocode/conta.json`, com permissão 600 onde o sistema tem
29
- * permissão (no Windows o modo é ignorado, e está dito no comentário de lá).
24
+ * ── O PLANO ──────────────────────────────────────────────────────────────
25
+ * Mora em users/{uid} no Firestore (plano, planoAte), escrito só pelo
26
+ * servidor quando o pagamento cai. O terminal lê com o token da pessoa; o
27
+ * arquivo local guarda a última leitura só para o selo da abertura.
30
28
  */
31
29
 
32
30
  'use strict';
@@ -34,67 +32,37 @@
34
32
  const fs = require('fs');
35
33
  const os = require('os');
36
34
  const path = require('path');
37
- const https = require('https');
38
35
  const http = require('http');
39
-
40
- // O servidor do Conecta Primo — que NÃO é o servidor do PrimoCode. São dois
41
- // serviços diferentes: um responde o chat do app e a conta, o outro é o motor
42
- // do agente. Confundir os dois dá "não autenticado" sem explicação.
43
- const CONECTA = process.env.PRIMOCODE_CONTA_SERVER || 'https://conecta-primo-api.onrender.com';
36
+ const crypto = require('crypto');
37
+ const { spawn } = require('child_process');
44
38
 
45
39
  const ARQUIVO = path.join(os.homedir(), '.primocode', 'conta.json');
46
40
 
47
- // O login com e-mail e senha é o do Firebase do Conecta Primo. A chave é a
48
- // PÚBLICA do app web (a mesma que vai no frontend, à vista de qualquer um):
49
- // ela identifica o projeto, não dá acesso a nada sem a senha da pessoa.
50
- const FIREBASE_CHAVE = process.env.PRIMOCODE_FIREBASE_API_KEY || 'AIzaSyAyaj994i9Hr2le8Q-4YxxkgJJnL2NtJLg';
51
- const FIREBASE_AUTH = process.env.PRIMOCODE_FIREBASE_AUTH_URL || 'https://identitytoolkit.googleapis.com/v1';
52
-
53
- // Os planos que liberam tudo. A lista é a MESMA do servidor
54
- // (`PLANOS_COM_PRO`), e quem decide de verdade é ele: isto aqui só evita uma
55
- // ida à rede para mostrar o selo na abertura.
56
- const PLANOS_PRO = ['premium', 'super'];
57
- /* ASSINANTE É QUEM NÃO ESTÁ NO GRÁTIS (11/10). A conta do Joel veio do Conecta
58
- como "empresarial" — um plano pago que esta lista não conhecia — e ele caiu
59
- no limite de quem não paga, com a mensagem "assine o Premium". O Conecta cria
60
- plano novo sem avisar o PrimoCode; o que não muda é o nome do grátis. */
61
- const PLANOS_GRATIS = ['free', 'gratis', 'grátis', 'gratuito', 'basico', 'básico', ''];
62
- const ehPago = (plano) => !PLANOS_GRATIS.includes(String(plano == null ? '' : plano).trim().toLowerCase());
63
-
64
- function pedir(rota, corpo, timeout = 30000, { token, base = CONECTA } = {}) {
65
- return new Promise((resolve, reject) => {
66
- const url = new URL(base + rota);
67
- const lib = url.protocol === 'https:' ? https : http;
68
- const dados = corpo == null ? null : Buffer.from(JSON.stringify(corpo), 'utf8');
69
- const cabecalhos = { Accept: 'application/json' };
70
- if (dados) {
71
- cabecalhos['Content-Type'] = 'application/json';
72
- cabecalhos['Content-Length'] = dados.length;
73
- }
74
- // Quem chama pode mandar o token (o do Firebase, no login) ou `null`
75
- // para ir SEM token nenhum — o pedido ao Firebase nunca pode levar o
76
- // `pc_` guardado. Sem a opção, vai o da credencial guardada.
77
- const cred = token !== undefined ? (token ? { token } : null) : ler();
78
- if (cred && cred.token) cabecalhos.Authorization = `Bearer ${cred.token}`;
79
-
80
- const req = lib.request({
81
- hostname: url.hostname, port: url.port || undefined,
82
- path: url.pathname + url.search,
83
- method: dados ? 'POST' : 'GET', timeout, headers: cabecalhos,
84
- }, (res) => {
85
- let texto = '';
86
- res.setEncoding('utf8');
87
- res.on('data', (c) => { texto += c; });
88
- res.on('end', () => {
89
- try { resolve(JSON.parse(texto)); }
90
- catch { reject(new Error(`resposta inesperada do servidor (${res.statusCode})`)); }
91
- });
92
- });
93
- req.on('timeout', () => req.destroy(new Error('o servidor demorou demais')));
94
- req.on('error', reject);
95
- if (dados) req.write(dados);
96
- req.end();
97
- });
41
+ /* PÚBLICA: identifica o projeto, não dá acesso a nada sem a senha da pessoa
42
+ (quem decide o que cada um faz são as regras do Firestore). */
43
+ const FIREBASE_CHAVE = process.env.PRIMOCODE_FIREBASE_API_KEY || 'AIzaSyBrzCAbG4AilaDyiOcJI0R1il_aldOxUsg';
44
+ const PROJETO = process.env.PRIMOCODE_FIREBASE_PROJETO || 'primocode';
45
+ const IDENTIDADE = process.env.PRIMOCODE_FIREBASE_AUTH_URL || 'https://identitytoolkit.googleapis.com/v1';
46
+ const RENOVAR = process.env.PRIMOCODE_FIREBASE_TOKEN_URL || 'https://securetoken.googleapis.com/v1';
47
+ const FIRESTORE = process.env.PRIMOCODE_FIRESTORE || 'https://firestore.googleapis.com';
48
+ const SITE = process.env.PRIMOCODE_SITE || 'https://primocode.web.app';
49
+
50
+ /* Os planos pagos, com os nomes antigos do Conecta ainda valendo. A lista
51
+ de verdade é a do servidor (lib/planos.js de lá); esta só desenha o selo. */
52
+ const PLANOS_PAGOS = ['primo', 'pro', 'max', 'studio', 'premium', 'super', 'empresarial'];
53
+ const ehPago = (plano, ate) => PLANOS_PAGOS.includes(String(plano == null ? '' : plano).trim().toLowerCase())
54
+ && (!ate || new Date(ate).getTime() > Date.now());
55
+
56
+ async function json(url, { metodo = 'POST', corpo, cabecalhos = {}, form = false, buscar = fetch } = {}) {
57
+ const op = { method: metodo, headers: { Accept: 'application/json', ...cabecalhos } };
58
+ if (corpo != null) {
59
+ op.headers['Content-Type'] = form ? 'application/x-www-form-urlencoded' : 'application/json';
60
+ op.body = form ? new URLSearchParams(corpo).toString() : JSON.stringify(corpo);
61
+ }
62
+ const r = await buscar(url, { ...op, signal: AbortSignal.timeout(20000) });
63
+ let j = {};
64
+ try { j = await r.json(); } catch { /* pode vir vazio */ }
65
+ return { ok: r.ok, status: r.status, j: j || {} };
98
66
  }
99
67
 
100
68
  // ── o arquivo ────────────────────────────────────────────────────────────
@@ -106,9 +74,8 @@ function ler() {
106
74
 
107
75
  function gravar(dados) {
108
76
  fs.mkdirSync(path.dirname(ARQUIVO), { recursive: true });
109
- fs.writeFileSync(ARQUIVO, JSON.stringify(dados, null, 2), 'utf8');
110
- // 600 = só o dono lê. No Windows o modo é ignorado pelo sistema de
111
- // arquivos e a chamada não falha — lá quem protege é a pasta do perfil.
77
+ fs.writeFileSync(ARQUIVO, JSON.stringify(dados, null, 2), { encoding: 'utf8', mode: 0o600 });
78
+ // No Windows o modo é ignorado e a chamada não falha: lá protege a pasta do perfil.
112
79
  try { fs.chmodSync(ARQUIVO, 0o600); } catch {}
113
80
  }
114
81
 
@@ -117,127 +84,185 @@ function sair() {
117
84
  catch { return false; }
118
85
  }
119
86
 
120
- /** Quem está conectado, sem ir à rede. */
87
+ /** Quem está conectado, sem ir à rede. A credencial antiga do Conecta (`pc_`) não vale mais. */
121
88
  function atual() {
122
89
  const c = ler();
123
- if (!c || !c.token) return null;
124
- return { uid: c.uid, plano: c.plano || 'free', email: c.email || null,
125
- pro: ehPago(c.plano), desde: c.desde };
90
+ if (!c || !c.refreshToken || !c.uid) return null;
91
+ return { uid: c.uid, email: c.email || null, plano: c.plano || 'free', planoAte: c.planoAte || null,
92
+ pro: ehPago(c.plano, c.planoAte), desde: c.desde };
126
93
  }
127
94
 
128
- // ── entrar ───────────────────────────────────────────────────────────────
95
+ /** Sobrou a credencial do Conecta? Então a pessoa precisa entrar de novo, e vale dizer por quê. */
96
+ function doConectaAntigo() {
97
+ const c = ler();
98
+ return !!(c && c.token && !c.refreshToken);
99
+ }
100
+
101
+ // ── os tokens ────────────────────────────────────────────────────────────
129
102
 
130
103
  /* O que o Firebase responde quando não entra → o que a pessoa lê. Os códigos
131
104
  vêm no começo de `error.message` ("INVALID_PASSWORD : …"). */
132
105
  function erroDoFirebase(codigo) {
133
106
  const c = String(codigo || '').split(/\s|:/)[0].toUpperCase();
134
107
  if (/INVALID_LOGIN_CREDENTIALS|INVALID_PASSWORD|EMAIL_NOT_FOUND/.test(c)) {
135
- return 'e-mail ou senha incorretos. Se você entra no app pelo Google, essa conta não tem senha para o terminal.';
108
+ return `e-mail ou senha incorretos. Esqueceu a senha? ${SITE}/entrar#esqueci`;
136
109
  }
137
110
  if (c === 'INVALID_EMAIL') return 'esse e-mail não parece válido.';
138
111
  if (c === 'USER_DISABLED') return 'essa conta está desativada.';
139
112
  if (/TOO_MANY_ATTEMPTS/.test(c)) return 'muitas tentativas seguidas. Espere alguns minutos e tente de novo.';
140
113
  if (c === 'MISSING_PASSWORD') return 'faltou a senha.';
114
+ if (/TOKEN_EXPIRED|INVALID_REFRESH_TOKEN|USER_NOT_FOUND/.test(c)) return 'a sessão venceu. Entre de novo com /login.';
141
115
  return `o login recusou (${c || 'sem motivo'}).`;
142
116
  }
143
117
 
118
+ /** Guarda um login novo (vindo do navegador ou da senha) e já lê o plano. */
119
+ async function guardarLogin({ refreshToken, idToken, uid, email, expiraEm }, op = {}) {
120
+ gravar({ uid, email: email || null, refreshToken, idToken: idToken || null,
121
+ expira: idToken ? Date.now() + (Number(expiraEm) || 3600) * 1000 : 0,
122
+ plano: 'free', planoAte: null, desde: new Date().toISOString() });
123
+ const p = await lerPlano(op);
124
+ const plano = p.ok ? p.plano : 'free';
125
+ return { ok: true, uid, email, plano, pro: ehPago(plano, p.planoAte) };
126
+ }
127
+
144
128
  /**
145
- * O login inteiro, sem navegador: e-mail e senha → token `pc_` guardado.
146
- *
147
- * @param {object} op
148
- * @param {string} op.email
149
- * @param {string} op.senha
129
+ * O token de acesso válido por pelo menos mais 5 minutos (renova se precisar).
130
+ * Devolve null se não há conta, e apaga a credencial se o Firebase disser que ela morreu.
150
131
  */
151
- async function entrar({ email, senha } = {}) {
152
- email = String(email || '').trim();
153
- if (!email || !senha) return { ok: false, error: 'informe o e-mail e a senha.' };
154
-
155
- // 1. Firebase: a mesma conta do app.
156
- let fb;
132
+ async function tokenValido({ buscar = fetch, forcar = false } = {}) {
133
+ const c = ler();
134
+ if (!c || !c.refreshToken) return null;
135
+ if (!forcar && c.idToken && Date.now() < (c.expira || 0) - 300000) return c.idToken;
136
+ let r;
157
137
  try {
158
- fb = await pedir(`/accounts:signInWithPassword?key=${encodeURIComponent(FIREBASE_CHAVE)}`,
159
- { email, password: String(senha), returnSecureToken: true }, 20000,
160
- { token: null, base: FIREBASE_AUTH });
161
- } catch (e) {
162
- return { ok: false, error: `não consegui falar com o login: ${e.message}` };
163
- }
164
- if (!fb || !fb.idToken) {
165
- return { ok: false, error: erroDoFirebase(fb && fb.error && fb.error.message) };
138
+ r = await json(`${RENOVAR}/token?key=${encodeURIComponent(FIREBASE_CHAVE)}`,
139
+ { corpo: { grant_type: 'refresh_token', refresh_token: c.refreshToken }, form: true, buscar });
140
+ } catch { return c.idToken || null; } // sem rede: o servidor responde se ainda vale
141
+ if (!r.ok || !r.j.id_token) {
142
+ const m = r.j.error && (r.j.error.message || r.j.error);
143
+ if (/TOKEN_EXPIRED|INVALID_REFRESH_TOKEN|USER_NOT_FOUND|USER_DISABLED/.test(String(m))) sair();
144
+ return null;
166
145
  }
146
+ gravar({ ...c, idToken: r.j.id_token, refreshToken: r.j.refresh_token || c.refreshToken,
147
+ expira: Date.now() + (Number(r.j.expires_in) || 3600) * 1000 });
148
+ return r.j.id_token;
149
+ }
167
150
 
168
- // 2. O pedido de dispositivo, como o navegador abria.
169
- let pedido;
170
- try {
171
- pedido = await pedir('/api/dispositivo/novo', { origem: `PrimoCode ${versao()}` },
172
- 30000, { token: fb.idToken });
173
- } catch (e) {
174
- return { ok: false, error: `não consegui falar com o Conecta Primo: ${e.message}` };
175
- }
176
- if (!pedido || !pedido.success) {
177
- return { ok: false, error: (pedido && pedido.error) || 'o servidor recusou abrir o login' };
151
+ let relogio = null;
152
+ /* Na abertura e a cada minuto: o token dura uma hora, e o config lê o do
153
+ arquivo a cada pedido. Depois do notebook dormir, no máximo um minuto de atraso. */
154
+ async function manterVivo() {
155
+ await tokenValido().catch(() => null);
156
+ if (!relogio) {
157
+ relogio = setInterval(() => { tokenValido().catch(() => null); }, 60000);
158
+ relogio.unref();
178
159
  }
160
+ }
161
+
162
+ // ── o plano ──────────────────────────────────────────────────────────────
179
163
 
180
- // 3. Aprovar com o token do Firebase e recolher o `pc_`. O apelido é o que
181
- // aparece na lista de computadores da conta.
164
+ async function lerPlano({ buscar = fetch } = {}) {
165
+ const token = await tokenValido({ buscar });
166
+ const c = ler();
167
+ if (!token || !c) return { ok: false, error: 'não conectado' };
168
+ let r;
182
169
  try {
183
- const ap = await pedir('/api/dispositivo/aprovar',
184
- { codigo: pedido.codigo, apelido: `PrimoCode · ${os.hostname()}`.slice(0, 60) },
185
- 30000, { token: fb.idToken });
186
- if (!ap || !ap.success) {
187
- return { ok: false, error: (ap && ap.error) || 'o servidor recusou aprovar o login' };
188
- }
189
- const r = await pedir('/api/dispositivo/consultar',
190
- { codigo: pedido.codigo, segredo: pedido.segredo }, 30000, { token: null });
191
- if (!r || !r.success || r.estado !== 'aprovado' || !r.token) {
192
- return { ok: false, error: (r && r.error) || 'o login não foi concluído. Tente de novo.' };
193
- }
194
- gravar({
195
- token: r.token, uid: r.uid, plano: r.plano || 'free', email,
196
- desde: new Date().toISOString(), servidor: CONECTA,
197
- });
198
- return { ok: true, uid: r.uid, plano: r.plano, pro: ehPago(r.plano) };
199
- } catch (e) {
200
- return { ok: false, error: `não consegui concluir o login: ${e.message}` };
201
- }
170
+ r = await json(`${FIRESTORE}/v1/projects/${PROJETO}/databases/(default)/documents/users/${encodeURIComponent(c.uid)}`,
171
+ { metodo: 'GET', cabecalhos: { Authorization: `Bearer ${token}` }, buscar });
172
+ } catch (e) { return { ok: false, error: e.message, offline: true }; }
173
+ if (r.status === 404) return { ok: true, plano: 'free', planoAte: null }; // conta nova, sem documento ainda
174
+ if (!r.ok) return { ok: false, error: `não consegui ler o plano (HTTP ${r.status})` };
175
+ const f = r.j.fields || {};
176
+ const plano = (f.plano && f.plano.stringValue) || 'free';
177
+ const planoAte = (f.planoAte && f.planoAte.timestampValue) || null;
178
+ if (plano !== c.plano || planoAte !== c.planoAte) gravar({ ...ler(), plano, planoAte });
179
+ return { ok: true, plano, planoAte };
202
180
  }
203
181
 
204
182
  /**
205
- * Confirma o plano no SERVIDOR e devolve a sessão para o Firestore.
206
- *
207
- * O plano guardado no arquivo é uma fotografia do dia do login: quem assinou
208
- * ontem continuaria como gratuito, e quem cancelou continuaria Premium. Quem
209
- * sabe é o servidor, e é ele quem diz — o arquivo local serve para mostrar o
210
- * selo na abertura sem esperar a rede, e só.
183
+ * Confirma o plano e devolve a sessão (o uid e o token) para o Firestore.
184
+ * O plano guardado é a foto do último login; quem sabe é o documento.
211
185
  */
212
- async function sessao() {
186
+ async function sessao(op = {}) {
213
187
  const c = ler();
214
- if (!c || !c.token) return { ok: false, error: 'não conectado' };
215
- let r;
216
- try { r = await pedir('/api/primocode/sessao', {}); }
217
- catch (e) { return { ok: false, error: e.message, offline: true }; }
218
- if (!r || !r.success) {
219
- // Token revogado no painel, ou vencido: o arquivo local não vale mais.
220
- // Apagar aqui evita a pessoa ficar vendo "conectado" para sempre com
221
- // uma credencial morta.
222
- if (r && /autenticado/i.test(r.error || '')) sair();
223
- return { ok: false, error: (r && r.error) || 'o servidor recusou a sessão' };
188
+ if (!c || !c.refreshToken) return { ok: false, error: 'não conectado' };
189
+ const p = await lerPlano(op);
190
+ if (!p.ok) {
191
+ if (!ler()) return { ok: false, error: 'a sessão venceu. Entre de novo com /login.' };
192
+ return { ok: false, error: p.error, offline: !!p.offline };
224
193
  }
225
- // O plano volta atualizado: grava por cima, senão o selo da abertura
226
- // continua mostrando o de ontem.
227
- if (r.plano && r.plano !== c.plano) gravar({ ...c, plano: r.plano });
228
- return { ok: true, uid: r.uid, plano: r.plano, pro: !!r.pro,
229
- customToken: r.custom_token, apiKey: r.api_key, projeto: r.projeto };
194
+ const agora = ler();
195
+ return { ok: true, uid: agora.uid, email: agora.email, plano: p.plano, planoAte: p.planoAte,
196
+ pro: ehPago(p.plano, p.planoAte), idToken: agora.idToken, projeto: PROJETO };
197
+ }
198
+
199
+ // ── entrar ───────────────────────────────────────────────────────────────
200
+
201
+ /** Sem navegador: e-mail e senha aqui mesmo. */
202
+ async function entrar({ email, senha } = {}, op = {}) {
203
+ email = String(email || '').trim();
204
+ if (!email || !senha) return { ok: false, error: 'informe o e-mail e a senha.' };
205
+ let r;
206
+ try {
207
+ r = await json(`${IDENTIDADE}/accounts:signInWithPassword?key=${encodeURIComponent(FIREBASE_CHAVE)}`,
208
+ { corpo: { email, password: String(senha), returnSecureToken: true }, buscar: op.buscar });
209
+ } catch (e) { return { ok: false, error: `não consegui falar com o login: ${e.message}` }; }
210
+ if (!r.ok || !r.j.idToken) return { ok: false, error: erroDoFirebase(r.j.error && r.j.error.message) };
211
+ return guardarLogin({ refreshToken: r.j.refreshToken, idToken: r.j.idToken, uid: r.j.localId,
212
+ email: r.j.email || email, expiraEm: r.j.expiresIn }, op);
230
213
  }
231
214
 
232
- /** Os computadores conectados nesta conta. */
233
- async function dispositivos() {
234
- const r = await pedir('/api/primocode/dispositivos').catch((e) => ({ error: e.message }));
235
- if (!r || !r.success) return { ok: false, error: (r && r.error) || 'não deu para listar' };
236
- return { ok: true, dispositivos: r.dispositivos || [] };
215
+ function abrirNoNavegador(url) {
216
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
217
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
218
+ : ['xdg-open', [url]];
219
+ try { spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref(); return true; }
220
+ catch { return false; }
237
221
  }
238
222
 
239
- function versao() {
240
- try { return require('../package.json').version; } catch { return '?'; }
223
+ const PAGINA = (titulo, texto) => `<!doctype html><meta charset="utf-8"><title>PrimoCode</title><link rel="icon" href="data:,">
224
+ <style>body{font:16px system-ui;background:#0b0b10;color:#eee;display:grid;place-items:center;height:100vh;margin:0}
225
+ main{text-align:center;max-width:28rem;padding:1rem}h1{font-size:1.4rem}</style>
226
+ <main><h1>${titulo}</h1><p>${texto}</p></main>`;
227
+
228
+ /**
229
+ * Pelo navegador: abre o site, espera o formulário voltar no localhost.
230
+ * @param {{abrir?:(url:string)=>any, aviso?:(url:string)=>void, prazo?:number, buscar?:Function}} op
231
+ */
232
+ function entrarPeloNavegador({ abrir = abrirNoNavegador, aviso = () => {}, prazo = 10 * 60000, buscar } = {}) {
233
+ const estado = crypto.randomBytes(16).toString('hex');
234
+ return new Promise((resolve) => {
235
+ let acabou = false;
236
+ const fim = (r) => { if (acabou) return; acabou = true; clearTimeout(timer); srv.close(); resolve(r); };
237
+ const srv = http.createServer((req, res) => {
238
+ if (req.method !== 'POST' || !req.url.startsWith('/pronto')) { res.statusCode = 404; return res.end(); }
239
+ let corpo = '';
240
+ req.on('data', (c) => { corpo += c; if (corpo.length > 20000) req.destroy(); });
241
+ req.on('end', async () => {
242
+ const f = new URLSearchParams(corpo);
243
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
244
+ if (f.get('estado') !== estado || !f.get('refreshToken') || !f.get('uid')) {
245
+ res.statusCode = 400;
246
+ return res.end(PAGINA('Esse login não é deste terminal', 'Volte ao terminal e rode /login de novo.'));
247
+ }
248
+ res.end(PAGINA('Pronto, você entrou ✓', 'Pode fechar esta aba e voltar ao terminal.'));
249
+ try {
250
+ fim(await guardarLogin({ refreshToken: f.get('refreshToken'), idToken: f.get('idToken'),
251
+ uid: f.get('uid'), email: f.get('email'), expiraEm: 3600 }, { buscar }));
252
+ } catch (e) { fim({ ok: false, error: e.message }); }
253
+ });
254
+ });
255
+ const timer = setTimeout(() => fim({ ok: false, error: 'o login no navegador não voltou em 10 minutos. Rode /login de novo.' }), prazo);
256
+ srv.on('error', (e) => fim({ ok: false, error: `não consegui abrir a porta do login: ${e.message}` }));
257
+ srv.listen(0, '127.0.0.1', () => {
258
+ const url = `${SITE}/entrar?porta=${srv.address().port}&estado=${estado}`;
259
+ aviso(url);
260
+ abrir(url);
261
+ });
262
+ });
241
263
  }
242
264
 
243
- module.exports = { entrar, sair, atual, sessao, dispositivos, erroDoFirebase, ARQUIVO, CONECTA, PLANOS_PRO, PLANOS_GRATIS, ehPago };
265
+ module.exports = {
266
+ entrar, entrarPeloNavegador, sair, atual, sessao, lerPlano, tokenValido, manterVivo, doConectaAntigo,
267
+ erroDoFirebase, ehPago, ARQUIVO, SITE, PROJETO, FIREBASE_CHAVE, PLANOS_PAGOS,
268
+ };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * conferencia.js — a conferência do vídeo PRONTO: o arquivo, medido, e não
3
+ * o plano.
4
+ *
5
+ * "gostei, ficou bom. mas ainda não. tá quase lá" (Joel, 11/10)
6
+ *
7
+ * O revisor.js olha o plano antes do render. Este olha o .mp4 que saiu, e
8
+ * troca o "quase lá" no olho por número que se confere sem abrir o vídeo
9
+ * (a ideia veio do review.mjs do cth9191/animate, MIT):
10
+ *
11
+ * som volume integrado (LUFS), variação (LRA) e pico verdadeiro, pelo
12
+ * ebur128 do ffmpeg. Alvo: -14 LUFS (±2) e pico até -1 dBTP.
13
+ * parado trecho em que a imagem não muda por 2 s ou mais, pelo
14
+ * freezedetect, com o quadro reduzido a 320 px. Calibrado em 13/10
15
+ * em 5 vídeos: na resolução cheia o ruído do fundo esconde a tela
16
+ * parada; a 160 px o texto fica pequeno demais e a palavra que
17
+ * entra não conta como mudança. A 320 px o teste-10-5-motion-com-
18
+ * imagem dá 6,5 s a 8,3 s ("torrado" parado, a pílula vazia, e
19
+ * para quando "café" entra), e os vídeos 3D não dão nada.
20
+ *
21
+ * Só mede e avisa. Não conserta nada: quem decide se refaz é o agente.
22
+ * As duas leituras (lerSom, lerParados) são puras e testáveis sem vídeo.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const video = require('./video.js');
28
+
29
+ const ALVO = { lufs: -14, folgaLufs: 2, picoMax: -1, paradoMin: 2 };
30
+ const r1 = (n) => Math.round(n * 10) / 10;
31
+
32
+ /* O ebur128 escreve uma linha a cada 100 ms e um resumo no fim. Só o resumo
33
+ vale: a última linha "I:" de antes dele é a média até ali, não do vídeo. */
34
+ function lerSom(texto) {
35
+ const t = String(texto || '');
36
+ const i = t.lastIndexOf('Summary:');
37
+ if (i < 0) return null;
38
+ const resumo = t.slice(i);
39
+ const num = (re) => { const m = resumo.match(re); return m ? Number(m[1]) : null; };
40
+ const lufs = num(/I:\s*(-?[\d.]+|-inf)\s*LUFS/);
41
+ if (lufs == null) return null;
42
+ return { lufs, lra: num(/LRA:\s*(-?[\d.]+)\s*LU\b/), pico: num(/Peak:\s*(-?[\d.]+)\s*dBFS/) };
43
+ }
44
+
45
+ /* O freezedetect marca o começo e o fim de cada trecho parado. Um começo
46
+ sem fim é tela parada até o último quadro. Dois trechos colados (o texto
47
+ some e o fundo fica) são, na tela, uma parada só. */
48
+ function lerParados(texto, duracao) {
49
+ const trechos = [];
50
+ const juntar = (de, ate) => {
51
+ const ultimo = trechos[trechos.length - 1];
52
+ if (ultimo && de - ultimo.ate < 0.2) ultimo.ate = r1(ate);
53
+ else trechos.push({ de: r1(de), ate: r1(ate) });
54
+ };
55
+ let de = null;
56
+ for (const linha of String(texto || '').split('\n')) {
57
+ const i = linha.match(/freeze_start:\s*([\d.]+)/);
58
+ if (i) { de = Number(i[1]); continue; }
59
+ const f = linha.match(/freeze_end:\s*([\d.]+)/);
60
+ if (f && de != null) { juntar(de, Number(f[1])); de = null; }
61
+ }
62
+ if (de != null && duracao > de) juntar(de, duracao);
63
+ return trechos;
64
+ }
65
+
66
+ /** As medidas viram avisos. Puro. */
67
+ function julgar({ som, parados = [] }, alvo = ALVO) {
68
+ const avisos = [];
69
+ if (som && Number.isFinite(som.lufs)) {
70
+ if (Math.abs(som.lufs - alvo.lufs) > alvo.folgaLufs) {
71
+ avisos.push(`volume em ${som.lufs} LUFS, longe do alvo de ${alvo.lufs} (${som.lufs < alvo.lufs ? 'baixo demais' : 'alto demais'})`);
72
+ }
73
+ if (som.pico != null && som.pico > alvo.picoMax) avisos.push(`pico de ${som.pico} dBTP: estoura no celular (o máximo é ${alvo.picoMax})`);
74
+ }
75
+ for (const p of parados) {
76
+ const dura = r1(p.ate - p.de);
77
+ if (dura >= alvo.paradoMin) avisos.push(`imagem parada por ${dura} s (de ${p.de} s a ${p.ate} s)`);
78
+ }
79
+ return avisos;
80
+ }
81
+
82
+ /** Mede o arquivo numa passada só do ffmpeg e devolve medidas e avisos. */
83
+ async function conferir(arquivo, { duracao = 0 } = {}) {
84
+ const c = video.conferir();
85
+ if (!c.ok) return c;
86
+ const r = await video.rodar(video.ffmpeg(), [
87
+ '-hide_banner', '-nostats', '-i', arquivo,
88
+ '-vf', 'scale=320:-2,freezedetect=n=0.002:d=1.5',
89
+ '-af', 'ebur128=peak=true',
90
+ '-f', 'null', '-',
91
+ ], { timeout: 10 * 60000 });
92
+ if (!r.ok) return { ok: false, error: `a conferência não leu o vídeo: ${r.erro.slice(-200)}` };
93
+ const som = lerSom(r.erro);
94
+ const parados = lerParados(r.erro, duracao);
95
+ return { ok: true, som, parados, avisos: julgar({ som, parados }) };
96
+ }
97
+
98
+ module.exports = { ALVO, lerSom, lerParados, julgar, conferir };
@@ -486,8 +486,17 @@ const itensDe = (p) => [...(p.itens || []), ...(p.barras || []).map((b) => `${b.
486
486
  ...(p.cores || []).map((c) => (typeof c === 'string' ? c : `${c.nome || ''} ${c.hex || ''}`.trim()))]
487
487
  .map((i) => (typeof i === 'string' ? i : i.texto || i.titulo || '')).filter(Boolean);
488
488
 
489
+ /* A parte que só traz `fala` (o modelo grátis escreve assim) não tinha nada
490
+ para a tela, e cada composição quebrava num lugar diferente (12/10). O
491
+ título vira a primeira oração da fala, com até 6 palavras. */
492
+ function tituloDaFala(fala) {
493
+ const oracao = String(fala || '').split(/[.?!;:—]|,\s/).map((x) => x.trim()).find(Boolean) || '';
494
+ const w = oracao.split(/\s+/).filter(Boolean);
495
+ return w.length > 6 ? w.slice(0, 6).join(' ') + '…' : oracao + (/\?/.test(String(fala || '').slice(oracao.length, oracao.length + 2)) ? '?' : '');
496
+ }
497
+
489
498
  function conteudoDe(p, marca = {}) {
490
- const titulo = p.titulo || p.texto || '';
499
+ const titulo = p.titulo || p.texto || tituloDaFala(p.fala);
491
500
  const itens = itensDe(p);
492
501
  if (p.tipo === 'numero' && numeroDe(p.valor)) return { tipo: 'numero', valor: String(p.valor), rotulo: p.rotulo || titulo, sub: p.sub || '' };
493
502
  if (itens.length >= 2) return { tipo: 'lista', titulo: p.tipo === 'cores' ? titulo : titulo, itens: itens.slice(0, 5), numerada: p.tipo === 'passos' };
@@ -1072,6 +1081,14 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1072
1081
  });
1073
1082
  return;
1074
1083
  }
1084
+ if (nome === 'duelo' && !(String(c.nao || '').trim() && String(c.sim || '').trim())) {
1085
+ /* O "contraste" sem nao/sim (o roteiro só trouxe a fala, 12/10) virava
1086
+ dois títulos vazios e o plano morria. Vira um título com o que a
1087
+ voz diz no começo do trecho. */
1088
+ const ditas = (ctx.palavras || []).filter((w) => w.de >= br.de - 0.05 && w.de < br.ate).slice(0, 5).map((w) => w.texto).join(' ');
1089
+ registra(compor(anterior === 'gigante' ? 'pilha' : 'gigante', { ...br, conteudo: { tipo: 'titulo', titulo: c.titulo || c.texto || ditas || '…', sub: '', destaque: c.destaque || '' } }, ctx));
1090
+ return;
1091
+ }
1075
1092
  if (nome === 'duelo') {
1076
1093
  const meio = casar([c.sim], ctx.palavras, { de: br.de + 1.2 })[0];
1077
1094
  const corte = r3(Math.min(br.ate - 1.4, Math.max(br.de + 1.3, meio != null ? meio - 0.1 : (br.de + br.ate) / 2)));
@@ -1483,7 +1500,8 @@ function compor(comp, br, ctx) {
1483
1500
  comportamento: o.comportamento || null, riscado: !!o.riscado, envelhece: null,
1484
1501
  };
1485
1502
  l.palavras = l.texto.split(/\s+/).filter(Boolean).map((w) => ({ texto: w, papel: ehTese(w) ? 'tese' : funcao(w) ? 'funcao' : 'normal' }));
1486
- b.linhas.push(l);
1503
+ // Linha sem palavra não entra na tela: todo o resto (acento, golpe, tempo) supõe ao menos uma (12/10).
1504
+ if (l.palavras.length) b.linhas.push(l);
1487
1505
  return l;
1488
1506
  };
1489
1507
  // Os pesos DENTRO da frase: artigo leve, verbo regular, tese pesada e maior.
@@ -1520,6 +1538,10 @@ function compor(comp, br, ctx) {
1520
1538
  if (!destaqueTk.length) l.palavras.forEach((p) => { p.papel = 'normal'; });
1521
1539
  }
1522
1540
  const ultima = b.linhas[b.linhas.length - 1];
1541
+ /* Sem texto que caiba (a parte veio só com fala, sem título), a tela
1542
+ fica sem linha — e o acento caía em undefined.palavras e matava o
1543
+ plano inteiro (12/10, o estilo padrão quebrava). Sem linha, sem acento. */
1544
+ if (!ultima) break;
1523
1545
  if (c.riscado) b.acento.push({ forma: 'risco', alvo: { linha: ultima.id } });
1524
1546
  else if (/[.!]$/.test(txt) || s.chance(0.4)) b.acento.push({ forma: 'ponto', alvo: { linha: ultima.id, fim: true } });
1525
1547
  else {
@@ -1859,7 +1881,7 @@ function contarAcentos(b) {
1859
1881
  return n;
1860
1882
  }
1861
1883
 
1862
- module.exports = {
1884
+ module.exports = { tituloDaFala,
1863
1885
  montarGerativo, partituraDe, sonsDoVideo, musicaDe, desenharSom, humorDe, escolherVisual, MOLDURAS, AMBIENTES, ACENTOS_JEITO, ARRANJOS_ICONE, RITMOS_ICONE, MATERIAIS_ICONE, corGenerica, BANIDAS, HUMORES, REGRAS, VARIAVEIS, COMPOSICOES, COMPORTAMENTOS, SAIDAS, ACENTOS, FPS,
1864
1886
  especEixos, sorteador, hash, larguraEm, conteudoDe, casar, HEROIS_OBJETO, HEROIS_CAMPO, HEROIS_3D, FAMILIA_HEROI,
1865
1887
  };