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.
- package/README.md +33 -6
- package/app/ansi.js +55 -0
- package/app/app.css +482 -281
- package/app/app.js +888 -315
- package/app/index.html +176 -101
- package/app/primo-marca.js +58 -1
- package/bin/primocode.js +697 -118
- package/comandos/explicar.md +14 -0
- package/comandos/publicar.md +10 -0
- package/comandos/revisar.md +13 -0
- package/lib/act.js +130 -26
- package/lib/api.js +8 -1
- package/lib/app.js +317 -74
- package/lib/browser.js +79 -3
- package/lib/catalogo.js +11 -3
- package/lib/checkpoint-git.js +218 -0
- package/lib/checkpoint.js +249 -0
- package/lib/claude-engine.js +25 -3
- package/lib/comandos-extra.js +481 -0
- package/lib/comandos.js +104 -0
- package/lib/config.js +21 -4
- package/lib/estudio/acervo-site.js +157 -0
- package/lib/estudio/assets.js +77 -3
- package/lib/estudio/cinema.js +354 -0
- package/lib/estudio/composicao.js +470 -0
- package/lib/estudio/edicao.js +450 -0
- package/lib/estudio/impacto.js +263 -0
- package/lib/estudio/index.js +769 -5
- package/lib/estudio/plano.js +5 -2
- package/lib/estudio/remotion.js +228 -23
- package/lib/estudio/slides.js +34 -30
- package/lib/fala.js +95 -1
- package/lib/local/cerebro.json +361 -180
- package/lib/local/entrada.js +18 -8
- package/lib/local/motor-cli.js +1 -1
- package/lib/mencoes.js +98 -0
- package/lib/offline.js +168 -0
- package/lib/painel.js +291 -0
- package/lib/perigo.js +256 -0
- package/lib/pre-leitura.js +118 -0
- package/lib/repeticao.js +4 -2
- package/lib/skills.js +6 -1
- package/lib/tempos.js +62 -0
- package/lib/ui.js +171 -17
- package/package.json +3 -2
- package/skills/apresentacao-pptx/SKILL.md +52 -0
- package/skills/cartaz-de-arte/LICENSE.txt +202 -0
- package/skills/cartaz-de-arte/SKILL.md +47 -0
- package/skills/criar-skill/LICENSE.txt +202 -0
- package/skills/criar-skill/SKILL.md +49 -0
- package/skills/depurar-de-verdade/LICENSE.txt +21 -0
- package/skills/depurar-de-verdade/SKILL.md +14 -5
- package/skills/design-de-verdade/LICENSE.txt +177 -0
- package/skills/design-de-verdade/SKILL.md +42 -0
- package/skills/documento-word/SKILL.md +57 -0
- package/skills/interface-revisada/SKILL.md +47 -0
- package/skills/me-ensina/LICENSE.txt +21 -0
- package/skills/me-ensina/SKILL.md +27 -0
- package/skills/me-entrevista/LICENSE.txt +21 -0
- package/skills/me-entrevista/SKILL.md +27 -0
- package/skills/motion-de-primeira/SKILL.md +1 -1
- package/skills/pdf-bonito/SKILL.md +48 -0
- package/skills/planilha-excel/SKILL.md +47 -0
- package/skills/react-sem-dor/SKILL.md +48 -0
- package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
- package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
- package/skills/site-com-motion/SKILL.md +39 -0
- package/skills/temas-prontos/LICENSE.txt +202 -0
- package/skills/temas-prontos/SKILL.md +50 -0
- package/skills/testar-o-site/LICENSE.txt +202 -0
- package/skills/testar-o-site/SKILL.md +46 -0
- package/skills/video-no-remotion/SKILL.md +48 -0
- package/studio/narrar.py +111 -10
- package/studio/rostos.py +52 -0
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/lib/catalogo.js
CHANGED
|
@@ -84,7 +84,11 @@ const GRUPOS = [
|
|
|
84
84
|
+ 'SEMPRE procure antes de criar quando o pedido falar de algo que já existe.' },
|
|
85
85
|
|
|
86
86
|
{ tool: 'estudio_plano', escreve: true, faz: 'escreve ou ajusta o que o vídeo É — cenas, elementos, animação, câmera, áudio',
|
|
87
|
-
args: 'id.
|
|
87
|
+
args: 'id. PREFIRA roteiro: {marca:{cores:{fundo,destaque,tinta},fontes:{titulo,texto},logo}, proporcao, '
|
|
88
|
+
+ 'partes:[{tipo, fala, …}]} — o PrimoCode faz o layout, a voz com entonação, os ícones, a música e os sons. '
|
|
89
|
+
+ 'Tipos: abertura{titulo,sub} titulo{titulo,sub} numero{valor,rotulo,sub} lista{titulo,itens} passos{titulo,itens} '
|
|
90
|
+
+ 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link}. '
|
|
91
|
+
+ 'voz (thalita|antonio|francisca). Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
|
|
88
92
|
+ 'cena (opcional): o número de UMA cena para trocar só ela, sem reescrever o resto. '
|
|
89
93
|
+ 'motivo (opcional): fica no caderno. voltarPara (opcional): nome de uma versão. '
|
|
90
94
|
+ 'Sem plano nem cenas, LÊ o plano atual. '
|
|
@@ -104,7 +108,10 @@ const GRUPOS = [
|
|
|
104
108
|
+ 'Use os tempos que voltam para a duração das cenas — estimar por número de palavras erra em segundos.' },
|
|
105
109
|
|
|
106
110
|
{ tool: 'estudio_video', escreve: true, faz: 'o que se faz com a gravação do usuário: importar, medir, cortar silêncio, transcrever, recortar a pessoa',
|
|
107
|
-
args: 'id. acao: importar | medir | silencio | cortar-silencio | quadro | legendas | recortar-pessoa. '
|
|
111
|
+
args: 'id. acao: editar | importar | medir | silencio | cortar-silencio | quadro | legendas | recortar-pessoa. '
|
|
112
|
+
+ 'editar: FAZ A EDIÇÃO INTEIRA sozinho — corta o silêncio, transcreve palavra por palavra e monta o plano '
|
|
113
|
+
+ '(legenda animada, ícone na hora da palavra, zoom de corte, pop e whoosh). estilo: viral (padrão) | limpo. '
|
|
114
|
+
+ 'Com gravação do usuário, é o PRIMEIRO passo depois de importar; depois só estudio_render. '
|
|
108
115
|
+ 'arquivo: caminho (em "importar", de fora; nas outras, o relativo dentro do projeto). '
|
|
109
116
|
+ 'cortar-silencio: minimoParaCortar (padrão 0.6s), respiro (padrão 0.18s) — os dois existem para o corte não soar robótico. '
|
|
110
117
|
+ 'legendas: devolve os trechos com tempo, para virarem elemento "legenda". '
|
|
@@ -119,7 +126,8 @@ const GRUPOS = [
|
|
|
119
126
|
|
|
120
127
|
{ tool: 'estudio_site', escreve: false, faz: 'lê a identidade visual de uma marca: cores, fontes, logo e o que ela diz de si',
|
|
121
128
|
args: 'url — OU marca: o nome da empresa, quando não há link ("faça um vídeo para a Paris Group"). '
|
|
122
|
-
+ 'id (opcional): anota no caderno do projeto
|
|
129
|
+
+ 'id (opcional): anota no caderno do projeto — e com id traz o ACERVO da marca: as fotos e os vídeos '
|
|
130
|
+
+ 'reais do site (que entram sozinhos no vídeo) e os FATOS das páginas (datas, local, números), para o roteiro. '
|
|
123
131
|
+ 'Devolve FATOS medidos, não um roteiro — o roteiro é seu. '
|
|
124
132
|
+ 'Use SEMPRE que o pedido citar uma empresa de verdade: inventar a cor de uma marca que existe '
|
|
125
133
|
+ 'é erro que sai pronto no vídeo entregue. '
|
|
@@ -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 };
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* checkpoint.js — toda mudança do agente num arquivo tem volta.
|
|
3
|
+
*
|
|
4
|
+
* Quem usa o PrimoCode quase nunca sabe git. Uma edição ruim ("ele apagou
|
|
5
|
+
* metade do meu site") não pode ser o fim da história: antes de o agente
|
|
6
|
+
* escrever, editar ou apagar um arquivo, guardamos o conteúdo ORIGINAL dele,
|
|
7
|
+
* uma vez por pedido. O `/desfazer` devolve tudo como estava.
|
|
8
|
+
*
|
|
9
|
+
* Por que o checkpoint é do PEDIDO e não do passo: um pedido vira vários
|
|
10
|
+
* runAct (passos do plano, continuações, subagentes). Para a pessoa, "desfaz"
|
|
11
|
+
* quer dizer "desfaz o que eu pedi", não "desfaz o terceiro passo interno".
|
|
12
|
+
* Por isso o CLI chama `iniciar(pedido)` antes do laço de passos, e o loop do
|
|
13
|
+
* agente só avisa `antesDeMudar(arquivo, pasta)` — o estado fica aqui, no
|
|
14
|
+
* módulo, e o subagente (mesmo processo) cai no mesmo checkpoint.
|
|
15
|
+
*
|
|
16
|
+
* Dois jeitos de guardar, o melhor que a máquina permitir:
|
|
17
|
+
* COM git — no `iniciar`, uma foto do projeto INTEIRO num git sombra
|
|
18
|
+
* (lib/checkpoint-git.js). Cobre o que o run_shell apaga, move ou
|
|
19
|
+
* gera, que a tool de arquivo nunca vê.
|
|
20
|
+
* SEM git — o de antes: a cópia de cada arquivo que as tools de arquivo
|
|
21
|
+
* vão mexer. Também cobre, com git, o que a foto não pega
|
|
22
|
+
* (arquivo ignorado, como dist/, e pasta fora do projeto).
|
|
23
|
+
*
|
|
24
|
+
* Onde fica: ~/.primocode/checkpoints/<projeto>-<hash>/
|
|
25
|
+
* git/ o repositório sombra (só com git)
|
|
26
|
+
* <id>/manifest.json o pedido, a hora, o sha da foto e os arquivos
|
|
27
|
+
* <id>/0, 1, 2… a cópia original de cada arquivo guardado um a um
|
|
28
|
+
* Só os últimos MAXIMO por projeto: é rede de segurança, não arquivo morto.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const os = require('os');
|
|
35
|
+
const path = require('path');
|
|
36
|
+
const crypto = require('crypto');
|
|
37
|
+
const foto = require('./checkpoint-git');
|
|
38
|
+
|
|
39
|
+
const MAXIMO = 20;
|
|
40
|
+
/** As tools de arquivo que mudam o disco. run_shell fica de fora: não dá para
|
|
41
|
+
saber, antes de rodar, que arquivos um comando vai tocar — quem cobre o
|
|
42
|
+
run_shell é a foto do projeto inteiro, tirada antes do pedido. */
|
|
43
|
+
const MUDAM_ARQUIVO = new Set(['write_file', 'edit_file', 'multi_edit']);
|
|
44
|
+
|
|
45
|
+
/* O pedido em andamento:
|
|
46
|
+
{ pedido, git: { projectDir, gitDir, dir, manifest } | null,
|
|
47
|
+
arq: { dir, manifest } | null }
|
|
48
|
+
`git` é o checkpoint com foto; `arq` é o por arquivo, para o que cai fora
|
|
49
|
+
da foto (ou para tudo, quando não há git). */
|
|
50
|
+
let atual = null;
|
|
51
|
+
|
|
52
|
+
/* os.homedir() lido na hora, não no require: o teste troca o HOME. */
|
|
53
|
+
function raiz() { return path.join(os.homedir(), '.primocode', 'checkpoints'); }
|
|
54
|
+
|
|
55
|
+
/** Uma pasta por projeto: o nome para a pessoa reconhecer, o hash para dois
|
|
56
|
+
projetos "site" em lugares diferentes não se misturarem. */
|
|
57
|
+
function pastaDoProjeto(projectDir) {
|
|
58
|
+
const abs = path.resolve(projectDir);
|
|
59
|
+
const nome = path.basename(abs).replace(/[^\w.-]+/g, '-').slice(0, 40) || 'raiz';
|
|
60
|
+
const hash = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 8);
|
|
61
|
+
return path.join(raiz(), `${nome}-${hash}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Pasta que merece foto. A casa da pessoa e o ~/primocode sem projeto
|
|
65
|
+
ficam de fora: fotografar o disco inteiro a cada pedido seria lento,
|
|
66
|
+
encheria o ~/.primocode e desfaria coisas que não são do pedido. */
|
|
67
|
+
function podeFotografar(projectDir) {
|
|
68
|
+
if (!projectDir || !foto.temGit()) return false;
|
|
69
|
+
const abs = path.resolve(projectDir);
|
|
70
|
+
const casa = path.resolve(os.homedir());
|
|
71
|
+
if (abs === casa || abs === path.join(casa, 'primocode') || abs === path.parse(abs).root) return false;
|
|
72
|
+
try { return fs.statSync(abs).isDirectory(); } catch { return false; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Uma pasta de checkpoint nova. O id é sempre maior que o anterior: a
|
|
76
|
+
ordem dos ids É a ordem dos pedidos. */
|
|
77
|
+
function novoCheckpoint(projectDir, extra) {
|
|
78
|
+
const base = pastaDoProjeto(projectDir);
|
|
79
|
+
fs.mkdirSync(base, { recursive: true });
|
|
80
|
+
const ultimo = fs.readdirSync(base).filter((n) => /^\d+$/.test(n)).reduce((m, n) => Math.max(m, Number(n)), 0);
|
|
81
|
+
const dir = path.join(base, String(Math.max(Date.now(), ultimo + 1)));
|
|
82
|
+
fs.mkdirSync(dir);
|
|
83
|
+
const manifest = { pedido: atual.pedido, quando: new Date().toISOString(),
|
|
84
|
+
projectDir: path.resolve(projectDir), ...extra, arquivos: [] };
|
|
85
|
+
const cp = { dir, manifest };
|
|
86
|
+
gravarManifest(cp);
|
|
87
|
+
podar(base);
|
|
88
|
+
return cp;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function gravarManifest(cp) {
|
|
92
|
+
fs.writeFileSync(path.join(cp.dir, 'manifest.json'), JSON.stringify(cp.manifest, null, 2));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Começa o checkpoint de um pedido. Com git e uma pasta de projeto, a foto
|
|
96
|
+
é tirada AGORA — antes de qualquer tool rodar. Sem git (ou sem pasta),
|
|
97
|
+
nada vai ao disco ainda: pedido que não mexe em arquivo não deixa rastro. */
|
|
98
|
+
function iniciar(pedido, projectDir) {
|
|
99
|
+
atual = { pedido: String(pedido || '').replace(/\s+/g, ' ').trim(), git: null, arq: null };
|
|
100
|
+
if (!podeFotografar(projectDir)) return;
|
|
101
|
+
try {
|
|
102
|
+
const abs = path.resolve(projectDir);
|
|
103
|
+
const gitDir = path.join(pastaDoProjeto(abs), 'git');
|
|
104
|
+
const sha = foto.fotografar(gitDir, abs, atual.pedido);
|
|
105
|
+
if (sha) atual.git = { projectDir: abs, gitDir, ...novoCheckpoint(abs, { git: { sha } }) };
|
|
106
|
+
} catch { /* sem foto, o checkpoint por arquivo segura o pedido */ }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Fecha o pedido. Com foto, anota o que mudou (é o que o `/desfazer lista`
|
|
110
|
+
mostra) — e, se nada mudou, o checkpoint some: uma pergunta não pode
|
|
111
|
+
ocupar o lugar de um pedido que o /desfazer deveria voltar. */
|
|
112
|
+
function encerrar() {
|
|
113
|
+
const g = atual && atual.git;
|
|
114
|
+
atual = null;
|
|
115
|
+
if (!g) return;
|
|
116
|
+
try {
|
|
117
|
+
const m = foto.mudancas(g.gitDir, g.projectDir, g.manifest.git.sha);
|
|
118
|
+
if (!m.length && !g.manifest.arquivos.length) { fs.rmSync(g.dir, { recursive: true, force: true }); return; }
|
|
119
|
+
g.manifest.mudancas = m;
|
|
120
|
+
gravarManifest(g);
|
|
121
|
+
} catch { /* sem a conta, a lista calcula na hora */ }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Guarda o original de um arquivo no checkpoint `cp`, uma vez só: a segunda
|
|
125
|
+
edição do mesmo arquivo não pode sobrescrever o original já editado. */
|
|
126
|
+
function guardarOriginal(cp, abs) {
|
|
127
|
+
if (cp.manifest.arquivos.some((a) => a.caminho === abs)) return;
|
|
128
|
+
const existia = fs.existsSync(abs) && fs.statSync(abs).isFile();
|
|
129
|
+
const item = { caminho: abs, existia };
|
|
130
|
+
if (existia) {
|
|
131
|
+
item.copia = String(cp.manifest.arquivos.length);
|
|
132
|
+
fs.copyFileSync(abs, path.join(cp.dir, item.copia));
|
|
133
|
+
}
|
|
134
|
+
cp.manifest.arquivos.push(item);
|
|
135
|
+
gravarManifest(cp);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Chamado pelo loop ANTES de uma tool de arquivo rodar. Se a foto do pedido
|
|
139
|
+
já cobre o arquivo, não há o que fazer; senão, guarda o original. */
|
|
140
|
+
function antesDeMudar(caminhoAbs, projectDir) {
|
|
141
|
+
if (!atual || !caminhoAbs) return;
|
|
142
|
+
try {
|
|
143
|
+
const abs = path.resolve(caminhoAbs);
|
|
144
|
+
const g = atual.git;
|
|
145
|
+
if (g && (abs + path.sep).startsWith(g.projectDir + path.sep)) {
|
|
146
|
+
if (!foto.ignorado(g.gitDir, g.projectDir, abs)) return;
|
|
147
|
+
guardarOriginal(g, abs); // dist/, .gitignore: fora da foto, mas com volta
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (!atual.arq) atual.arq = novoCheckpoint(projectDir || path.dirname(abs), {});
|
|
151
|
+
guardarOriginal(atual.arq, abs);
|
|
152
|
+
} catch { /* checkpoint nunca derruba o trabalho do agente */ }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function podar(base) {
|
|
156
|
+
const ids = fs.readdirSync(base).filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(b) - Number(a));
|
|
157
|
+
for (const velho of ids.slice(MAXIMO)) fs.rmSync(path.join(base, velho), { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const ACAO_DA_FOTO = { A: 'criado', M: 'mudou', D: 'apagado' };
|
|
161
|
+
|
|
162
|
+
/** O que o pedido mudou, num formato só para os dois jeitos de guardar. */
|
|
163
|
+
function mudancasDe(cp) {
|
|
164
|
+
const vistos = new Map();
|
|
165
|
+
let daFoto = cp.mudancas;
|
|
166
|
+
if (cp.git && !daFoto) {
|
|
167
|
+
// Pedido interrompido (ou o que está rodando): a conta é feita agora.
|
|
168
|
+
try { daFoto = foto.mudancas(path.join(path.dirname(cp.dir), 'git'), cp.projectDir, cp.git.sha); } catch { daFoto = []; }
|
|
169
|
+
}
|
|
170
|
+
for (const m of daFoto || []) vistos.set(path.join(cp.projectDir, m.rel), ACAO_DA_FOTO[m.status] || 'mudou');
|
|
171
|
+
for (const a of cp.arquivos || []) if (!vistos.has(a.caminho)) vistos.set(a.caminho, a.existia ? 'mudou' : 'criado');
|
|
172
|
+
return [...vistos].map(([caminho, acao]) => ({ caminho, acao }));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Os checkpoints do projeto, o mais novo primeiro. Cada um traz `mudou`:
|
|
176
|
+
os arquivos que o pedido criou, mudou ou apagou. */
|
|
177
|
+
function lista(projectDir) {
|
|
178
|
+
const base = pastaDoProjeto(projectDir);
|
|
179
|
+
let ids = [];
|
|
180
|
+
try { ids = fs.readdirSync(base).filter((n) => /^\d+$/.test(n)); } catch { return []; }
|
|
181
|
+
return ids.sort((a, b) => Number(b) - Number(a)).map((id) => {
|
|
182
|
+
try {
|
|
183
|
+
const m = JSON.parse(fs.readFileSync(path.join(base, id, 'manifest.json'), 'utf8'));
|
|
184
|
+
const cp = { id, dir: path.join(base, id), ...m };
|
|
185
|
+
cp.mudou = mudancasDe(cp);
|
|
186
|
+
return cp;
|
|
187
|
+
} catch { return null; }
|
|
188
|
+
}).filter(Boolean);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** O que o /desfazer N vai fazer, para a pessoa ler ANTES de confirmar.
|
|
192
|
+
Com foto, a conta é contra o disco de agora (inclui o que mudou depois do
|
|
193
|
+
pedido, que a restauração também volta). */
|
|
194
|
+
function previa(projectDir, n = 1) {
|
|
195
|
+
const cps = lista(projectDir).slice(0, Math.max(1, n));
|
|
196
|
+
const vistos = new Map();
|
|
197
|
+
const maisVelhoComFoto = [...cps].reverse().find((cp) => cp.git);
|
|
198
|
+
if (maisVelhoComFoto) {
|
|
199
|
+
try {
|
|
200
|
+
for (const m of foto.mudancas(path.join(path.dirname(maisVelhoComFoto.dir), 'git'),
|
|
201
|
+
maisVelhoComFoto.projectDir, maisVelhoComFoto.git.sha)) {
|
|
202
|
+
vistos.set(path.join(maisVelhoComFoto.projectDir, m.rel), m.status === 'A' ? 'apaga (é novo)' : m.status === 'D' ? 'recria (tinha sumido)' : 'volta ao original');
|
|
203
|
+
}
|
|
204
|
+
} catch {}
|
|
205
|
+
}
|
|
206
|
+
for (const cp of cps) for (const a of cp.arquivos || []) {
|
|
207
|
+
if (!vistos.has(a.caminho)) vistos.set(a.caminho, a.existia ? 'volta ao original' : 'apaga (era novo)');
|
|
208
|
+
}
|
|
209
|
+
return [...vistos].map(([caminho, acao]) => ({ caminho, acao }));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Desfaz os N pedidos mais recentes, do mais novo para o mais velho — assim,
|
|
213
|
+
se dois pedidos mexeram no mesmo arquivo, o que sobra é o original do mais
|
|
214
|
+
antigo. Cada checkpoint desfeito é apagado: o próximo /desfazer volta mais
|
|
215
|
+
um pedido, em vez de repetir o mesmo. */
|
|
216
|
+
function desfazer(projectDir, n = 1) {
|
|
217
|
+
const feitos = new Map(); // um arquivo, uma linha: vale a última ação
|
|
218
|
+
const anotar = (caminho, acao, erro) => { feitos.delete(caminho); feitos.set(caminho, erro ? { caminho, acao, erro } : { caminho, acao }); };
|
|
219
|
+
for (const cp of lista(projectDir).slice(0, Math.max(1, n))) {
|
|
220
|
+
if (cp.git) {
|
|
221
|
+
try {
|
|
222
|
+
for (const f of foto.restaurar(path.join(path.dirname(cp.dir), 'git'), cp.projectDir, cp.git.sha)) {
|
|
223
|
+
anotar(path.join(cp.projectDir, f.rel), f.acao, f.erro);
|
|
224
|
+
}
|
|
225
|
+
} catch (e) { anotar(cp.projectDir, 'falhou', e.message); }
|
|
226
|
+
}
|
|
227
|
+
for (const a of cp.arquivos) {
|
|
228
|
+
try {
|
|
229
|
+
if (a.existia) {
|
|
230
|
+
fs.mkdirSync(path.dirname(a.caminho), { recursive: true });
|
|
231
|
+
fs.copyFileSync(path.join(cp.dir, a.copia), a.caminho);
|
|
232
|
+
anotar(a.caminho, 'restaurado');
|
|
233
|
+
} else {
|
|
234
|
+
fs.rmSync(a.caminho, { force: true });
|
|
235
|
+
anotar(a.caminho, 'apagado');
|
|
236
|
+
}
|
|
237
|
+
} catch (e) { anotar(a.caminho, 'falhou', e.message); }
|
|
238
|
+
}
|
|
239
|
+
fs.rmSync(cp.dir, { recursive: true, force: true });
|
|
240
|
+
if (atual && atual.git && atual.git.dir === cp.dir) atual.git = null;
|
|
241
|
+
if (atual && atual.arq && atual.arq.dir === cp.dir) atual.arq = null;
|
|
242
|
+
}
|
|
243
|
+
return [...feitos.values()];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Qual jeito de guardar vale nesta pasta — o /desfazer lista conta à pessoa. */
|
|
247
|
+
function modo(projectDir) { return podeFotografar(projectDir) ? 'foto' : 'arquivo'; }
|
|
248
|
+
|
|
249
|
+
module.exports = { MAXIMO, MUDAM_ARQUIVO, iniciar, encerrar, antesDeMudar, lista, previa, desfazer, modo, pastaDoProjeto };
|