primocode 10.6.0 → 10.7.1

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,91 @@
1
+ /**
2
+ * cenarios.js — os cenários que a IA criou e a pessoa deixou guardar.
3
+ *
4
+ * "a IA vai criar os próprios cenários, além de ter os próprios cenários
5
+ * prontos, e pede pro usuário se pode salvar pra usar na próxima vez."
6
+ * (Joel, 12/10)
7
+ *
8
+ * Os lugares prontos moram no mundo.js (praia, cidade, cobertura…). A IA já
9
+ * podia mudar o céu, o chão, a luz e os elementos de UMA cena; o que se perdia
10
+ * era o resultado: o próximo vídeo começava do zero. Aqui o cenário inventado
11
+ * ganha nome e fica em ~/.primocode/cenarios.json — só com o "sim" da pessoa
12
+ * (a ferramenta recusa sem `autorizado: true`) — e vira um lugar como os
13
+ * outros: `lugar: "<nome>"` na cena o traz de volta.
14
+ *
15
+ * Só se guarda o que o mundo.js sabe desenhar: o lugar base, cores #rrggbb,
16
+ * a hora e até 7 elementos. Nada de caminho de arquivo nem código.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+
25
+ const arquivo = () => path.join(os.homedir(), '.primocode', 'cenarios.json');
26
+ const ehCor = (c) => typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c);
27
+ const TIPOS_DE_CHAO = ['areia', 'asfalto', 'grama', 'neve', 'lua', 'madeira', 'ladrilho'];
28
+ const HORAS = ['noite', 'por_do_sol', 'amanhecer'];
29
+
30
+ /** "Loja da Ana à noite" → "loja_da_ana_a_noite" (o mesmo jeito dos lugares do mundo.js). */
31
+ function chave(nome) {
32
+ return String(nome || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
33
+ .replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 40);
34
+ }
35
+
36
+ function lerTudo() {
37
+ try { const j = JSON.parse(fs.readFileSync(arquivo(), 'utf8')); return j && typeof j === 'object' ? j : {}; } catch { return {}; }
38
+ }
39
+
40
+ /** Só o que o mundo sabe desenhar; o resto cai fora em silêncio. */
41
+ function limpar(c, bases) {
42
+ const o = c && typeof c === 'object' ? c : {};
43
+ const base = chave(o.base || o.lugar);
44
+ const out = { base: bases.includes(base) ? base : 'cobertura' };
45
+ if (typeof o.descricao === 'string') out.descricao = o.descricao.slice(0, 200);
46
+ if (Array.isArray(o.ceu) && o.ceu.length === 2 && o.ceu.every(ehCor)) out.ceu = o.ceu;
47
+ for (const k of ['parede', 'luz', 'acento']) if (ehCor(o[k])) out[k] = o[k];
48
+ if (o.chao && typeof o.chao === 'object') {
49
+ const ch = {};
50
+ if (TIPOS_DE_CHAO.includes(o.chao.tipo)) ch.tipo = o.chao.tipo;
51
+ if (ehCor(o.chao.cor)) ch.cor = o.chao.cor;
52
+ if (ehCor(o.chao.cor2)) ch.cor2 = o.chao.cor2;
53
+ if (Object.keys(ch).length) out.chao = ch;
54
+ } else if (ehCor(o.chao)) out.chao = { cor: o.chao, cor2: o.chao };
55
+ if (HORAS.includes(o.hora)) out.hora = o.hora.replace(/_/g, ' ');
56
+ if (Array.isArray(o.elementos)) {
57
+ const els = o.elementos.map((e) => (typeof e === 'string' ? e : e && e.nome) || '').map((s) => String(s).slice(0, 40)).filter(Boolean).slice(0, 7);
58
+ if (els.length) out.elementos = els;
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /**
64
+ * Guarda o cenário. `autorizado` é a resposta da pessoa: sem ela, nada é escrito.
65
+ * @param {{nome:string, cenario:object, autorizado:boolean, bases:string[]}} op
66
+ */
67
+ function salvar({ nome, cenario, autorizado, bases = [] } = {}) {
68
+ if (autorizado !== true) {
69
+ return { ok: false, error: 'falta o sim da pessoa.', comoResolver: 'pergunte "posso salvar este cenário pra usar na próxima vez?" e só chame de novo com autorizado:true se ela disser que sim.' };
70
+ }
71
+ const k = chave(nome);
72
+ if (!k) return { ok: false, error: 'dê um nome ao cenário (ex: "loja da Ana à noite").' };
73
+ if (bases.includes(k)) return { ok: false, error: `"${k}" já é um lugar pronto; escolha outro nome.` };
74
+ const tudo = lerTudo();
75
+ const novo = { ...limpar(cenario, bases), nome: String(nome).slice(0, 60), salvo: new Date().toISOString() };
76
+ tudo[k] = novo;
77
+ fs.mkdirSync(path.dirname(arquivo()), { recursive: true });
78
+ fs.writeFileSync(arquivo(), JSON.stringify(tudo, null, 2));
79
+ return { ok: true, chave: k, cenario: novo, comoUsar: `na próxima cena, lugar:"${k}" traz este cenário de volta.` };
80
+ }
81
+
82
+ function ler(nome) {
83
+ const k = chave(nome);
84
+ return k ? lerTudo()[k] || null : null;
85
+ }
86
+
87
+ function listar() {
88
+ return Object.entries(lerTudo()).map(([k, c]) => ({ chave: k, nome: c.nome, base: c.base, descricao: c.descricao || '' }));
89
+ }
90
+
91
+ module.exports = { salvar, ler, listar, limpar, chave, arquivo };
@@ -198,6 +198,7 @@ function cardapio() {
198
198
  personagens3d: 'o estilo personagem (voxel): bonecos de blocos e o mascote, com a câmera de cinema — estudio_roteiro estilo:"personagem"',
199
199
  efeitosSonoros: { sintetizados: sons, internet: 'acao:efeito termo:"whoosh" (Openverse, licença aberta, até 6 s)' },
200
200
  musicas: { internet: 'acao:trilha termo:"lofi" (Openverse, 30 s ou mais)', automatica: 'sem trilha pedida, o PrimoCode escolhe uma sem repetir as últimas' },
201
+ movimentos: { quantos: Object.keys(require('./movimentos.js').CATALOGO).length, acao: 'movimentos', exemplo: 'termo:"quique" → movimento:"quicar-de-baixo"' },
201
202
  fontes: { quantas: fontes || 128, acao: 'fontes', nota: 'Google Fonts, por tipo (condensada, display, serifada, manuscrita…)' },
202
203
  imagens: 'acao:imagem termo:"…" (Openverse e Wikimedia; Google com chave) — só quando a pessoa pediu imagem',
203
204
  };
@@ -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
  };