shilo-cli 0.7.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.
@@ -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,14 +6,15 @@
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
 
13
13
  const fs = require('fs');
14
14
  const path = require('path');
15
15
  const config = require('./config');
16
- const { walk } = require('./walk');
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
@@ -409,7 +456,11 @@ module.exports = {
409
456
  runPool,
410
457
  walkRemote,
411
458
  walkLocal,
459
+ // Re-exportado para que los comandos filtren el árbol REMOTO con los mismos
460
+ // ignores que `walkLocal` aplica al local, sin importar `walk.js` aparte.
461
+ makePathMatcher,
412
462
  compareTrees,
463
+ refineByHash,
413
464
  buildAuthorIndex,
414
465
  describeAuthor,
415
466
  formatDate,
package/src/walk.js CHANGED
@@ -195,6 +195,35 @@ function loadIgnoreRules(rootDir, extraLines = []) {
195
195
  return rules;
196
196
  }
197
197
 
198
+ /**
199
+ * Matcher de ignores por RUTA DE REPO, sin tocar disco.
200
+ *
201
+ * `walk()` decide los ignores mientras recorre el árbol local, así que sólo
202
+ * contesta por lo que existe acá. Esto contesta la otra mitad: dada una ruta
203
+ * que vino del REMOTO, ¿estaría ignorada localmente? Sin esto los ignores valen
204
+ * en un solo sentido —el push— y el árbol remoto nunca se filtra: `status`
205
+ * cuenta como "atrás" archivos que jamás va a tener y `pull` baja cosas
206
+ * ignoradas.
207
+ *
208
+ * Sólo carga las reglas de la raíz: un ignore anidado vive dentro de una
209
+ * carpeta que, si el archivo sólo existe en el remoto, puede no estar en disco.
210
+ * Es una aproximación conservadora — ignora de menos, nunca de más.
211
+ */
212
+ function makePathMatcher(rootDir, { extraIgnore = [] } = {}) {
213
+ const isIgnored = makeMatcher(loadIgnoreRules(rootDir, extraIgnore));
214
+ return function isIgnoredPath(repoPath) {
215
+ const parts = String(repoPath).split('/');
216
+ // Cada ancestro se consulta como CARPETA: `makeMatcher` saltea las reglas
217
+ // `dirOnly` cuando `isDir` es false, así que preguntar derecho por
218
+ // `.claude/settings.local.json` daría false y la regla `.claude/` no
219
+ // alcanzaría nunca a lo de adentro.
220
+ for (let i = 0; i < parts.length - 1; i++) {
221
+ if (isIgnored(parts.slice(0, i + 1).join('/'), true)) return true;
222
+ }
223
+ return isIgnored(repoPath, false);
224
+ };
225
+ }
226
+
198
227
  /**
199
228
  * Recorre `rootDir` y devuelve:
200
229
  * {
@@ -312,6 +341,7 @@ module.exports = {
312
341
  loadIgnoreRules,
313
342
  loadDirRules,
314
343
  makeMatcher,
344
+ makePathMatcher,
315
345
  compileRule,
316
346
  globToRegExp,
317
347
  looksLikeGitDir,