primocode 9.8.0-beta.5 → 9.8.0-beta.7

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 (52) hide show
  1. package/README.md +15 -4
  2. package/app/ansi.js +55 -0
  3. package/app/ide.css +345 -0
  4. package/app/ide.html +183 -0
  5. package/app/ide.js +639 -0
  6. package/bin/primocode.js +338 -88
  7. package/kits/acolhedor/index.html +130 -0
  8. package/kits/acolhedor/script.js +25 -0
  9. package/kits/acolhedor/style.css +146 -0
  10. package/kits/editorial/index.html +115 -0
  11. package/kits/editorial/script.js +19 -0
  12. package/kits/editorial/style.css +112 -0
  13. package/kits/premium/index.html +83 -0
  14. package/kits/premium/script.js +11 -0
  15. package/kits/premium/style.css +87 -0
  16. package/kits/vitrine/index.html +108 -0
  17. package/kits/vitrine/script.js +20 -0
  18. package/kits/vitrine/style.css +117 -0
  19. package/lib/act.js +66 -7
  20. package/lib/app.js +41 -6
  21. package/lib/browser.js +79 -3
  22. package/lib/checkpoint-git.js +218 -0
  23. package/lib/checkpoint.js +159 -44
  24. package/lib/comandos-extra.js +481 -0
  25. package/lib/config.js +5 -2
  26. package/lib/ide.js +337 -0
  27. package/lib/kits.js +165 -54
  28. package/lib/pre-leitura.js +118 -0
  29. package/lib/repeticao.js +4 -2
  30. package/lib/skills.js +6 -1
  31. package/lib/tempos.js +62 -0
  32. package/lib/ui.js +171 -17
  33. package/package.json +2 -2
  34. package/skills/apresentacao-pptx/SKILL.md +52 -0
  35. package/skills/cartaz-de-arte/LICENSE.txt +202 -0
  36. package/skills/cartaz-de-arte/SKILL.md +47 -0
  37. package/skills/criar-skill/LICENSE.txt +202 -0
  38. package/skills/criar-skill/SKILL.md +49 -0
  39. package/skills/depurar-de-verdade/LICENSE.txt +21 -0
  40. package/skills/depurar-de-verdade/SKILL.md +14 -5
  41. package/skills/documento-word/SKILL.md +57 -0
  42. package/skills/interface-revisada/SKILL.md +47 -0
  43. package/skills/pdf-bonito/SKILL.md +48 -0
  44. package/skills/planilha-excel/SKILL.md +47 -0
  45. package/skills/react-sem-dor/SKILL.md +48 -0
  46. package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
  47. package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
  48. package/skills/temas-prontos/LICENSE.txt +202 -0
  49. package/skills/temas-prontos/SKILL.md +50 -0
  50. package/skills/testar-o-site/LICENSE.txt +202 -0
  51. package/skills/testar-o-site/SKILL.md +46 -0
  52. package/skills/video-no-remotion/SKILL.md +48 -0
package/lib/app.js CHANGED
@@ -98,6 +98,9 @@ function tipoDe(arquivo) {
98
98
  * @param {()=>void} op.interromper para o que estiver rodando
99
99
  * @param {()=>object} op.estado { pasta, modo, esforco, projeto }
100
100
  * @param {string} op.versao
101
+ * @param {()=>string} [op.pastaProjeto] liga a IDE (/ide) nesta pasta
102
+ * @param {'rc'|'ide'} [op.abrirEm] qual tela a janela abre
103
+ * @param {boolean} [op.semJanela] só o servidor, sem abrir navegador
101
104
  */
102
105
  async function abrir(op) {
103
106
  const chave = crypto.randomBytes(16).toString('hex');
@@ -117,6 +120,13 @@ async function abrir(op) {
117
120
  }
118
121
  }
119
122
 
123
+ /* A IDE (lib/ide.js) mora neste mesmo servidor e fala pelo mesmo fluxo:
124
+ "um app ao invés de CLI, aí fica uma IDE e uma CLI". Só sobe quando
125
+ quem abriu diz qual é a pasta do projeto. */
126
+ const ide = op.pastaProjeto
127
+ ? require('./ide.js').montar({ pasta: op.pastaProjeto, emitir: (e, d) => emitir(e, d) })
128
+ : null;
129
+
120
130
  const pararEspelho = espelhar((pedaco) => {
121
131
  if (ehRuido(pedaco)) return;
122
132
  memoria.push(pedaco);
@@ -175,7 +185,11 @@ async function abrir(op) {
175
185
  /app.js sem parâmetro nenhum. A página vinha, ficava sem estilo e
176
186
  sem script, e o "app bonito" era um formulário branco. */
177
187
  if (rota.startsWith('/api/')) {
178
- const dada = url.searchParams.get('k') || req.headers['x-primo-chave'];
188
+ // A prévia da IDE leva a chave no CAMINHO (ver lib/ide.js): os
189
+ // arquivos que a página do projeto pede por endereço relativo não
190
+ // carregam query nenhuma.
191
+ const naPrevia = rota.match(/^\/api\/ide\/previa\/([0-9a-f]+)/);
192
+ const dada = (naPrevia && naPrevia[1]) || url.searchParams.get('k') || req.headers['x-primo-chave'];
179
193
  if (dada !== chave) {
180
194
  res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
181
195
  return res.end('chave inválida — abra a janela com o comando /app');
@@ -185,7 +199,13 @@ async function abrir(op) {
185
199
  127.0.0.1 sem preflight. Ela não saberia a chave, mas o cinto e o
186
200
  suspensório custam três linhas. */
187
201
  const origem = req.headers.origin;
188
- if (origem && !origem.startsWith(`http://127.0.0.1:${servidor.__porta}`)
202
+ /* A exceção é a prévia da IDE: ela roda num iframe sandbox de origem
203
+ opaca, e o navegador manda `Origin: null` até num <script
204
+ type="module">. Só GET e só nas rotas da prévia, que apenas LEEM o
205
+ projeto — nenhuma delas dá ordem ao agente. */
206
+ const previaOpaca = origem === 'null' && req.method === 'GET'
207
+ && rota.startsWith('/api/ide/previa/');
208
+ if (origem && !previaOpaca && !origem.startsWith(`http://127.0.0.1:${servidor.__porta}`)
189
209
  && !origem.startsWith(`http://localhost:${servidor.__porta}`)) {
190
210
  res.writeHead(403).end();
191
211
  return;
@@ -254,6 +274,8 @@ async function abrir(op) {
254
274
  return res.end(JSON.stringify(r));
255
275
  }
256
276
 
277
+ if (ide && await ide.atender(req, res, url)) return;
278
+
257
279
  if (rota === '/api/estado') {
258
280
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
259
281
  return res.end(JSON.stringify(Object.assign({ versao: op.versao }, op.estado())));
@@ -262,7 +284,7 @@ async function abrir(op) {
262
284
  // Estáticos. `basename` corta qualquer "../" antes de virar caminho:
263
285
  // este servidor fala com o disco do usuário e não pode servir o que
264
286
  // estiver fora da pasta do aplicativo.
265
- const nome = rota === '/' ? 'index.html' : path.basename(rota);
287
+ const nome = rota === '/' ? 'index.html' : rota === '/ide' ? 'ide.html' : path.basename(rota);
266
288
  const arquivo = path.join(WEB, nome);
267
289
  fs.readFile(arquivo, (erro, dados) => {
268
290
  if (erro) { res.writeHead(404).end('não encontrado'); return; }
@@ -274,11 +296,22 @@ async function abrir(op) {
274
296
  const porta = await escutar(servidor, PORTA_PADRAO);
275
297
  servidor.__porta = porta;
276
298
  const url = `http://127.0.0.1:${porta}/?k=${chave}`;
299
+ const urlIde = `http://127.0.0.1:${porta}/ide?k=${chave}`;
277
300
 
278
- const janela = await abrirJanela(url);
301
+ // `semJanela` é para o teste (e para quem só quer o endereço): sem ele,
302
+ // rodar a suíte abriria uma janela de verdade na tela de quem roda.
303
+ const janela = op.semJanela ? { modo: 'nenhum' }
304
+ : await abrirJanela(op.abrirEm === 'ide' && ide ? urlIde : url);
279
305
  return {
280
- url, porta, chave, servidor, janela,
281
- fechar() { try { pararEspelho(); } catch {} try { servidor.close(); } catch {} },
306
+ url, urlIde: ide ? urlIde : null, porta, chave, servidor, janela,
307
+ fechar() {
308
+ try { pararEspelho(); } catch {}
309
+ try { if (ide) ide.fechar(); } catch {}
310
+ try { servidor.close(); } catch {}
311
+ // SSE abertos seguram o close() para sempre; a janela é que
312
+ // fechou, então as conexões dela também.
313
+ for (const r of ouvintes) { try { r.end(); } catch {} }
314
+ },
282
315
  };
283
316
  }
284
317
 
@@ -345,6 +378,8 @@ function abrirJanela(url) {
345
378
  const filho = execFile(exe, [
346
379
  `--app=${url}`,
347
380
  `--user-data-dir=${perfil}`,
381
+ // Sem Keychain: ver SEM_KEYCHAIN em lib/browser.js.
382
+ ...require('./browser.js').SEM_KEYCHAIN,
348
383
  '--window-size=1180,820',
349
384
  '--no-first-run',
350
385
  '--no-default-browser-check',
package/lib/browser.js CHANGED
@@ -177,6 +177,16 @@ function httpPutJson(url, timeoutMs = 3000) {
177
177
 
178
178
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
179
179
 
180
+ /* O Chrome NUNCA mexe no Keychain do Mac (nem no cofre de senhas do Linux).
181
+ * "Para de fazer coisas de tentar armazenar chave no Chrome, eu apaguei o
182
+ * Chrome." (24/09/2026)
183
+ * Cada perfil novo do PrimoCode (o do agente, o revisor escondido, a janela
184
+ * do /rc e do /ide) fazia o Chrome pedir o "Chrome Safe Storage" ao sistema —
185
+ * uma janela de senha do Mac, do nada, várias vezes. O PrimoCode não guarda
186
+ * senha em navegador nenhum: estas duas flags fazem o Chrome usar um cofre de
187
+ * mentira e texto simples no próprio perfil, sem tocar no do sistema. */
188
+ const SEM_KEYCHAIN = ['--use-mock-keychain', '--password-store=basic'];
189
+
180
190
  // ---- WebSocket client mínimo (RFC 6455) para CDP ----------------
181
191
  // Suficiente pra CDP (mensagens curtas, texto UTF-8, um cliente por página).
182
192
  class MiniWebSocket {
@@ -393,6 +403,7 @@ async function ensureChromeRunning() {
393
403
  const args = [
394
404
  `--remote-debugging-port=${DEBUG_PORT}`,
395
405
  `--user-data-dir=${PROFILE_DIR}`,
406
+ ...SEM_KEYCHAIN,
396
407
  '--no-first-run',
397
408
  '--no-default-browser-check',
398
409
  '--disable-features=Translate',
@@ -810,6 +821,7 @@ function close() {
810
821
  let PORTA_REVISOR = Number(process.env.PRIMOCODE_CDP_PORT_REVISOR || 0);
811
822
  let perfilRevisor = null;
812
823
  let revisorChild = null;
824
+ let saidaRegistrada = false;
813
825
 
814
826
  function lerPortaDoPerfil(perfil) {
815
827
  try {
@@ -819,7 +831,20 @@ function lerPortaDoPerfil(perfil) {
819
831
  } catch { return 0; }
820
832
  }
821
833
 
822
- async function subirRevisor() {
834
+ /* Quem está subindo o revisor AGORA. O agente liga o navegador escondido
835
+ adiantado, quando escreve a primeira página (lib/act.js), e a conferência
836
+ do "pronto" chama isto de novo — às vezes com o primeiro ainda subindo.
837
+ Sem esta promessa em comum, a segunda chamada via PORTA_REVISOR zerada e
838
+ lançava um SEGUNDO Chrome, que ficava órfão. */
839
+ let subindoRevisor = null;
840
+
841
+ function subirRevisor() {
842
+ if (subindoRevisor) return subindoRevisor;
843
+ subindoRevisor = _subirRevisor().finally(() => { subindoRevisor = null; });
844
+ return subindoRevisor;
845
+ }
846
+
847
+ async function _subirRevisor() {
823
848
  if (PORTA_REVISOR) {
824
849
  try {
825
850
  const v = await httpGetJson(`http://127.0.0.1:${PORTA_REVISOR}/json/version`, 800);
@@ -839,6 +864,7 @@ async function subirRevisor() {
839
864
  // Zero: o Chrome escolhe uma porta livre e escreve qual no perfil.
840
865
  `--remote-debugging-port=${process.env.PRIMOCODE_CDP_PORT_REVISOR || 0}`,
841
866
  `--user-data-dir=${perfilRevisor}`,
867
+ ...SEM_KEYCHAIN,
842
868
  // Sempre invisível: é o pedido, e é o que separa este do navegador da
843
869
  // pessoa. Não há variável para ligar a janela aqui de propósito.
844
870
  '--headless=new', '--disable-gpu', '--disable-dev-shm-usage',
@@ -868,6 +894,17 @@ async function subirRevisor() {
868
894
  revisorChild = spawn(chrome, args, { detached: true, stdio: 'ignore' });
869
895
  revisorChild.on('error', () => {});
870
896
  revisorChild.unref();
897
+ /* O REVISOR MORRE COM O PROCESSO. Ele é `detached` + `unref` para não
898
+ segurar o CLI de pé — e por isso mesmo sobrevivia a ele: só o pipeline
899
+ (construir_app) chamava fecharRevisor, e toda sessão que conferiu uma
900
+ página deixava um Chrome invisível para trás. Medido em 24/09 nesta
901
+ máquina: 7 revisores órfãos somando ~2 GB de memória, um deles com 3
902
+ minutos de CPU. É o tipo de lentidão que a pessoa sente no computador
903
+ inteiro e não liga ao PrimoCode. */
904
+ if (!saidaRegistrada) {
905
+ saidaRegistrada = true;
906
+ process.on('exit', () => { try { fecharRevisor(); } catch { /* saindo */ } });
907
+ }
871
908
 
872
909
  const prazo = Date.now() + 15000;
873
910
  while (Date.now() < prazo) {
@@ -1098,6 +1135,44 @@ async function abrirEscondido(url) {
1098
1135
  /* O revisor é ferramenta de uma tarefa, não um processo que fica. Deixá-lo de
1099
1136
  pé come memória até a pessoa desligar o computador, e ele não é o navegador
1100
1137
  dela — ninguém sentiria falta nem saberia que existe para fechar. */
1138
+ /* LIGAR O REVISOR ANTES DA HORA — e não só ligar: navegar uma vez.
1139
+ *
1140
+ * Medido em 24/09, cada fase da conferência num Chrome recém-aberto:
1141
+ * subir 313ms · abrir aba 24ms · canal 29ms · PRIMEIRA navegação 3.019ms ·
1142
+ * assentar 1.200ms. Na segunda página, no mesmo Chrome, a navegação levou
1143
+ * 4ms. O caro não é o processo, é a primeira ida à rede (o Chrome sobe o
1144
+ * serviço de rede e o primeiro renderizador nela) — navegar para about:blank
1145
+ * ou data: não aquece isso (a conferência seguinte continuou em ~2,1s);
1146
+ * navegar para um http local aquece (caiu para 1.240ms, que é o assentar).
1147
+ *
1148
+ * Por isso isto abre uma aba num servidor de uma linha, em 127.0.0.1, e fecha.
1149
+ * Uma vez por navegador: depois da primeira, não há o que aquecer. Nunca
1150
+ * rejeita — aquecer é atalho, e quem confere de verdade tenta de novo e diz
1151
+ * o que houve. */
1152
+ let aquecido = null; // a porta do revisor que já foi aquecido
1153
+ let aquecendo = null;
1154
+ function aquecerRevisor() {
1155
+ if (aquecendo) return aquecendo;
1156
+ aquecendo = (async () => {
1157
+ await subirRevisor();
1158
+ if (aquecido && aquecido === PORTA_REVISOR) return true;
1159
+ const srv = http.createServer((req, res) => {
1160
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1161
+ res.end('<!doctype html><title>.</title>');
1162
+ });
1163
+ try {
1164
+ await new Promise((ok, falhou) => { srv.once('error', falhou); srv.listen(0, '127.0.0.1', ok); });
1165
+ const aba = await abaEscondida(`http://127.0.0.1:${srv.address().port}/`, { assentar: 0, prazoLoad: 10000 });
1166
+ await aba.fechar();
1167
+ aquecido = PORTA_REVISOR;
1168
+ return true;
1169
+ } finally {
1170
+ try { srv.close(); } catch { /* já fechou */ }
1171
+ }
1172
+ })().catch(() => false).finally(() => { aquecendo = null; });
1173
+ return aquecendo;
1174
+ }
1175
+
1101
1176
  function fecharRevisor() {
1102
1177
  if (revisorChild && revisorChild.pid) {
1103
1178
  try { process.kill(-revisorChild.pid); } catch {
@@ -1116,8 +1191,9 @@ function fecharRevisor() {
1116
1191
  }
1117
1192
 
1118
1193
  module.exports = {
1119
- scriptDaSeta, verSeta,
1194
+ SEM_KEYCHAIN,
1195
+ scriptDaSeta, verSeta, findChrome,
1120
1196
  open, click, clickText, clickSelector, type, typeInSelector,
1121
1197
  key, screenshot, evaluate, waitFor, wait, scroll, close,
1122
- inspecionarEscondido, rodarEscondido, abrirEscondido, fecharRevisor,
1198
+ inspecionarEscondido, rodarEscondido, abrirEscondido, fecharRevisor, subirRevisor, aquecerRevisor,
1123
1199
  };
@@ -0,0 +1,218 @@
1
+ /**
2
+ * checkpoint-git.js — a foto do projeto inteiro, num git que a pessoa não vê.
3
+ *
4
+ * O checkpoint por arquivo (lib/checkpoint.js) só enxerga o que passa por
5
+ * write_file/edit_file/multi_edit. Um `rm`, um `mv`, um `sed -i` ou um
6
+ * `npm run build` pelo run_shell mudam o disco sem avisar ninguém — e é
7
+ * justamente aí que "ele apagou meu site" acontece. A saída (a mesma ideia do
8
+ * Cline, escrita aqui do zero) é tirar uma foto do projeto INTEIRO no começo
9
+ * de cada pedido, num repositório git "sombra":
10
+ *
11
+ * ~/.primocode/checkpoints/<projeto>-<hash>/git ← GIT_DIR
12
+ * a pasta do projeto ← GIT_WORK_TREE
13
+ *
14
+ * Por que um git à parte e não o .git do projeto: o repositório da pessoa é
15
+ * dela. Branch, index, stash e HEAD não podem mudar porque o agente tirou uma
16
+ * foto. Por isso TODO comando daqui passa --git-dir e --work-tree explícitos
17
+ * E as variáveis GIT_DIR/GIT_WORK_TREE — nunca há descoberta de repositório
18
+ * a partir da pasta, que acharia o .git da pessoa.
19
+ *
20
+ * Por que a config global fica de fora (GIT_CONFIG_GLOBAL=/dev/null): quem
21
+ * tem commit.gpgsign=true ou um core.hooksPath global veria o PrimoCode pedir
22
+ * a senha da chave GPG ou rodar hooks a cada pedido.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+ const path = require('path');
30
+ const { spawnSync } = require('child_process');
31
+
32
+ /** Maior que isso não entra na foto: vídeo exportado, dump de banco, zip.
33
+ A foto é de código e texto; binário grande encheria o disco da pessoa. */
34
+ const LIMITE_BYTES = 20 * 1024 * 1024;
35
+ /** Pasta com mais arquivos novos que isso na primeira foto provavelmente não
36
+ é um projeto (é Documentos, Downloads…). Aí fica o checkpoint por arquivo. */
37
+ const MAX_ARQUIVOS = 20000;
38
+ /** O pedido não pode ficar parado esperando a foto. */
39
+ const TEMPO_MAXIMO_MS = 20000;
40
+
41
+ /** O que nunca entra na foto, além do .gitignore do próprio projeto. São
42
+ coisas que se regeneram (npm install, build) ou que não são da pessoa. */
43
+ const IGNORAR_SEMPRE = [
44
+ 'node_modules/', '.git/', 'dist/', 'build/', '.next/', '.nuxt/', '.turbo/',
45
+ '.cache/', 'coverage/', '__pycache__/', '.venv/', 'venv/', '*.log', '.DS_Store',
46
+ ];
47
+
48
+ let _temGit = null;
49
+ /** Detectado uma vez por processo; o teste zera com _reiniciar(). */
50
+ function temGit() {
51
+ if (process.env.PRIMOCODE_SEM_GIT === '1') return false;
52
+ if (_temGit === null) {
53
+ try {
54
+ const r = spawnSync('git', ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true });
55
+ _temGit = r.status === 0 && /git version/.test(r.stdout || '');
56
+ } catch { _temGit = false; }
57
+ }
58
+ return _temGit;
59
+ }
60
+ function _reiniciar() { _temGit = null; }
61
+
62
+ /** O ambiente limpo: nenhuma variável GIT_* herdada (um hook que chamou o
63
+ PrimoCode deixaria GIT_INDEX_FILE apontando para o index da pessoa). */
64
+ function ambiente(gitDir, workTree) {
65
+ const env = {};
66
+ for (const [k, v] of Object.entries(process.env)) if (!/^GIT_/.test(k)) env[k] = v;
67
+ env.GIT_DIR = gitDir;
68
+ env.GIT_WORK_TREE = workTree;
69
+ env.GIT_CONFIG_NOSYSTEM = '1';
70
+ env.GIT_CONFIG_GLOBAL = os.devNull;
71
+ env.GIT_TERMINAL_PROMPT = '0';
72
+ env.GIT_OPTIONAL_LOCKS = '0';
73
+ return env;
74
+ }
75
+
76
+ function git(gitDir, workTree, args, { input } = {}) {
77
+ const r = spawnSync('git', [
78
+ `--git-dir=${gitDir}`, `--work-tree=${workTree}`, '--literal-pathspecs',
79
+ '-c', 'core.autocrlf=false', '-c', 'core.quotepath=false', '-c', 'core.fsmonitor=false',
80
+ '-c', 'commit.gpgsign=false', '-c', `core.hooksPath=${path.join(gitDir, 'sem-hooks')}`,
81
+ '-c', 'user.name=PrimoCode', '-c', 'user.email=desfazer@primocode.local',
82
+ '-c', 'advice.addEmbeddedRepo=false', '-c', 'core.safecrlf=false',
83
+ ...args,
84
+ ], { cwd: workTree, env: ambiente(gitDir, workTree), encoding: 'utf8', input,
85
+ timeout: TEMPO_MAXIMO_MS, maxBuffer: 64 * 1024 * 1024, windowsHide: true });
86
+ if (r.error) throw r.error;
87
+ if (r.status !== 0) throw new Error((r.stderr || '').trim() || `git ${args[0]} saiu com ${r.status}`);
88
+ return r.stdout;
89
+ }
90
+
91
+ /** Um processo morto no meio (timeout, Ctrl+C) deixa o index.lock para trás,
92
+ e dali em diante TODA foto falharia. Lock velho é lixo: sai. */
93
+ function destravar(gitDir) {
94
+ const lock = path.join(gitDir, 'index.lock');
95
+ try { if (Date.now() - fs.statSync(lock).mtimeMs > 60000) fs.rmSync(lock, { force: true }); } catch {}
96
+ }
97
+
98
+ /** Cria o repositório sombra na primeira vez. Devolve false quando a pasta
99
+ não serve para foto (grande demais). */
100
+ function preparar(gitDir, workTree) {
101
+ if (fs.existsSync(path.join(gitDir, 'HEAD'))) return true;
102
+ fs.mkdirSync(gitDir, { recursive: true });
103
+ git(gitDir, workTree, ['init', '-q']);
104
+ fs.mkdirSync(path.join(gitDir, 'info'), { recursive: true });
105
+ fs.writeFileSync(path.join(gitDir, 'info', 'exclude'),
106
+ '# o que o /desfazer nunca fotografa (o .gitignore do projeto vale também)\n'
107
+ + IGNORAR_SEMPRE.join('\n') + '\n');
108
+ // Os hooks de exemplo do template não rodam, mas não precisam ficar lá.
109
+ fs.rmSync(path.join(gitDir, 'hooks'), { recursive: true, force: true });
110
+ return true;
111
+ }
112
+
113
+ /** Arquivo novo grande demais vai para o exclude ANTES do add: o git não
114
+ sabe ignorar por tamanho, então listamos os não rastreados e medimos. */
115
+ function excluirGrandes(gitDir, workTree, { primeira } = {}) {
116
+ const novos = git(gitDir, workTree, ['ls-files', '-z', '--others', '--exclude-standard']).split('\0').filter(Boolean);
117
+ if (primeira && novos.length > MAX_ARQUIVOS) return false;
118
+ const grandes = [];
119
+ for (const rel of novos) {
120
+ try { if (fs.statSync(path.join(workTree, rel)).size > LIMITE_BYTES) grandes.push(rel); } catch {}
121
+ }
122
+ if (grandes.length) {
123
+ // Âncora com "/" e caracteres especiais escapados: o padrão pega só ESTE arquivo.
124
+ const linhas = grandes.map((r) => '/' + r.replace(/([\\*?[\]!# ])/g, '\\$1'));
125
+ fs.appendFileSync(path.join(gitDir, 'info', 'exclude'), linhas.join('\n') + '\n');
126
+ }
127
+ return true;
128
+ }
129
+
130
+ /** Põe o estado atual do disco no index da sombra (só no dela). */
131
+ function registrarEstado(gitDir, workTree, opcoes) {
132
+ destravar(gitDir);
133
+ if (excluirGrandes(gitDir, workTree, opcoes) === false) return false;
134
+ git(gitDir, workTree, ['add', '-A', '--ignore-errors', '--', '.']);
135
+ return true;
136
+ }
137
+
138
+ /** A foto do começo do pedido. Devolve o sha, ou null quando não deu (sem
139
+ git, pasta grande demais, erro) — quem chama cai no checkpoint por arquivo. */
140
+ function fotografar(gitDir, workTree, mensagem) {
141
+ const primeira = !fs.existsSync(path.join(gitDir, 'HEAD'));
142
+ if (!preparar(gitDir, workTree)) return null;
143
+ if (!registrarEstado(gitDir, workTree, { primeira })) {
144
+ // Não é projeto: a sombra criada agora não serve e não pode ficar.
145
+ if (primeira) fs.rmSync(gitDir, { recursive: true, force: true });
146
+ return null;
147
+ }
148
+ git(gitDir, workTree, ['commit', '-q', '--allow-empty', '--no-verify', '-m', String(mensagem || 'pedido').slice(0, 500) || 'pedido']);
149
+ return git(gitDir, workTree, ['rev-parse', 'HEAD']).trim();
150
+ }
151
+
152
+ /** O que mudou da foto `sha` até o disco de agora: [{ rel, status }] com
153
+ status A (nasceu depois), M (mudou) ou D (sumiu). Renomear aparece como
154
+ D + A de propósito (--no-renames): desfazer é recriar um e apagar o outro. */
155
+ function mudancas(gitDir, workTree, sha) {
156
+ registrarEstado(gitDir, workTree);
157
+ const saida = git(gitDir, workTree, ['diff', '--cached', '--no-renames', '--name-status', '-z', sha, '--']);
158
+ const partes = saida.split('\0').filter(Boolean);
159
+ const lista = [];
160
+ for (let i = 0; i + 1 < partes.length; i += 2) {
161
+ const status = partes[i][0] === 'T' ? 'M' : partes[i][0];
162
+ lista.push({ rel: partes[i + 1], status });
163
+ }
164
+ return lista;
165
+ }
166
+
167
+ /** Tira pastas que ficaram vazias depois de apagar o que nasceu no pedido
168
+ (quem criou `css/novo.css` espera que `css/` suma junto). Para na raiz. */
169
+ function podarVazias(workTree, rel) {
170
+ let d = path.dirname(path.join(workTree, rel));
171
+ const raiz = path.resolve(workTree);
172
+ while (d.startsWith(raiz + path.sep)) {
173
+ try { if (fs.readdirSync(d).length) break; fs.rmdirSync(d); } catch { break; }
174
+ d = path.dirname(d);
175
+ }
176
+ }
177
+
178
+ /** Volta o disco para a foto `sha`: o que mudou ou sumiu volta, o que nasceu
179
+ depois sai. Só se apaga o que a sombra VIU nascer — arquivo ignorado
180
+ (node_modules, .gitignore, grande demais) nunca entra no index, então nunca
181
+ aparece como A, então nunca é apagado. */
182
+ function restaurar(gitDir, workTree, sha) {
183
+ const feitos = [];
184
+ const lista = mudancas(gitDir, workTree, sha);
185
+ const statusDe = new Map(lista.map((m) => [m.rel, m.status]));
186
+ const voltar = lista.filter((m) => m.status !== 'A').map((m) => m.rel);
187
+ // Em lotes: um projeto com milhares de mudanças estouraria a linha de comando.
188
+ for (let i = 0; i < voltar.length; i += 200) {
189
+ const lote = voltar.slice(i, i + 200);
190
+ try {
191
+ git(gitDir, workTree, ['checkout', sha, '--', ...lote]);
192
+ for (const rel of lote) feitos.push({ rel, acao: statusDe.get(rel) === 'D' ? 'recriado' : 'restaurado' });
193
+ } catch (e) { for (const rel of lote) feitos.push({ rel, acao: 'falhou', erro: e.message }); }
194
+ }
195
+ for (const m of lista.filter((x) => x.status === 'A')) {
196
+ try {
197
+ fs.rmSync(path.join(workTree, m.rel), { force: true });
198
+ podarVazias(workTree, m.rel);
199
+ feitos.push({ rel: m.rel, acao: 'apagado' });
200
+ } catch (e) { feitos.push({ rel: m.rel, acao: 'falhou', erro: e.message }); }
201
+ }
202
+ // O index da sombra volta a espelhar o disco, para a próxima comparação.
203
+ try { registrarEstado(gitDir, workTree); } catch {}
204
+ return feitos;
205
+ }
206
+
207
+ /** O arquivo está fora da foto (ignorado pelas regras)? Aí o checkpoint por
208
+ arquivo cobre — editar `dist/app.js` continua tendo volta. */
209
+ function ignorado(gitDir, workTree, abs) {
210
+ const rel = path.relative(workTree, abs);
211
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return true;
212
+ const r = spawnSync('git', [`--git-dir=${gitDir}`, `--work-tree=${workTree}`, 'check-ignore', '-q', '--no-index', '--', rel],
213
+ { cwd: workTree, env: ambiente(gitDir, workTree), timeout: 5000, windowsHide: true });
214
+ return r.status === 0;
215
+ }
216
+
217
+ module.exports = { IGNORAR_SEMPRE, LIMITE_BYTES, MAX_ARQUIVOS, temGit, _reiniciar,
218
+ fotografar, mudancas, restaurar, ignorado };