primocode 8.27.0 → 8.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/primocode.js CHANGED
@@ -26,6 +26,8 @@ const catalogo = require('../lib/catalogo');
26
26
  const effort = require('../lib/effort');
27
27
  const memoria = require('../lib/memoria-conversa');
28
28
  const appLib = require('../lib/app');
29
+ const conta = require('../lib/conta');
30
+ const nuvem = require('../lib/nuvem');
29
31
 
30
32
  // Rede de segurança de último recurso: sem isto, uma exceção fora de um
31
33
  // try/catch (ex.: dentro de um callback de socket, como o parser de SSE) não
@@ -55,6 +57,9 @@ const state = {
55
57
  // O resumo incremental da conversa — um /compact automático. O porquê
56
58
  // está em lib/memoria-conversa.js.
57
59
  memoria: memoria.nova(),
60
+ // O histórico que o app desktop e o celular enxergam. Cifrado antes de
61
+ // sair daqui; o porquê de cada peça está em lib/nuvem.js.
62
+ gravador: nuvem.gravador(),
58
63
  lastResponse: '',
59
64
  currentProject: null,
60
65
  workDir: null, // pasta onde o agente trabalha nesta sessão
@@ -81,6 +86,9 @@ ${c.bold('Comandos')}
81
86
  ${c.brand('/run')} <comando> executa no shell do projeto
82
87
  ${c.brand('/open')} <arquivo|url> abre no navegador
83
88
  ${c.brand('/app')} abre a janela do PrimoCode (chat, sem terminal)
89
+ ${c.brand('/entrar')} entra na sua conta Conecta Primo · ${c.brand('/conta')} mostra o plano
90
+ ${c.brand('/conversas')} [n] suas conversas salvas — as mesmas do app e do celular
91
+ ${c.brand('/sair')} desconecta este computador
84
92
  ${c.brand('/status')} checa o servidor
85
93
  ${c.brand('/name')} <nome> como o Primo te chama
86
94
  ${c.brand('/mascot')} <estilo> mascote: primo|fox|cat
@@ -122,6 +130,10 @@ const SLASH_COMANDOS = [
122
130
  ['/run', 'roda um comando'],
123
131
  ['/open', 'abre no navegador'],
124
132
  ['/app', 'abre a janela do PrimoCode'],
133
+ ['/entrar', 'entra na sua conta Conecta Primo'],
134
+ ['/conta', 'sua conta, plano e computadores conectados'],
135
+ ['/conversas', 'suas conversas salvas, as mesmas do app e do celular'],
136
+ ['/sair', 'desconecta este computador'],
125
137
  ['/status', 'checa o servidor'],
126
138
  ['/name', 'como ele te chama'],
127
139
  ['/mascot', 'primo | fox | cat'],
@@ -492,6 +504,10 @@ async function sendPrompt(userText) {
492
504
  state.history.push({ role: 'assistant', content: r.assistantMessage.content });
493
505
  state.lastResponse = r.assistantMessage.content;
494
506
  cfg.appendHistory({ mode: 'primocode', project: state.currentProject, prompt: userText });
507
+ // Sobe cifrado, atrás, sem segurar o prompt. Se não houver conta ou
508
+ // rede, não acontece nada — a conversa segue aqui do mesmo jeito.
509
+ state.gravador.turno(userText, r.assistantMessage.content,
510
+ { modo: 'primocode', projeto: state.currentProject || null });
495
511
  } else {
496
512
  await comprimirConversa();
497
513
  await runChat(prompt, userText);
@@ -634,6 +650,7 @@ async function runChat(promptForModel, userTextForMemory) {
634
650
  state.history.push({ role: 'assistant', content: text });
635
651
  state.lastResponse = text;
636
652
  cfg.appendHistory({ mode: 'chat', prompt: userTextForMemory });
653
+ state.gravador.turno(userTextForMemory, text, { modo: 'chat' });
637
654
  }
638
655
  }
639
656
 
@@ -903,7 +920,11 @@ async function handleSlash(text) {
903
920
  break;
904
921
  }
905
922
 
906
- case 'clear': state.history = []; state.memoria = memoria.nova(); console.log(c.ok('Conversa limpa.')); break;
923
+ // Limpar aqui não apaga o que já subiu: encerra ESTA conversa e a
924
+ // próxima pergunta abre outra. Apagar do histórico é decisão da
925
+ // pessoa, e ela toma no app — não de raspão num comando de limpar tela.
926
+ case 'clear': state.history = []; state.memoria = memoria.nova();
927
+ state.gravador.zerar(); console.log(c.ok('Conversa limpa.')); break;
907
928
  case 'cls': process.stdout.write('\x1Bc'); break;
908
929
 
909
930
  case 'status': {
@@ -939,6 +960,21 @@ async function handleSlash(text) {
939
960
  Uma janela por sessão: mandar /app de novo traz a que já existe
940
961
  para a frente em vez de abrir outra do lado. */
941
962
  case 'app': {
963
+ // `/app --instalar` põe o PrimoCode no Dock, no menu Iniciar ou no
964
+ // menu de aplicativos, com o ícone do mascote. Uma vez só.
965
+ if (/^--?(instalar|install)$/.test(rest)) {
966
+ const r = require('../lib/instalar-app.js').instalar(VERSION);
967
+ console.log(r.ok
968
+ ? c.ok(' Instalado.') + c.muted(` Procure "PrimoCode" no ${r.onde}.`)
969
+ + '\n' + c.dim(` ${r.caminho}`)
970
+ : c.err(` Não deu: ${r.error}`));
971
+ break;
972
+ }
973
+ if (/^--?(desinstalar|uninstall)$/.test(rest)) {
974
+ const r = require('../lib/instalar-app.js').desinstalar();
975
+ console.log(r.ok ? c.ok(' Removido do sistema.') : c.err(` ${r.error}`));
976
+ break;
977
+ }
942
978
  if (janelaAberta) {
943
979
  // Já existe: reabre no MESMO endereço. O navegador traz a
944
980
  // janela daquela URL para a frente em vez de fazer outra.
@@ -970,6 +1006,12 @@ async function handleSlash(text) {
970
1006
  console.log(c.warn(' Não consegui abrir sozinho — copie o endereço acima no navegador.'));
971
1007
  }
972
1008
  console.log(c.muted(' O terminal continua funcionando: os dois mostram a mesma coisa.'));
1009
+ // Oferecido UMA vez, e só a quem ainda não tem: repetir todo
1010
+ // /app viraria ruído para quem já instalou de propósito.
1011
+ if (!require('../lib/instalar-app.js').instalado()) {
1012
+ console.log(c.dim(' Quer o ícone no ') + c.dim(os.platform() === 'darwin' ? 'Dock' : 'menu Iniciar')
1013
+ + c.dim('? ') + c.brand('/app --instalar'));
1014
+ }
973
1015
  } catch (e) {
974
1016
  sp.stop();
975
1017
  console.log(c.err(` Não subiu: ${e.message}`));
@@ -977,6 +1019,135 @@ async function handleSlash(text) {
977
1019
  break;
978
1020
  }
979
1021
 
1022
+ /* A conta do Conecta Primo. O login inteiro mora em lib/conta.js;
1023
+ aqui é a tela dele. */
1024
+ case 'entrar': case 'login': {
1025
+ const jaEsta = conta.atual();
1026
+ if (jaEsta && rest !== 'trocar') {
1027
+ console.log(c.muted(' Já conectado como ') + c.brand(jaEsta.uid.slice(0, 12) + '…')
1028
+ + c.muted(` · plano ${jaEsta.plano}`));
1029
+ console.log(c.dim(' Para entrar com outra conta: ') + c.brand('/entrar trocar'));
1030
+ break;
1031
+ }
1032
+ if (rest === 'trocar') conta.sair();
1033
+
1034
+ const sp = new Spinner('abrindo o login').start();
1035
+ let primeiro = true;
1036
+ const r = await conta.entrar({
1037
+ abrir: (u) => tools.openInBrowser(u),
1038
+ contar: (msg) => {
1039
+ if (primeiro) {
1040
+ sp.stop();
1041
+ console.log(c.muted(' Abrindo no navegador:'));
1042
+ console.log(' ' + c.brand(msg));
1043
+ primeiro = false;
1044
+ } else {
1045
+ // O código aparece AQUI para a pessoa comparar com o
1046
+ // da página. Sem essa comparação, um link mandado por
1047
+ // outra pessoa conectaria o computador dela à conta
1048
+ // de quem clicou.
1049
+ console.log(c.muted(' Confira o código na página: ') + c.bold(c.warn(msg)));
1050
+ console.log(c.dim(' Esperando você aprovar…'));
1051
+ }
1052
+ },
1053
+ });
1054
+ sp.stop();
1055
+ if (!r.ok) { console.log(c.err(` ${r.error}`)); break; }
1056
+ console.log(c.ok(' Conectado.')
1057
+ + c.muted(` Plano ${r.plano}${r.pro ? '' : ' — assine Premium no app para liberar tudo'}`));
1058
+ break;
1059
+ }
1060
+
1061
+ /* O histórico da conta — o MESMO que o app desktop e o celular
1062
+ mostram. Sem número, lista; com número, abre e continua dali. */
1063
+ case 'conversas': case 'historico': {
1064
+ if (!conta.atual()) {
1065
+ console.log(c.muted(' Não conectado. ') + c.brand('/entrar')
1066
+ + c.muted(' para ver suas conversas aqui, no app e no celular.'));
1067
+ break;
1068
+ }
1069
+ const sp = new Spinner('lendo suas conversas').start();
1070
+ const l = await nuvem.listarConversas({ limite: 20 });
1071
+ sp.stop();
1072
+ if (!l.ok) { console.log(c.err(` ${l.error}`)); break; }
1073
+ if (!l.conversas.length) {
1074
+ console.log(c.muted(' Nenhuma conversa salva ainda. A próxima já fica.'));
1075
+ break;
1076
+ }
1077
+ if (!rest) {
1078
+ console.log(c.white('\n Suas conversas'));
1079
+ l.conversas.forEach((x, i) => {
1080
+ const quando = x.atualizada
1081
+ ? new Date(x.atualizada).toLocaleDateString('pt-BR') : '—';
1082
+ console.log(' ' + c.dim(String(i + 1).padStart(2))
1083
+ + ' ' + c.white(x.titulo.slice(0, 52).padEnd(52))
1084
+ + c.dim(`${String(x.mensagens).padStart(3)} msg ${quando}`));
1085
+ });
1086
+ console.log(c.dim('\n /conversas 1 abre a primeira e continua dela.'));
1087
+ break;
1088
+ }
1089
+ const n = Number(rest);
1090
+ const alvo = l.conversas[n - 1];
1091
+ if (!alvo) { console.log(c.err(` Não achei a conversa ${rest}.`)); break; }
1092
+ const sp2 = new Spinner('abrindo').start();
1093
+ const conv = await nuvem.lerConversa(alvo.id);
1094
+ sp2.stop();
1095
+ if (!conv.ok) { console.log(c.err(` ${conv.error}`)); break; }
1096
+ // A memória entra ZERADA: o resumo antigo não veio junto, e um
1097
+ // resumo de outra conversa colado aqui faria o modelo responder
1098
+ // sobre coisa que não foi dita nesta.
1099
+ state.history = conv.mensagens.map((m) => ({
1100
+ role: m.papel, content: m.texto,
1101
+ }));
1102
+ state.memoria = memoria.nova();
1103
+ state.gravador.retomar(alvo.id, alvo.titulo, conv.mensagens.length);
1104
+ console.log(c.ok(` ${alvo.titulo}`)
1105
+ + c.muted(` · ${conv.mensagens.length} mensagens carregadas`));
1106
+ console.log(c.dim(' Pode continuar daqui — o que você disser volta pro app e pro celular.'));
1107
+ break;
1108
+ }
1109
+
1110
+ case 'sair': case 'logout': {
1111
+ if (!conta.atual()) { console.log(c.muted(' Você não está conectado.')); break; }
1112
+ conta.sair();
1113
+ console.log(c.ok(' Desconectado deste computador.')
1114
+ + c.dim(' A conta continua intacta; entre de novo com /entrar.'));
1115
+ break;
1116
+ }
1117
+
1118
+ case 'conta': {
1119
+ const eu = conta.atual();
1120
+ if (!eu) {
1121
+ console.log(c.muted(' Não conectado. ') + c.brand('/entrar') + c.muted(' para entrar.'));
1122
+ break;
1123
+ }
1124
+ const sp = new Spinner('conferindo').start();
1125
+ // O plano guardado é a foto do dia do login: quem assinou ontem
1126
+ // continuaria gratuito. Quem sabe é o servidor.
1127
+ const s = await conta.sessao();
1128
+ sp.stop();
1129
+ console.log(c.white(' Conta ') + c.muted(eu.uid));
1130
+ if (s.ok) {
1131
+ console.log(c.white(' Plano ')
1132
+ + (s.pro ? c.brand(s.plano) : c.muted(s.plano + ' (gratuito)')));
1133
+ } else if (s.offline) {
1134
+ console.log(c.white(' Plano ') + c.muted(eu.plano) + c.dim(' (sem rede — último conhecido)'));
1135
+ } else {
1136
+ console.log(c.err(` ${s.error}`));
1137
+ break;
1138
+ }
1139
+ const d = await conta.dispositivos();
1140
+ if (d.ok && d.dispositivos.length) {
1141
+ console.log(c.white('\n Computadores conectados'));
1142
+ for (const x of d.dispositivos) {
1143
+ const quando = x.ultimo_uso
1144
+ ? new Date(x.ultimo_uso * 1000).toLocaleDateString('pt-BR') : '—';
1145
+ console.log(' ' + c.muted((x.apelido || 'Computador').padEnd(14)) + c.dim(quando));
1146
+ }
1147
+ }
1148
+ break;
1149
+ }
1150
+
980
1151
  case 'uninstall': {
981
1152
  if (await confirm('Remover o PrimoCode? Seus projetos em ~/primocode NÃO serão apagados.')) {
982
1153
  await tools.uninstall();
@@ -1049,6 +1220,53 @@ async function main() {
1049
1220
  process.exit(0);
1050
1221
  }
1051
1222
 
1223
+ /* `primo --app`: a janela SEM terminal.
1224
+ *
1225
+ * É por aqui que o aplicativo instalado entra. Quem clica no ícone do Dock
1226
+ * ou do menu Iniciar não tem terminal nenhum — não há readline, não há
1227
+ * stdin, e o processo precisa ficar de pé sozinho segurando a janela.
1228
+ *
1229
+ * O `/app` de dentro da sessão continua existindo e é outra coisa: ali há
1230
+ * um terminal, e a janela espelha o que ele mostra. Os dois chamam a MESMA
1231
+ * `appLib.abrir`, porque uma segunda montagem divergiria da primeira. */
1232
+ if (args[0] === '--app') {
1233
+ const instalador = require('../lib/instalar-app.js');
1234
+ if (args[1] === '--instalar') {
1235
+ const r = instalador.instalar(VERSION);
1236
+ console.log(r.ok
1237
+ ? c.ok(` Instalado.`) + c.muted(` Procure "PrimoCode" no ${r.onde}.`)
1238
+ + '\n' + c.dim(` ${r.caminho}`)
1239
+ : c.err(` Não deu: ${r.error}`));
1240
+ process.exit(r.ok ? 0 : 1);
1241
+ }
1242
+ if (args[1] === '--desinstalar') {
1243
+ const r = instalador.desinstalar();
1244
+ console.log(r.ok ? c.ok(' Removido do sistema.') : c.err(` ${r.error}`));
1245
+ process.exit(r.ok ? 0 : 1);
1246
+ }
1247
+
1248
+ const janela = await appLib.abrir({
1249
+ versao: VERSION,
1250
+ enviar: (t) => sendPrompt(t),
1251
+ barra: (t) => handleSlash(t),
1252
+ interromper,
1253
+ estado: () => ({
1254
+ pasta: state.workDir ? shortPath(state.workDir) : null,
1255
+ projeto: state.currentProject,
1256
+ modo: state.mode,
1257
+ esforco: state.config.effort,
1258
+ }),
1259
+ });
1260
+ janelaAberta = janela;
1261
+ console.log(c.brand(` PrimoCode`) + c.muted(` ${janela.url.split('?')[0]}`));
1262
+ // Sem readline não há nada segurando o laço de eventos além do
1263
+ // servidor — e é ele que segura, porque está escutando. Fechar a
1264
+ // janela NÃO derruba o processo de propósito: o navegador em modo app
1265
+ // reabre a mesma URL, e um servidor morto deixaria a pessoa com uma
1266
+ // aba de erro em vez do aplicativo.
1267
+ return;
1268
+ }
1269
+
1052
1270
  // Modo one-shot: primocode "crie um jogo de carro"
1053
1271
  if (args.length && !args[0].startsWith('-')) {
1054
1272
  const fileFlag = args.indexOf('-f');
@@ -1150,7 +1368,19 @@ async function main() {
1150
1368
  function sayBye() {
1151
1369
  if (saidBye) return;
1152
1370
  saidBye = true;
1153
- console.log(c.muted('\nAté logo!'));
1371
+ // O último turno pode ainda estar subindo — gravar é de propósito
1372
+ // solto, para não segurar o prompt. Fechar o terminal em cima dele
1373
+ // perderia justamente a resposta que a pessoa acabou de ler, e ela
1374
+ // não veria falta nenhuma até procurar no celular.
1375
+ const fim = () => console.log(c.muted('\nAté logo!'));
1376
+ Promise.race([
1377
+ state.gravador.esperar(),
1378
+ // Rede pendurada não vira terminal pendurado. `unref` porque o
1379
+ // temporizador sozinho seguraria o processo por 3s mesmo quando a
1380
+ // gravação já terminou — um "Até logo!" seguido de três segundos
1381
+ // de nada é o tipo de coisa que quem usa chama de travado.
1382
+ new Promise((r) => { const t = setTimeout(r, 3000); if (t.unref) t.unref(); }),
1383
+ ]).then(fim, fim);
1154
1384
  }
1155
1385
 
1156
1386
  async function drain() {
package/lib/catalogo.js CHANGED
@@ -89,6 +89,10 @@ const GRUPOS = [
89
89
  { tool: 'studio_listar', escreve: false, faz: 'lista o que já existe no estúdio', args: null },
90
90
  { tool: 'studio_midia', escreve: false, faz: 'manda imagem, música ou vídeo seu para usar nas peças',
91
91
  args: 'arquivo (opcional): caminho no projeto — sem ele, lista a galeria. nome (opcional).' },
92
+ { tool: 'remotion_projeto', escreve: true, faz: 'transforma uma peça do estúdio em projeto Remotion (React) na pasta do usuário',
93
+ args: 'id da peça. pasta (opcional): nome da pasta. fps (opcional, padrão 30). renderizar: true só se o npm install já foi feito. '
94
+ + 'Use SÓ quando o usuário pedir Remotion pelo nome, ou quando precisar de render determinístico, vídeo longo, codec/fps específico ou o projeto em React. '
95
+ + 'O caminho normal continua sendo studio_criar + o botão de baixar, que sai em segundos sem instalar nada.' },
92
96
  { tool: 'studio_analisar', escreve: false, faz: 'ouve um vídeo já enviado e diz onde estão as pausas, para cortá-las',
93
97
  args: 'arquivo: a URL "/midia/..." do vídeo. pausa (opcional): segundos a partir dos quais conta como pausa (padrão 0.6). Devolve "trechos": vire cada um numa cena com {"tipo":"clipe","de":...,"ate":...}.' },
94
98
  ],
package/lib/conta.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * conta.js — entrar na conta Conecta Primo, de dentro do terminal.
3
+ *
4
+ * "Quando você dá `primo`, ele vai abrir pra primeiro você fazer o login. E
5
+ * aí ele vai abrir lá o Conecta Primo AI pra você fazer o login, com algum
6
+ * token que ele gera automaticamente. Você faz o login ali e aparece uma
7
+ * confirmação na tela; ela até fecha sozinha depois de 5 segundos. Daí no
8
+ * terminal ele vai ver que você fez login, e se a sua conta for Premium ele
9
+ * libera pra você usar."
10
+ *
11
+ * O desenho inteiro está no outro lado (backend/dispositivos.py do
12
+ * conecta-primo-ai). Daqui o que importa é: pedir um código, abrir o navegador,
13
+ * esperar, e guardar o que voltar.
14
+ *
15
+ * ── O ARQUIVO DA CREDENCIAL ──────────────────────────────────────────────
16
+ * Ele NÃO entra no config.json. Aquele arquivo guarda preferência — modo,
17
+ * esforço, mascote — e é o que alguém abre para conferir um ajuste, cola num
18
+ * chamado de suporte ou copia para outra máquina. Credencial junto de
19
+ * preferência é credencial que vaza por hábito, não por ataque.
20
+ *
21
+ * Mora em `~/.primocode/conta.json`, com permissão 600 onde o sistema tem
22
+ * permissão (no Windows o modo é ignorado, e está dito no comentário de lá).
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+ const path = require('path');
30
+ const https = require('https');
31
+ const http = require('http');
32
+
33
+ // O servidor do Conecta Primo — que NÃO é o servidor do PrimoCode. São dois
34
+ // serviços diferentes: um responde o chat do app e a conta, o outro é o motor
35
+ // do agente. Confundir os dois dá "não autenticado" sem explicação.
36
+ const CONECTA = process.env.PRIMOCODE_CONTA_SERVER || 'https://conecta-primo-api.onrender.com';
37
+
38
+ const ARQUIVO = path.join(os.homedir(), '.primocode', 'conta.json');
39
+
40
+ // Os planos que liberam tudo. A lista é a MESMA do servidor
41
+ // (`PLANOS_COM_PRO`), e quem decide de verdade é ele: isto aqui só evita uma
42
+ // ida à rede para mostrar o selo na abertura.
43
+ const PLANOS_PRO = ['premium', 'super'];
44
+
45
+ function pedir(rota, corpo, timeout = 30000) {
46
+ return new Promise((resolve, reject) => {
47
+ const url = new URL(CONECTA + rota);
48
+ const lib = url.protocol === 'https:' ? https : http;
49
+ const dados = corpo == null ? null : Buffer.from(JSON.stringify(corpo), 'utf8');
50
+ const cabecalhos = { Accept: 'application/json' };
51
+ if (dados) {
52
+ cabecalhos['Content-Type'] = 'application/json';
53
+ cabecalhos['Content-Length'] = dados.length;
54
+ }
55
+ const cred = ler();
56
+ if (cred && cred.token) cabecalhos.Authorization = `Bearer ${cred.token}`;
57
+
58
+ const req = lib.request({
59
+ hostname: url.hostname, port: url.port || undefined,
60
+ path: url.pathname + url.search,
61
+ method: dados ? 'POST' : 'GET', timeout, headers: cabecalhos,
62
+ }, (res) => {
63
+ let texto = '';
64
+ res.setEncoding('utf8');
65
+ res.on('data', (c) => { texto += c; });
66
+ res.on('end', () => {
67
+ try { resolve(JSON.parse(texto)); }
68
+ catch { reject(new Error(`resposta inesperada do servidor (${res.statusCode})`)); }
69
+ });
70
+ });
71
+ req.on('timeout', () => req.destroy(new Error('o servidor demorou demais')));
72
+ req.on('error', reject);
73
+ if (dados) req.write(dados);
74
+ req.end();
75
+ });
76
+ }
77
+
78
+ // ── o arquivo ────────────────────────────────────────────────────────────
79
+
80
+ function ler() {
81
+ try { return JSON.parse(fs.readFileSync(ARQUIVO, 'utf8')); }
82
+ catch { return null; }
83
+ }
84
+
85
+ function gravar(dados) {
86
+ fs.mkdirSync(path.dirname(ARQUIVO), { recursive: true });
87
+ fs.writeFileSync(ARQUIVO, JSON.stringify(dados, null, 2), 'utf8');
88
+ // 600 = só o dono lê. No Windows o modo é ignorado pelo sistema de
89
+ // arquivos e a chamada não falha — lá quem protege é a pasta do perfil.
90
+ try { fs.chmodSync(ARQUIVO, 0o600); } catch {}
91
+ }
92
+
93
+ function sair() {
94
+ try { fs.rmSync(ARQUIVO, { force: true }); return true; }
95
+ catch { return false; }
96
+ }
97
+
98
+ /** Quem está conectado, sem ir à rede. */
99
+ function atual() {
100
+ const c = ler();
101
+ if (!c || !c.token) return null;
102
+ return { uid: c.uid, plano: c.plano || 'free', email: c.email || null,
103
+ pro: PLANOS_PRO.includes(c.plano), desde: c.desde };
104
+ }
105
+
106
+ // ── entrar ───────────────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * O login inteiro: pede o código, abre o navegador, espera a aprovação.
110
+ *
111
+ * @param {object} op
112
+ * @param {(url:string)=>any} op.abrir abre o navegador (vem do tools.js)
113
+ * @param {(msg:string)=>void} [op.contar] para a tela ir dizendo o que faz
114
+ * @param {number} [op.minutos] teto da espera
115
+ */
116
+ async function entrar({ abrir, contar = () => {}, minutos = 5 } = {}) {
117
+ let pedido;
118
+ try {
119
+ pedido = await pedir('/api/dispositivo/novo', { origem: `PrimoCode ${versao()}` });
120
+ } catch (e) {
121
+ return { ok: false, error: `não consegui falar com o Conecta Primo: ${e.message}` };
122
+ }
123
+ if (!pedido || !pedido.success) {
124
+ return { ok: false, error: (pedido && pedido.error) || 'o servidor recusou abrir o login' };
125
+ }
126
+
127
+ contar(pedido.url);
128
+ contar(pedido.codigo);
129
+ if (abrir) { try { await abrir(pedido.url); } catch { /* segue: o link está na tela */ } }
130
+
131
+ /* A espera. Dois segundos entre perguntas: mais rápido é marteladas no
132
+ servidor por uma pessoa que ainda está digitando a senha; mais devagar e
133
+ o terminal parece travado depois de a página já ter confirmado. */
134
+ const prazo = Date.now() + minutos * 60000;
135
+ while (Date.now() < prazo) {
136
+ await new Promise((ok) => setTimeout(ok, 2000));
137
+ let r;
138
+ try {
139
+ r = await pedir('/api/dispositivo/consultar',
140
+ { codigo: pedido.codigo, segredo: pedido.segredo }, 10000);
141
+ } catch { continue; } // rede tropeçou: pergunta de novo
142
+ if (!r || !r.success) continue;
143
+ if (r.estado === 'expirado') {
144
+ return { ok: false, error: 'o código venceu antes de você aprovar. Tente de novo.' };
145
+ }
146
+ if (r.estado !== 'aprovado') continue;
147
+
148
+ gravar({
149
+ token: r.token, uid: r.uid, plano: r.plano || 'free',
150
+ desde: new Date().toISOString(), servidor: CONECTA,
151
+ });
152
+ return { ok: true, uid: r.uid, plano: r.plano,
153
+ pro: PLANOS_PRO.includes(r.plano) };
154
+ }
155
+ return { ok: false, error: `passaram ${minutos} minutos sem aprovação. Rode /entrar de novo.` };
156
+ }
157
+
158
+ /**
159
+ * Confirma o plano no SERVIDOR e devolve a sessão para o Firestore.
160
+ *
161
+ * O plano guardado no arquivo é uma fotografia do dia do login: quem assinou
162
+ * ontem continuaria como gratuito, e quem cancelou continuaria Premium. Quem
163
+ * sabe é o servidor, e é ele quem diz — o arquivo local serve para mostrar o
164
+ * selo na abertura sem esperar a rede, e só.
165
+ */
166
+ async function sessao() {
167
+ const c = ler();
168
+ if (!c || !c.token) return { ok: false, error: 'não conectado' };
169
+ let r;
170
+ try { r = await pedir('/api/primocode/sessao', {}); }
171
+ catch (e) { return { ok: false, error: e.message, offline: true }; }
172
+ if (!r || !r.success) {
173
+ // Token revogado no painel, ou vencido: o arquivo local não vale mais.
174
+ // Apagar aqui evita a pessoa ficar vendo "conectado" para sempre com
175
+ // uma credencial morta.
176
+ if (r && /autenticado/i.test(r.error || '')) sair();
177
+ return { ok: false, error: (r && r.error) || 'o servidor recusou a sessão' };
178
+ }
179
+ // O plano volta atualizado: grava por cima, senão o selo da abertura
180
+ // continua mostrando o de ontem.
181
+ if (r.plano && r.plano !== c.plano) gravar({ ...c, plano: r.plano });
182
+ return { ok: true, uid: r.uid, plano: r.plano, pro: !!r.pro,
183
+ customToken: r.custom_token, apiKey: r.api_key, projeto: r.projeto };
184
+ }
185
+
186
+ /** Os computadores conectados nesta conta. */
187
+ async function dispositivos() {
188
+ const r = await pedir('/api/primocode/dispositivos').catch((e) => ({ error: e.message }));
189
+ if (!r || !r.success) return { ok: false, error: (r && r.error) || 'não deu para listar' };
190
+ return { ok: true, dispositivos: r.dispositivos || [] };
191
+ }
192
+
193
+ function versao() {
194
+ try { return require('../package.json').version; } catch { return '?'; }
195
+ }
196
+
197
+ module.exports = { entrar, sair, atual, sessao, dispositivos, ARQUIVO, CONECTA, PLANOS_PRO };