primocode 9.3.1 → 9.4.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v9.3.1
1
+ # PrimoCode v9.4.0
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 v9.3.1
12
+ ▐▛███▜▌ Primo Code v9.4.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/app/index.html CHANGED
@@ -40,7 +40,7 @@
40
40
  <div class="rotulo">Conversas</div>
41
41
  <div class="recentes" id="recentes">
42
42
  <div class="vazio-lista" id="listaVazia">
43
- As conversas aparecem aqui quando você entra na sua conta com <code>/connect</code>.
43
+ As conversas aparecem aqui quando você entra na sua conta com <code>/login</code>.
44
44
  </div>
45
45
  </div>
46
46
 
package/bin/primocode.js CHANGED
@@ -104,7 +104,7 @@ ${c.bold('Comandos')}
104
104
  ${c.brand('/run')} <comando> executa no shell do projeto
105
105
  ${c.brand('/open')} <arq|url> abre no navegador
106
106
  ${c.brand('/rc')} abre a janela do PrimoCode (chat, sem terminal)
107
- ${c.brand('/connect')} entra na sua conta · ${c.brand('/account')} mostra o plano
107
+ ${c.brand('/login')} entra na sua conta · ${c.brand('/account')} mostra o plano
108
108
  ${c.brand('/chats')} [n] suas conversas — as mesmas do app e do celular
109
109
  ${c.brand('/logout')} desconecta este computador da conta
110
110
  ${c.brand('/status')} checa o servidor
@@ -148,7 +148,7 @@ const SLASH_COMANDOS = [
148
148
  ['/run', 'roda um comando'],
149
149
  ['/open', 'abre no navegador'],
150
150
  ['/rc', 'abre a janela do PrimoCode'],
151
- ['/connect', 'entra na sua conta Conecta Primo'],
151
+ ['/login', 'entra na sua conta Conecta Primo'],
152
152
  ['/account', 'sua conta, plano e computadores conectados'],
153
153
  ['/chats', 'suas conversas salvas, as mesmas do app e do celular'],
154
154
  ['/logout', 'desconecta este computador da conta'],
@@ -382,6 +382,42 @@ function ask(question) {
382
382
  });
383
383
  }
384
384
 
385
+ /**
386
+ * Pergunta sem mostrar o que se digita — para a senha do /login.
387
+ *
388
+ * Com a caixa de digitar no ar, ela mesma oculta a próxima linha (ver
389
+ * `ocultarProxima` em lib/entrada.js). Sem a caixa, o readline ecoa cada
390
+ * tecla pelo `_writeToOutput`; enquanto a pergunta está aberta, só a quebra
391
+ * de linha passa. Nos dois casos a resposta fica fora do histórico: senão a
392
+ * seta para cima devolveria a senha na tela.
393
+ */
394
+ function askSecret(question) {
395
+ if (!process.stdin.isTTY) return ask(question);
396
+ if (rl && typeof rl.ocultarProxima === 'function') {
397
+ return new Promise((resolve) => {
398
+ process.stdout.write(question);
399
+ rl.ocultarProxima();
400
+ pendingAsk = resolve;
401
+ });
402
+ }
403
+ return new Promise((resolve) => {
404
+ const alvo = rl || readline.createInterface({ input: process.stdin, output: process.stdout });
405
+ const original = alvo._writeToOutput;
406
+ process.stdout.write(question);
407
+ alvo._writeToOutput = function (texto) {
408
+ if (/[\r\n]/.test(texto)) original.call(alvo, '\n');
409
+ };
410
+ const fim = (resposta) => {
411
+ alvo._writeToOutput = original;
412
+ if (Array.isArray(alvo.history) && alvo.history[0] === resposta) alvo.history.shift();
413
+ if (!rl) alvo.close();
414
+ resolve(resposta);
415
+ };
416
+ if (rl) pendingAsk = fim;
417
+ else alvo.question('', fim);
418
+ });
419
+ }
420
+
385
421
  async function confirm(question) {
386
422
  const raw = await ask(`${c.brand('?')} ${question} ${c.muted('(S/n)')} `);
387
423
  if (!process.stdin.isTTY) process.stdout.write('\n'); // pipe não ecoa o Enter
@@ -731,7 +767,7 @@ async function sendPrompt(userText) {
731
767
  if (state.liberado === false) {
732
768
  console.log(c.err(' O PrimoCode pede o plano Premium ou Super.'));
733
769
  console.log(c.muted(' Assine em ') + c.brand('conecta-primo-ai.vercel.app')
734
- + c.muted(' · já assinou? ') + c.brand('/connect'));
770
+ + c.muted(' · já assinou? ') + c.brand('/login'));
735
771
  return;
736
772
  }
737
773
 
@@ -1105,7 +1141,7 @@ const RENOMEADOS = {
1105
1141
  // o que abre a janela
1106
1142
  app: 'rc',
1107
1143
  // a conta
1108
- entrar: 'connect', login: 'connect', conectar: 'connect',
1144
+ entrar: 'login', conectar: 'login',
1109
1145
  conta: 'account',
1110
1146
  sair: 'exit', // sair é SAIR; desconectar a conta é /logout
1111
1147
  // o histórico
@@ -1736,52 +1772,25 @@ async function handleSlash(text) {
1736
1772
  break;
1737
1773
  }
1738
1774
 
1739
- /* A conta do Conecta Primo. O login inteiro mora em lib/conta.js;
1740
- aqui é a tela dele. */
1741
- case 'connect': {
1775
+ /* A conta do Conecta Primo, sem navegador: e-mail e senha aqui
1776
+ mesmo. O login inteiro mora em lib/conta.js; aqui é a tela dele. */
1777
+ case 'login': {
1742
1778
  const jaEsta = conta.atual();
1743
1779
  if (jaEsta && rest !== 'trocar') {
1744
- console.log(c.muted(' Já conectado como ') + c.brand(jaEsta.uid.slice(0, 12) + '…')
1780
+ console.log(c.muted(' Já conectado como ') + c.brand(jaEsta.email || (jaEsta.uid.slice(0, 12) + '…'))
1745
1781
  + c.muted(` · plano ${jaEsta.plano}`));
1746
- console.log(c.dim(' Para entrar com outra conta: ') + c.brand('/connect trocar'));
1782
+ console.log(c.dim(' Para entrar com outra conta: ') + c.brand('/login trocar'));
1747
1783
  break;
1748
1784
  }
1749
1785
  if (rest === 'trocar') conta.sair();
1750
1786
 
1751
- const sp = new Spinner('abrindo o login').start();
1752
- const r = await conta.entrar({
1753
- abrir: (u) => tools.openInBrowser(u),
1754
- contar: (info) => {
1755
- sp.stop();
1756
- const meio = info.codigo.length === 8
1757
- ? info.codigo.slice(0, 4) + '-' + info.codigo.slice(4) : info.codigo;
1758
- console.log(c.muted(' Abrindo no navegador:'));
1759
- console.log(' ' + c.brand(info.url.replace(/[?&]porta=\d+/, '')));
1760
- console.log('');
1761
- if (info.porta) {
1762
- /* COM O SERVIDOR LOCAL DE PÉ, NÃO HÁ CÓDIGO PARA LER. A
1763
- página pergunta ao 127.0.0.1 desta máquina, recebe o
1764
- código e aprova sozinha assim que a pessoa entra com a
1765
- conta — "não precisa colocar código, não precisa pra
1766
- nada, porque ele já libera". Mostrar as oito letras
1767
- grandes aqui era pedir para ler o que ninguém precisa
1768
- mais; elas ficam numa linha apagada, só para o caso
1769
- de o navegador bloquear o 127.0.0.1 (Safari) e a
1770
- página voltar a pedir. */
1771
- console.log(' ' + c.white('Entre com a sua conta na página que abriu — o resto é automático.'));
1772
- console.log(c.dim(` Se a página pedir um código: ${meio}${info.copiado ? ' (já copiado)' : ''}.`));
1773
- } else {
1774
- /* Sem porta local (ocupadas, ou bloqueadas), o código
1775
- volta a ser o caminho — grande, partido em dois,
1776
- porque é o que se lê de uma tela e se confere noutra. */
1777
- console.log(' ' + c.bold(c.brand(meio.split('').join(' '))));
1778
- console.log('');
1779
- console.log(c.dim(' ' + (info.copiado ? 'O código já está copiado — cole na página.'
1780
- : 'Digite o código na página.')));
1781
- }
1782
- console.log(c.dim(' Esperando o navegador…'));
1783
- },
1784
- });
1787
+ const email = String(await ask(c.brand(' E-mail: ')) || '').trim();
1788
+ if (!email) { console.log(c.muted(' Login cancelado.')); break; }
1789
+ const senha = await askSecret(c.brand(' Senha: '));
1790
+ if (!senha) { console.log(c.muted(' Login cancelado.')); break; }
1791
+
1792
+ const sp = new Spinner('entrando').start();
1793
+ const r = await conta.entrar({ email, senha });
1785
1794
  sp.stop();
1786
1795
  if (!r.ok) { console.log(c.err(` ${r.error}`)); break; }
1787
1796
  console.log(c.ok(' Conectado.')
@@ -1793,7 +1802,7 @@ async function handleSlash(text) {
1793
1802
  mostram. Sem número, lista; com número, abre e continua dali. */
1794
1803
  case 'chats': {
1795
1804
  if (!conta.atual()) {
1796
- console.log(c.muted(' Não conectado. ') + c.brand('/connect')
1805
+ console.log(c.muted(' Não conectado. ') + c.brand('/login')
1797
1806
  + c.muted(' para ver suas conversas aqui, no app e no celular.'));
1798
1807
  break;
1799
1808
  }
@@ -1842,14 +1851,14 @@ async function handleSlash(text) {
1842
1851
  if (!conta.atual()) { console.log(c.muted(' Você não está conectado.')); break; }
1843
1852
  conta.sair();
1844
1853
  console.log(c.ok(' Desconectado deste computador.')
1845
- + c.dim(' A conta continua intacta; entre de novo com /connect.'));
1854
+ + c.dim(' A conta continua intacta; entre de novo com /login.'));
1846
1855
  break;
1847
1856
  }
1848
1857
 
1849
1858
  case 'account': {
1850
1859
  const eu = conta.atual();
1851
1860
  if (!eu) {
1852
- console.log(c.muted(' Não conectado. ') + c.brand('/connect') + c.muted(' para entrar.'));
1861
+ console.log(c.muted(' Não conectado. ') + c.brand('/login') + c.muted(' para entrar.'));
1853
1862
  break;
1854
1863
  }
1855
1864
  const sp = new Spinner('conferindo').start();
@@ -2213,7 +2222,7 @@ async function main() {
2213
2222
  cores: c,
2214
2223
  interativo: !!(process.stdin.isTTY && process.stdout.isTTY),
2215
2224
  conectado: () => conta.atual(),
2216
- conectar: () => handleSlash('/connect').then(() => conta.atual()),
2225
+ conectar: () => handleSlash('/login').then(() => conta.atual()),
2217
2226
  sessao: () => conta.sessao(),
2218
2227
  }).then((r) => {
2219
2228
  // O bloqueio de quem não é Premium/Super. É de CORTESIA: este arquivo
@@ -17,6 +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).
22
+ *
20
23
  * ── O BLOQUEIO É DE CORTESIA ─────────────────────────────────────────────
21
24
  * Este arquivo vai publicado no npm com o código à vista: um `if (pro)` aqui
22
25
  * é uma linha que qualquer um apaga. Quem segura de verdade é o
@@ -59,7 +62,7 @@ function esquecer() {
59
62
  * @param {object} op.cores
60
63
  * @param {boolean} op.interativo false = pipe/CI: não pergunta nada
61
64
  * @param {()=>any} op.conectado a credencial local, ou null
62
- * @param {()=>Promise<any>} op.conectar roda o login (abre o navegador)
65
+ * @param {()=>Promise<any>} op.conectar roda o login (e-mail e senha no terminal)
63
66
  * @param {()=>Promise<any>} op.sessao confirma o plano NO SERVIDOR
64
67
  */
65
68
  async function rodar({ perguntar, mostrar, cores: c, interativo = true,
@@ -78,13 +81,12 @@ async function rodar({ perguntar, mostrar, cores: c, interativo = true,
78
81
  mostrar('');
79
82
  mostrar(c.bold(c.brand(' Bem-vindo ao PrimoCode.')));
80
83
  mostrar(c.muted(' Primeiro, entre na sua conta do Conecta Primo AI.'));
81
- mostrar(c.dim(' Vou abrir o navegador; é só entrar com a conta por lá — o resto é automático.'));
82
84
  mostrar('');
83
85
  const r = await conectar();
84
86
  if (!r || r.ok === false) {
85
87
  mostrar('');
86
88
  mostrar(c.err(' Não deu para conectar' + (r && r.error ? `: ${r.error}` : '.')));
87
- mostrar(c.muted(' Tente de novo com ') + c.brand('/connect') + c.muted('.'));
89
+ mostrar(c.muted(' Tente de novo com ') + c.brand('/login') + c.muted('.'));
88
90
  return { liberado: false, motivo: 'sem-login' };
89
91
  }
90
92
  credencial = conectado ? conectado() : { plano: r.plano };
@@ -102,7 +104,7 @@ async function rodar({ perguntar, mostrar, cores: c, interativo = true,
102
104
  } else if (!s || !s.ok) {
103
105
  mostrar('');
104
106
  mostrar(c.err(' Não consegui confirmar sua conta' + (s && s.error ? `: ${s.error}` : '.')));
105
- mostrar(c.muted(' Entre de novo com ') + c.brand('/connect') + c.muted('.'));
107
+ mostrar(c.muted(' Entre de novo com ') + c.brand('/login') + c.muted('.'));
106
108
  return { liberado: false, motivo: 'sessao-invalida' };
107
109
  }
108
110
 
@@ -116,7 +118,7 @@ async function rodar({ perguntar, mostrar, cores: c, interativo = true,
116
118
  mostrar('');
117
119
  mostrar(c.dim(' Assine em ') + c.brand('conecta-primo-ai.vercel.app')
118
120
  + c.dim(' e rode ') + c.brand('primo') + c.dim(' de novo.'));
119
- mostrar(c.dim(' Já assinou? ') + c.brand('/connect')
121
+ mostrar(c.dim(' Já assinou? ') + c.brand('/login')
120
122
  + c.dim(' atualiza o plano nesta máquina.'));
121
123
  mostrar('');
122
124
  return { liberado: false, motivo: 'sem-plano', plano };
package/lib/conta.js CHANGED
@@ -1,16 +1,23 @@
1
1
  /**
2
2
  * conta.js — entrar na conta Conecta Primo, de dentro do terminal.
3
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."
4
+ * "Com /login você só entra na conta de novo, não tem mais ligar terminal
5
+ * com o navegador." (23/09/2026)
10
6
  *
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.
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:
11
+ *
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
+ *
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.
19
+ *
20
+ * Conta criada só com o Google não tem senha, e não entra por aqui.
14
21
  *
15
22
  * ── O ARQUIVO DA CREDENCIAL ──────────────────────────────────────────────
16
23
  * Ele NÃO entra no config.json. Aquele arquivo guarda preferência — modo,
@@ -29,7 +36,6 @@ const os = require('os');
29
36
  const path = require('path');
30
37
  const https = require('https');
31
38
  const http = require('http');
32
- const area = require('./area.js');
33
39
 
34
40
  // O servidor do Conecta Primo — que NÃO é o servidor do PrimoCode. São dois
35
41
  // serviços diferentes: um responde o chat do app e a conta, o outro é o motor
@@ -38,14 +44,20 @@ const CONECTA = process.env.PRIMOCODE_CONTA_SERVER || 'https://conecta-primo-api
38
44
 
39
45
  const ARQUIVO = path.join(os.homedir(), '.primocode', 'conta.json');
40
46
 
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
+
41
53
  // Os planos que liberam tudo. A lista é a MESMA do servidor
42
54
  // (`PLANOS_COM_PRO`), e quem decide de verdade é ele: isto aqui só evita uma
43
55
  // ida à rede para mostrar o selo na abertura.
44
56
  const PLANOS_PRO = ['premium', 'super'];
45
57
 
46
- function pedir(rota, corpo, timeout = 30000) {
58
+ function pedir(rota, corpo, timeout = 30000, { token, base = CONECTA } = {}) {
47
59
  return new Promise((resolve, reject) => {
48
- const url = new URL(CONECTA + rota);
60
+ const url = new URL(base + rota);
49
61
  const lib = url.protocol === 'https:' ? https : http;
50
62
  const dados = corpo == null ? null : Buffer.from(JSON.stringify(corpo), 'utf8');
51
63
  const cabecalhos = { Accept: 'application/json' };
@@ -53,7 +65,10 @@ function pedir(rota, corpo, timeout = 30000) {
53
65
  cabecalhos['Content-Type'] = 'application/json';
54
66
  cabecalhos['Content-Length'] = dados.length;
55
67
  }
56
- const cred = ler();
68
+ // Quem chama pode mandar o token (o do Firebase, no login) ou `null`
69
+ // para ir SEM token nenhum — o pedido ao Firebase nunca pode levar o
70
+ // `pc_` guardado. Sem a opção, vai o da credencial guardada.
71
+ const cred = token !== undefined ? (token ? { token } : null) : ler();
57
72
  if (cred && cred.token) cabecalhos.Authorization = `Bearer ${cred.token}`;
58
73
 
59
74
  const req = lib.request({
@@ -106,93 +121,49 @@ function atual() {
106
121
 
107
122
  // ── entrar ───────────────────────────────────────────────────────────────
108
123
 
109
- /* ── O CÓDIGO CHEGA À PÁGINA SEM NINGUÉM DIGITAR ─────────────────────────
110
- *
111
- * "No login quero que ele copie o código pra área de transferência
112
- * automaticamente. A tela de login deve ter só o negócio pra verificar o
113
- * código e ele já cola automaticamente o código."
114
- *
115
- * A regra da casa continua a mesma: O CÓDIGO NÃO VAI NO LINK. Com ele na URL,
116
- * um link mandado por outra pessoa conectaria o computador DELA à conta de
117
- * quem clicou. O que muda é COMO ele chega à página:
118
- *
119
- * 1. Enquanto espera a aprovação, o CLI serve `GET /pareamento` em
120
- * 127.0.0.1, e a página pergunta ali. Só uma página aberta NESTA máquina
121
- * alcança 127.0.0.1 desta máquina — o que é mais seguro que digitar, não
122
- * menos: o link do atacante abriria a página na máquina da vítima, cujo
123
- * CLI (se estiver rodando) tem o código DA VÍTIMA, nunca o dele.
124
- * 2. O código também vai para a área de transferência: no Safari, que
125
- * bloqueia http://127.0.0.1 a partir de página https, a página oferece
126
- * "Colar" — um toque, sem ler oito letras.
127
- * 3. Se nada disso servir, a pessoa digita, como antes.
128
- *
129
- * A resposta do servidor local só sai com `Access-Control-Allow-Origin` para a
130
- * ORIGEM DO SITE (a que veio no `pedido.url`). Sem isso qualquer página aberta
131
- * no navegador poderia ler 127.0.0.1 e roubar o código para aprová-lo na
132
- * própria conta. A porta vai na URL (`?porta=`), e a porta não é segredo: ela
133
- * só diz ONDE perguntar, e quem não está nesta máquina não alcança.
134
- */
135
- const PORTA_PAREAMENTO = Number(process.env.PRIMOCODE_PORTA_PAREAMENTO) || 7799;
136
-
137
- function servirPareamento(codigo, origemDoSite) {
138
- const permitidas = new Set([origemDoSite, 'http://127.0.0.1', 'http://localhost']
139
- .filter(Boolean));
140
- const permitida = (origem) => {
141
- if (!origem) return false;
142
- if (permitidas.has(origem)) return true;
143
- // Os testes e a prévia local rodam em portas variadas de 127.0.0.1.
144
- return /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origem);
145
- };
146
-
147
- const servidor = http.createServer((req, res) => {
148
- const origem = req.headers.origin;
149
- const cab = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' };
150
- if (permitida(origem)) {
151
- cab['Access-Control-Allow-Origin'] = origem;
152
- cab.Vary = 'Origin';
153
- cab['Access-Control-Allow-Methods'] = 'GET, OPTIONS';
154
- cab['Access-Control-Allow-Private-Network'] = 'true';
155
- }
156
- if (req.method === 'OPTIONS') { res.writeHead(204, cab); return res.end(); }
157
- const rota = (req.url || '/').split('?')[0];
158
- if (rota !== '/pareamento' || req.method !== 'GET') {
159
- res.writeHead(404, cab); return res.end('{"ok":false}');
160
- }
161
- // Sem origem permitida, a resposta sai SEM o cabeçalho CORS: o navegador
162
- // recebe e descarta antes de a página ler. O corpo continua igual de
163
- // propósito — o que protege é o cabeçalho, não esconder o corpo, e
164
- // dois corpos diferentes seriam dois caminhos para manter.
165
- res.writeHead(200, cab);
166
- res.end(JSON.stringify({ ok: true, codigo, origem: `PrimoCode ${versao()}` }));
167
- });
168
-
169
- return new Promise((ok) => {
170
- let porta = PORTA_PAREAMENTO;
171
- const tentar = () => {
172
- servidor.once('error', (e) => {
173
- if (e.code === 'EADDRINUSE' && porta < PORTA_PAREAMENTO + 10) { porta++; tentar(); }
174
- else ok({ porta: null, fechar: () => {} }); // sem servidor local: digita ou cola
175
- });
176
- servidor.listen(porta, '127.0.0.1', () => {
177
- ok({ porta, fechar: () => { try { servidor.close(); } catch { /* já fechou */ } } });
178
- });
179
- };
180
- tentar();
181
- });
124
+ /* O que o Firebase responde quando não entra → o que a pessoa lê. Os códigos
125
+ vêm no começo de `error.message` ("INVALID_PASSWORD : …"). */
126
+ function erroDoFirebase(codigo) {
127
+ const c = String(codigo || '').split(/\s|:/)[0].toUpperCase();
128
+ if (/INVALID_LOGIN_CREDENTIALS|INVALID_PASSWORD|EMAIL_NOT_FOUND/.test(c)) {
129
+ return 'e-mail ou senha incorretos. Se você entra no app pelo Google, essa conta não tem senha para o terminal.';
130
+ }
131
+ if (c === 'INVALID_EMAIL') return 'esse e-mail não parece válido.';
132
+ if (c === 'USER_DISABLED') return 'essa conta está desativada.';
133
+ if (/TOO_MANY_ATTEMPTS/.test(c)) return 'muitas tentativas seguidas. Espere alguns minutos e tente de novo.';
134
+ if (c === 'MISSING_PASSWORD') return 'faltou a senha.';
135
+ return `o login recusou (${c || 'sem motivo'}).`;
182
136
  }
183
137
 
184
138
  /**
185
- * O login inteiro: pede o código, abre o navegador, espera a aprovação.
139
+ * O login inteiro, sem navegador: e-mail e senha → token `pc_` guardado.
186
140
  *
187
141
  * @param {object} op
188
- * @param {(url:string)=>any} op.abrir abre o navegador (vem do tools.js)
189
- * @param {(info:object)=>void} [op.contar] recebe {url, codigo, copiado, porta} uma vez, antes de esperar
190
- * @param {number} [op.minutos] teto da espera
142
+ * @param {string} op.email
143
+ * @param {string} op.senha
191
144
  */
192
- async function entrar({ abrir, contar = () => {}, minutos = 5 } = {}) {
145
+ async function entrar({ email, senha } = {}) {
146
+ email = String(email || '').trim();
147
+ if (!email || !senha) return { ok: false, error: 'informe o e-mail e a senha.' };
148
+
149
+ // 1. Firebase: a mesma conta do app.
150
+ let fb;
151
+ try {
152
+ fb = await pedir(`/accounts:signInWithPassword?key=${encodeURIComponent(FIREBASE_CHAVE)}`,
153
+ { email, password: String(senha), returnSecureToken: true }, 20000,
154
+ { token: null, base: FIREBASE_AUTH });
155
+ } catch (e) {
156
+ return { ok: false, error: `não consegui falar com o login: ${e.message}` };
157
+ }
158
+ if (!fb || !fb.idToken) {
159
+ return { ok: false, error: erroDoFirebase(fb && fb.error && fb.error.message) };
160
+ }
161
+
162
+ // 2. O pedido de dispositivo, como o navegador abria.
193
163
  let pedido;
194
164
  try {
195
- pedido = await pedir('/api/dispositivo/novo', { origem: `PrimoCode ${versao()}` });
165
+ pedido = await pedir('/api/dispositivo/novo', { origem: `PrimoCode ${versao()}` },
166
+ 30000, { token: fb.idToken });
196
167
  } catch (e) {
197
168
  return { ok: false, error: `não consegui falar com o Conecta Primo: ${e.message}` };
198
169
  }
@@ -200,51 +171,27 @@ async function entrar({ abrir, contar = () => {}, minutos = 5 } = {}) {
200
171
  return { ok: false, error: (pedido && pedido.error) || 'o servidor recusou abrir o login' };
201
172
  }
202
173
 
203
- let origemDoSite = null;
204
- try { origemDoSite = new URL(pedido.url).origin; } catch { /* url estranha: sem CORS para ela */ }
205
- const local = await servirPareamento(pedido.codigo, origemDoSite);
206
- const copiado = await area.copiar(pedido.codigo);
207
-
208
- let url = pedido.url;
209
- if (local.porta) url += (url.includes('?') ? '&' : '?') + 'porta=' + local.porta;
210
-
211
- contar({ url, codigo: pedido.codigo, copiado: copiado.ok, porta: local.porta });
212
- if (abrir) { try { await abrir(url); } catch { /* segue: o link está na tela */ } }
213
-
174
+ // 3. Aprovar com o token do Firebase e recolher o `pc_`. O apelido é o que
175
+ // aparece na lista de computadores da conta.
214
176
  try {
215
- /* A espera. Dois segundos entre perguntas: mais rápido é marteladas no
216
- servidor por uma pessoa que ainda está digitando a senha; mais devagar e
217
- o terminal parece travado depois de a página já ter confirmado. */
218
- const prazo = Date.now() + minutos * 60000;
219
- while (Date.now() < prazo) {
220
- await new Promise((ok) => setTimeout(ok, 2000));
221
- let r;
222
- try {
223
- r = await pedir('/api/dispositivo/consultar',
224
- { codigo: pedido.codigo, segredo: pedido.segredo }, 10000);
225
- } catch { continue; } // rede tropeçou: pergunta de novo
226
- if (!r || !r.success) continue;
227
- if (r.estado === 'expirado') {
228
- return { ok: false, error: 'o código venceu antes de você aprovar. Tente de novo.' };
229
- }
230
- if (r.estado !== 'aprovado') continue;
231
-
232
- gravar({
233
- token: r.token, uid: r.uid, plano: r.plano || 'free',
234
- desde: new Date().toISOString(), servidor: CONECTA,
235
- });
236
- /* Aprovou lá: a atenção da pessoa está no navegador. O sino e o foco
237
- trazem-na de volta — o foco só no macOS, e o módulo diz por quê. */
238
- area.apitar();
239
- const volta = area.voltarAoTerminal();
240
- return { ok: true, uid: r.uid, plano: r.plano,
241
- pro: PLANOS_PRO.includes(r.plano), voltou: !!volta.ok };
177
+ const ap = await pedir('/api/dispositivo/aprovar',
178
+ { codigo: pedido.codigo, apelido: `PrimoCode · ${os.hostname()}`.slice(0, 60) },
179
+ 30000, { token: fb.idToken });
180
+ if (!ap || !ap.success) {
181
+ return { ok: false, error: (ap && ap.error) || 'o servidor recusou aprovar o login' };
182
+ }
183
+ const r = await pedir('/api/dispositivo/consultar',
184
+ { codigo: pedido.codigo, segredo: pedido.segredo }, 30000, { token: null });
185
+ if (!r || !r.success || r.estado !== 'aprovado' || !r.token) {
186
+ return { ok: false, error: (r && r.error) || 'o login não foi concluído. Tente de novo.' };
242
187
  }
243
- return { ok: false, error: `passaram ${minutos} minutos sem aprovação. Rode /connect de novo.` };
244
- } finally {
245
- // O servidor local só existe durante a espera. Deixá-lo de pé seria
246
- // uma porta respondendo um código morto para sempre.
247
- local.fechar();
188
+ gravar({
189
+ token: r.token, uid: r.uid, plano: r.plano || 'free', email,
190
+ desde: new Date().toISOString(), servidor: CONECTA,
191
+ });
192
+ return { ok: true, uid: r.uid, plano: r.plano, pro: PLANOS_PRO.includes(r.plano) };
193
+ } catch (e) {
194
+ return { ok: false, error: `não consegui concluir o login: ${e.message}` };
248
195
  }
249
196
  }
250
197
 
@@ -287,4 +234,4 @@ function versao() {
287
234
  try { return require('../package.json').version; } catch { return '?'; }
288
235
  }
289
236
 
290
- module.exports = { entrar, sair, atual, sessao, dispositivos, servirPareamento, ARQUIVO, CONECTA, PLANOS_PRO, PORTA_PAREAMENTO };
237
+ module.exports = { entrar, sair, atual, sessao, dispositivos, erroDoFirebase, ARQUIVO, CONECTA, PLANOS_PRO };
package/lib/entrada.js CHANGED
@@ -218,6 +218,7 @@ class Entrada extends EventEmitter {
218
218
  this.ocupado = false;
219
219
  this.fechado = false;
220
220
  this.colando = false;
221
+ this.oculto = false; // a próxima resposta é senha: ver `ocultarProxima`
221
222
  this.tty = !!(process.stdin.isTTY && process.stdout.isTTY);
222
223
  this.escreverCru = process.stdout.write.bind(process.stdout);
223
224
  }
@@ -341,14 +342,21 @@ class Entrada extends EventEmitter {
341
342
 
342
343
  limpar() { this.line = ''; this.cursor = 0; this.menu = null; this._mudou(); }
343
344
 
345
+ /**
346
+ * A PRÓXIMA linha é senha: a caixa desenha `•` no lugar das letras, não
347
+ * repete o texto na tela ao enviar e não o guarda no histórico — senão a
348
+ * seta para cima devolveria a senha. Vale para uma linha só.
349
+ */
350
+ ocultarProxima() { this.oculto = true; this.menu = null; this._mudou(); }
351
+
344
352
  // ── desenho ──────────────────────────────────────────────────────────
345
353
 
346
354
  _quadro() {
347
355
  return montar({
348
- texto: this.line,
356
+ texto: this.oculto ? '•'.repeat(this.line.length) : this.line,
349
357
  cursor: this.cursor,
350
358
  colunas: process.stdout.columns || 80,
351
- fantasma: this._fantasma(),
359
+ fantasma: this.oculto ? '' : this._fantasma(),
352
360
  vazio: this.op.vazio || '',
353
361
  status: this.op.status ? this.op.status() : [],
354
362
  menu: this.menu,
@@ -408,6 +416,7 @@ class Entrada extends EventEmitter {
408
416
  }
409
417
 
410
418
  _filtrarMenu() {
419
+ if (this.oculto) { this.menu = null; return; }
411
420
  const lista = this.op.comandos ? this.op.comandos() : [];
412
421
  const baixa = this.line.toLowerCase();
413
422
  const itens = this.line.startsWith('/') && !/\s/.test(this.line)
@@ -575,6 +584,8 @@ class Entrada extends EventEmitter {
575
584
  _enviar() {
576
585
  if (this.menu) return this._escolherDoMenu();
577
586
  const texto = this.line;
587
+ const oculto = this.oculto;
588
+ this.oculto = false;
578
589
  this.line = '';
579
590
  this.cursor = 0;
580
591
  this.menu = null;
@@ -584,7 +595,7 @@ class Entrada extends EventEmitter {
584
595
  senão o pedido some no instante em que a resposta começa, e quem
585
596
  rola para cima não acha o que perguntou. */
586
597
  this._apagar();
587
- if (texto.trim()) {
598
+ if (texto.trim() && !oculto) {
588
599
  const marca = c.brand('❯ ');
589
600
  this.escreverCru(texto.split('\n').map((l, i) => (i ? ' ' : marca) + c.white(l)).join('\n') + '\n');
590
601
  if (this.historico[this.historico.length - 1] !== texto) this.historico.push(texto);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.3.1",
3
+ "version": "9.4.0",
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/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.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/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/aplicativo.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/janela.test.js && node test/pareamento.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"
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/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.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/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/aplicativo.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/janela.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"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
package/lib/area.js DELETED
@@ -1,104 +0,0 @@
1
- /**
2
- * area.js — a área de transferência e o foco do terminal, sem dependência.
3
- *
4
- * "No login quero que ele copie o código pra área de transferência
5
- * automaticamente. [...] Ele libera, você volta ao terminal
6
- * automaticamente."
7
- *
8
- * ── POR QUE FALAR COM O SISTEMA, E NÃO COM UM PACOTE ────────────────────
9
- * O CLI tem zero dependências e isto não é motivo para abrir a primeira: todo
10
- * sistema já tem um programa que escreve na área de transferência (pbcopy,
11
- * clip, wl-copy, xclip, xsel). O que este módulo faz é achar qual existe e
12
- * falar com ele — e FALHAR EM SILÊNCIO quando nenhum existe, porque copiar é
13
- * conveniência: o código continua na tela e a página continua aceitando
14
- * digitação. Conveniência que derruba o login não é conveniência.
15
- *
16
- * ── O FOCO SÓ VOLTA NO MAC, E É HONESTO DIZER ────────────────────────────
17
- * Trazer o terminal para a frente depois de a página aprovar é o que fecha o
18
- * laço "clicou → voltou". No macOS dá: o `TERM_PROGRAM` diz qual app é o
19
- * terminal e o `osascript` o ativa. No Windows e no Linux não há um jeito
20
- * geral que funcione sem depender do gerenciador de janelas — então lá o CLI
21
- * apita e imprime o próximo passo, e a janela do navegador diz "volte ao
22
- * terminal". Prometer o foco onde ele não vem seria pior que não prometer.
23
- */
24
-
25
- 'use strict';
26
-
27
- const os = require('os');
28
- const { execFile, execFileSync } = require('child_process');
29
-
30
- /* Cada candidato é [programa, argumentos]. A ordem dentro de cada sistema é
31
- a do que existe com mais frequência: no Linux o Wayland vem primeiro
32
- porque, quando ele é a sessão, o xclip existe e NÃO funciona. */
33
- function candidatos() {
34
- const p = os.platform();
35
- if (p === 'darwin') return [['pbcopy', []]];
36
- if (p === 'win32') return [['clip', []]];
37
- const lista = [];
38
- if (process.env.WAYLAND_DISPLAY) lista.push(['wl-copy', []]);
39
- lista.push(['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]);
40
- if (!process.env.WAYLAND_DISPLAY) lista.push(['wl-copy', []]);
41
- return lista;
42
- }
43
-
44
- /**
45
- * Copia o texto para a área de transferência.
46
- * @returns {Promise<{ok:boolean, como?:string}>} nunca rejeita
47
- */
48
- function copiar(texto) {
49
- const fila = candidatos();
50
- return new Promise((resolve) => {
51
- const tentar = (i) => {
52
- if (i >= fila.length) return resolve({ ok: false });
53
- const [cmd, args] = fila[i];
54
- let filho;
55
- try {
56
- filho = execFile(cmd, args, { timeout: 3000, windowsHide: true }, (erro) => {
57
- if (erro) return tentar(i + 1);
58
- resolve({ ok: true, como: cmd });
59
- });
60
- } catch { return tentar(i + 1); }
61
- filho.on('error', () => tentar(i + 1));
62
- try { filho.stdin.on('error', () => {}); filho.stdin.end(String(texto)); }
63
- catch { tentar(i + 1); }
64
- };
65
- tentar(0);
66
- });
67
- }
68
-
69
- /* O app do terminal, pelo que ele mesmo declara. Os cinco da lista são os que
70
- têm nome de aplicativo diferente do valor da variável — os outros
71
- (Warp, Alacritty, kitty, Ghostty) já se chamam pelo próprio nome. */
72
- const APPS_MAC = {
73
- Apple_Terminal: 'Terminal',
74
- 'iTerm.app': 'iTerm',
75
- vscode: 'Visual Studio Code',
76
- Hyper: 'Hyper',
77
- WezTerm: 'WezTerm',
78
- };
79
-
80
- /**
81
- * Traz o terminal de volta para a frente. Só faz algo no macOS; nos outros
82
- * sistemas devolve {ok:false, motivo} sem tentar nada.
83
- */
84
- function voltarAoTerminal() {
85
- if (os.platform() !== 'darwin') return { ok: false, motivo: 'só no macOS' };
86
- const programa = process.env.TERM_PROGRAM || '';
87
- const app = APPS_MAC[programa] || programa.replace(/\.app$/, '');
88
- if (!app) return { ok: false, motivo: 'terminal não se identificou' };
89
- try {
90
- // O nome vai como ARGUMENTO do AppleScript, nunca dentro do texto do
91
- // script: TERM_PROGRAM é ambiente, e ambiente é o que qualquer coisa
92
- // escreve.
93
- execFileSync('osascript', ['-e', 'on run argv', '-e', 'tell application (item 1 of argv) to activate',
94
- '-e', 'end run', app], { timeout: 3000, stdio: 'ignore' });
95
- return { ok: true, app };
96
- } catch { return { ok: false, motivo: 'osascript recusou' }; }
97
- }
98
-
99
- /** O sino do terminal: chama a atenção de quem está olhando o navegador. */
100
- function apitar() {
101
- try { if (process.stdout.isTTY) process.stdout.write('\x07'); } catch { /* sem tty */ }
102
- }
103
-
104
- module.exports = { copiar, voltarAoTerminal, apitar, candidatos, APPS_MAC };