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 +232 -2
- package/lib/catalogo.js +4 -0
- package/lib/conta.js +197 -0
- package/lib/icone.js +247 -0
- package/lib/instalar-app.js +231 -0
- package/lib/nuvem.js +407 -0
- package/lib/remotion.js +463 -0
- package/lib/tools.js +30 -0
- package/package.json +2 -2
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/ferramentas/corte.md +31 -0
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
|
-
|
|
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
|
-
|
|
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 };
|