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.
- package/README.md +19 -0
- package/bin/shilo.js +15 -1
- package/package.json +1 -1
- package/src/ShiloClient.js +45 -6
- package/src/checks.js +57 -0
- package/src/commands/deploys.js +105 -0
- package/src/commands/diff.js +1 -1
- package/src/commands/init.js +8 -0
- package/src/commands/push.js +436 -62
- package/src/commands/status.js +112 -14
- package/src/projectconfig.js +76 -0
- package/src/safelist.js +101 -0
- package/src/secrets.js +75 -9
- package/src/sync.js +52 -4
package/src/commands/status.js
CHANGED
|
@@ -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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
56
|
-
|
|
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:
|
|
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 (
|
|
134
|
-
ui.info(ui.c.dim(`Última sincronización de esta carpeta: ${sync.formatDate(
|
|
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
|
|
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(
|
|
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
|
|
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 };
|
package/src/safelist.js
ADDED
|
@@ -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: '
|
|
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 }]` —
|
|
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
|
-
|
|
113
|
-
|
|
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({
|
|
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).
|
|
245
|
-
*
|
|
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,
|