primocode 9.8.0-beta.2 → 9.8.0-beta.21

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.
Files changed (74) hide show
  1. package/README.md +33 -6
  2. package/app/ansi.js +55 -0
  3. package/app/app.css +482 -281
  4. package/app/app.js +888 -315
  5. package/app/index.html +176 -101
  6. package/app/primo-marca.js +58 -1
  7. package/bin/primocode.js +697 -118
  8. package/comandos/explicar.md +14 -0
  9. package/comandos/publicar.md +10 -0
  10. package/comandos/revisar.md +13 -0
  11. package/lib/act.js +130 -26
  12. package/lib/api.js +8 -1
  13. package/lib/app.js +317 -74
  14. package/lib/browser.js +79 -3
  15. package/lib/catalogo.js +11 -3
  16. package/lib/checkpoint-git.js +218 -0
  17. package/lib/checkpoint.js +249 -0
  18. package/lib/claude-engine.js +25 -3
  19. package/lib/comandos-extra.js +481 -0
  20. package/lib/comandos.js +104 -0
  21. package/lib/config.js +21 -4
  22. package/lib/estudio/acervo-site.js +157 -0
  23. package/lib/estudio/assets.js +77 -3
  24. package/lib/estudio/cinema.js +354 -0
  25. package/lib/estudio/composicao.js +470 -0
  26. package/lib/estudio/edicao.js +450 -0
  27. package/lib/estudio/impacto.js +263 -0
  28. package/lib/estudio/index.js +769 -5
  29. package/lib/estudio/plano.js +5 -2
  30. package/lib/estudio/remotion.js +228 -23
  31. package/lib/estudio/slides.js +34 -30
  32. package/lib/fala.js +95 -1
  33. package/lib/local/cerebro.json +361 -180
  34. package/lib/local/entrada.js +18 -8
  35. package/lib/local/motor-cli.js +1 -1
  36. package/lib/mencoes.js +98 -0
  37. package/lib/offline.js +168 -0
  38. package/lib/painel.js +291 -0
  39. package/lib/perigo.js +256 -0
  40. package/lib/pre-leitura.js +118 -0
  41. package/lib/repeticao.js +4 -2
  42. package/lib/skills.js +6 -1
  43. package/lib/tempos.js +62 -0
  44. package/lib/ui.js +171 -17
  45. package/package.json +3 -2
  46. package/skills/apresentacao-pptx/SKILL.md +52 -0
  47. package/skills/cartaz-de-arte/LICENSE.txt +202 -0
  48. package/skills/cartaz-de-arte/SKILL.md +47 -0
  49. package/skills/criar-skill/LICENSE.txt +202 -0
  50. package/skills/criar-skill/SKILL.md +49 -0
  51. package/skills/depurar-de-verdade/LICENSE.txt +21 -0
  52. package/skills/depurar-de-verdade/SKILL.md +14 -5
  53. package/skills/design-de-verdade/LICENSE.txt +177 -0
  54. package/skills/design-de-verdade/SKILL.md +42 -0
  55. package/skills/documento-word/SKILL.md +57 -0
  56. package/skills/interface-revisada/SKILL.md +47 -0
  57. package/skills/me-ensina/LICENSE.txt +21 -0
  58. package/skills/me-ensina/SKILL.md +27 -0
  59. package/skills/me-entrevista/LICENSE.txt +21 -0
  60. package/skills/me-entrevista/SKILL.md +27 -0
  61. package/skills/motion-de-primeira/SKILL.md +1 -1
  62. package/skills/pdf-bonito/SKILL.md +48 -0
  63. package/skills/planilha-excel/SKILL.md +47 -0
  64. package/skills/react-sem-dor/SKILL.md +48 -0
  65. package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
  66. package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
  67. package/skills/site-com-motion/SKILL.md +39 -0
  68. package/skills/temas-prontos/LICENSE.txt +202 -0
  69. package/skills/temas-prontos/SKILL.md +50 -0
  70. package/skills/testar-o-site/LICENSE.txt +202 -0
  71. package/skills/testar-o-site/SKILL.md +46 -0
  72. package/skills/video-no-remotion/SKILL.md +48 -0
  73. package/studio/narrar.py +111 -10
  74. package/studio/rostos.py +52 -0
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * ── A IDEIA ──────────────────────────────────────────────────────────────
19
19
  * Ninguém precisa das 45 ferramentas para fazer um slide. Quem pede um vídeo
20
- * não usa `desktop_click`; quem conserta um bug não usa `studio_criar`. Então
20
+ * não usa `desktop_click`; quem conserta um bug não usa `estudio_render`. Então
21
21
  * cada pedido leva só o que aquele pedido pode usar — ferramentas E seções do
22
22
  * prompt — e o que sobra do orçamento vira espaço para a conversa.
23
23
  *
@@ -74,10 +74,19 @@ const GRUPOS = [
74
74
  },
75
75
  {
76
76
  nome: 'estudio',
77
- tools: ['studio_manual', 'studio_spec', 'studio_criar', 'studio_ler',
78
- 'studio_atualizar', 'studio_versoes', 'studio_listar', 'studio_midia'],
79
- // O que o estúdio produz, e os apps que ele substitui.
80
- gatilho: /\b(slides?|apresenta[çc][ãa]o|deck|v[íi]deo|reels?|anima[çc][ãa]o|post|banner|capa|thumbnail|arte|cartaz|logo|planilha|tabela|or[çc]amento|documento|relat[óo]rio|artigo|proposta|contrato|diagrama|fluxograma|organograma|mapa mental|narra[çc][ãa]o|trilha|est[úu]dio|canva|powerpoint|keynote|word|docs|excel|sheets|photoshop|figma|capcut|premiere|miro)\b/i,
77
+ tools: ['estudio_projeto', 'estudio_plano', 'estudio_midia', 'estudio_narrar',
78
+ 'estudio_render', 'estudio_site', 'planejar_producao'],
79
+ // O que o estúdio produz, e os apps que ele substitui. Documento,
80
+ // planilha e PDF saíram: o Estúdio novo faz vídeo, slides e arte, e o
81
+ // resto é arquivo escrito com código.
82
+ gatilho: /\b(slides?|apresenta[çc][ãa]o|deck|v[íi]deo|reels?|anima[çc][ãa]o|motion|vinheta|teaser|an[úu]ncio|post|banner|capa|thumbnail|arte|cartaz|narra[çc][ãa]o|trilha|legenda|grava[çc][ãa]o|est[úu]dio|canva|powerpoint|keynote|photoshop|figma|capcut|premiere)\b/i,
83
+ },
84
+ {
85
+ /* A GRAVAÇÃO do usuário é outra ferramenta, e só entra quando o pedido
86
+ fala dela: "faz um vídeo" não precisa cortar silêncio de ninguém. */
87
+ nome: 'gravacao',
88
+ tools: ['estudio_video'],
89
+ gatilho: /\b(grava[çc][ãa]o|gravei|filmagem|filmei|meu v[íi]deo|edit\w* (o |a |meu |minha |esse |essa |este |esta )?(v[íi]deo|grava[çc][ãa]o)|\.mp4|\.mov|legendas?|cort(a|ar|e) (o )?sil[êe]ncio|recort(a|ar|e) (a )?pessoa|transcrev\w*|importa\w* (o )?v[íi]deo)\b/i,
81
90
  },
82
91
  {
83
92
  nome: 'navegador',
@@ -118,7 +127,7 @@ const GRUPOS = [
118
127
  quando o pedido fala do arquivo — é o que mantém o pedido de vídeo
119
128
  dentro do teto do minuto (medido: ela custa ~180 tokens). */
120
129
  nome: 'exportar',
121
- tools: ['studio_exportar'],
130
+ tools: ['estudio_render'],
122
131
  gatilho: /\b(mp4|webm|baix[ae]r?|download|exporta\w*|salv[ae]r? (o )?v[íi]deo|arquivo (do|de) v[íi]deo|me manda o v[íi]deo|manda o arquivo|renderiza\w*)\b/i,
123
132
  },
124
133
  {
@@ -196,7 +205,8 @@ function jaAprendeu(messages) {
196
205
  if (m && m.role === 'tool' && nomePorId.has(m.tool_call_id)) feitas.add(nomePorId.get(m.tool_call_id));
197
206
  }
198
207
  const dispensaveis = new Set();
199
- if (feitas.has('studio_spec') || feitas.has('studio_manual')) dispensaveis.add('estudio');
208
+ // O Estúdio novo não tem spec para ler: a seção dele é o único mapa do
209
+ // fluxo, e fica até o fim.
200
210
  if (feitas.has('pesquisar_referencia')) dispensaveis.add('pesquisa');
201
211
  return dispensaveis;
202
212
  }
@@ -253,7 +263,7 @@ function relatorio({ prompt, ferramentas, messages }) {
253
263
  // O CLI já protege as mesmas mensagens antes de mandar (lib/repeticao.js), mas
254
264
  // quem falava por último aqui era `caberNoTeto`, que não sabia disso e cortava
255
265
  // assim mesmo. Dois compactadores brigando, e o do servidor ganhava.
256
- const REFERENCIAS = new Set(['studio_manual', 'studio_spec']);
266
+ const REFERENCIAS = new Set(['planejar_producao', 'estudio_site']);
257
267
 
258
268
  // Referência tem teto GENEROSO, não infinito.
259
269
  //
@@ -37,7 +37,7 @@ function motorDe(id) {
37
37
  function perguntar(id, texto) {
38
38
  if (id === 'codex') return String(motorDe('codex').chamarCodex(texto) || '').trim();
39
39
  const claude = motorDe('claude-code');
40
- const saida = execFileSync('claude', ['-p', '--output-format', 'json', ...claude.argsDoModelo()], {
40
+ const saida = execFileSync('claude', ['-p', '--output-format', 'json', ...claude.argsDoMotor()], {
41
41
  encoding: 'utf-8', timeout: 120000, input: texto, maxBuffer: 8 * 1024 * 1024, ...SEM_APARECER,
42
42
  });
43
43
  try { return String(JSON.parse(saida).result || '').trim(); } catch { return String(saida || '').trim(); }
package/lib/mencoes.js ADDED
@@ -0,0 +1,98 @@
1
+ // O que a pessoa TRAZ para o pedido, além do texto: as instruções do projeto
2
+ // (PRIMO.md, AGENTS.md, CLAUDE.md) e os arquivos citados com @.
3
+ //
4
+ // Ideia tirada do Claude Code (CLAUDE.md, @arquivo, /init) e do Codex
5
+ // (AGENTS.md). Quem já escreveu um AGENTS.md para outro agente não deveria ter
6
+ // de escrever de novo para o PrimoCode — então os três nomes valem.
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ /* ── INSTRUÇÕES DO PROJETO ───────────────────────────────────────────────
12
+ * Só o PRIMEIRO que existir, na ordem abaixo: um repo com AGENTS.md e
13
+ * CLAUDE.md quase sempre tem o mesmo texto nos dois (um aponta pro outro), e
14
+ * mandar os dois é pagar duas vezes pela mesma regra. O PRIMO.md vem antes
15
+ * porque é o que a pessoa escreveu PARA nós. */
16
+ const NOMES_INSTRUCOES = ['PRIMO.md', 'AGENTS.md', 'CLAUDE.md'];
17
+ const LIMITE_INSTRUCOES = 6000;
18
+
19
+ // A cabeça fica, o rabo sai: é no começo que se escreve o que mais importa.
20
+ function cortar(texto, limite) {
21
+ if (texto.length <= limite) return texto;
22
+ return texto.slice(0, limite).trimEnd() + '\n(cortado)';
23
+ }
24
+
25
+ function lerSeExiste(arquivo) {
26
+ try {
27
+ if (!fs.statSync(arquivo).isFile()) return '';
28
+ return fs.readFileSync(arquivo, 'utf-8');
29
+ } catch { return ''; }
30
+ }
31
+
32
+ /** O texto que entra no contexto, ou '' se não há instrução nenhuma. */
33
+ function instrucoesDoProjeto(projectDir, limite = LIMITE_INSTRUCOES) {
34
+ const partes = [];
35
+ // HOME lido na hora, não na carga do módulo: o teste troca a casa.
36
+ const global = lerSeExiste(path.join(os.homedir(), '.primocode', 'PRIMO.md'));
37
+ if (global.trim()) partes.push(`[~/.primocode/PRIMO.md — suas instruções para todo projeto]\n${cortar(global, limite)}`);
38
+ if (projectDir) {
39
+ for (const nome of NOMES_INSTRUCOES) {
40
+ const texto = lerSeExiste(path.join(projectDir, nome));
41
+ if (!texto.trim()) continue;
42
+ partes.push(`[${nome} — instruções deste projeto: siga]\n${cortar(texto, limite)}`);
43
+ break;
44
+ }
45
+ }
46
+ return partes.join('\n\n');
47
+ }
48
+
49
+ /* ── @ARQUIVO ────────────────────────────────────────────────────────────
50
+ * O @ só conta no começo de uma palavra: "joel@x.com" e "@" solto passam
51
+ * direto. E só vira anexo o que existe DENTRO da pasta do projeto — "@../../
52
+ * .ssh/id_rsa" não pode mandar a chave de ninguém para o modelo. O que não
53
+ * resolve fica como está no texto: pode ser um @usuario de verdade. */
54
+ const LIMITE_ARQUIVO = 40 * 1024;
55
+ const LIMITE_TOTAL = 120 * 1024;
56
+ const RE_MENCAO = /(^|[\s(])@(?:"([^"]+)"|([^\s"]+))/g;
57
+
58
+ function pareceBinario(abs) {
59
+ let fd;
60
+ try {
61
+ fd = fs.openSync(abs, 'r');
62
+ const buf = Buffer.alloc(8000);
63
+ const lidos = fs.readSync(fd, buf, 0, buf.length, 0);
64
+ return buf.subarray(0, lidos).includes(0);
65
+ } catch { return true; }
66
+ finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* já fechado */ } }
67
+ }
68
+
69
+ function mencoes(texto, projectDir, limites = {}) {
70
+ const porArquivo = limites.porArquivo || LIMITE_ARQUIVO;
71
+ const total = limites.total || LIMITE_TOTAL;
72
+ const arquivos = [];
73
+ const ignorados = [];
74
+ if (!texto || !projectDir) return { arquivos, ignorados };
75
+ const raiz = path.resolve(projectDir);
76
+ const vistos = new Set();
77
+ let soma = 0;
78
+ for (const m of String(texto).matchAll(RE_MENCAO)) {
79
+ // Pontuação colada ("veja @index.html.") é da frase, não do nome.
80
+ const bruto = m[2] || m[3].replace(/[.,;:!?)\]]+$/, '');
81
+ if (!bruto) continue;
82
+ const abs = path.resolve(raiz, bruto);
83
+ const rel = path.relative(raiz, abs);
84
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
85
+ let st;
86
+ try { st = fs.statSync(abs); } catch { continue; }
87
+ if (!st.isFile() || vistos.has(abs)) continue;
88
+ vistos.add(abs);
89
+ if (st.size > porArquivo) { ignorados.push({ rel, motivo: `grande demais (limite ${Math.round(porArquivo / 1024)} KB)` }); continue; }
90
+ if (pareceBinario(abs)) { ignorados.push({ rel, motivo: 'arquivo binário' }); continue; }
91
+ if (soma + st.size > total) { ignorados.push({ rel, motivo: `passou do total de ${Math.round(total / 1024)} KB` }); continue; }
92
+ soma += st.size;
93
+ arquivos.push({ rel, abs, bytes: st.size });
94
+ }
95
+ return { arquivos, ignorados };
96
+ }
97
+
98
+ module.exports = { mencoes, instrucoesDoProjeto, NOMES_INSTRUCOES, LIMITE_INSTRUCOES, LIMITE_ARQUIVO, LIMITE_TOTAL };
package/lib/offline.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * offline.js — o primocode-server rodando AQUI, no computador da pessoa.
3
+ *
4
+ * "Como eu não tenho acesso ao Railway, enquanto isso eu posso testar com o
5
+ * projeto rodando localmente, chamando as APIs pelo meu computador."
6
+ * (24/09/2026)
7
+ *
8
+ * O /offline sobe o servidor da pasta primocode-server com as MESMAS chaves
9
+ * que o Render usa (coladas em ~/.primocode/servidor.env) e aponta o CLI para
10
+ * ele até a sessão acabar. É temporário: serve para testar o servidor novo
11
+ * antes de ele ir para produção.
12
+ *
13
+ * "Offline" é o nome que o pedido deu, e ele mente um pouco: as IAs continuam
14
+ * na internet. O que sai do caminho é o servidor de produção.
15
+ *
16
+ * ── O QUE NUNCA APARECE ──────────────────────────────────────────────────
17
+ * O valor de uma chave. Mostram-se só os NOMES das variáveis que o arquivo
18
+ * tem, o bastante para ver o que falta.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+ const http = require('http');
27
+ const { spawn } = require('child_process');
28
+
29
+ const PORTA = Number(process.env.PRIMOCODE_OFFLINE_PORTA || 3190);
30
+ const CASA = path.join(os.homedir(), '.primocode');
31
+ const ARQUIVO_ENV = path.join(CASA, 'servidor.env');
32
+ const ARQUIVO_LOG = path.join(CASA, 'servidor-local.log');
33
+
34
+ // As variáveis que o primocode-server lê. As de chave vêm primeiro: sem pelo
35
+ // menos uma delas, o servidor sobe mas não tem com quem falar.
36
+ const CHAVES = ['GROQ_API_KEY', 'OPENROUTER_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY', 'COHERE_API_KEY', 'HF_TOKEN', 'HUGGINGFACE_API_KEY', 'PG_GATEWAY_API_KEY'];
37
+ const OUTRAS = ['PG_GATEWAY_BASE_URL', 'CONECTA_API', 'PRIMOCODE_TOKEN', 'REQUIRE_TOKEN', 'RATE_LIMIT_PER_HOUR'];
38
+
39
+ const MODELO_ENV = `# As chaves do primocode-server rodando no seu computador (/offline).
40
+ # Copie do Render: o serviço primocode-server → Environment. Uma por linha,
41
+ # NOME=valor, sem aspas. Este arquivo é só seu: não mande para ninguém.
42
+ #
43
+ ${CHAVES.map((k) => `# ${k}=`).join('\n')}
44
+ #
45
+ ${OUTRAS.map((k) => `# ${k}=`).join('\n')}
46
+ `;
47
+
48
+ /** Lê um .env simples: NOME=valor, # comenta, aspas em volta saem. */
49
+ function lerEnv(texto) {
50
+ const env = {};
51
+ for (const linha of String(texto || '').split(/\r?\n/)) {
52
+ const m = linha.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
53
+ if (!m) continue;
54
+ let valor = m[2];
55
+ if (/^(['"]).*\1$/.test(valor)) valor = valor.slice(1, -1);
56
+ if (valor) env[m[1]] = valor;
57
+ }
58
+ return env;
59
+ }
60
+
61
+ /** O arquivo de chaves: cria o modelo (vazio, 600) na primeira vez. */
62
+ function prepararEnv(arquivo = ARQUIVO_ENV) {
63
+ let criado = false;
64
+ if (!fs.existsSync(arquivo)) {
65
+ fs.mkdirSync(path.dirname(arquivo), { recursive: true });
66
+ fs.writeFileSync(arquivo, MODELO_ENV, { mode: 0o600 });
67
+ criado = true;
68
+ }
69
+ const env = lerEnv(fs.readFileSync(arquivo, 'utf8'));
70
+ return { arquivo, criado, env, nomes: Object.keys(env), temChave: CHAVES.some((k) => env[k]) };
71
+ }
72
+
73
+ function ehServidor(dir) {
74
+ try {
75
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
76
+ return pkg.name === 'primocode-server' && fs.existsSync(path.join(dir, 'server.js'));
77
+ } catch { return false; }
78
+ }
79
+
80
+ /** Onde está a pasta do primocode-server. A primeira que existir ganha. */
81
+ function acharServidor({ pedido = '', raizCli = path.join(__dirname, '..') } = {}) {
82
+ const candidatos = [
83
+ pedido,
84
+ process.env.PRIMOCODE_SERVER_DIR,
85
+ path.join(raizCli, '..', 'primocode-server'),
86
+ path.join(os.homedir(), 'Primocode CLI', 'primocode-server'),
87
+ path.join(os.homedir(), 'primocode-server'),
88
+ ].filter(Boolean).map((p) => path.resolve(p.replace(/^~(?=$|\/)/, os.homedir())));
89
+ return candidatos.find(ehServidor) || null;
90
+ }
91
+
92
+ /** GET /health; devolve o JSON ou null. */
93
+ function saude(url, espera = 1500) {
94
+ return new Promise((resolve) => {
95
+ const req = http.get(url + '/health', { timeout: espera }, (res) => {
96
+ let corpo = '';
97
+ res.on('data', (d) => { corpo += d; });
98
+ res.on('end', () => { try { resolve(JSON.parse(corpo)); } catch { resolve(null); } });
99
+ });
100
+ req.on('timeout', () => req.destroy());
101
+ req.on('error', () => resolve(null));
102
+ });
103
+ }
104
+
105
+ let filho = null;
106
+
107
+ /**
108
+ * Sobe o servidor (ou reaproveita um que já responde na porta).
109
+ * Devolve { ok, url, versao, reaproveitado } ou { ok: false, error, comoResolver }.
110
+ */
111
+ async function ligar({ dir, env = {}, porta = PORTA, log = ARQUIVO_LOG, prazo = 20000 } = {}) {
112
+ const url = `http://localhost:${porta}`;
113
+ const ja = await saude(url);
114
+ if (ja && ja.status === 'ok') return { ok: true, url, versao: ja.version, reaproveitado: true };
115
+ if (!dir) {
116
+ return {
117
+ ok: false,
118
+ error: 'Não achei a pasta do primocode-server.',
119
+ comoResolver: 'clone o repo ao lado do primocode, ou diga onde ele está: /offline ~/caminho/primocode-server',
120
+ };
121
+ }
122
+ if (!fs.existsSync(path.join(dir, 'node_modules'))) {
123
+ return {
124
+ ok: false,
125
+ error: `O servidor em ${dir} ainda não tem as dependências.`,
126
+ comoResolver: `rode uma vez: cd "${dir}" && npm install`,
127
+ };
128
+ }
129
+ fs.mkdirSync(path.dirname(log), { recursive: true });
130
+ const saida = fs.openSync(log, 'a');
131
+ filho = spawn(process.execPath, ['server.js'], {
132
+ cwd: dir,
133
+ env: { ...process.env, ...env, PORT: String(porta) },
134
+ stdio: ['ignore', saida, saida],
135
+ });
136
+ let morreu = null;
137
+ filho.on('exit', (codigo) => { morreu = codigo; filho = null; });
138
+
139
+ const fim = Date.now() + prazo;
140
+ while (Date.now() < fim) {
141
+ if (morreu !== null) {
142
+ return { ok: false, error: `O servidor fechou sozinho (código ${morreu}).`, comoResolver: `veja o fim de ${log}` };
143
+ }
144
+ const h = await saude(url);
145
+ if (h && h.status === 'ok') return { ok: true, url, versao: h.version, reaproveitado: false };
146
+ await new Promise((r) => setTimeout(r, 300));
147
+ }
148
+ desligar();
149
+ return { ok: false, error: `O servidor não respondeu em ${Math.round(prazo / 1000)}s.`, comoResolver: `veja o fim de ${log}` };
150
+ }
151
+
152
+ /** Desliga o servidor que ESTE processo subiu. Um reaproveitado fica de pé. */
153
+ function desligar() {
154
+ if (!filho) return false;
155
+ try { filho.kill(); } catch {}
156
+ filho = null;
157
+ return true;
158
+ }
159
+
160
+ function ligado() { return Boolean(filho); }
161
+
162
+ // O servidor não pode sobrar rodando quando o CLI fecha.
163
+ process.on('exit', desligar);
164
+
165
+ module.exports = {
166
+ PORTA, ARQUIVO_ENV, ARQUIVO_LOG, CHAVES,
167
+ lerEnv, prepararEnv, acharServidor, ehServidor, saude, ligar, desligar, ligado,
168
+ };
package/lib/painel.js ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * painel.js — o painel da direita da janela: a prévia do site e os arquivos.
3
+ *
4
+ * "Remove a IDE ou deixa idêntico ao Codex."
5
+ *
6
+ * Antes isto era a PrimoCode IDE: árvore, EDITOR de código, prévia e chat. O
7
+ * editor saiu. Quem usa o PrimoCode não programa — um editor de código na
8
+ * cara dessa pessoa é uma porta para quebrar o que o agente fez, sem saber
9
+ * como voltar. O que ficou é o que ela usa de verdade: ver o site que está
10
+ * nascendo (Prévia) e olhar o que tem na pasta (Arquivos), só leitura.
11
+ *
12
+ * ── POR QUE MORA DENTRO DO SERVIDOR DA JANELA ────────────────────────────
13
+ * A janela (lib/app.js) já resolveu o difícil: 127.0.0.1, chave sorteada e o
14
+ * fluxo de eventos (SSE). Aqui só entram as rotas do painel (árvore, ler,
15
+ * prévia) e o vigia da pasta, que empurra "arquivo mudou" pelo MESMO fluxo —
16
+ * é o que faz a prévia recarregar enquanto o agente escreve.
17
+ *
18
+ * ── SEGURANÇA ────────────────────────────────────────────────────────────
19
+ * · a chave do app.js vale aqui também (tudo mora em /api/);
20
+ * · todo caminho é resolvido DENTRO da pasta do projeto — "../", caminho
21
+ * absoluto e link simbólico que aponta para fora são recusados;
22
+ * · nada aqui escreve no disco: sem editor, não há rota de salvar;
23
+ * · a prévia roda num iframe sandbox SEM allow-same-origin, e o servidor
24
+ * manda o mesmo sandbox no cabeçalho. O código da prévia foi escrito pelo
25
+ * agente; se ele herdasse a origem da janela, poderia chamar /api/enviar
26
+ * e dar ordens ao agente. Com origem opaca, o fetch dele sai com
27
+ * `Origin: null` e o app.js recusa.
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+
35
+ /* O que a árvore não mostra e o vigia não escuta. node_modules sozinho tem
36
+ dezenas de milhares de arquivos: listar travaria a janela, e vigiar faria
37
+ cada `npm install` virar uma avalanche de eventos. */
38
+ const IGNORADOS = new Set(['node_modules', '.git', '.DS_Store', '.next', '.cache',
39
+ '__pycache__', '.venv', 'venv', '.turbo', '.parcel-cache']);
40
+ const MAX_ITENS = 4000;
41
+ const MAX_PROFUNDIDADE = 12;
42
+ // 1 MB: acima disso o visualizador engasga, e arquivo desse tamanho num
43
+ // projeto de iniciante é quase sempre gerado (bundle, dado).
44
+ const MAX_LEITURA = 1024 * 1024;
45
+
46
+ // ── caminhos ─────────────────────────────────────────────────────────────
47
+
48
+ /* Resolve `relativo` dentro de `raiz`, ou devolve null.
49
+ *
50
+ * `path.relative` pega o "../" e o caminho absoluto; o `realpath` pega o link
51
+ * simbólico, que passa pelo primeiro teste com cara de arquivo de dentro e
52
+ * aponta para ~/.ssh. Caminho que ainda não existe é checado
53
+ * pela pasta onde vai nascer. */
54
+ function dentro(raiz, relativo) {
55
+ if (typeof relativo !== 'string' || relativo.includes('\0')) return null;
56
+ // Absoluto é recusado de cara, e não "reinterpretado" como relativo:
57
+ // quem manda /etc/hosts quer o /etc/hosts, e ler <projeto>/etc/hosts
58
+ // no lugar seria uma surpresa. `C:\` e `\\servidor` também contam.
59
+ if (path.isAbsolute(relativo) || path.win32.isAbsolute(relativo)) return null;
60
+ const base = path.resolve(raiz);
61
+ const alvo = path.resolve(base, relativo);
62
+ if (!contido(base, alvo)) return null;
63
+ let real = base;
64
+ try { real = fs.realpathSync(base); } catch { return null; }
65
+ let existente = alvo;
66
+ while (!fs.existsSync(existente) && existente !== base) existente = path.dirname(existente);
67
+ try {
68
+ if (!contido(real, fs.realpathSync(existente))) return null;
69
+ } catch { return null; }
70
+ return alvo;
71
+ }
72
+
73
+ function contido(base, alvo) {
74
+ const rel = path.relative(base, alvo);
75
+ return rel === '' || (rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel));
76
+ }
77
+
78
+ function relativoDe(raiz, abs) {
79
+ return path.relative(raiz, abs).split(path.sep).join('/');
80
+ }
81
+
82
+ function ignorado(rel) {
83
+ return rel.split(/[/\\]/).some((p) => IGNORADOS.has(p));
84
+ }
85
+
86
+ // ── árvore ───────────────────────────────────────────────────────────────
87
+
88
+ /* Lista plana, pastas antes de arquivos, em ordem alfabética. Plana porque a
89
+ página monta a árvore dela do jeito que quiser, e o teste confere sem ter de
90
+ descer em objetos aninhados. */
91
+ function arvore(raiz) {
92
+ const itens = [];
93
+ let cortada = false;
94
+ function andar(dir, prof) {
95
+ if (prof > MAX_PROFUNDIDADE || cortada) return;
96
+ let entradas;
97
+ try { entradas = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
98
+ entradas = entradas.filter((e) => !IGNORADOS.has(e.name));
99
+ entradas.sort((a, b) => (b.isDirectory() - a.isDirectory())
100
+ || a.name.localeCompare(b.name, 'pt-BR', { sensitivity: 'base' }));
101
+ for (const e of entradas) {
102
+ if (itens.length >= MAX_ITENS) { cortada = true; return; }
103
+ const abs = path.join(dir, e.name);
104
+ const rel = relativoDe(raiz, abs);
105
+ if (e.isDirectory()) {
106
+ itens.push({ caminho: rel, tipo: 'pasta' });
107
+ andar(abs, prof + 1);
108
+ } else if (e.isFile()) {
109
+ let tamanho = 0;
110
+ try { tamanho = fs.statSync(abs).size; } catch {}
111
+ itens.push({ caminho: rel, tipo: 'arquivo', tamanho });
112
+ }
113
+ }
114
+ }
115
+ andar(raiz, 0);
116
+ return { itens, cortada };
117
+ }
118
+
119
+ // ── ler ──────────────────────────────────────────────────────────────────
120
+
121
+ /* Binário é o que tem byte zero nos primeiros 8 KB — o mesmo teste do git.
122
+ Mostrar um PNG como texto é uma tela de lixo que parece arquivo quebrado. */
123
+ function ehBinario(buf) {
124
+ const n = Math.min(buf.length, 8000);
125
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
126
+ return false;
127
+ }
128
+
129
+ function ler(raiz, rel) {
130
+ const abs = dentro(raiz, rel);
131
+ if (!abs) return { status: 403, corpo: { ok: false, error: 'fora da pasta do projeto' } };
132
+ let st;
133
+ try { st = fs.statSync(abs); } catch { return { status: 404, corpo: { ok: false, error: 'não existe' } }; }
134
+ if (!st.isFile()) return { status: 400, corpo: { ok: false, error: 'não é arquivo' } };
135
+ const base = { ok: true, caminho: relativoDe(raiz, abs), tamanho: st.size, mtime: st.mtimeMs };
136
+ if (st.size > MAX_LEITURA) return { status: 200, corpo: Object.assign(base, { grande: true }) };
137
+ const buf = fs.readFileSync(abs);
138
+ if (ehBinario(buf)) return { status: 200, corpo: Object.assign(base, { binario: true }) };
139
+ return { status: 200, corpo: Object.assign(base, { conteudo: buf.toString('utf8') }) };
140
+ }
141
+
142
+ // ── prévia ───────────────────────────────────────────────────────────────
143
+
144
+ const TIPOS = {
145
+ '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
146
+ '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8',
147
+ '.mjs': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
148
+ '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
149
+ '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8',
150
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.mp4': 'video/mp4', '.webm': 'video/webm',
151
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.md': 'text/plain; charset=utf-8',
152
+ };
153
+
154
+ /* Onde está a página de entrada do projeto. index.html na raiz é o caso de
155
+ quase todo pedido ("crie um site"); os kits de Vite e afins põem em public/
156
+ ou src/. Sem nenhum, a prévia diz isso em vez de mostrar um 404. */
157
+ function entradaDaPrevia(raiz) {
158
+ for (const c of ['index.html', 'public/index.html', 'src/index.html', 'dist/index.html']) {
159
+ if (fs.existsSync(path.join(raiz, c))) return c;
160
+ }
161
+ return null;
162
+ }
163
+
164
+ function servirPrevia(raiz, rel, res) {
165
+ const abs = dentro(raiz, decodeURIComponent(rel || '') || 'index.html');
166
+ const alvo = abs && fs.existsSync(abs) && fs.statSync(abs).isDirectory() ? path.join(abs, 'index.html') : abs;
167
+ if (!alvo || !fs.existsSync(alvo)) {
168
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
169
+ return res.end('não encontrado');
170
+ }
171
+ res.writeHead(200, {
172
+ 'Content-Type': TIPOS[path.extname(alvo).toLowerCase()] || 'application/octet-stream',
173
+ 'Cache-Control': 'no-store',
174
+ // O mesmo sandbox do iframe, dito pelo servidor: vale até se alguém
175
+ // abrir o endereço da prévia fora da janela.
176
+ 'Content-Security-Policy': 'sandbox allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock',
177
+ });
178
+ fs.createReadStream(alvo).pipe(res);
179
+ }
180
+
181
+ // ── vigia ────────────────────────────────────────────────────────────────
182
+
183
+ /* fs.watch recursivo existe no Mac e no Windows desde sempre e no Linux desde
184
+ o Node 20. Onde não existe, vigia só a raiz — a prévia de um index.html
185
+ continua acompanhando, que é o que mais importa.
186
+
187
+ Os eventos vêm em rajada (um write vira 2–3 avisos, um `npm run build` vira
188
+ centenas), então juntam-se por 150 ms e saem numa mensagem só. */
189
+ function vigiar(raiz, aoMudar, espera = 150) {
190
+ const pendentes = new Set();
191
+ let timer = null;
192
+ const soltar = () => {
193
+ timer = null;
194
+ const lista = [...pendentes];
195
+ pendentes.clear();
196
+ if (lista.length) aoMudar(lista);
197
+ };
198
+ const anotar = (_tipo, nome) => {
199
+ if (!nome) return;
200
+ const rel = String(nome).split(path.sep).join('/');
201
+ if (ignorado(rel)) return;
202
+ pendentes.add(rel);
203
+ if (!timer) timer = setTimeout(soltar, espera);
204
+ };
205
+ let vigia;
206
+ try { vigia = fs.watch(raiz, { recursive: true }, anotar); } catch {
207
+ try { vigia = fs.watch(raiz, anotar); } catch { return () => {}; }
208
+ }
209
+ vigia.on('error', () => {});
210
+ return function parar() {
211
+ try { vigia.close(); } catch {}
212
+ if (timer) clearTimeout(timer);
213
+ };
214
+ }
215
+
216
+ // ── as rotas ─────────────────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Monta o painel num servidor do app.js.
220
+ *
221
+ * @param {object} op
222
+ * @param {()=>string} op.pasta a pasta do projeto AGORA (o /dir muda ela)
223
+ * @param {(evento:string, dados:any)=>void} op.emitir o fluxo SSE do app.js
224
+ * @returns {{atender:Function, fechar:Function, conferirPasta:Function}}
225
+ */
226
+ function montar(op) {
227
+ let pastaVigiada = null;
228
+ let pararVigia = () => {};
229
+
230
+ function conferirPasta() {
231
+ const atual = op.pasta && op.pasta();
232
+ if (!atual || atual === pastaVigiada) return atual;
233
+ pararVigia();
234
+ pastaVigiada = atual;
235
+ if (!fs.existsSync(atual)) { pararVigia = () => {}; return atual; }
236
+ pararVigia = vigiar(atual, (mudados) => op.emitir('arquivos', { pasta: atual, mudados }));
237
+ op.emitir('arquivos', { pasta: atual, mudados: [], trocou: true });
238
+ return atual;
239
+ }
240
+ /* O /dir e os projetos da barra lateral trocam a pasta sem passar por
241
+ aqui: sem esta ronda o painel vigiaria o projeto anterior. E só depois
242
+ que o painel foi aberto de verdade — no Linux o fs.watch recursivo custa
243
+ um aviso do sistema por pasta, e quem só conversa não precisa disso. */
244
+ let usada = false;
245
+ const ronda = setInterval(() => { if (usada) conferirPasta(); }, 2000);
246
+ if (ronda.unref) ronda.unref();
247
+
248
+ function json(res, status, corpo) {
249
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
250
+ res.end(JSON.stringify(corpo));
251
+ }
252
+
253
+ /* Devolve true se a rota era do painel. A chave já foi conferida pelo
254
+ app.js antes de chegar aqui — por isso a prévia também mora em /api/. */
255
+ async function atender(req, res, url) {
256
+ const rota = url.pathname;
257
+ const ehArquivos = rota.startsWith('/api/arquivos/');
258
+ const m = rota.match(/^\/api\/previa\/[0-9a-f]+\/?(.*)$/);
259
+ if (!ehArquivos && !m) return false;
260
+ usada = true;
261
+ const raiz = conferirPasta();
262
+ if (!raiz || !fs.existsSync(raiz)) { json(res, 404, { ok: false, error: 'sem pasta de projeto' }); return true; }
263
+
264
+ if (rota === '/api/arquivos/arvore') {
265
+ const a = arvore(raiz);
266
+ json(res, 200, { ok: true, pasta: raiz, nome: path.basename(raiz), itens: a.itens,
267
+ cortada: a.cortada, entrada: entradaDaPrevia(raiz) });
268
+ return true;
269
+ }
270
+ if (rota === '/api/arquivos/ler') {
271
+ const r = ler(raiz, url.searchParams.get('p') || '');
272
+ json(res, r.status, r.corpo);
273
+ return true;
274
+ }
275
+ /* /api/previa/<chave>/<caminho>: a chave vai no CAMINHO, não na
276
+ query, porque a página da prévia pede style.css e script.js por
277
+ endereço relativo — e o relativo herda o caminho, não a query. */
278
+ if (m) { servirPrevia(raiz, m[1], res); return true; }
279
+
280
+ json(res, 404, { ok: false, error: 'rota desconhecida' });
281
+ return true;
282
+ }
283
+
284
+ return {
285
+ atender,
286
+ conferirPasta,
287
+ fechar() { clearInterval(ronda); pararVigia(); },
288
+ };
289
+ }
290
+
291
+ module.exports = { montar, dentro, arvore, ler, vigiar, ehBinario, entradaDaPrevia, MAX_LEITURA };