primocode 10.7.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v10.7.0
1
+ # PrimoCode v10.7.1
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v10.7.0
12
+ ▐▛███▜▌ Primo Code v10.7.1
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/bin/primocode.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * PrimoCode CLI · ConectaPrimoAI
3
+ * PrimoCode CLI
4
4
  * Modos: primocode (agente completo, PADRÃO) · chat (conversa pura)
5
5
  * Cada sessão trabalha dentro de uma pasta de projeto em ~/primocode/.
6
6
  */
@@ -42,6 +42,7 @@ const marketplace = require('../lib/marketplace');
42
42
  const portaoSkill = require('../lib/portao-skill');
43
43
  const vozModo = require('../lib/voz');
44
44
  const conta = require('../lib/conta');
45
+ const planosTela = require('../lib/planos.js');
45
46
  const nuvem = require('../lib/nuvem');
46
47
  const compat = require('../lib/compatibilidade');
47
48
  const mencoesLib = require('../lib/mencoes');
@@ -141,6 +142,7 @@ ${c.bold('Comandos')}
141
142
  ${c.brand('/copiar')} copia a última resposta
142
143
  ${c.brand('/historico')} [termo] procura nos seus pedidos antigos
143
144
  ${c.brand('/login')} entra na sua conta · ${c.brand('/account')} mostra o plano
145
+ ${c.brand('/planos')} os planos e o link para assinar
144
146
  ${c.brand('/chats')} [n] suas conversas — as mesmas do app e do celular
145
147
  ${c.brand('/logout')} desconecta este computador da conta
146
148
  ${c.brand('/comandos')} seus comandos (/revisar, /explicar…) · /comandos novo <nome>
@@ -196,8 +198,9 @@ const SLASH_COMANDOS = [
196
198
  ['/abrir', 'abre o projeto atual: o servidor de pé, ou a index.html'],
197
199
  ['/copiar', 'copia a última resposta'],
198
200
  ['/historico', 'procura nos seus pedidos antigos: /historico login'],
199
- ['/login', 'entra na sua conta Conecta Primo'],
200
- ['/account', 'sua conta, plano e computadores conectados'],
201
+ ['/login', 'entra na sua conta (abre o navegador) · /login senha: e-mail e senha aqui'],
202
+ ['/account', 'sua conta e o seu plano'],
203
+ ['/planos', 'os planos do PrimoCode e o link para assinar'],
201
204
  ['/chats', 'suas conversas salvas, as mesmas do app e do celular'],
202
205
  ['/logout', 'desconecta este computador da conta'],
203
206
  ['!comando', 'roda direto no shell, sem passar pelo agente (o campo fica âmbar)'],
@@ -1131,9 +1134,9 @@ async function sendPrompt(userText) {
1131
1134
  que houve e como resolver, em vez de deixar a pessoa achando que o
1132
1135
  produto está quebrado. */
1133
1136
  if (state.liberado === false) {
1134
- console.log(c.err(' O PrimoCode pede o plano Premium ou Super.'));
1135
- console.log(c.muted(' Assine em ') + c.brand('conecta-primo-ai.vercel.app')
1136
- + c.muted(' · já assinou? ') + c.brand('/login'));
1137
+ console.log(c.err(' O PrimoCode pede um plano (a partir de R$ 69,90/mês).'));
1138
+ console.log(c.muted(' Veja e assine com ') + c.brand('/planos')
1139
+ + c.muted(' · já assinou? ') + c.brand('/account'));
1137
1140
  return;
1138
1141
  }
1139
1142
 
@@ -1736,6 +1739,7 @@ const RENOMEADOS = {
1736
1739
  // a conta
1737
1740
  entrar: 'login', conectar: 'login',
1738
1741
  conta: 'account',
1742
+ plano: 'planos', assinar: 'planos', assinatura: 'planos', precos: 'planos',
1739
1743
  sair: 'exit', // sair é SAIR; desconectar a conta é /logout
1740
1744
  // o histórico
1741
1745
  // /historico deixou de ser apelido do /chats: agora procura nos pedidos
@@ -2478,30 +2482,46 @@ async function handleSlash(text) {
2478
2482
  console.log(c.muted(' A janela saiu na 10.0 — o PrimoCode agora é só o terminal.'));
2479
2483
  break;
2480
2484
 
2481
- /* A conta do Conecta Primo, sem navegador: e-mail e senha aqui
2482
- mesmo. O login inteiro mora em lib/conta.js; aqui é a tela dele. */
2485
+ /* A conta do PrimoCode (lib/conta.js). Padrão: abre o navegador, onde
2486
+ se cria a conta, entra ou redefine a senha. `/login senha` é o
2487
+ caminho sem navegador (SSH, servidor). */
2483
2488
  case 'login': {
2489
+ const partes = String(rest || '').trim().split(/\s+/);
2484
2490
  const jaEsta = conta.atual();
2485
- if (jaEsta && rest !== 'trocar') {
2491
+ if (jaEsta && !partes.includes('trocar')) {
2486
2492
  console.log(c.muted(' Já conectado como ') + c.brand(jaEsta.email || (jaEsta.uid.slice(0, 12) + '…'))
2487
2493
  + c.muted(` · plano ${jaEsta.plano}`));
2488
2494
  console.log(c.dim(' Para entrar com outra conta: ') + c.brand('/login trocar'));
2489
2495
  break;
2490
2496
  }
2491
- if (rest === 'trocar') conta.sair();
2492
-
2493
- const email = String(await ask(c.brand(' E-mail: ')) || '').trim();
2494
- if (!email) { console.log(c.muted(' Login cancelado.')); break; }
2495
- const senha = await askSecret(c.brand(' Senha: '));
2496
- if (!senha) { console.log(c.muted(' Login cancelado.')); break; }
2497
-
2498
- const sp = new Spinner('entrando').start();
2499
- const r = await conta.entrar({ email, senha });
2500
- sp.stop();
2497
+ if (jaEsta || conta.doConectaAntigo()) conta.sair();
2498
+
2499
+ let r;
2500
+ if (partes.includes('senha')) {
2501
+ const email = String(await ask(c.brand(' E-mail: ')) || '').trim();
2502
+ if (!email) { console.log(c.muted(' Login cancelado.')); break; }
2503
+ const senha = await askSecret(c.brand(' Senha: '));
2504
+ if (!senha) { console.log(c.muted(' Login cancelado.')); break; }
2505
+ const sp = new Spinner('entrando').start();
2506
+ r = await conta.entrar({ email, senha });
2507
+ sp.stop();
2508
+ } else {
2509
+ const sp = new Spinner('esperando você entrar no navegador').start();
2510
+ r = await conta.entrarPeloNavegador({
2511
+ aviso: (url) => {
2512
+ sp.stop();
2513
+ console.log(c.muted(' Abri o navegador para você criar a conta ou entrar.'));
2514
+ console.log(c.dim(' Não abriu? Copie: ') + c.brand(url));
2515
+ console.log(c.dim(' Sem navegador aqui? ') + c.brand('/login senha'));
2516
+ sp.start();
2517
+ },
2518
+ });
2519
+ sp.stop();
2520
+ }
2501
2521
  if (!r.ok) { console.log(c.err(` ${r.error}`)); break; }
2502
2522
  console.log(c.ok(' Conectado.')
2503
- + c.muted(` Plano ${r.plano}${r.pro ? '' : ' — assine Premium no app para liberar tudo'}`));
2504
- break;
2523
+ + c.muted(r.pro ? ` Plano ${r.plano}.` : ' Sua conta ainda não tem plano: ') + (r.pro ? '' : c.brand('/planos')));
2524
+ return r;
2505
2525
  }
2506
2526
 
2507
2527
  /* O histórico da conta — o MESMO que o app desktop e o celular
@@ -2558,29 +2578,28 @@ async function handleSlash(text) {
2558
2578
  break;
2559
2579
  }
2560
2580
  const sp = new Spinner('conferindo').start();
2561
- // O plano guardado é a foto do dia do login: quem assinou ontem
2562
- // continuaria gratuito. Quem sabe é o servidor.
2581
+ // O plano guardado é a foto do último login: quem sabe é o documento da conta.
2563
2582
  const s = await conta.sessao();
2564
2583
  sp.stop();
2565
- console.log(c.white(' Conta ') + c.muted(eu.uid));
2584
+ console.log(c.white(' Conta ') + c.muted(eu.email || eu.uid));
2566
2585
  if (s.ok) {
2567
- console.log(c.white(' Plano ')
2568
- + (s.pro ? c.brand(s.plano) : c.muted(s.plano + ' (gratuito)')));
2586
+ const ate = s.planoAte ? c.dim(` até ${new Date(s.planoAte).toLocaleDateString('pt-BR')}`) : '';
2587
+ console.log(c.white(' Plano ') + (s.pro ? c.brand(s.plano) + ate : c.muted('sem plano ') + c.brand('/planos')));
2569
2588
  } else if (s.offline) {
2570
2589
  console.log(c.white(' Plano ') + c.muted(eu.plano) + c.dim(' (sem rede — último conhecido)'));
2571
2590
  } else {
2572
2591
  console.log(c.err(` ${s.error}`));
2573
- break;
2574
- }
2575
- const d = await conta.dispositivos();
2576
- if (d.ok && d.dispositivos.length) {
2577
- console.log(c.white('\n Computadores conectados'));
2578
- for (const x of d.dispositivos) {
2579
- const quando = x.ultimo_uso
2580
- ? new Date(x.ultimo_uso * 1000).toLocaleDateString('pt-BR') : '—';
2581
- console.log(' ' + c.muted((x.apelido || 'Computador').padEnd(14)) + c.dim(quando));
2582
- }
2583
2592
  }
2593
+ console.log(c.dim(' Senha: ') + c.brand(`${conta.SITE}/entrar#esqueci`));
2594
+ break;
2595
+ }
2596
+
2597
+ case 'planos': {
2598
+ const sp = new Spinner('buscando os planos').start();
2599
+ const r = await planosTela.buscarPlanos(state.config.server);
2600
+ sp.stop();
2601
+ const eu = conta.atual();
2602
+ for (const l of planosTela.desenhar(r, { cores: c, atual: eu && eu.pro ? eu.plano : null, site: conta.SITE })) console.log(l);
2584
2603
  break;
2585
2604
  }
2586
2605
 
@@ -2771,6 +2790,8 @@ async function main() {
2771
2790
  process.exit(await require('../lib/mcp.js').main(process.argv.slice(3)));
2772
2791
  }
2773
2792
  cfg.ensureStructure();
2793
+ // O token da conta dura uma hora: renova agora e a cada minuto (lib/conta.js).
2794
+ await conta.manterVivo();
2774
2795
 
2775
2796
  // Padrão fixo: ~/primocode, não importa de onde o comando foi dado. Antes ele
2776
2797
  // adotava a pasta atual do terminal, e o mesmo "crie um site" caía num lugar
@@ -3047,7 +3068,7 @@ async function main() {
3047
3068
  cores: c,
3048
3069
  interativo: !!(process.stdin.isTTY && process.stdout.isTTY),
3049
3070
  conectado: () => conta.atual(),
3050
- conectar: () => handleSlash('/login').then(() => conta.atual()),
3071
+ conectar: () => handleSlash('/login').then((r) => (r && r.ok === false ? r : conta.atual() || r)),
3051
3072
  sessao: () => conta.sessao(),
3052
3073
  }).then((r) => {
3053
3074
  // O bloqueio de quem não é Premium/Super. É de CORTESIA: este arquivo
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * boas-vindas.js — a abertura do PrimoCode, na primeira vez e em toda vez.
3
3
  *
4
- * Duas coisas acontecem aqui, nesta ordem: a pessoa entra na conta do Conecta
5
- * Primo AI, e o plano dela é conferido. Acabou. Não há terceira pergunta.
4
+ * Duas coisas acontecem aqui, nesta ordem: a pessoa entra na conta do
5
+ * PrimoCode, e o plano dela é conferido. Acabou. Não há terceira pergunta.
6
6
  *
7
7
  * ── POR QUE NÃO SE ESCOLHE MAIS A IA ─────────────────────────────────────
8
8
  * Existiam duas telas depois do login: "Com qual IA você quer trabalhar?" e
@@ -17,8 +17,9 @@
17
17
  * A API vem configurada no produto. Entrou na conta, pode pedir a primeira
18
18
  * coisa.
19
19
  *
20
- * Desde 23/09/2026 o login não abre mais o navegador: é e-mail e senha no
21
- * próprio terminal (/login, ver lib/conta.js).
20
+ * Desde 12/10/2026 a conta é do próprio PrimoCode (Firebase, sem o Conecta):
21
+ * o /login abre o navegador, onde se cria a conta, entra ou redefine a senha,
22
+ * e `/login senha` faz pelo terminal (ver lib/conta.js).
22
23
  *
23
24
  * ── O BLOQUEIO É DE CORTESIA ─────────────────────────────────────────────
24
25
  * Este arquivo vai publicado no npm com o código à vista: um `if (pro)` aqui
@@ -62,8 +63,8 @@ function esquecer() {
62
63
  * @param {object} op.cores
63
64
  * @param {boolean} op.interativo false = pipe/CI: não pergunta nada
64
65
  * @param {()=>any} op.conectado a credencial local, ou null
65
- * @param {()=>Promise<any>} op.conectar roda o login (e-mail e senha no terminal)
66
- * @param {()=>Promise<any>} op.sessao confirma o plano NO SERVIDOR
66
+ * @param {()=>Promise<any>} op.conectar roda o login (abre o navegador)
67
+ * @param {()=>Promise<any>} op.sessao confirma o plano no documento da conta
67
68
  */
68
69
  async function rodar({ perguntar, mostrar, cores: c, interativo = true,
69
70
  conectado, conectar, sessao } = {}) {
@@ -80,7 +81,7 @@ async function rodar({ perguntar, mostrar, cores: c, interativo = true,
80
81
  if (!credencial) {
81
82
  mostrar('');
82
83
  mostrar(c.bold(c.brand(' Bem-vindo ao PrimoCode.')));
83
- mostrar(c.muted(' Primeiro, entre na sua conta do Conecta Primo AI.'));
84
+ mostrar(c.muted(' Primeiro, crie sua conta ou entre nela: vou abrir o navegador.'));
84
85
  mostrar('');
85
86
  const r = await conectar();
86
87
  if (!r || r.ok === false) {
@@ -112,13 +113,11 @@ async function rodar({ perguntar, mostrar, cores: c, interativo = true,
112
113
  const pro = s && s.ok ? !!s.pro : !!(credencial && credencial.pro);
113
114
  if (!pro) {
114
115
  mostrar('');
115
- mostrar(c.err(' Você ainda não tem acesso ao PrimoCode.'));
116
- mostrar(c.muted(` Sua conta está no plano ${plano}, e o PrimoCode pede `)
117
- + c.brand('Premium') + c.muted(' ou ') + c.brand('Super') + c.muted('.'));
116
+ mostrar(c.err(' Sua conta ainda não tem um plano.'));
117
+ mostrar(c.muted(' O PrimoCode é por assinatura, a partir de ') + c.brand('R$ 69,90/mês') + c.muted('.'));
118
118
  mostrar('');
119
- mostrar(c.dim(' Assine em ') + c.brand('conecta-primo-ai.vercel.app')
120
- + c.dim(' e rode ') + c.brand('primo') + c.dim(' de novo.'));
121
- mostrar(c.dim(' Já assinou? ') + c.brand('/login')
119
+ mostrar(c.dim(' Veja os planos e assine: ') + c.brand('/planos'));
120
+ mostrar(c.dim(' Já assinou? ') + c.brand('/account')
122
121
  + c.dim(' atualiza o plano nesta máquina.'));
123
122
  mostrar('');
124
123
  return { liberado: false, motivo: 'sem-plano', plano };
package/lib/config.js CHANGED
@@ -32,7 +32,8 @@ const PROJETO_ATIVO_FILE = path.join(CONFIG_DIR, 'projeto-ativo.json');
32
32
  * função no meio de um turno. Ler quatro linhas de JSON é mais barato que
33
33
  * essa aula. */
34
34
  function tokenDaConta() {
35
- try { return JSON.parse(fs.readFileSync(CONTA_FILE, 'utf8')).token || null; }
35
+ // O token de acesso do Firebase (dura 1h; o conta.manterVivo renova no arquivo).
36
+ try { return JSON.parse(fs.readFileSync(CONTA_FILE, 'utf8')).idToken || null; }
36
37
  catch { return null; }
37
38
  }
38
39
 
@@ -321,7 +322,9 @@ function loadConfig() {
321
322
  * arquivo, e o arquivo ganha da conta. Quem hospeda o próprio
322
323
  * servidor e configurou um token não pode ter isso trocado por
323
324
  * entrar numa conta. */
324
- token: process.env.PRIMOCODE_TOKEN || file.token || tokenDaConta(),
325
+ /* Getter, e não valor: o token da conta vence em uma hora e é
326
+ renovado no arquivo; uma sessão de três horas leria o vencido. */
327
+ get token() { return process.env.PRIMOCODE_TOKEN || file.token || tokenDaConta(); },
325
328
  autoApply: file.autoApply === true,
326
329
  currentProject: file.currentProject || null,
327
330
  userName: file.userName || null,
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
+ };
@@ -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
  };
@@ -1910,7 +1910,7 @@ async function prepararRoteiro(p, args) {
1910
1910
  return r;
1911
1911
  }
1912
1912
  if (estilo === 'personagem' && modo === 'video') {
1913
- const r = personagem.montarPersonagem({ roteiro: { ...roteiro, pedido: roteiro.pedido || pedidoTxt }, largura, altura, tempos, narracao, sons, trilha });
1913
+ const r = personagem.montarPersonagem({ roteiro: { ...roteiro, pedido: roteiro.pedido || pedidoTxt, voz: args.voz || roteiro.voz }, largura, altura, tempos, narracao, sons, trilha });
1914
1914
  if (r.ok) {
1915
1915
  /* O que não é peça do catálogo vira ícone 3D da biblioteca, em blocos
1916
1916
  (kit/mundo.jsx): "uma pizza que fala", "um violão na praia". Sem
@@ -706,13 +706,13 @@ function texturaCartaoUso(titulo, linhas, k, uso) {
706
706
  return cv;
707
707
  }
708
708
  g.fillStyle = '#ff6a2b'; g.beginPath(); g.arc(46, 50, 9, 0, Math.PI * 2); g.fill();
709
- g.fillStyle = '#f2f2f7'; g.font = `600 28px ${F}`; g.fillText(`Claude · sessão ${k + 1}`, 66, 60);
709
+ g.fillStyle = '#f2f2f7'; g.font = `600 28px ${F}`; g.fillText(`Primo · sessão ${k + 1}`, 66, 60); // a marca é a do PrimoCode, nunca a da referência (12/10)
710
710
  g.fillStyle = '#ffffff'; g.font = `700 30px ${F}`; g.fillText(String(titulo).slice(0, 30), 34, 134);
711
711
  g.fillStyle = 'rgba(225,225,235,0.75)'; g.font = `400 24px ${F}`;
712
712
  (linhas || []).slice(0, 3).forEach((l, i) => g.fillText('· ' + String(l).slice(0, 34), 34, 178 + i * 36));
713
713
  g.fillStyle = '#ff6a2b'; g.font = `500 22px ${F}`; g.fillText(`● trabalhando · ${30 + k * 7}:${String(10 + k * 13).slice(0, 2)}`, 34, 312);
714
714
  g.fillStyle = 'rgba(255,255,255,0.06)'; g.beginPath(); g.roundRect(34, 336, 572, 40, 12); g.fill();
715
- g.fillStyle = 'rgba(225,225,235,0.45)'; g.font = `400 20px ${F}`; g.fillText('Pergunte ao Claude…', 50, 363);
715
+ g.fillStyle = 'rgba(225,225,235,0.45)'; g.font = `400 20px ${F}`; g.fillText('Peça qualquer coisa…', 50, 363);
716
716
  return cv;
717
717
  }
718
718
  function CartoesDeUso({ paineis = [], t }) {
@@ -289,18 +289,32 @@ function CabeloFacetado({ estilo, cor }) {
289
289
  const u = hash(i * 3.7 + 1), v = hash(i * 9.1 + 4);
290
290
  const az = u * Math.PI * 2;
291
291
  const el = estilo === 'longo' ? -0.9 + v * 2.2 : 0.15 + v * 1.25;
292
- const atras = Math.cos(az) < 0.35 || el > 0.75;
293
- if (!atras) continue;
292
+ /* O rosto olha para +z (fz = 0.335 na CabecaFacetada). Até a 10.7 o
293
+ filtro guardava cos(az) < 0.35 — z positivo, a FRENTE — e os cachos
294
+ caíam no olho e na bochecha (12/10). Agora a pedra fica atrás da linha
295
+ das orelhas, ou no alto da cabeça, acima da testa. */
296
+ const z = -Math.cos(az) * Math.cos(el) * 0.34;
297
+ const y = 0.04 + Math.sin(el) * 0.38;
298
+ if (z > 0.05 && y < 0.3) continue;
299
+ if (z > 0.18) continue;
294
300
  const r = estilo === 'cacheado' ? 0.105 + hash(i) * 0.05 : 0.09 + hash(i) * 0.03;
295
- out.push({ p: [Math.sin(az) * Math.cos(el) * 0.33, 0.04 + Math.sin(el) * 0.38, -Math.cos(az) * Math.cos(el) * 0.34 + (Math.cos(az) > 0 ? 0.0 : -0.02)], r, k: i });
301
+ out.push({ p: [Math.sin(az) * Math.cos(el) * 0.33, y, z - 0.02], r, k: i });
296
302
  }
297
- if (estilo === 'coque') out.push({ p: [0, 0.36, -0.3], r: 0.16, k: 99 });
303
+ // O coque no alto e um pouco para trás: visível de frente (atrás, sumia e virava cabelo curto).
304
+ if (estilo === 'coque') out.push({ p: [0, 0.46, -0.12], r: 0.17, k: 99 });
298
305
  return out;
299
306
  }, [estilo]);
300
307
  // A franja: a frente do cabelo, um pouco acima da sobrancelha.
301
308
  return (
302
309
  <group>
303
310
  {estilo !== 'careca' && <Faceta p={[0, 0.2, -0.02]} s={[0.36, 0.26, 0.36]} c={cor} det={1} rough={0.9} />}
311
+ {/* O LONGO cai pelos lados até o ombro, por FORA da cabeça — as pedrinhas
312
+ ficavam dentro do crânio e, de frente, ela parecia de cabelo curto (12/10). */}
313
+ {estilo === 'longo' && <>
314
+ <Faceta p={[-0.33, -0.1, -0.04]} s={[0.11, 0.34, 0.24]} c={cor} det={1} rough={0.9} />
315
+ <Faceta p={[0.33, -0.1, -0.04]} s={[0.11, 0.34, 0.24]} c={cor} det={1} rough={0.9} />
316
+ <Faceta p={[0, -0.08, -0.26]} s={[0.34, 0.38, 0.13]} c={cor} det={1} rough={0.9} />
317
+ </>}
304
318
  {pedras.map((x) => <Faceta key={x.k} p={x.p} s={[x.r, x.r * 0.9, x.r]} rot={[hash(x.k) * 3, hash(x.k + 1) * 3, 0]} c={escurecer(cor, 0.85 + hash(x.k + 2) * 0.3)} det={0} rough={0.95} />)}
305
319
  </group>
306
320
  );
@@ -599,9 +613,11 @@ function alturaDaCabeca(p) {
599
613
  return 1.8;
600
614
  }
601
615
  /* Os figurantes ficam nas pontas do arco, um pouco atrás: estão na cena, mas não roubam o plano. */
602
- function lugarDoFigurante(el, fig, j) {
616
+ function lugarDoFigurante(el, fig, j, vert = false) {
603
617
  const n = Math.max(1, el.length);
604
- const borda = n === 1 ? 1.15 : n === 2 ? 1.75 : 2.5;
618
+ /* No vertical o quadro é estreito: na ponta do arco (1,15) o gato pedido
619
+ ficava fora da tela o vídeo inteiro (12/10). Vem para perto, aos pés. */
620
+ const borda = vert ? (n === 1 ? 0.62 : n === 2 ? 1.1 : 1.6) : n === 1 ? 1.15 : n === 2 ? 1.75 : 2.5;
605
621
  const lado = j % 2 ? 1 : -1;
606
622
  const baixo = fig.tipo === 'animal' || fig.tipo === 'coisa';
607
623
  return [lado * (borda + Math.floor(j / 2) * 0.9), 0, baixo ? 0.45 : -0.5];
@@ -836,7 +852,7 @@ export function VoxelCena({ el, t }) {
836
852
  </group>
837
853
  ))}
838
854
  {figurantes.map((p, j) => (
839
- <group key={'f' + j} position={lugarDoFigurante(elenco, p, j)} rotation={[0, (j % 2 ? -1 : 1) * 0.5, 0]}>
855
+ <group key={'f' + j} position={lugarDoFigurante(elenco, p, j, W < H)} rotation={[0, (j % 2 ? -1 : 1) * 0.5, 0]}>
840
856
  {desenhar(p, estado(10 + j, true))}
841
857
  </group>
842
858
  ))}
@@ -29,6 +29,7 @@
29
29
 
30
30
  const { volumesDaTrilha } = require('./ritmo.js');
31
31
  const mundo = require('./mundo.js');
32
+ const falantes = require('./falantes.js');
32
33
  const colecao = require('./colecao.js');
33
34
 
34
35
  const FPS = 30;
@@ -110,6 +111,23 @@ function tipoDe(nome, pedido = {}) {
110
111
  return 'pessoa';
111
112
  }
112
113
 
114
+ /* QUEM APRESENTA TEM O GÊNERO DA VOZ (12/10: "Narradora: a Júlia" saiu com
115
+ voz de mulher e boneco de homem). O pedido manda primeiro ("narradora",
116
+ "apresentadora", o nome dela), depois a voz escolhida. */
117
+ const VOZES_F = /\b(kore|aoede|leda|zephyr|thalita|francisca|brenda|elza|giovanna|leila|leticia|manuela|yara|camila|vitoria)/i;
118
+ const VOZES_M = /\b(charon|puck|orus|fenrir|antonio|donato|fabio|humberto|julio|nicolau|valerio)/i;
119
+ function generoDoApresentador(roteiro = {}) {
120
+ const pedido = mundo.sem(roteiro.pedido || '');
121
+ if (/\b(narradora|apresentadora|locutora)\b/.test(pedido)) return 'f';
122
+ if (/\b(narrador|apresentador|locutor)\b/.test(pedido)) return 'm';
123
+ const voz = String(roteiro.voz || '');
124
+ if (VOZES_F.test(voz)) return 'f';
125
+ if (VOZES_M.test(voz)) return 'm';
126
+ return null;
127
+ }
128
+ const CABELOS_F = ['longo']; // o coque e o cacheado, de frente, liam como homem (12/10); só quando pedidos
129
+ const CABELOS_M = ['curto', 'topete', 'careca', 'cacheado'];
130
+
113
131
  function aparencia(nome, pedido = {}) {
114
132
  const k = (s) => hashDe(nome + ':' + s);
115
133
  const tipo = tipoDe(nome, pedido);
@@ -128,12 +146,14 @@ function aparencia(nome, pedido = {}) {
128
146
  return {
129
147
  nome, rotulo, tipo,
130
148
  pele: pedido.pele || pegar(PELES, k('pele')),
131
- cabelo: CABELOS.includes(pedido.cabelo) ? pedido.cabelo : pegar(CABELOS, k('cabelo')),
149
+ cabelo: CABELOS.includes(pedido.cabelo) ? pedido.cabelo
150
+ : pegar(pedido.genero === 'f' ? CABELOS_F : pedido.genero === 'm' ? CABELOS_M : CABELOS, k('cabelo')),
132
151
  corCabelo: pedido.corCabelo || pegar(CORES_CABELO, k('corCabelo')),
133
152
  roupa: pedido.roupa || pegar(ROUPAS, k('roupa')),
134
153
  oculos: pedido.oculos != null ? !!pedido.oculos : k('oculos') < 0.3,
135
- barba: !!pedido.barba, // só quando pedida: sorteada, ficava estranha em quem tem cabelo longo
136
- bigode: !!pedido.bigode,
154
+ barba: !!pedido.barba && pedido.genero !== 'f', // só quando pedida: sorteada, ficava estranha em quem tem cabelo longo
155
+ bigode: !!pedido.bigode && pedido.genero !== 'f',
156
+ ...(pedido.genero ? { genero: pedido.genero } : {}),
137
157
  // A cabeça facetada (low-poly) é o padrão; 'cubo' é o rosto de pixel da 10.5.
138
158
  ...(pedido.cabeca === 'cubo' ? { cabeca: 'cubo' } : {}),
139
159
  };
@@ -316,7 +336,13 @@ function partituraDe({ roteiro = {}, largura = 1920, altura = 1080, tempos = []
316
336
  return bicho ? { ...dado, tipo: 'animal', especie: bicho[1] } : dado;
317
337
  };
318
338
  // Até QUATRO falam no set ("três amigos no parque"); do quinto em diante dividem o lugar.
319
- const elenco = nomes.slice(0, 4).map((n, i) => ({ ...aparencia(n, pedidoDe(n)), lado: nomes.length > 1 ? (i < nomes.length / 2 ? -1 : 1) : 0, ordem: i }));
339
+ const comGenero = (n) => {
340
+ const d = pedidoDe(n);
341
+ if (d.genero) return d;
342
+ const g = n === 'apresentador' ? generoDoApresentador(roteiro) : falantes.generoDe(n, d);
343
+ return g === 'f' || g === 'm' ? { ...d, genero: g } : d;
344
+ };
345
+ const elenco = nomes.slice(0, 4).map((n, i) => ({ ...aparencia(n, comGenero(n)), lado: nomes.length > 1 ? (i < nomes.length / 2 ? -1 : 1) : 0, ordem: i }));
320
346
  const quemDe = (pt) => {
321
347
  const f = typeof pt.falante === 'string' && pt.falante.trim() ? pt.falante.trim().toLowerCase() : 'apresentador';
322
348
  return nomes.indexOf(f) % Math.max(1, elenco.length);
@@ -453,4 +479,4 @@ function montarPersonagem({ roteiro, largura = 1920, altura = 1080, tempos = [],
453
479
  };
454
480
  }
455
481
 
456
- module.exports = { calarBichos, montarPersonagem, partituraDe, planosDe, paineisDe, aparencia, tipoDe, acaoDe, destaqueDe, emocaoDe, CENARIOS, CENARIOS_VOXEL, EH_ROBO, FPS };
482
+ module.exports = { generoDoApresentador, calarBichos, montarPersonagem, partituraDe, planosDe, paineisDe, aparencia, tipoDe, acaoDe, destaqueDe, emocaoDe, CENARIOS, CENARIOS_VOXEL, EH_ROBO, FPS };
package/lib/nuvem.js CHANGED
@@ -24,13 +24,12 @@
24
24
  * A alternativa era o servidor intermediar cada leitura e escrita, com uma
25
25
  * autorização própria. Seria um SEGUNDO modelo de permissão ao lado das
26
26
  * `firestore.rules`, e os dois divergiriam na primeira mudança delas. Aqui o
27
- * CLI troca o token do PrimoCode por uma sessão curta (custom token → token de
28
- * acesso) e usa a API REST: as regras do banco valem para o terminal
29
- * exatamente como valem para o app e para o celular.
27
+ * CLI usa o token de acesso da própria conta (lib/conta.js) na API REST: as
28
+ * regras do banco valem para o terminal exatamente como valem para o site.
30
29
  *
31
30
  * ── ZERO DEPENDÊNCIA ─────────────────────────────────────────────────────
32
31
  * Nada de `firebase-admin` nem do SDK web. O que se usa é o `crypto` e o
33
- * `https` do Node, contra a API REST do Firestore e a do Identity Toolkit.
32
+ * `https` do Node, contra a API REST do Firestore.
34
33
  */
35
34
 
36
35
  'use strict';
@@ -41,13 +40,12 @@ const http = require('http');
41
40
 
42
41
  const conta = require('./conta.js');
43
42
 
44
- /* Os dois endereços saem daqui em vez de estarem escritos no meio do código.
43
+ /* O endereço sai daqui em vez de estarem escritos no meio do código.
45
44
  Não é para trocar de nuvem: é para o teste conseguir pôr um Firestore de
46
45
  mentira no lugar e CONFERIR O QUE SOBE. Sem isso, a única forma de saber se
47
46
  um título vai em claro seria olhar o console do Firebase depois — que é
48
47
  exatamente tarde demais. */
49
48
  const FIRESTORE = process.env.PRIMOCODE_FIRESTORE || 'https://firestore.googleapis.com';
50
- const IDENTIDADE = process.env.PRIMOCODE_IDENTITY || 'https://identitytoolkit.googleapis.com';
51
49
 
52
50
  const VERSAO_CIFRA = 2; // o mesmo `v` do frontend/crypto.js
53
51
  const ITERACOES = 310000;
@@ -132,26 +130,17 @@ function httpsJson(url, metodo, corpo, cabecalhos = {}, timeout = 20000) {
132
130
 
133
131
  let _sessao = null; // { uid, idToken, expira, projeto }
134
132
 
135
- /* Abre (ou renova) a sessão. O token de acesso do Firebase dura uma hora;
136
- renovar cinco minutos antes evita a escrita que falha no meio de uma
137
- conversa longa — e falhar ali perde a mensagem, não só o pedido. */
133
+ /* Abre (ou renova) a sessão. O conta.sessao() já devolve o token de acesso
134
+ válido por mais 5 minutos no mínimo (renova sozinho): aqui só se guarda,
135
+ para não ler o plano a cada mensagem. Guardado por 4 minutos. */
138
136
  async function abrir() {
139
- if (_sessao && Date.now() < _sessao.expira - 300000) return _sessao;
137
+ if (_sessao && Date.now() < _sessao.expira) return _sessao;
140
138
  const s = await conta.sessao();
141
- if (!s.ok) return null;
142
- if (!s.apiKey || !s.projeto) {
143
- // O servidor não está configurado para isto (falta FIREBASE_API_KEY /
144
- // FIREBASE_PROJECT_ID no ambiente dele). Dizer é melhor que tentar e
145
- // falhar com "requisição inválida" mais adiante.
146
- return null;
147
- }
148
- const r = await httpsJson(
149
- `${IDENTIDADE}/v1/accounts:signInWithCustomToken?key=${s.apiKey}`,
150
- 'POST', { token: s.customToken, returnSecureToken: true });
139
+ if (!s.ok || !s.idToken) return null;
151
140
  _sessao = {
152
141
  uid: s.uid, projeto: s.projeto, plano: s.plano, pro: s.pro,
153
- idToken: r.idToken,
154
- expira: Date.now() + (Number(r.expiresIn || 3600) * 1000),
142
+ idToken: s.idToken,
143
+ expira: Date.now() + 240000,
155
144
  };
156
145
  return _sessao;
157
146
  }
package/lib/planos.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * planos.js — a tela /planos do terminal.
3
+ *
4
+ * "eles vão ter que assinar um plano obrigatoriamente pra poder usar …
5
+ * tem que ser por mês" (Joel, 12/10/2026)
6
+ *
7
+ * A lista de verdade vem do servidor (GET /api/planos, lib/planos.js de lá),
8
+ * com o link de checkout de cada plano. A cópia abaixo só aparece quando o
9
+ * servidor não responde, para a pessoa nunca ver uma tela vazia — e sem
10
+ * link de compra, porque um preço velho com botão de pagar é pior que nenhum.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const https = require('https');
16
+ const http = require('http');
17
+
18
+ const RESERVA = [
19
+ { id: 'primo', nome: 'Primo', preco: 69.9, inclui: ['app e site sem configurar nada', 'modelos do servidor', 'vídeo em 720p'] },
20
+ { id: 'pro', nome: 'Primo Pro', preco: 129.9, inclui: ['limite alto', 'estúdio de vídeo completo, em 1080p', 'cenários salvos'] },
21
+ { id: 'max', nome: 'Primo Max', preco: 299.9, inclui: ['sem limite por hora', 'prioridade na fila', 'os melhores modelos', 'vídeos longos'] },
22
+ { id: 'studio', nome: 'Primo Studio', preco: 599.9, inclui: ['para agência e empresa', 'várias pessoas', 'marca própria nos vídeos'] },
23
+ ];
24
+
25
+ function buscarPlanos(servidor, { timeout = 8000 } = {}) {
26
+ return new Promise((resolve) => {
27
+ let url;
28
+ try { url = new URL('/api/planos', servidor); } catch { return resolve({ ok: false, planos: RESERVA }); }
29
+ const lib = url.protocol === 'https:' ? https : http;
30
+ const req = lib.get(url, { timeout }, (res) => {
31
+ let t = '';
32
+ res.setEncoding('utf8');
33
+ res.on('data', (c) => { t += c; });
34
+ res.on('end', () => {
35
+ try {
36
+ const j = JSON.parse(t);
37
+ const lista = Array.isArray(j) ? j : j && j.planos;
38
+ if (res.statusCode === 200 && Array.isArray(lista) && lista.length) return resolve({ ok: true, planos: lista });
39
+ } catch { /* cai na reserva */ }
40
+ resolve({ ok: false, planos: RESERVA });
41
+ });
42
+ });
43
+ req.on('timeout', () => req.destroy());
44
+ req.on('error', () => resolve({ ok: false, planos: RESERVA }));
45
+ });
46
+ }
47
+
48
+ const reais = (n) => `R$ ${Number(n).toFixed(2).replace('.', ',')}`;
49
+
50
+ /**
51
+ * As linhas da tela. `atual` é o id do plano da pessoa (ou null).
52
+ * @param {{planos:object[], ok:boolean}} r
53
+ * @param {{cores:object, atual?:string, site:string}} op
54
+ */
55
+ function desenhar({ planos, ok }, { cores: c, atual = null, site }) {
56
+ const linhas = ['', c.white(' Planos do PrimoCode') + c.dim(' · por mês, cancele quando quiser'), ''];
57
+ for (const p of planos) {
58
+ const meu = atual && p.id === atual;
59
+ linhas.push(' ' + (meu ? c.ok('● ') : ' ') + c.brand(p.nome.padEnd(14)) + c.white(reais(p.preco).padStart(11))
60
+ + c.dim(' /mês') + (meu ? c.ok(' ← o seu') : ''));
61
+ linhas.push(' ' + c.muted((p.inclui || []).join(' · ')));
62
+ if (ok && !meu) linhas.push(' ' + (p.checkout ? c.dim('assinar: ') + c.brand(p.checkout) : c.dim('checkout em breve')));
63
+ linhas.push('');
64
+ }
65
+ if (!ok) linhas.push(c.dim(' (sem resposta do servidor: preços de referência, sem link de compra)'));
66
+ linhas.push(c.dim(' Também em ') + c.brand(`${site}/planos`) + c.dim(' · já assinou? ') + c.brand('/account') + c.dim(' atualiza.'));
67
+ linhas.push('');
68
+ return linhas;
69
+ }
70
+
71
+ module.exports = { buscarPlanos, desenhar, RESERVA, reais };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.7.0",
3
+ "version": "10.7.1",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/gerativo-identidade.test.js && node test/gerativo-moldura.test.js && node test/gerativo-heroi.test.js && node test/gerativo-ambiente.test.js && node test/gerativo-variedade.test.js && node test/hibrido.test.js && node test/batida.test.js && node test/estudio-acervo.test.js && node test/estudio-diretor.test.js && node test/estudio-marca.test.js && node test/estudio-novo-projeto.test.js && node test/estudio-plano.test.js && node test/estudio-slides.test.js && node test/estudio-video.test.js && node test/revisor.test.js && node test/assinatura.test.js && node test/biblioteca.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/orquestra.test.js && node test/shell-limpo.test.js && node test/editar-remotion.test.js && node test/variacao3d.test.js && node test/portao-skill.test.js && node test/clones.test.js && node test/kokoro.test.js && node test/fluido.test.js && node test/zoom.test.js && node test/personagem.test.js && node test/tela.test.js && node test/margem.test.js && node test/travado.test.js && node test/leitura.test.js && node test/producao-provedores.test.js && node test/broll-frase.test.js && node test/vazamento.test.js && node test/colecao.test.js && node test/mundo.test.js && node test/mcp-procurar.test.js && node test/selecao.test.js && node test/nome-curto.test.js && node test/agents-md.test.js && node test/outra-pasta.test.js && node test/estudio-sem-id.test.js && node test/estudio-objeto-do-site.test.js && node test/cenarios.test.js && node test/movimentos.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/conta.test.mjs && node test/planos-tela.test.js && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/gerativo-identidade.test.js && node test/gerativo-moldura.test.js && node test/gerativo-heroi.test.js && node test/gerativo-ambiente.test.js && node test/gerativo-variedade.test.js && node test/hibrido.test.js && node test/batida.test.js && node test/estudio-acervo.test.js && node test/estudio-diretor.test.js && node test/estudio-marca.test.js && node test/estudio-novo-projeto.test.js && node test/estudio-plano.test.js && node test/estudio-slides.test.js && node test/estudio-video.test.js && node test/revisor.test.js && node test/assinatura.test.js && node test/biblioteca.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/orquestra.test.js && node test/shell-limpo.test.js && node test/editar-remotion.test.js && node test/variacao3d.test.js && node test/portao-skill.test.js && node test/clones.test.js && node test/kokoro.test.js && node test/fluido.test.js && node test/zoom.test.js && node test/personagem.test.js && node test/tela.test.js && node test/margem.test.js && node test/travado.test.js && node test/leitura.test.js && node test/producao-provedores.test.js && node test/broll-frase.test.js && node test/vazamento.test.js && node test/colecao.test.js && node test/mundo.test.js && node test/mcp-procurar.test.js && node test/selecao.test.js && node test/nome-curto.test.js && node test/agents-md.test.js && node test/outra-pasta.test.js && node test/estudio-sem-id.test.js && node test/estudio-objeto-do-site.test.js && node test/cenarios.test.js && node test/movimentos.test.js && node test/gerativo-so-fala.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"