@fefsbenson/jarvis 1.1.0 → 1.2.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/cli.js CHANGED
@@ -12,7 +12,9 @@
12
12
  *
13
13
  * JARVIS · PRAGMA · by Felipe Brandão
14
14
  */
15
- import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
15
+ import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync,
16
+ readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
17
+ import { createHash } from 'node:crypto';
16
18
  import { spawn, spawnSync } from 'node:child_process';
17
19
  import { createInterface } from 'node:readline';
18
20
  import { tmpdir } from 'node:os';
@@ -323,11 +325,368 @@ async function instalar(args) {
323
325
  return r.status ?? 0;
324
326
  }
325
327
 
328
+ // ══════════════════════════════════════════════════════════════════
329
+ // UPDATE — traz a versão nova sem apagar o que o dono fez
330
+ // ══════════════════════════════════════════════════════════════════
331
+
332
+ /**
333
+ * O manifesto que a instalação grava: hash de cada arquivo COMO SAIU do
334
+ * pacote. É ele que torna a atualização possível sem escolher entre duas
335
+ * coisas ruins — apagar o trabalho do dono, ou nunca entregar correção.
336
+ *
337
+ * Sem manifesto (instalação anterior à v1.1.7) o update ainda funciona,
338
+ * mas não sabe o que foi alterado: aí ele preserva por precaução tudo que
339
+ * difere do pacote novo, e diz isso na tela.
340
+ */
341
+ function lerManifesto(raiz) {
342
+ const f = join(raiz, '.claude', 'jarvis', 'instalacao-original.json');
343
+ if (!existsSync(f)) return null;
344
+ try {
345
+ const d = JSON.parse(readFileSync(f, 'utf8'));
346
+ return (d && d.arquivos) ? d : null;
347
+ } catch { return null; }
348
+ }
349
+
350
+ function hashDe(caminho) {
351
+ try {
352
+ return createHash('sha256').update(readFileSync(caminho)).digest('hex');
353
+ } catch { return null; }
354
+ }
355
+
356
+ /**
357
+ * Pastas que NUNCA se sobrescreve, porque são do dono por natureza:
358
+ * credenciais, memória do sistema, o trabalho dele, estado e registros.
359
+ * Isto não depende do manifesto — é contrato, e vale mesmo sem ele.
360
+ */
361
+ const INTOCAVEIS = [
362
+ '.env', '.venv', 'node_modules', '.git',
363
+ 'jarvis.config.yaml',
364
+ '.claude/settings.local.json',
365
+ '.claude/jarvis/', // memória, estado, ponteiros
366
+ '.claude/sessions/', // histórico de sessões
367
+ 'workspace/', // o negócio do dono
368
+ 'knowledge/business/', 'knowledge/personal/',
369
+ 'logs/', '.data/', 'artifacts/', 'processing/',
370
+ ];
371
+
372
+ /**
373
+ * Exceções DENTRO de pasta protegida: arquivos que o sistema mantém e o dono
374
+ * não escreve. Sem esta lista, `.claude/jarvis/` inteiro fica congelado — e o
375
+ * manifesto nunca se renova, então uma instalação antiga seguiria para sempre
376
+ * sem saber o que o dono alterou.
377
+ */
378
+ const SEMPRE_ATUALIZA = ['VERSION', 'MANIFESTO-PACOTE.json', 'LICENSE'];
379
+
380
+ const EXCECOES = [
381
+ '.claude/jarvis/instalacao-original.json',
382
+ ];
383
+
384
+ function ehIntocavel(rel) {
385
+ const n = rel.split('\\').join('/');
386
+ if (EXCECOES.includes(n)) return false;
387
+ return INTOCAVEIS.some(i => i.endsWith('/') ? n.startsWith(i) : n === i);
388
+ }
389
+
390
+ function listar(dir, base = dir, saida = []) {
391
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
392
+ if (e.name === 'node_modules' || e.name === '.git' ||
393
+ e.name === '__pycache__' || e.name === '.venv') continue;
394
+ const cheio = join(dir, e.name);
395
+ const rel = cheio.slice(base.length + 1).split('\\').join('/');
396
+ if (e.isDirectory()) listar(cheio, base, saida);
397
+ else saida.push(rel);
398
+ }
399
+ return saida;
400
+ }
401
+
402
+ /**
403
+ * Compara a instalação atual com o pacote novo e classifica cada arquivo.
404
+ *
405
+ * Os quatro casos, e por que cada um se resolve assim:
406
+ *
407
+ * novo — não existe aqui: entra.
408
+ * igual — o dono não mexeu E o pacote não mudou: nada a fazer.
409
+ * atualiza — o dono não mexeu e o pacote mudou: entra (é a correção).
410
+ * preserva — o dono MEXEU: fica o dele. É o ponto todo do manifesto;
411
+ * sem ele, "atualizar" seria apagar o trabalho de alguém.
412
+ */
413
+ /**
414
+ * O "motor": o que faz o sistema funcionar, por oposição ao que ele guarda.
415
+ * Numa instalação sem manifesto é a única forma de distinguir a correção
416
+ * chegando de uma edição do dono — e a chance de alguém ter editado um hook
417
+ * à mão é muito menor que a de ficar sem o conserto.
418
+ */
419
+ function ehDoMotor(rel) {
420
+ const n = rel.split('\\').join('/');
421
+ return n.startsWith('.claude/hooks/') ||
422
+ n.startsWith('.claude/scripts/') ||
423
+ n.startsWith('core/') ||
424
+ n.startsWith('bin/') ||
425
+ n === 'install.py' ||
426
+ n === 'requirements.txt' ||
427
+ n === 'requirements-opcional.txt';
428
+ }
429
+
430
+ function planejar(raizAtual, raizNova, manifesto) {
431
+ const orig = manifesto ? manifesto.arquivos : null;
432
+ const plano = { novo: [], atualiza: [], preserva: [], igual: [], protegido: [] };
433
+
434
+ for (const rel of listar(raizNova)) {
435
+ if (ehIntocavel(rel)) { plano.protegido.push(rel); continue; }
436
+
437
+ const aqui = join(raizAtual, rel);
438
+ if (!existsSync(aqui)) { plano.novo.push(rel); continue; }
439
+
440
+ const hAqui = hashDe(aqui);
441
+ const hNovo = hashDe(join(raizNova, rel));
442
+ if (hAqui === hNovo) { plano.igual.push(rel); continue; }
443
+
444
+ // difere do pacote novo. Foi o dono que mexeu, ou é a correção chegando?
445
+ // Metadados do pacote são do sistema por definição — o dono nunca os
446
+ // edita. Preservar o VERSION deixaria o update dizendo "já está na versão
447
+ // mais recente" para sempre numa instalação sem manifesto.
448
+ if (SEMPRE_ATUALIZA.includes(rel)) { plano.atualiza.push(rel); continue; }
449
+
450
+ if (orig) {
451
+ const hOrig = orig[rel];
452
+ // sem registro de origem = arquivo que o dono criou depois: preserva
453
+ if (!hOrig) { plano.preserva.push(rel); continue; }
454
+ // igual ao que foi instalado = intocado pelo dono: pode atualizar
455
+ if (hAqui === hOrig) { plano.atualiza.push(rel); continue; }
456
+ plano.preserva.push(rel);
457
+ } else {
458
+ // Sem manifesto não dá para saber quem alterou o quê. Preservar TUDO
459
+ // parece seguro, mas trava o update onde ele mais importa: numa
460
+ // instalação antiga, os arquivos do MOTOR (scripts, hooks, instalador)
461
+ // são exatamente os que carregam a correção — e quase nunca são
462
+ // editados pelo dono. Preservá-los entregaria um update que não
463
+ // atualiza nada, com um .novo inerte ao lado.
464
+ //
465
+ // Então: motor atualiza, conteúdo preserva. E o que for sobrescrito
466
+ // ganha backup .antigo, para nada ser perdido sem volta.
467
+ plano[ehDoMotor(rel) ? 'atualiza' : 'preserva'].push(rel);
468
+ }
469
+ }
470
+ return plano;
471
+ }
472
+
473
+ function mostrarPlano(plano, semManifesto) {
474
+ const l = (rot, arr, cor) => {
475
+ if (!arr.length) return;
476
+ console.log(` ${cor(String(arr.length).padStart(5))} ${branco(rot)}`);
477
+ };
478
+ console.log();
479
+ console.log(` ${branco(neg('O que vai acontecer'))}`);
480
+ console.log();
481
+ l('arquivos novos', plano.novo, verde);
482
+ l('atualizados', plano.atualiza, verde);
483
+ l('preservados (você alterou)', plano.preserva, laranja);
484
+ l('já estavam iguais', plano.igual, fraco);
485
+ l('protegidos (memória, workspace, .env)', plano.protegido, cinza);
486
+ console.log();
487
+
488
+ if (plano.preserva.length) {
489
+ const amostra = plano.preserva.slice(0, 6);
490
+ console.log(` ${laranja('Preservados')} ${fraco('— fica a SUA versão, e guardo a nova ao lado como .novo')}`);
491
+ for (const a of amostra) console.log(` ${fraco('·')} ${cinza(a)}`);
492
+ if (plano.preserva.length > amostra.length) {
493
+ console.log(` ${fraco(`· e mais ${plano.preserva.length - amostra.length}`)}`);
494
+ }
495
+ console.log();
496
+ }
497
+
498
+ if (semManifesto) {
499
+ console.log(` ${laranja('!')} ${cinza('Sua instalação é anterior ao registro de arquivos.')}`);
500
+ console.log(` ${fraco('Sem ele não sei o que você alterou. Então atualizo o motor do sistema')}`);
501
+ console.log(` ${fraco('(hooks, scripts, instalador) — é onde a correção vem — e preservo o')}`);
502
+ console.log(` ${fraco('resto. O que for sobrescrito fica salvo como .antigo, e dá para voltar.')}`);
503
+ console.log(` ${fraco('A partir desta atualização, o registro passa a existir.')}`);
504
+ console.log();
505
+ }
506
+ }
507
+
508
+ function aplicar(raizAtual, raizNova, plano) {
509
+ let n = 0;
510
+ for (const rel of [...plano.novo, ...plano.atualiza]) {
511
+ const destino = join(raizAtual, rel);
512
+ mkdirSync(join(destino, '..'), { recursive: true });
513
+ // guarda o anterior quando o conteúdo difere: sem manifesto a decisão de
514
+ // atualizar é uma aposta informada, e aposta precisa ter volta.
515
+ if (existsSync(destino) && hashDe(destino) !== hashDe(join(raizNova, rel))) {
516
+ try { copyFileSync(destino, destino + '.antigo'); } catch {}
517
+ }
518
+ copyFileSync(join(raizNova, rel), destino);
519
+ n++;
520
+ }
521
+ // o que o dono alterou fica intacto; a versão nova vai ao lado, para ele ver
522
+ for (const rel of plano.preserva) {
523
+ copyFileSync(join(raizNova, rel), join(raizAtual, rel + '.novo'));
524
+ }
525
+ return n;
526
+ }
527
+
528
+ async function atualizar(args) {
529
+ abertura();
530
+
531
+ const raiz = resolve(process.cwd(), args[0] || '.');
532
+ const marca = join(raiz, '.claude', 'hooks');
533
+ if (!existsSync(marca)) {
534
+ console.log(` ${vermelho(neg('Não encontrei um JARVIS aqui.'))}`);
535
+ console.log(` ${cinza('Rode este comando DENTRO da pasta do JARVIS, ou passe o caminho:')}`);
536
+ console.log(` ${fraco('npx @fefsbenson/jarvis update C:\\Users\\voce\\jarvis')}`);
537
+ console.log();
538
+ return 1;
539
+ }
540
+
541
+ const versaoAtual = existsSync(join(raiz, 'VERSION'))
542
+ ? readFileSync(join(raiz, 'VERSION'), 'utf8').trim() : '?';
543
+
544
+ const py = acharPython();
545
+ const plataforma = existsSync(join(raiz, '.claude', 'hooks', 'pyrun.py'))
546
+ ? 'windows' : 'linux';
547
+
548
+ console.log(` ${verde('✓')} ${branco('JARVIS encontrado')} ${fraco(raiz)}`);
549
+ console.log(` ${verde('✓')} ${branco('Versão atual')} ${cinza('v' + versaoAtual)}` +
550
+ ` ${fraco('edição ' + PLATAFORMAS[plataforma].rotulo)}`);
551
+ console.log();
552
+ console.log(` ${cinza('Vou baixar a versão mais recente e trazer só o que mudou.')}`);
553
+ console.log(` ${cinza('O que você alterou fica como está — nada seu é sobrescrito.')}`);
554
+ console.log();
555
+
556
+ const token = process.env.JARVIS_TOKEN || await perguntar('Token:', true);
557
+ if (!token) {
558
+ console.log(`\n ${fraco('Sem token, não há o que baixar. Cancelado.')}\n`);
559
+ return 130;
560
+ }
561
+
562
+ const tmp = join(tmpdir(), `jarvis-update-${Date.now()}`);
563
+ console.log();
564
+ try {
565
+ await baixar(token, tmp, plataforma);
566
+ } catch (e) {
567
+ if (e.message === 'TOKEN_INVALIDO' || e.message === 'SEM_ACESSO') erroDeAcesso(e.message);
568
+ else {
569
+ console.log(`\n ${vermelho(neg('Não consegui baixar.'))}`);
570
+ console.log(` ${cinza(e.message)}\n`);
571
+ }
572
+ return 1;
573
+ }
574
+
575
+ const versaoNova = existsSync(join(tmp, 'VERSION'))
576
+ ? readFileSync(join(tmp, 'VERSION'), 'utf8').trim() : '?';
577
+
578
+ if (versaoNova === versaoAtual) {
579
+ console.log();
580
+ console.log(` ${verde('✓')} ${branco('Você já está na versão mais recente')} ${cinza('(v' + versaoNova + ')')}`);
581
+ console.log();
582
+ rmSync(tmp, { recursive: true, force: true });
583
+ return 0;
584
+ }
585
+
586
+ // Sanidade do que foi baixado, ANTES de encostar na pasta do dono.
587
+ // Um pacote que extrai pela metade (rede caindo, disco cheio) não faz o tar
588
+ // falhar necessariamente — e aplicar um pacote incompleto por cima de uma
589
+ // instalação boa é exatamente o estrago que este comando não pode causar.
590
+ const marcasEsperadas = ['.claude/hooks', '.claude/scripts', 'VERSION', 'install.py'];
591
+ const faltando = marcasEsperadas.filter(m => !existsSync(join(tmp, m)));
592
+ const totalBaixado = listar(tmp).length;
593
+ if (faltando.length || totalBaixado < 500) {
594
+ console.log();
595
+ console.log(` ${vermelho(neg('O pacote baixado veio incompleto.'))}`);
596
+ console.log(` ${cinza(faltando.length ? 'falta: ' + faltando.join(', ')
597
+ : `só ${totalBaixado} arquivos`)}`);
598
+ console.log();
599
+ console.log(` ${branco('Sua instalação NÃO foi tocada.')} ${fraco('Tente de novo.')}`);
600
+ console.log();
601
+ rmSync(tmp, { recursive: true, force: true });
602
+ return 1;
603
+ }
604
+
605
+ const manifesto = lerManifesto(raiz);
606
+ const plano = planejar(raiz, tmp, manifesto);
607
+
608
+ console.log();
609
+ console.log(` ${branco(neg(`v${versaoAtual}`))} ${fraco('→')} ${verde(neg(`v${versaoNova}`))}`);
610
+ mostrarPlano(plano, !manifesto);
611
+
612
+ if (!plano.novo.length && !plano.atualiza.length) {
613
+ console.log(` ${verde('✓')} ${branco('Nada a mudar nos arquivos do sistema.')}`);
614
+ console.log();
615
+ rmSync(tmp, { recursive: true, force: true });
616
+ return 0;
617
+ }
618
+
619
+ const ok = (await perguntar(`Aplicar? ${fraco('(enter = sim, n = cancelar)')}`)).trim().toLowerCase();
620
+ if (ok === 'n' || ok === 'nao' || ok === 'não') {
621
+ console.log(`\n ${fraco('Cancelado. Nada foi alterado.')}\n`);
622
+ rmSync(tmp, { recursive: true, force: true });
623
+ return 130;
624
+ }
625
+
626
+ console.log();
627
+ const n = aplicar(raiz, tmp, plano);
628
+ console.log(` ${verde('✓')} ${branco('Aplicado')} ${fraco(n.toLocaleString('pt-BR') + ' arquivo(s)')}`);
629
+ rmSync(tmp, { recursive: true, force: true });
630
+
631
+ // O settings.json que veio no pacote traz o caminho como `$CLAUDE_PROJECT_DIR`
632
+ // — sintaxe POSIX. Precisa virar o caminho ABSOLUTO desta máquina, senão no
633
+ // Windows os 105 hooks morrem (foi o defeito que quebrou a instalação de um
634
+ // aluno em 24/08).
635
+ //
636
+ // Chamar o médico com --fix NÃO basta: em Linux o exame passa (lá a variável
637
+ // expande), então o conserto nem é acionado e o marcador fica. Por isso a
638
+ // regravação é INCONDICIONAL — o install.py sabe fazê-la, e é a mesma função
639
+ // que roda em toda instalação.
640
+ if (py) {
641
+ console.log();
642
+ console.log(` ${cinza('Ajustando os caminhos para esta máquina...')}`);
643
+ const fix = spawnSync(py.cmd, [...(py.args || []), '-c',
644
+ 'import sys;sys.path.insert(0,sys.argv[1]);import install;' +
645
+ 'r=install.fixar_launcher_dos_hooks();print(r[0],r[1])', raiz],
646
+ { encoding: 'utf8', cwd: raiz });
647
+ if (fix.status === 0) {
648
+ console.log(` ${verde('✓')} ${branco('Caminhos e launcher gravados')} ${fraco((fix.stdout || '').trim())}`);
649
+ } else {
650
+ console.log(` ${laranja('!')} ${cinza('Não consegui regravar os caminhos automaticamente.')}`);
651
+ console.log(` ${fraco('Rode: ' + py.cmd + ' .claude/scripts/jarvis_doctor.py --fix')}`);
652
+ }
653
+
654
+ // e o registro de arquivos precisa refletir a versão que acabou de entrar,
655
+ // senão a PRÓXIMA atualização compara contra a versão errada
656
+ spawnSync(py.cmd, [...(py.args || []), '-c',
657
+ 'import sys;sys.path.insert(0,sys.argv[1]);import install;install.gravar_impressao_digital()', raiz],
658
+ { encoding: 'utf8', cwd: raiz });
659
+
660
+ console.log();
661
+ console.log(` ${cinza('Conferindo a instalação...')}`);
662
+ console.log();
663
+ const r = spawnSync(py.cmd, [...(py.args || []),
664
+ join(raiz, '.claude', 'scripts', 'jarvis_doctor.py')],
665
+ { stdio: 'inherit', cwd: raiz });
666
+ if (r.status !== 0) {
667
+ console.log();
668
+ console.log(` ${laranja('!')} ${cinza('O médico apontou algo. Veja acima o que ficou pendente.')}`);
669
+ }
670
+ }
671
+
672
+ console.log();
673
+ console.log(` ${verde(neg('Atualizado para v' + versaoNova + '.'))}`);
674
+ if (plano.preserva.length) {
675
+ console.log(` ${fraco('Seus arquivos alterados continuam lá; a versão nova de cada um está como *.novo')}`);
676
+ }
677
+ console.log();
678
+ return 0;
679
+ }
680
+
326
681
  function ajuda() {
327
682
  abertura();
328
683
  console.log(` ${branco('npx @fefsbenson/jarvis install')} ${fraco('baixa e instala em ./jarvis')}`);
329
684
  console.log(` ${branco('npx @fefsbenson/jarvis install PASTA')} ${fraco('instala na pasta que você escolher')}`);
330
685
  console.log();
686
+ console.log(` ${branco('npx @fefsbenson/jarvis update')} ${fraco('atualiza o JARVIS desta pasta')}`);
687
+ console.log(` ${branco('npx @fefsbenson/jarvis update PASTA')} ${fraco('atualiza o de outra pasta')}`);
688
+ console.log(` ${fraco(' o update preserva o que você alterou — nada seu é sobrescrito')}`);
689
+ console.log();
331
690
  console.log(` ${fraco('Precisa de: Node 18+, Python 3.10+ e o token de acesso.')}`);
332
691
  console.log(` ${fraco('O token também pode vir da variável JARVIS_TOKEN.')}`);
333
692
  console.log();
@@ -336,6 +695,8 @@ function ajuda() {
336
695
  const [, , comando, ...resto] = process.argv;
337
696
  if (comando === 'install') {
338
697
  instalar(resto).then(cod => process.exit(cod));
698
+ } else if (comando === 'update' || comando === 'atualizar') {
699
+ atualizar(resto).then(cod => process.exit(cod));
339
700
  } else {
340
701
  ajuda();
341
702
  process.exit(comando ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fefsbenson/jarvis",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "JARVIS — sistema operacional de conhecimento e agentes para Claude Code (Windows e Linux)",
5
5
  "bin": {
6
6
  "jarvis": "bin/cli.js"
@@ -1,297 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * JARVIS — baixador.
4
- *
5
- * Este pacote npm é SÓ o transporte (~20 KB). O sistema em si (108 MB, com
6
- * conhecimento destilado de material de terceiros) vive num Release PRIVADO
7
- * do GitHub e exige token de acesso — a licença proíbe distribuição pública.
8
- *
9
- * npx @fefsbenson/jarvis install
10
- *
11
- * Só Node built-in: precisa rodar antes de qualquer dependência existir.
12
- *
13
- * JARVIS · PRAGMA · by Felipe Brandão
14
- */
15
- import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
16
- import { spawn, spawnSync } from 'node:child_process';
17
- import { createInterface } from 'node:readline';
18
- import { tmpdir } from 'node:os';
19
- import { join, resolve } from 'node:path';
20
- import { pipeline } from 'node:stream/promises';
21
- import { Readable } from 'node:stream';
22
-
23
- const REPO = 'fefsbenson/jarvis-entrega';
24
- const PASTA_PADRAO = 'jarvis';
25
-
26
- // ── cores (somem se o terminal não suportar) ─────────────────────────
27
- const tty = process.stdout.isTTY && !process.env.NO_COLOR;
28
- const c = (t, k) => (tty ? `\x1b[${k}m${t}\x1b[0m` : t);
29
- const laranja = t => c(t, '38;5;208');
30
- const branco = t => c(t, '38;5;255');
31
- const cinza = t => c(t, '38;5;250');
32
- const fraco = t => c(t, '38;5;240');
33
- const verde = t => c(t, '38;5;71');
34
- const vermelho = t => c(t, '38;5;167');
35
- const neg = t => c(t, '1');
36
-
37
- const LOGO = [
38
- ' ██ █████ ██████ ██ ██ ██ ███████',
39
- ' ██ ██ ██ ██ ██ ██ ██ ██ ██',
40
- ' ██ ███████ ██████ ██ ██ ██ ███████',
41
- '██ ██ ██ ██ ██ ██ ██ ██ ██ ██',
42
- ' █████ ██ ██ ██ ██ ████ ██ ███████',
43
- ];
44
-
45
- function abertura() {
46
- console.log();
47
- for (const l of LOGO) console.log(' ' + laranja(neg(l)));
48
- console.log();
49
- console.log(` ${cinza('Sistema operacional de conhecimento e agentes')}`);
50
- console.log(` ${fraco('PRAGMA · by Felipe Brandão')}`);
51
- console.log();
52
- console.log(' ' + fraco('─'.repeat(66)));
53
- console.log();
54
- }
55
-
56
- function perguntar(texto, oculto = false) {
57
- return new Promise(res => {
58
- const rl = createInterface({ input: process.stdin, output: process.stdout });
59
- if (oculto && process.stdin.isTTY) {
60
- // não ecoa o token na tela nem deixa no scrollback
61
- const escrever = rl._writeToOutput.bind(rl);
62
- rl._writeToOutput = function (s) {
63
- if (s.includes(texto)) escrever(s);
64
- else escrever('*');
65
- };
66
- }
67
- rl.question(` ${laranja('▸')} ${texto} `, r => { rl.close(); if (oculto) console.log(); res(r.trim()); });
68
- });
69
- }
70
-
71
- /**
72
- * Precisa de Python — o instalador de verdade é ele.
73
- *
74
- * ARMADILHA DO WINDOWS: `python3.exe` em WindowsApps é um STUB da Microsoft
75
- * Store. Ele imprime "Python não foi encontrado" e sai com código 0 — passa
76
- * por qualquer teste ingênuo de status. O jeito confiável é validar a SAÍDA,
77
- * e tentar o `py` launcher, que é o padrão no Windows.
78
- * (o pyrun.sh do harness já sabia disso; eu não tinha reusado a lição)
79
- */
80
- function acharPython() {
81
- const tentativas = [
82
- ['py', ['-3']], // launcher oficial do Windows — o mais confiável lá
83
- ['python3', []],
84
- ['python', []],
85
- ];
86
- for (const [cmd, pre] of tentativas) {
87
- const r = spawnSync(cmd, [...pre, '-c', 'import sys;print(sys.version_info[:2])'],
88
- { encoding: 'utf8' });
89
- const saida = (r.stdout || '') + (r.stderr || '');
90
- // o stub da Store não imprime tupla nenhuma — só o texto da propaganda
91
- if (/n.o foi encontrado|not found|Microsoft Store/i.test(saida)) continue;
92
- const m = saida.match(/\((\d+),\s*(\d+)\)/);
93
- if (r.status === 0 && m && (+m[1] > 3 || (+m[1] === 3 && +m[2] >= 10))) {
94
- return { cmd, args: pre, versao: `${m[1]}.${m[2]}` };
95
- }
96
- }
97
- return null;
98
- }
99
-
100
- async function api(caminho, token, aceitar = 'application/vnd.github+json') {
101
- const r = await fetch(`https://api.github.com${caminho}`, {
102
- headers: {
103
- Authorization: `Bearer ${token}`,
104
- Accept: aceitar,
105
- 'User-Agent': 'jarvis-installer',
106
- 'X-GitHub-Api-Version': '2022-11-28',
107
- },
108
- redirect: 'follow',
109
- });
110
- return r;
111
- }
112
-
113
- function barra(feito, total) {
114
- const largura = 28;
115
- const pct = total ? feito / total : 0;
116
- const cheio = Math.round(pct * largura);
117
- return ` ${laranja('█'.repeat(cheio))}${fraco('░'.repeat(largura - cheio))} ${cinza(`${Math.round(pct * 100)}%`)}`;
118
- }
119
-
120
- async function baixar(token, destino) {
121
- // 1. qual é a versão mais recente
122
- const rel = await api(`/repos/${REPO}/releases/latest`, token);
123
- if (rel.status === 401) throw new Error('TOKEN_INVALIDO');
124
- if (rel.status === 404) throw new Error('SEM_ACESSO');
125
- if (!rel.ok) throw new Error(`GitHub respondeu ${rel.status}`);
126
- const dados = await rel.json();
127
-
128
- const asset = (dados.assets || []).find(a => a.name.endsWith('.tar.gz'));
129
- if (!asset) throw new Error('Release sem pacote .tar.gz');
130
-
131
- console.log(` ${verde('✓')} ${branco('Versão')} ${cinza(dados.tag_name)} ${fraco((asset.size / 1048576).toFixed(1) + ' MB')}`);
132
-
133
- // 2. baixa o tarball (asset privado exige Accept: octet-stream)
134
- const bin = await api(`/repos/${REPO}/releases/assets/${asset.id}`, token,
135
- 'application/octet-stream');
136
- if (!bin.ok) throw new Error(`download falhou (${bin.status})`);
137
-
138
- const tmp = join(tmpdir(), `jarvis-${Date.now()}.tar.gz`);
139
- const total = Number(bin.headers.get('content-length')) || asset.size;
140
- let feito = 0;
141
- let ultimo = 0;
142
-
143
- const origem = Readable.fromWeb(bin.body);
144
- origem.on('data', pedaco => {
145
- feito += pedaco.length;
146
- const agora = Date.now();
147
- // A barra so redesenha em TTY: com o output capturado (pipe, log, CI) o
148
- // \r nao apaga a linha e o progresso vira uma parede de barras.
149
- if (tty && agora - ultimo > 200) {
150
- ultimo = agora;
151
- process.stdout.write('\r' + barra(feito, total));
152
- }
153
- });
154
- await pipeline(origem, createWriteStream(tmp));
155
- if (tty) process.stdout.write('\r' + barra(total, total) + '\n');
156
- else console.log(` ${verde('✓')} ${branco('Baixado')} ${fraco((total / 1048576).toFixed(1) + ' MB')}`);
157
-
158
- // 3. extrai
159
- mkdirSync(destino, { recursive: true });
160
- const tar = spawnSync('tar', ['xzf', tmp, '-C', destino, '--strip-components=1'],
161
- { encoding: 'utf8' });
162
- if (tar.status !== 0) throw new Error(`tar falhou: ${(tar.stderr || '').slice(0, 120)}`);
163
-
164
- const n = contar(destino);
165
- console.log(` ${verde('✓')} ${branco('Extraído')} ${fraco(n.toLocaleString('pt-BR') + ' arquivos')}`);
166
- return destino;
167
- }
168
-
169
- function contar(dir) {
170
- let n = 0;
171
- for (const e of readdirSync(dir, { withFileTypes: true })) {
172
- if (e.name === 'node_modules' || e.name === '.git') continue;
173
- n += e.isDirectory() ? contar(join(dir, e.name)) : 1;
174
- }
175
- return n;
176
- }
177
-
178
- function erroDeAcesso(tipo) {
179
- console.log();
180
- if (tipo === 'TOKEN_INVALIDO') {
181
- console.log(` ${vermelho(neg('Token inválido.'))}`);
182
- console.log(` ${cinza('O token expirou ou foi digitado errado.')}`);
183
- } else {
184
- console.log(` ${vermelho(neg('Este token não tem acesso ao JARVIS.'))}`);
185
- console.log(` ${cinza('A distribuição é privada — o acesso é liberado por quem entregou o sistema.')}`);
186
- }
187
- console.log();
188
- console.log(` ${fraco('Fale com quem forneceu o JARVIS para receber um token válido.')}`);
189
- console.log();
190
- }
191
-
192
- /**
193
- * O harness roda em POSIX, não em Windows nativo: os 104 hooks do
194
- * settings.json são invocados com `bash .../pyrun.sh` e 153 arquivos usam
195
- * caminho POSIX. Instalar no PowerShell criaria um sistema que copia mas
196
- * não funciona — pior que não instalar.
197
- */
198
- function avisarWindows() {
199
- if (process.platform !== 'win32') return true;
200
- console.log(` ${vermelho(neg('O JARVIS precisa do WSL — não roda no Windows direto.'))}`);
201
- console.log();
202
- console.log(` ${cinza('O sistema usa bash para executar seus 104 hooks. No PowerShell')}`);
203
- console.log(` ${cinza('ele até instalaria, mas não funcionaria.')}`);
204
- console.log();
205
- console.log(` ${branco('Abra o Ubuntu (WSL) e rode lá o mesmo comando:')}`);
206
- console.log();
207
- console.log(` ${fraco('wsl')}`);
208
- console.log(` ${fraco('npx @fefsbenson/jarvis install')}`);
209
- console.log();
210
- console.log(` ${fraco('Não tem WSL? No PowerShell como administrador:')} ${cinza('wsl --install')}`);
211
- console.log();
212
- return false;
213
- }
214
-
215
- async function instalar(args) {
216
- abertura();
217
-
218
- if (!avisarWindows()) return 1;
219
-
220
- const py = acharPython();
221
- if (!py) {
222
- console.log(` ${vermelho(neg('Falta Python 3.10 ou superior.'))}`);
223
- console.log();
224
- console.log(` ${cinza('O JARVIS roda em Python. O Node serve só para este baixador.')}`);
225
- console.log();
226
- console.log(` ${branco('No Ubuntu/WSL:')} ${fraco('sudo apt install python3 python3-venv')}`);
227
- console.log(` ${branco('No macOS:')} ${fraco('brew install python@3.12')}`);
228
- console.log();
229
- return 1;
230
- }
231
-
232
- const destino = resolve(process.cwd(), args[0] || PASTA_PADRAO);
233
- if (existsSync(destino) && readdirSync(destino).length) {
234
- console.log(` ${vermelho(neg('A pasta ' + destino + ' já existe e não está vazia.'))}`);
235
- console.log(` ${cinza('Escolha outro nome: ')}${fraco('npx @fefsbenson/jarvis install minha-pasta')}`);
236
- console.log();
237
- return 1;
238
- }
239
-
240
- console.log(` ${branco('Vou baixar o JARVIS e instalar aqui.')}`);
241
- console.log(` ${fraco('Destino:')} ${branco(destino)}`);
242
- console.log(` ${fraco('Python:')} ${branco(py.cmd + ' ' + py.versao)}`);
243
- console.log();
244
- console.log(` ${cinza('O JARVIS não é público: ele carrega conhecimento licenciado, então')}`);
245
- console.log(` ${cinza('mora num repositório privado. Preciso de um token para baixá-lo.')}`);
246
- console.log();
247
- console.log(` ${fraco('Quem te entregou o sistema forneceu esse token junto.')}`);
248
- console.log(` ${fraco('Se você mesmo publicou e tem o GitHub CLI aqui, use:')}`);
249
- console.log(` ${cinza('JARVIS_TOKEN=$(gh auth token) npx @fefsbenson/jarvis install')}`);
250
- console.log();
251
- console.log(` ${fraco('(o token não aparece na tela enquanto você digita)')}`);
252
- console.log();
253
-
254
- const token = process.env.JARVIS_TOKEN || await perguntar('Token:', true);
255
- if (!token) {
256
- console.log(`\n ${fraco('Sem token, não há o que baixar. Cancelado.')}\n`);
257
- return 130;
258
- }
259
-
260
- console.log();
261
- try {
262
- await baixar(token, destino);
263
- } catch (e) {
264
- if (e.message === 'TOKEN_INVALIDO' || e.message === 'SEM_ACESSO') {
265
- erroDeAcesso(e.message);
266
- } else {
267
- console.log(`\n ${vermelho(neg('Não consegui baixar.'))}`);
268
- console.log(` ${cinza(e.message)}`);
269
- console.log(`\n ${fraco('Verifique sua conexão e tente de novo.')}\n`);
270
- }
271
- return 1;
272
- }
273
-
274
- // entrega o comando ao instalador de verdade, que é Python
275
- console.log();
276
- const r = spawnSync(py.cmd, [...(py.args || []), join(destino, 'install.py'), ...args.slice(1)],
277
- { stdio: 'inherit', cwd: destino });
278
- return r.status ?? 0;
279
- }
280
-
281
- function ajuda() {
282
- abertura();
283
- console.log(` ${branco('npx @fefsbenson/jarvis install')} ${fraco('baixa e instala em ./jarvis')}`);
284
- console.log(` ${branco('npx @fefsbenson/jarvis install PASTA')} ${fraco('instala na pasta que você escolher')}`);
285
- console.log();
286
- console.log(` ${fraco('Precisa de: Node 18+, Python 3.10+ e o token de acesso.')}`);
287
- console.log(` ${fraco('O token também pode vir da variável JARVIS_TOKEN.')}`);
288
- console.log();
289
- }
290
-
291
- const [, , comando, ...resto] = process.argv;
292
- if (comando === 'install') {
293
- instalar(resto).then(cod => process.exit(cod));
294
- } else {
295
- ajuda();
296
- process.exit(comando ? 1 : 0);
297
- }