shilo-cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,19 +6,29 @@ const sync = require('../sync');
6
6
  const remote = require('../remote');
7
7
  const { resolveContext } = require('../context');
8
8
 
9
+ // A partir de cuántos días sin sincronizar se recuerda hacerlo.
10
+ const STALE_DAYS = 30;
11
+
9
12
  /**
10
13
  * shilo status [carpeta] [--project <uuid>] [--base-url <url>] [--short] [--json]
11
14
  *
12
- * Comando de sólo lectura: compara la carpeta local contra el remoto SIN bajar
13
- * contenido y sin escribir nada, ni local ni remoto. Contesta las dos preguntas
14
- * que uno se hace antes de arrancar a trabajar:
15
+ * Compara la carpeta local contra el remoto SIN bajar contenido y sin escribir
16
+ * nada en el servidor. Contesta las preguntas que uno se hace antes de arrancar
17
+ * a trabajar:
15
18
  *
16
19
  * - ¿el remoto tiene cambios que no tengo? (y de quién son)
17
20
  * - ¿tengo cambios locales sin subir?
21
+ * - ¿hay algo mío esperando aprobación?
22
+ *
23
+ * La comparación es por ruta + tamaño (un listado por carpeta, cero descargas)
24
+ * y, cuando el tamaño coincide, por el SHA-256 que el remoto publica en el
25
+ * listado (`sync.refineByHash`): sigue sin bajar nada.
18
26
  *
19
- * La comparación es por ruta + tamaño (un listado por carpeta, cero descargas).
20
- * Dos archivos del mismo tamaño se reportan como "iguales" aunque su contenido
21
- * pudiera diferir: para confirmar byte a byte están `shilo diff` y `push`.
27
+ * Lo único que escribe es `.shilo.json`, y sólo cuando la carpeta coincide
28
+ * entera con la versión actual: ahí anota la fecha y la `base`. Es el caso de
29
+ * una propuesta aprobada en la web, que si no dejaba "Al día" al lado de
30
+ * "última sincronización hace 2 meses" y al push siguiente partiendo de una
31
+ * base vieja.
22
32
  */
23
33
  async function status(args) {
24
34
  const ctx = resolveContext(args);
@@ -52,8 +62,25 @@ async function status(args) {
52
62
  const attribution = await sync.buildAuthorIndex(client, projectId, mgr, { commits: 50 });
53
63
  config.persistCredsIfChanged(cfg, mgr.current);
54
64
 
55
- const me = cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : null;
56
- const cmp = sync.compareTrees(local.files, tree.files);
65
+ // 2b) Propuestas abiertas (best-effort: un servidor viejo no las tiene). De
66
+ // paso dicen quién soy —con token personal la sesión no lo trae—, si soy
67
+ // el dueño, y cuál es la versión actual.
68
+ let listed = null;
69
+ try {
70
+ listed = await client.listProposals(projectId, await mgr.valid(), { state: 'abierta' });
71
+ config.persistCredsIfChanged(cfg, mgr.current);
72
+ } catch (_) {
73
+ listed = null;
74
+ }
75
+ const viewer = (listed && listed.viewer) || null;
76
+
77
+ const me =
78
+ cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : viewer ? viewer.id : null;
79
+ const cmp = sync.refineByHash(sync.compareTrees(local.files, tree.files), {
80
+ dir,
81
+ projectId,
82
+ baseURL,
83
+ });
57
84
 
58
85
  // 3) "Detrás del remoto" = lo que otro subió y yo no tengo igual: archivos
59
86
  // que faltan localmente, o que difieren y cambiaron después de mi lastSync.
@@ -99,6 +126,34 @@ async function status(args) {
99
126
  };
100
127
  };
101
128
 
129
+ // 4b) Propuestas abiertas: las mías (esperan aprobación) y, si soy el dueño,
130
+ // las de los demás (esperan mi decisión).
131
+ const openProposals = listed ? listed.proposals : [];
132
+ const mineOpen = me ? openProposals.filter((p) => p.author_id === me) : [];
133
+ const othersOpen = viewer && viewer.is_owner ? openProposals.filter((p) => p.author_id !== me) : [];
134
+ const projectInfo = (listed && listed.project) || null;
135
+ const owner = projectInfo && projectInfo.owner;
136
+ const ownerName = owner ? owner.display_name || owner.username : null;
137
+ const linkTo = (p) =>
138
+ projectInfo && projectInfo.path
139
+ ? `${baseURL.replace(/\/+$/, '')}${projectInfo.path}?propuesta=${p.id.slice(0, 8)}`
140
+ : null;
141
+
142
+ // 4c) Carpeta idéntica a la versión actual → anotarlo (ver cabecera).
143
+ const aligned = behind.length === 0 && ahead.length === 0 && tree.errors.length === 0;
144
+ let syncedAt = lastSync;
145
+ if (aligned && listed && listed.main) {
146
+ const stale = !savedRemote || savedRemote.base !== listed.main || !lastSync;
147
+ if (stale && remote.touchSync(dir, { projectId, baseURL, base: listed.main })) {
148
+ syncedAt = new Date().toISOString();
149
+ }
150
+ }
151
+
152
+ // 4d) Hace cuánto que esta carpeta no se alinea con el proyecto.
153
+ const daysSinceSync = syncedAt
154
+ ? Math.floor((Date.now() - new Date(syncedAt).getTime()) / 86400000)
155
+ : null;
156
+
102
157
  if (asJson) {
103
158
  console.log(
104
159
  JSON.stringify(
@@ -106,7 +161,9 @@ async function status(args) {
106
161
  projectId,
107
162
  baseURL,
108
163
  dir,
109
- lastSync: lastSync || null,
164
+ lastSync: syncedAt || null,
165
+ pendingProposals: mineOpen.map((p) => ({ id: p.id, title: p.title, createdAt: p.created_at, url: linkTo(p) })),
166
+ awaitingMyDecision: othersOpen.map((p) => ({ id: p.id, title: p.title, url: linkTo(p) })),
110
167
  remoteFiles: tree.files.length,
111
168
  localFiles: local.files.length,
112
169
  behind: behind.map((i) => ({ path: i.repoPath, kind: i.kind, lastCommit: decorate(i) })),
@@ -130,8 +187,8 @@ async function status(args) {
130
187
  }
131
188
 
132
189
  ui.info('');
133
- if (lastSync) {
134
- ui.info(ui.c.dim(`Última sincronización de esta carpeta: ${sync.formatDate(lastSync)}`));
190
+ if (syncedAt) {
191
+ ui.info(ui.c.dim(`Última sincronización de esta carpeta: ${sync.formatDate(syncedAt)}`));
135
192
  } else {
136
193
  ui.info(
137
194
  ui.c.dim(
@@ -157,8 +214,35 @@ async function status(args) {
157
214
  ui.info('');
158
215
  };
159
216
 
217
+ /** Propuestas abiertas: lo primero que hay que saber, antes que la lista de archivos. */
218
+ const reportProposals = () => {
219
+ if (mineOpen.length > 0) {
220
+ const who = viewer && viewer.is_owner ? 'tu aprobación' : ownerName ? `la aprobación de ${ownerName}` : 'aprobación';
221
+ ui.info(ui.c.bold(`Tenés ${mineOpen.length} propuesta(s) esperando ${who}:`));
222
+ for (const p of mineOpen) {
223
+ const link = linkTo(p);
224
+ ui.info(
225
+ ` ${ui.c.yellow('⧗')} ${p.title || p.id.slice(0, 8)} ${ui.c.dim(`— ${sync.formatDate(p.created_at)}`)}` +
226
+ (link ? `\n ${ui.c.cyan(link)}` : '')
227
+ );
228
+ }
229
+ ui.info('');
230
+ }
231
+ if (othersOpen.length > 0) {
232
+ ui.info(ui.c.bold(`${othersOpen.length} propuesta(s) de otras personas esperan tu decisión:`));
233
+ for (const p of othersOpen) {
234
+ const by = p.author ? p.author.display_name || p.author.username : 'alguien';
235
+ const link = linkTo(p);
236
+ ui.info(` ${ui.c.yellow('⧗')} ${p.title || p.id.slice(0, 8)} ${ui.c.dim(`— de ${by}`)}` + (link ? `\n ${ui.c.cyan(link)}` : ''));
237
+ }
238
+ ui.info('');
239
+ }
240
+ };
241
+
242
+ reportProposals();
243
+
160
244
  if (behind.length === 0 && ahead.length === 0) {
161
- ui.success(`Al día: ${cmp.same.length} archivo(s) coinciden con el remoto.`);
245
+ ui.success(`Al día: ${cmp.same.length} archivo(s) coinciden con el proyecto.`);
162
246
  ui.info('');
163
247
  reportIgnored();
164
248
  return 0;
@@ -182,7 +266,10 @@ async function status(args) {
182
266
  }
183
267
 
184
268
  if (ahead.length > 0) {
185
- ui.info(ui.c.bold(`Tenés ${ahead.length} cambio(s) local(es) sin subir:`));
269
+ ui.info(
270
+ ui.c.bold(`Tenés ${ahead.length} cambio(s) local(es) que todavía no son parte del proyecto`) +
271
+ (mineOpen.length > 0 ? ui.c.bold(' (pueden estar en tu propuesta pendiente):') : ui.c.bold(':'))
272
+ );
186
273
  for (const item of ahead) {
187
274
  const mark = item.kind === 'nuevo local' ? ui.c.green('+') : ui.c.yellow('~');
188
275
  ui.info(` ${mark} ${item.repoPath}`);
@@ -194,7 +281,7 @@ async function status(args) {
194
281
 
195
282
  ui.info(
196
283
  ui.c.dim(
197
- ` ${cmp.same.length} sin cambios (por tamaño) ` +
284
+ ` ${cmp.same.length} sin cambios ` +
198
285
  `${local.ignored} ignorados ${local.skipped.length} saltados`
199
286
  )
200
287
  );
@@ -211,10 +298,21 @@ async function status(args) {
211
298
  ui.info(ui.c.dim('Mirá "shilo diff <archivo>" antes de decidir.'));
212
299
  } else if (behind.length > 0) {
213
300
  ui.info(ui.c.dim('Sugerido: "shilo pull --dry-run" para ver qué bajaría.'));
301
+ } else if (mineOpen.length > 0) {
302
+ ui.info(ui.c.dim('Tus cambios ya están guardados: falta que se apruebe la propuesta de arriba.'));
214
303
  } else {
215
304
  ui.info(ui.c.dim('Sugerido: "shilo push --dry-run" para ver qué subiría.'));
216
305
  }
217
306
 
307
+ // Recordatorio: sincronizar seguido da propuestas chicas y fáciles de revisar.
308
+ if (ahead.length > 0 && daysSinceSync !== null && daysSinceSync >= STALE_DAYS) {
309
+ ui.info('');
310
+ ui.warn(
311
+ `Hace ${daysSinceSync} días que esta carpeta no se sincroniza. Subí seguido: ` +
312
+ 'cuanto más chico el cambio, más fácil de revisar y menos choques con lo de otros.'
313
+ );
314
+ }
315
+
218
316
  // Exit code 2 = "estás desactualizado". Útil para encadenar en scripts:
219
317
  // `shilo status || echo "bajá antes de trabajar"`.
220
318
  return behind.length > 0 ? 2 : 0;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `shilo.config.json` del lado del CLI (EP-S2): **espejo** de
3
+ * `backend/utils/projectConfig.js`, que es donde está explicado el porqué.
4
+ * El CLI no puede importar del backend; `backend/utils/__tests__/projectConfig.test.js`
5
+ * corre los dos sobre las mismas entradas para que no se separen.
6
+ *
7
+ * El CLI la lee de la carpeta local: los chequeos (MU-93) se corren antes de
8
+ * proponer, con lo que hay en disco.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const CONFIG_PATH = 'shilo.config.json';
14
+ const UNIONES = ['archivo', 'lineas'];
15
+ const MAX_CHEQUEOS = 10;
16
+ const MAX_COMANDO = 500;
17
+
18
+ /** Los comandos válidos de `chequeos`, anotando en `avisos` lo que se ignora. */
19
+ function parseChequeos(raw, avisos) {
20
+ if (!Array.isArray(raw)) {
21
+ avisos.push('"chequeos" debe ser una lista de comandos');
22
+ return [];
23
+ }
24
+ const out = [];
25
+ for (const cmd of raw) {
26
+ if (typeof cmd !== 'string' || !cmd.trim() || cmd.length > MAX_COMANDO) {
27
+ avisos.push('Se ignoró un chequeo que no es un comando de texto');
28
+ } else if (out.length >= MAX_CHEQUEOS) {
29
+ avisos.push(`Más de ${MAX_CHEQUEOS} chequeos: se ignoran los demás`);
30
+ break;
31
+ } else {
32
+ out.push(cmd.trim());
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ /** El objeto de `shilo.config.json`, o null con el aviso de por qué no. */
39
+ function parseObject(text, avisos) {
40
+ let raw;
41
+ try {
42
+ const clean = String(text);
43
+ // Sin BOM: JSON.parse no lo acepta y algunos editores de Windows lo ponen.
44
+ raw = JSON.parse(clean.charCodeAt(0) === 0xfeff ? clean.slice(1) : clean);
45
+ } catch (err) {
46
+ avisos.push(`${CONFIG_PATH} no es JSON válido: se usan los valores por defecto`);
47
+ return null;
48
+ }
49
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
50
+ avisos.push(`${CONFIG_PATH} debe ser un objeto: se usan los valores por defecto`);
51
+ return null;
52
+ }
53
+ return raw;
54
+ }
55
+
56
+ /**
57
+ * @param {string|null|undefined} text - contenido de `shilo.config.json`
58
+ * @returns {{ union: 'archivo'|'lineas', chequeos: string[], avisos: string[] }}
59
+ */
60
+ function parseProjectConfig(text) {
61
+ const config = { union: 'archivo', chequeos: [], avisos: [] };
62
+ if (text === null || text === undefined || String(text).trim() === '') return config;
63
+
64
+ const raw = parseObject(text, config.avisos);
65
+ if (!raw) return config;
66
+
67
+ if (raw.union !== undefined) {
68
+ if (UNIONES.includes(raw.union)) config.union = raw.union;
69
+ else config.avisos.push(`"union" debe ser ${UNIONES.join(' o ')}: se usa "archivo"`);
70
+ }
71
+ if (raw.chequeos !== undefined) config.chequeos = parseChequeos(raw.chequeos, config.avisos);
72
+
73
+ return config;
74
+ }
75
+
76
+ module.exports = { CONFIG_PATH, UNIONES, MAX_CHEQUEOS, parseProjectConfig };
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Decisiones "esto no es un secreto" que la persona ya tomó en esta carpeta.
5
+ *
6
+ * Vive en `.shilo/secretos-seguros.json` (`.shilo/` está en los ignores por
7
+ * defecto: nunca se sube). Guarda la huella de la LÍNEA (ver
8
+ * `secrets.fingerprintOf`), nunca el valor: si la línea cambia, la huella deja
9
+ * de coincidir y se vuelve a preguntar. Así no hace falta `--allow-secrets` en
10
+ * cada push, y esa bandera —que apaga todos los avisos, también los reales—
11
+ * queda para casos excepcionales.
12
+ *
13
+ * {
14
+ * "version": 1,
15
+ * "seguros": [{ "path": "src/supabase.js", "fingerprint": "<sha256>", "label": "...", "at": "<ISO>" }]
16
+ * }
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const FILE = path.join('.shilo', 'secretos-seguros.json');
23
+
24
+ function load(dir) {
25
+ try {
26
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, FILE), 'utf8'));
27
+ return Array.isArray(raw.seguros) ? raw.seguros : [];
28
+ } catch (_) {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ const keyOf = (p, fp) => `${p}\n${fp}`;
34
+
35
+ /**
36
+ * Separa los hallazgos ya marcados como seguros de los que siguen pendientes.
37
+ * Sólo los de contenido se pueden marcar: un `.env` no se "aprueba", se ignora.
38
+ */
39
+ function partition(dir, findings) {
40
+ const known = new Set(load(dir).map((e) => keyOf(e.path, e.fingerprint)));
41
+ const pending = [];
42
+ const accepted = [];
43
+ for (const f of findings) {
44
+ if (f.kind === 'content' && f.fingerprint && known.has(keyOf(f.relPath, f.fingerprint))) {
45
+ accepted.push(f);
46
+ } else {
47
+ pending.push(f);
48
+ }
49
+ }
50
+ return { pending, accepted };
51
+ }
52
+
53
+ /** Anota hallazgos de contenido como seguros. Devuelve false si no pudo escribir. */
54
+ function remember(dir, findings) {
55
+ const list = load(dir);
56
+ const known = new Set(list.map((e) => keyOf(e.path, e.fingerprint)));
57
+ const at = new Date().toISOString();
58
+ for (const f of findings) {
59
+ if (f.kind !== 'content' || !f.fingerprint) continue;
60
+ const k = keyOf(f.relPath, f.fingerprint);
61
+ if (known.has(k)) continue;
62
+ known.add(k);
63
+ list.push({ path: f.relPath, fingerprint: f.fingerprint, label: f.label, at });
64
+ }
65
+ try {
66
+ fs.mkdirSync(path.join(dir, '.shilo'), { recursive: true });
67
+ fs.writeFileSync(
68
+ path.join(dir, FILE),
69
+ JSON.stringify({ version: 1, seguros: list }, null, 2) + '\n',
70
+ 'utf8'
71
+ );
72
+ return true;
73
+ } catch (_) {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Agrega rutas a `.shiloignore` (lo crea si no existe). Devuelve false si no
80
+ * pudo escribir.
81
+ */
82
+ function addToShiloignore(dir, relPaths) {
83
+ const p = path.join(dir, '.shiloignore');
84
+ try {
85
+ const current = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
86
+ const lines = new Set(current.split(/\r?\n/).map((l) => l.trim()));
87
+ const add = relPaths.map((r) => `/${r}`).filter((r) => !lines.has(r));
88
+ if (add.length === 0) return true;
89
+ const sep = current && !current.endsWith('\n') ? '\n' : '';
90
+ fs.writeFileSync(
91
+ p,
92
+ `${current}${sep}# Agregado por shilo push: posibles contraseñas\n${add.join('\n')}\n`,
93
+ 'utf8'
94
+ );
95
+ return true;
96
+ } catch (_) {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ module.exports = { FILE, load, partition, remember, addToShiloignore };
package/src/secrets.js CHANGED
@@ -17,6 +17,8 @@
17
17
  * NUNCA se imprime el valor detectado, sólo archivo, línea y qué parece ser.
18
18
  */
19
19
 
20
+ const crypto = require('crypto');
21
+
20
22
  // Patrones de nombre (mismo subconjunto glob que .gitignore, sobre el basename).
21
23
  const DENY_NAMES = [
22
24
  '.env',
@@ -75,6 +77,57 @@ function deniedByName(relPath) {
75
77
  return null;
76
78
  }
77
79
 
80
+ const JWT_RE = /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/;
81
+ const JWT_RE_ALL = new RegExp(JWT_RE.source, 'g');
82
+
83
+ /**
84
+ * Qué es un JWT según su payload, sin verificar la firma (no hace falta: sólo
85
+ * se decide si avisar). Supabase pone el rol ahí:
86
+ *
87
+ * - `anon` la clave pública: está hecha para ir en el navegador.
88
+ * - `service_role` la que saltea todas las reglas de acceso: secreta.
89
+ *
90
+ * Devuelve 'public', 'secret' o null (no se pudo leer, u otro JWT).
91
+ */
92
+ function classifyJwt(token) {
93
+ const part = token.split('.')[1];
94
+ if (!part) return null;
95
+ let payload;
96
+ try {
97
+ payload = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
98
+ } catch (_) {
99
+ return null;
100
+ }
101
+ if (!payload || typeof payload !== 'object') return null;
102
+ if (payload.role === 'anon') return 'public';
103
+ if (payload.role === 'service_role') return 'secret';
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * La etiqueta del primer JWT de la línea que valga la pena avisar, o null si
109
+ * todos son claves públicas conocidas.
110
+ */
111
+ function jwtLabel(line) {
112
+ let label = null;
113
+ for (const m of line.matchAll(JWT_RE_ALL)) {
114
+ const kind = classifyJwt(m[0]);
115
+ if (kind === 'public') continue;
116
+ if (kind === 'secret') return 'clave service_role de Supabase (secreta)';
117
+ label = 'token de Supabase / JWT';
118
+ }
119
+ return label;
120
+ }
121
+
122
+ /**
123
+ * Huella de una línea con un posible secreto: permite recordar "esto ya me
124
+ * dijeron que es seguro" sin guardar el valor. Si la línea cambia, la huella
125
+ * cambia y se vuelve a preguntar.
126
+ */
127
+ function fingerprintOf(line) {
128
+ return crypto.createHash('sha256').update(line.trim(), 'utf8').digest('hex');
129
+ }
130
+
78
131
  /**
79
132
  * Formas de credencial reconocibles por su prefijo/estructura. Se apunta a
80
133
  * precisión razonable: prefijos propietarios y claves PEM, no "cualquier string
@@ -88,7 +141,8 @@ const CONTENT_PATTERNS = [
88
141
  { label: 'clave de API de Google', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
89
142
  { label: 'token de Slack', re: /\bxox[abprs]-[0-9A-Za-z-]{10,}/ },
90
143
  { label: 'clave de Stripe', re: /\b[sr]k_live_[0-9A-Za-z]{20,}/ },
91
- { label: 'token de Supabase / JWT', re: /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
144
+ { label: 'clave secreta de Supabase', re: /\bsb_secret_[A-Za-z0-9_-]{20,}/ },
145
+ { label: 'token de Supabase / JWT', re: JWT_RE, jwt: true },
92
146
  { label: 'clave privada PEM', re: /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/ },
93
147
  { label: 'token de acceso personal de Shilo', re: /\bshilo_pat_[A-Za-z0-9_-]{20,}/ },
94
148
  { label: 'contraseña en una URL', re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]{6,}@/i },
@@ -99,7 +153,8 @@ const CONTENT_PATTERNS = [
99
153
  const SCAN_BYTES = 8192;
100
154
 
101
155
  /**
102
- * Escanea un fragmento de texto. Devuelve `[{ line, label }]` — sin el valor.
156
+ * Escanea un fragmento de texto. Devuelve `[{ line, label, fingerprint }]` —
157
+ * sin el valor.
103
158
  */
104
159
  function scanText(text) {
105
160
  const hits = [];
@@ -107,11 +162,14 @@ function scanText(text) {
107
162
  for (let i = 0; i < lines.length; i++) {
108
163
  const line = lines[i];
109
164
  if (line.length > 4000) continue; // minificado/base64: ruido, no señal
110
- for (const { label, re } of CONTENT_PATTERNS) {
111
- if (re.test(line)) {
112
- hits.push({ line: i + 1, label });
113
- break; // un hallazgo por línea alcanza para avisar
114
- }
165
+ for (const { label, re, jwt } of CONTENT_PATTERNS) {
166
+ if (!re.test(line)) continue;
167
+ // La clave `anon` de Supabase es pública por diseño. Avisar por ella
168
+ // empujaba a usar --allow-secrets, que apaga también los avisos reales.
169
+ const finalLabel = jwt ? jwtLabel(line) : label;
170
+ if (!finalLabel) continue;
171
+ hits.push({ line: i + 1, label: finalLabel, fingerprint: fingerprintOf(line) });
172
+ break; // un hallazgo por línea alcanza para avisar
115
173
  }
116
174
  }
117
175
  return hits;
@@ -132,7 +190,13 @@ function inspect(relPath, head) {
132
190
 
133
191
  if (typeof head === 'string' && head) {
134
192
  for (const hit of scanText(head.slice(0, SCAN_BYTES))) {
135
- findings.push({ relPath, kind: 'content', line: hit.line, label: hit.label });
193
+ findings.push({
194
+ relPath,
195
+ kind: 'content',
196
+ line: hit.line,
197
+ label: hit.label,
198
+ fingerprint: hit.fingerprint,
199
+ });
136
200
  }
137
201
  }
138
202
 
@@ -151,7 +215,7 @@ function groupByFile(findings) {
151
215
  }
152
216
  const entry = byPath.get(f.relPath);
153
217
  if (f.kind === 'name') entry.name = f.pattern;
154
- else entry.content.push({ line: f.line, label: f.label });
218
+ else entry.content.push({ line: f.line, label: f.label, fingerprint: f.fingerprint });
155
219
  }
156
220
  return [...byPath.values()];
157
221
  }
@@ -162,6 +226,8 @@ module.exports = {
162
226
  CONTENT_PATTERNS,
163
227
  SCAN_BYTES,
164
228
  deniedByName,
229
+ classifyJwt,
230
+ fingerprintOf,
165
231
  scanText,
166
232
  inspect,
167
233
  groupByFile,
package/src/sync.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * cambios a partir del historial de commits.
7
7
  *
8
8
  * Todo lo de acá es de sólo lectura: ninguna función escribe en disco ni en el
9
- * servidor. La idea es que un `shilo status` pueda contestar "¿estoy
9
+ * servidor (`refineByHash` lee archivos locales, nada más). La idea es que un `shilo status` pueda contestar "¿estoy
10
10
  * desactualizado?" sin bajar contenido.
11
11
  */
12
12
 
@@ -14,6 +14,7 @@ const fs = require('fs');
14
14
  const path = require('path');
15
15
  const config = require('./config');
16
16
  const { walk, makePathMatcher } = require('./walk');
17
+ const pushindex = require('./pushindex');
17
18
 
18
19
  const CONCURRENCY = 8; // pool para el N+1 de listar carpetas (el backend no tiene árbol recursivo)
19
20
  const MAX_CONCURRENCY = 32;
@@ -231,7 +232,7 @@ function walkLocal(dir) {
231
232
  const { files, skipped, ignored } = walk(dir);
232
233
  return {
233
234
  files: files
234
- .map((f) => ({ repoPath: f.relPath, absPath: f.absPath, size: f.size }))
235
+ .map((f) => ({ repoPath: f.relPath, absPath: f.absPath, size: f.size, mtimeMs: f.mtimeMs }))
235
236
  .sort((a, b) => a.repoPath.localeCompare(b.repoPath)),
236
237
  skipped,
237
238
  ignored,
@@ -241,8 +242,8 @@ function walkLocal(dir) {
241
242
  /**
242
243
  * Compara los dos árboles por ruta y tamaño. NO baja contenido: por eso un
243
244
  * archivo con el mismo tamaño en ambos lados cae en `same` aunque su contenido
244
- * pudiera diferir (mismo largo, distinto texto). Esa es la concesión que hace
245
- * que `status` sea barato; `diff` y `push` sí confirman byte a byte.
245
+ * pudiera diferir (mismo largo, distinto texto). `refineByHash` lo confirma
246
+ * por contenido sin bajar nada; `diff` y `push` comparan byte a byte.
246
247
  *
247
248
  * Devuelve:
248
249
  * {
@@ -285,6 +286,52 @@ function compareTrees(localFiles, remoteFiles) {
285
286
  return { onlyLocal, onlyRemote, differing, same };
286
287
  }
287
288
 
289
+ /**
290
+ * Confirma por contenido los `same` de `compareTrees`: cambiar un número por
291
+ * otro deja el archivo del mismo tamaño, y sólo por tamaño pasaba como "sin
292
+ * cambios". El remoto publica el SHA-256 en el listado (`content_hash`), así que
293
+ * esto sigue sin bajar nada: sólo lee archivos locales, y ni eso si el índice
294
+ * del push (`.shilo/index.json`) dice que el archivo está intacto y su hash ya
295
+ * coincidía.
296
+ *
297
+ * Muta y devuelve `cmp`: lo que difiere pasa de `same` a `differing`. Un
298
+ * remoto sin hash (anterior a la migración 010) o un archivo ilegible quedan
299
+ * como estaban.
300
+ */
301
+ function refineByHash(cmp, { dir, projectId, baseURL } = {}) {
302
+ const index = dir ? pushindex.read(dir, { projectId, baseURL }) : { entries: {} };
303
+
304
+ const same = [];
305
+ for (const item of cmp.same) {
306
+ const hash = item.remote && item.remote.content_hash;
307
+ if (!hash) {
308
+ same.push(item);
309
+ continue;
310
+ }
311
+ const { fresh, entry } = pushindex.isFresh(index, {
312
+ relPath: item.repoPath,
313
+ size: item.local.size,
314
+ mtimeMs: item.local.mtimeMs,
315
+ });
316
+ if (fresh && entry && entry.hash === hash) {
317
+ same.push(item);
318
+ continue;
319
+ }
320
+ let localHash;
321
+ try {
322
+ localHash = pushindex.hashContent(fs.readFileSync(item.local.absPath));
323
+ } catch (_) {
324
+ same.push(item);
325
+ continue;
326
+ }
327
+ if (localHash === hash) same.push(item);
328
+ else cmp.differing.push(item);
329
+ }
330
+ cmp.same = same;
331
+ cmp.differing.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
332
+ return cmp;
333
+ }
334
+
288
335
  /**
289
336
  * Baja las últimas páginas del historial con sus archivos y arma un índice
290
337
  * `file_id -> { message, author, date, hash, action }` con el commit MÁS
@@ -413,6 +460,7 @@ module.exports = {
413
460
  // ignores que `walkLocal` aplica al local, sin importar `walk.js` aparte.
414
461
  makePathMatcher,
415
462
  compareTrees,
463
+ refineByHash,
416
464
  buildAuthorIndex,
417
465
  describeAuthor,
418
466
  formatDate,