shilo-cli 0.3.1 → 0.5.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 +74 -2
- package/bin/shilo.js +12 -5
- package/package.json +2 -2
- package/src/ShiloClient.js +134 -7
- package/src/args.js +4 -2
- package/src/commands/init.js +17 -6
- package/src/commands/login.js +14 -3
- package/src/commands/push.js +712 -165
- package/src/config.js +1 -1
- package/src/context.js +1 -1
- package/src/prompt.js +10 -2
- package/src/pushindex.js +151 -0
- package/src/remote.js +3 -2
- package/src/secrets.js +168 -0
- package/src/sync.js +86 -10
- package/src/textguard.js +44 -14
- package/src/token.js +72 -0
- package/src/ui.js +61 -1
- package/src/walk.js +142 -34
package/src/config.js
CHANGED
|
@@ -58,7 +58,7 @@ function decrypt(payload) {
|
|
|
58
58
|
* Estructura en disco:
|
|
59
59
|
* {
|
|
60
60
|
* version: 1,
|
|
61
|
-
* baseURL: "https://shilo
|
|
61
|
+
* baseURL: "https://www.shilo.city",
|
|
62
62
|
* supabaseUrl?: "...", // opcional, habilita refresh de token
|
|
63
63
|
* supabaseAnonKey?: "...", // opcional (valor público)
|
|
64
64
|
* credentials: { iv, data } // cifrado -> { jwt, refreshToken, expiresAt, user }
|
package/src/context.js
CHANGED
|
@@ -17,7 +17,7 @@ const ui = require('./ui');
|
|
|
17
17
|
const sync = require('./sync');
|
|
18
18
|
const { ShiloClient } = require('./ShiloClient');
|
|
19
19
|
|
|
20
|
-
const DEFAULT_BASE_URL = 'https://shilo
|
|
20
|
+
const DEFAULT_BASE_URL = 'https://www.shilo.city';
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Devuelve `{ ok: true, dir, projectId, baseURL, cfg, client, mgr, savedRemote }`
|
package/src/prompt.js
CHANGED
|
@@ -9,10 +9,18 @@ const readline = require('readline');
|
|
|
9
9
|
function question(query) {
|
|
10
10
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
11
11
|
return new Promise((resolve) => {
|
|
12
|
-
|
|
12
|
+
let done = false;
|
|
13
|
+
const finish = (answer) => {
|
|
14
|
+
if (done) return;
|
|
15
|
+
done = true;
|
|
13
16
|
rl.close();
|
|
14
17
|
resolve(answer.trim());
|
|
15
|
-
}
|
|
18
|
+
};
|
|
19
|
+
// Sin esto, stdin cerrado (pipe agotado, CI, `< /dev/null`) deja el callback
|
|
20
|
+
// de `question` sin disparar nunca: la promesa no resuelve y el proceso
|
|
21
|
+
// termina en silencio. Con EOF respondemos vacío y el llamador decide.
|
|
22
|
+
rl.on('close', () => finish(''));
|
|
23
|
+
rl.question(query, finish);
|
|
16
24
|
});
|
|
17
25
|
}
|
|
18
26
|
|
package/src/pushindex.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Índice local de sincronización (`.shilo/index.json`).
|
|
5
|
+
*
|
|
6
|
+
* Guarda, por archivo subido con éxito, el `size` + `mtimeMs` + hash que tenía
|
|
7
|
+
* en el momento del push. En el push siguiente, un archivo cuyo size y mtime
|
|
8
|
+
* coinciden con el índice no cambió localmente: no hace falta leerlo ni pedirle
|
|
9
|
+
* el contenido al servidor para compararlo. Es la misma idea que el index de
|
|
10
|
+
* git, y es lo que convierte un "push sin cambios" de cientos de round-trips a
|
|
11
|
+
* casi ninguno.
|
|
12
|
+
*
|
|
13
|
+
* Dos límites conocidos, asumidos a conciencia:
|
|
14
|
+
*
|
|
15
|
+
* - `mtime` tiene resolución de 1 s en varios sistemas de archivos: un cambio
|
|
16
|
+
* hecho dentro del mismo segundo que el push podría no detectarse. Por eso
|
|
17
|
+
* las entradas escritas en el mismo segundo del push se marcan `racy: true`
|
|
18
|
+
* y en el push siguiente se revalidan por hash en vez de por mtime (mismo
|
|
19
|
+
* truco que usa git).
|
|
20
|
+
* - El índice dice qué subimos nosotros, no qué hay hoy en el servidor. Si
|
|
21
|
+
* alguien edita el archivo en la web dejándolo del mismo tamaño, el índice
|
|
22
|
+
* lo daría por igual. La planificación cruza el índice con el árbol remoto
|
|
23
|
+
* (tamaño + id) para acotar eso; la ventana que queda es la misma que ya
|
|
24
|
+
* acepta `shilo status`.
|
|
25
|
+
*
|
|
26
|
+
* `.shilo/` está en DEFAULT_IGNORES, así que el índice nunca se sube.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const crypto = require('crypto');
|
|
32
|
+
|
|
33
|
+
const INDEX_VERSION = 1;
|
|
34
|
+
const DIR_NAME = '.shilo';
|
|
35
|
+
const FILE_NAME = 'index.json';
|
|
36
|
+
|
|
37
|
+
function indexPath(dir) {
|
|
38
|
+
return path.join(dir, DIR_NAME, FILE_NAME);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** SHA-256 hex de un contenido utf8. */
|
|
42
|
+
function hashContent(content) {
|
|
43
|
+
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Lee el índice de `dir`. Devuelve un índice vacío si no existe, está corrupto,
|
|
48
|
+
* es de otra versión o pertenece a otro proyecto/servidor: en todos esos casos
|
|
49
|
+
* la única consecuencia es que el push replanifica desde cero, que es
|
|
50
|
+
* exactamente el comportamiento anterior.
|
|
51
|
+
*/
|
|
52
|
+
function read(dir, { projectId, baseURL } = {}) {
|
|
53
|
+
const empty = { version: INDEX_VERSION, projectId, baseURL, entries: {} };
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = fs.readFileSync(indexPath(dir), 'utf8');
|
|
57
|
+
} catch (_) {
|
|
58
|
+
return empty;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let data;
|
|
62
|
+
try {
|
|
63
|
+
data = JSON.parse(raw);
|
|
64
|
+
} catch (_) {
|
|
65
|
+
return empty;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!data || data.version !== INDEX_VERSION || !data.entries) return empty;
|
|
69
|
+
if (projectId && data.projectId && data.projectId !== projectId) return empty;
|
|
70
|
+
if (baseURL && data.baseURL && data.baseURL !== baseURL) return empty;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
version: INDEX_VERSION,
|
|
74
|
+
projectId: projectId || data.projectId || null,
|
|
75
|
+
baseURL: baseURL || data.baseURL || null,
|
|
76
|
+
entries: data.entries,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Escribe el índice. Es una optimización, no un dato de verdad: si falla (disco
|
|
82
|
+
* lleno, carpeta de sólo lectura) se devuelve `false` y el push sigue.
|
|
83
|
+
*/
|
|
84
|
+
function write(dir, index) {
|
|
85
|
+
const target = indexPath(dir);
|
|
86
|
+
try {
|
|
87
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
88
|
+
fs.writeFileSync(
|
|
89
|
+
target,
|
|
90
|
+
JSON.stringify({ ...index, version: INDEX_VERSION, updatedAt: new Date().toISOString() }, null, 2),
|
|
91
|
+
'utf8'
|
|
92
|
+
);
|
|
93
|
+
return true;
|
|
94
|
+
} catch (_) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* ¿El archivo local está intacto respecto de lo que registra el índice?
|
|
101
|
+
*
|
|
102
|
+
* `file` es una entrada del walk: { relPath, size, mtimeMs }.
|
|
103
|
+
* Devuelve:
|
|
104
|
+
* { fresh: true, entry } -> no cambió; no hace falta leerlo
|
|
105
|
+
* { fresh: false, entry } -> cambió, o hay que revalidar por hash
|
|
106
|
+
*/
|
|
107
|
+
function isFresh(index, file) {
|
|
108
|
+
const entry = index.entries[file.relPath];
|
|
109
|
+
if (!entry) return { fresh: false, entry: null };
|
|
110
|
+
if (entry.size !== file.size) return { fresh: false, entry };
|
|
111
|
+
// Entrada dudosa (escrita en el mismo segundo que el push): no confiar en el
|
|
112
|
+
// mtime, hay que mirar el contenido.
|
|
113
|
+
if (entry.racy) return { fresh: false, entry };
|
|
114
|
+
if (Math.abs(entry.mtimeMs - file.mtimeMs) > 1) return { fresh: false, entry };
|
|
115
|
+
return { fresh: true, entry };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Registra un archivo recién subido. `pushedAtMs` es el instante del push: si
|
|
120
|
+
* el mtime del archivo cae en ese mismo segundo, la entrada queda marcada como
|
|
121
|
+
* dudosa (ver comentario de cabecera).
|
|
122
|
+
*/
|
|
123
|
+
function record(index, { relPath, size, mtimeMs, hash }, pushedAtMs = Date.now()) {
|
|
124
|
+
index.entries[relPath] = {
|
|
125
|
+
size,
|
|
126
|
+
mtimeMs,
|
|
127
|
+
hash,
|
|
128
|
+
racy: Math.floor(mtimeMs / 1000) >= Math.floor(pushedAtMs / 1000),
|
|
129
|
+
};
|
|
130
|
+
return index;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Saca del índice los archivos que ya no existen localmente. */
|
|
134
|
+
function prune(index, presentPaths) {
|
|
135
|
+
const present = presentPaths instanceof Set ? presentPaths : new Set(presentPaths);
|
|
136
|
+
for (const relPath of Object.keys(index.entries)) {
|
|
137
|
+
if (!present.has(relPath)) delete index.entries[relPath];
|
|
138
|
+
}
|
|
139
|
+
return index;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
INDEX_VERSION,
|
|
144
|
+
indexPath,
|
|
145
|
+
hashContent,
|
|
146
|
+
read,
|
|
147
|
+
write,
|
|
148
|
+
isFresh,
|
|
149
|
+
record,
|
|
150
|
+
prune,
|
|
151
|
+
};
|
package/src/remote.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Estructura en disco:
|
|
11
11
|
* {
|
|
12
12
|
* "projectId": "<uuid>",
|
|
13
|
-
* "baseURL": "https://shilo
|
|
13
|
+
* "baseURL": "https://www.shilo.city",
|
|
14
14
|
* "lastSync": "2026-07-27T12:00:00.000Z"
|
|
15
15
|
* }
|
|
16
16
|
*
|
|
@@ -40,7 +40,8 @@ function read(dir) {
|
|
|
40
40
|
if (!fs.existsSync(p)) return null;
|
|
41
41
|
let raw;
|
|
42
42
|
try {
|
|
43
|
-
|
|
43
|
+
// Notepad y PowerShell escriben UTF-8 con BOM, que `JSON.parse` rechaza.
|
|
44
|
+
raw = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
|
|
44
45
|
} catch (e) {
|
|
45
46
|
throw new Error(`Remote corrupto en ${p}: ${e.message}`);
|
|
46
47
|
}
|
package/src/secrets.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Red de contención contra subir secretos.
|
|
5
|
+
*
|
|
6
|
+
* Dos capas, ambas offline y baratas:
|
|
7
|
+
*
|
|
8
|
+
* 1. Denylist por nombre: archivos que casi nunca deberían vivir en un repo
|
|
9
|
+
* compartido (`.env`, claves privadas, credenciales de service account).
|
|
10
|
+
* 2. Escaneo de contenido sobre los primeros KB de cada archivo de texto,
|
|
11
|
+
* buscando las formas de token más comunes.
|
|
12
|
+
*
|
|
13
|
+
* El criterio de diseño es asimétrico a propósito: un falso positivo cuesta una
|
|
14
|
+
* bandera (`--allow-secrets`) o una línea en `.shiloignore`; un falso negativo
|
|
15
|
+
* cuesta rotar todas las claves de producción. Ante la duda, se avisa.
|
|
16
|
+
*
|
|
17
|
+
* NUNCA se imprime el valor detectado, sólo archivo, línea y qué parece ser.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// Patrones de nombre (mismo subconjunto glob que .gitignore, sobre el basename).
|
|
21
|
+
const DENY_NAMES = [
|
|
22
|
+
'.env',
|
|
23
|
+
'.env.*',
|
|
24
|
+
'*.pem',
|
|
25
|
+
'*.key',
|
|
26
|
+
'*.p12',
|
|
27
|
+
'*.pfx',
|
|
28
|
+
'*.keystore',
|
|
29
|
+
'*.jks',
|
|
30
|
+
'id_rsa*',
|
|
31
|
+
'id_dsa*',
|
|
32
|
+
'id_ecdsa*',
|
|
33
|
+
'id_ed25519*',
|
|
34
|
+
'credentials.json',
|
|
35
|
+
'service-account*.json',
|
|
36
|
+
'serviceaccount*.json',
|
|
37
|
+
'.npmrc',
|
|
38
|
+
'.netrc',
|
|
39
|
+
'.pypirc',
|
|
40
|
+
'.htpasswd',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Excepciones: plantillas pensadas justamente para commitear.
|
|
44
|
+
const DENY_EXCEPTIONS = [
|
|
45
|
+
'.env.example',
|
|
46
|
+
'.env.sample',
|
|
47
|
+
'.env.template',
|
|
48
|
+
'.env.defaults',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
function globToRegExp(glob) {
|
|
52
|
+
let out = '^';
|
|
53
|
+
for (const ch of glob) {
|
|
54
|
+
if (ch === '*') out += '.*';
|
|
55
|
+
else if (ch === '?') out += '.';
|
|
56
|
+
else if ('\\^$.|+()[]{}'.includes(ch)) out += '\\' + ch;
|
|
57
|
+
else out += ch;
|
|
58
|
+
}
|
|
59
|
+
return new RegExp(out + '$', 'i');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const DENY_RES = DENY_NAMES.map(globToRegExp);
|
|
63
|
+
const EXCEPTION_RES = DENY_EXCEPTIONS.map(globToRegExp);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* ¿El nombre del archivo está en la denylist? Devuelve `null` o el patrón que
|
|
67
|
+
* lo capturó (para poder explicarlo).
|
|
68
|
+
*/
|
|
69
|
+
function deniedByName(relPath) {
|
|
70
|
+
const name = relPath.split('/').pop();
|
|
71
|
+
if (EXCEPTION_RES.some((re) => re.test(name))) return null;
|
|
72
|
+
for (let i = 0; i < DENY_RES.length; i++) {
|
|
73
|
+
if (DENY_RES[i].test(name)) return DENY_NAMES[i];
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Formas de credencial reconocibles por su prefijo/estructura. Se apunta a
|
|
80
|
+
* precisión razonable: prefijos propietarios y claves PEM, no "cualquier string
|
|
81
|
+
* largo", que dispararía en cada hash del repo.
|
|
82
|
+
*/
|
|
83
|
+
const CONTENT_PATTERNS = [
|
|
84
|
+
{ label: 'clave de API de Anthropic', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
85
|
+
{ label: 'clave de API de OpenAI', re: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}/ },
|
|
86
|
+
{ label: 'token de GitHub', re: /\bgh[pousr]_[A-Za-z0-9]{36,}/ },
|
|
87
|
+
{ label: 'access key de AWS', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
88
|
+
{ label: 'clave de API de Google', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
89
|
+
{ label: 'token de Slack', re: /\bxox[abprs]-[0-9A-Za-z-]{10,}/ },
|
|
90
|
+
{ 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,}/ },
|
|
92
|
+
{ label: 'clave privada PEM', re: /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/ },
|
|
93
|
+
{ label: 'token de acceso personal de Shilo', re: /\bshilo_pat_[A-Za-z0-9_-]{20,}/ },
|
|
94
|
+
{ label: 'contraseña en una URL', re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]{6,}@/i },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
// Cuánto se mira de cada archivo. Los secretos viven arriba (config, headers),
|
|
98
|
+
// y acotarlo mantiene el escaneo en costo despreciable.
|
|
99
|
+
const SCAN_BYTES = 8192;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Escanea un fragmento de texto. Devuelve `[{ line, label }]` — sin el valor.
|
|
103
|
+
*/
|
|
104
|
+
function scanText(text) {
|
|
105
|
+
const hits = [];
|
|
106
|
+
const lines = text.split('\n');
|
|
107
|
+
for (let i = 0; i < lines.length; i++) {
|
|
108
|
+
const line = lines[i];
|
|
109
|
+
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
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return hits;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Inspecciona un archivo ya leído (o su cabecera) y devuelve los hallazgos.
|
|
122
|
+
*
|
|
123
|
+
* inspect('.env', head) ->
|
|
124
|
+
* [{ relPath, kind: 'name', pattern: '.env' },
|
|
125
|
+
* { relPath, kind: 'content', line: 4, label: '...' }]
|
|
126
|
+
*/
|
|
127
|
+
function inspect(relPath, head) {
|
|
128
|
+
const findings = [];
|
|
129
|
+
|
|
130
|
+
const pattern = deniedByName(relPath);
|
|
131
|
+
if (pattern) findings.push({ relPath, kind: 'name', pattern });
|
|
132
|
+
|
|
133
|
+
if (typeof head === 'string' && head) {
|
|
134
|
+
for (const hit of scanText(head.slice(0, SCAN_BYTES))) {
|
|
135
|
+
findings.push({ relPath, kind: 'content', line: hit.line, label: hit.label });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return findings;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Agrupa hallazgos por archivo, preservando el orden de aparición.
|
|
144
|
+
* Devuelve `[{ relPath, name: <patrón|null>, content: [{ line, label }] }]`.
|
|
145
|
+
*/
|
|
146
|
+
function groupByFile(findings) {
|
|
147
|
+
const byPath = new Map();
|
|
148
|
+
for (const f of findings) {
|
|
149
|
+
if (!byPath.has(f.relPath)) {
|
|
150
|
+
byPath.set(f.relPath, { relPath: f.relPath, name: null, content: [] });
|
|
151
|
+
}
|
|
152
|
+
const entry = byPath.get(f.relPath);
|
|
153
|
+
if (f.kind === 'name') entry.name = f.pattern;
|
|
154
|
+
else entry.content.push({ line: f.line, label: f.label });
|
|
155
|
+
}
|
|
156
|
+
return [...byPath.values()];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
DENY_NAMES,
|
|
161
|
+
DENY_EXCEPTIONS,
|
|
162
|
+
CONTENT_PATTERNS,
|
|
163
|
+
SCAN_BYTES,
|
|
164
|
+
deniedByName,
|
|
165
|
+
scanText,
|
|
166
|
+
inspect,
|
|
167
|
+
groupByFile,
|
|
168
|
+
};
|
package/src/sync.js
CHANGED
|
@@ -16,6 +16,43 @@ const config = require('./config');
|
|
|
16
16
|
const { walk } = require('./walk');
|
|
17
17
|
|
|
18
18
|
const CONCURRENCY = 8; // pool para el N+1 de listar carpetas (el backend no tiene árbol recursivo)
|
|
19
|
+
const MAX_CONCURRENCY = 32;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Concurrencia efectiva: `--concurrency <n>` pisa a `SHILO_CONCURRENCY`, que
|
|
23
|
+
* pisa al default. Un valor inválido no rompe el comando, cae al default.
|
|
24
|
+
* El default (8) es conservador en fibra y agresivo en una conexión mala; esto
|
|
25
|
+
* deja que quien sabe cuál tiene, lo diga.
|
|
26
|
+
*/
|
|
27
|
+
function resolveConcurrency(args = {}) {
|
|
28
|
+
const raw = args.concurrency != null ? args.concurrency : process.env.SHILO_CONCURRENCY;
|
|
29
|
+
const n = Number(raw);
|
|
30
|
+
if (!Number.isFinite(n) || n < 1) return CONCURRENCY;
|
|
31
|
+
return Math.min(Math.floor(n), MAX_CONCURRENCY);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Control de concurrencia que puede bajar sola ante un 429. `runPool` la
|
|
36
|
+
* consulta antes de tomar cada ítem, así que el ajuste tiene efecto dentro de
|
|
37
|
+
* la misma tanda, no recién en la siguiente.
|
|
38
|
+
*/
|
|
39
|
+
function makeThrottle(initial) {
|
|
40
|
+
let limit = initial;
|
|
41
|
+
let warned = false;
|
|
42
|
+
return {
|
|
43
|
+
get limit() {
|
|
44
|
+
return limit;
|
|
45
|
+
},
|
|
46
|
+
/** Devuelve true la primera vez, para que el caller avise una sola vez. */
|
|
47
|
+
reduce() {
|
|
48
|
+
const before = limit;
|
|
49
|
+
limit = Math.max(1, Math.floor(limit / 2));
|
|
50
|
+
const first = !warned && limit < before;
|
|
51
|
+
if (first) warned = true;
|
|
52
|
+
return first;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
19
56
|
|
|
20
57
|
// Mime types que sabemos que vuelven como texto legible. El backend siempre
|
|
21
58
|
// devuelve el contenido vía `data.text()`, así que un binario (p.ej. image/png
|
|
@@ -76,44 +113,72 @@ function makeCredsManager(client, cfg) {
|
|
|
76
113
|
* Corre `worker(item)` sobre `items` con concurrencia limitada. Preserva el
|
|
77
114
|
* orden de `items` en el array de resultados. Un worker que lanza deja el hueco
|
|
78
115
|
* como `{ __error: <msg>, item }` para que el caller decida.
|
|
116
|
+
*
|
|
117
|
+
* `concurrency` puede ser un número o un throttle de `makeThrottle()`: en ese
|
|
118
|
+
* caso el límite se relee antes de tomar cada ítem, y los workers sobrantes se
|
|
119
|
+
* retiran solos si el límite bajó (p. ej. tras un 429).
|
|
120
|
+
*
|
|
121
|
+
* `onProgress(done, total)` se llama después de cada ítem terminado.
|
|
79
122
|
*/
|
|
80
|
-
async function runPool(items, concurrency, worker) {
|
|
123
|
+
async function runPool(items, concurrency, worker, { onProgress = null } = {}) {
|
|
81
124
|
const results = new Array(items.length);
|
|
125
|
+
const throttle = typeof concurrency === 'number' ? { limit: concurrency } : concurrency;
|
|
126
|
+
const initial = Math.min(throttle.limit, items.length);
|
|
82
127
|
let next = 0;
|
|
128
|
+
let active = 0;
|
|
129
|
+
let done = 0;
|
|
83
130
|
|
|
84
131
|
async function drain() {
|
|
132
|
+
active++;
|
|
85
133
|
while (next < items.length) {
|
|
134
|
+
// Si el límite bajó mientras corríamos, este worker se retira.
|
|
135
|
+
if (active > throttle.limit) break;
|
|
86
136
|
const i = next++;
|
|
87
137
|
try {
|
|
88
138
|
results[i] = await worker(items[i], i);
|
|
89
139
|
} catch (e) {
|
|
90
140
|
results[i] = { __error: e.message, item: items[i] };
|
|
91
141
|
}
|
|
142
|
+
done++;
|
|
143
|
+
if (onProgress) onProgress(done, items.length);
|
|
92
144
|
}
|
|
145
|
+
active--;
|
|
93
146
|
}
|
|
94
147
|
|
|
95
148
|
const runners = [];
|
|
96
|
-
for (let k = 0; k <
|
|
97
|
-
runners.push(drain());
|
|
98
|
-
}
|
|
149
|
+
for (let k = 0; k < initial; k++) runners.push(drain());
|
|
99
150
|
await Promise.all(runners);
|
|
151
|
+
|
|
152
|
+
// Si todos los workers se retiraron por un recorte de concurrencia pero
|
|
153
|
+
// quedaron ítems, se completa con un pase al límite vigente.
|
|
154
|
+
while (next < items.length) {
|
|
155
|
+
const rest = [];
|
|
156
|
+
for (let k = 0; k < Math.max(1, throttle.limit); k++) rest.push(drain());
|
|
157
|
+
await Promise.all(rest);
|
|
158
|
+
}
|
|
159
|
+
|
|
100
160
|
return results;
|
|
101
161
|
}
|
|
102
162
|
|
|
103
163
|
/**
|
|
104
164
|
* Recorre el árbol remoto de un proyecto listando carpeta por carpeta (BFS con
|
|
105
165
|
* pool). El backend no expone un endpoint recursivo, así que es N+1: un request
|
|
106
|
-
* por carpeta, pero ninguno baja contenido. Devuelve `{ files, errors }`:
|
|
107
|
-
* files:
|
|
108
|
-
*
|
|
166
|
+
* por carpeta, pero ninguno baja contenido. Devuelve `{ files, folders, errors }`:
|
|
167
|
+
* files: [{ repoPath, name, id, size, mime_type, content_hash, storage_path, updated_at }]
|
|
168
|
+
* folders: Set<repoPath> — lo que ya existe, para no re-crearlo en el push
|
|
169
|
+
* errors: [{ folderPath, reason }]
|
|
170
|
+
*
|
|
171
|
+
* Un solo recorrido acá reemplaza un `getFileMeta` por archivo en el push: para
|
|
172
|
+
* un repo de 142 archivos en 30 carpetas, 142 requests pasan a 30.
|
|
109
173
|
*/
|
|
110
|
-
async function walkRemote(client, projectId, mgr) {
|
|
174
|
+
async function walkRemote(client, projectId, mgr, { concurrency = CONCURRENCY } = {}) {
|
|
111
175
|
const files = [];
|
|
176
|
+
const folders = new Set();
|
|
112
177
|
const errors = [];
|
|
113
178
|
let frontier = ['']; // rutas de carpeta a listar; '' = raíz
|
|
114
179
|
|
|
115
180
|
while (frontier.length > 0) {
|
|
116
|
-
const listed = await runPool(frontier,
|
|
181
|
+
const listed = await runPool(frontier, concurrency, async (folderPath) => {
|
|
117
182
|
const creds = await mgr.valid();
|
|
118
183
|
const { files: entries } = await client.listFiles(projectId, creds, folderPath);
|
|
119
184
|
return { folderPath, entries };
|
|
@@ -129,6 +194,7 @@ async function walkRemote(client, projectId, mgr) {
|
|
|
129
194
|
for (const entry of entries) {
|
|
130
195
|
const repoPath = folderPath ? `${folderPath}/${entry.name}` : entry.name;
|
|
131
196
|
if (entry.type === 'folder') {
|
|
197
|
+
folders.add(repoPath);
|
|
132
198
|
nextFrontier.push(repoPath);
|
|
133
199
|
} else {
|
|
134
200
|
files.push({
|
|
@@ -137,6 +203,13 @@ async function walkRemote(client, projectId, mgr) {
|
|
|
137
203
|
id: entry.id,
|
|
138
204
|
size: entry.size,
|
|
139
205
|
mime_type: entry.mime_type,
|
|
206
|
+
// content_hash y storage_path vienen en el listado desde la
|
|
207
|
+
// migración 010: con el hash se decide "cambió / no cambió" sin
|
|
208
|
+
// descargar el archivo, y con el storage_path se arma el commit sin
|
|
209
|
+
// pedir la metadata de nuevo. En archivos anteriores a 010 el hash
|
|
210
|
+
// es null y el llamador cae al camino viejo.
|
|
211
|
+
content_hash: entry.content_hash || null,
|
|
212
|
+
storage_path: entry.storage_path || null,
|
|
140
213
|
updated_at: entry.updated_at || entry.created_at || null,
|
|
141
214
|
});
|
|
142
215
|
}
|
|
@@ -146,7 +219,7 @@ async function walkRemote(client, projectId, mgr) {
|
|
|
146
219
|
}
|
|
147
220
|
|
|
148
221
|
files.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
|
|
149
|
-
return { files, errors };
|
|
222
|
+
return { files, folders, errors };
|
|
150
223
|
}
|
|
151
224
|
|
|
152
225
|
/**
|
|
@@ -328,6 +401,9 @@ function toRepoPath(dir, userPath) {
|
|
|
328
401
|
|
|
329
402
|
module.exports = {
|
|
330
403
|
CONCURRENCY,
|
|
404
|
+
MAX_CONCURRENCY,
|
|
405
|
+
resolveConcurrency,
|
|
406
|
+
makeThrottle,
|
|
331
407
|
isTextMime,
|
|
332
408
|
makeCredsManager,
|
|
333
409
|
runPool,
|
package/src/textguard.js
CHANGED
|
@@ -4,14 +4,20 @@
|
|
|
4
4
|
* Validación de nombres y detección de binarios para el push.
|
|
5
5
|
*
|
|
6
6
|
* Shilo sólo acepta contenido de texto (el endpoint path-based sube el body
|
|
7
|
-
* como utf8, así que un binario se corrompería) y
|
|
8
|
-
* archivo/carpeta
|
|
9
|
-
* tocar la red, para poder avisar y saltar.
|
|
7
|
+
* como utf8, así que un binario se corrompería) y rechaza los nombres de
|
|
8
|
+
* archivo/carpeta que rompen una ruta o no son portables. Aquí filtramos ambos
|
|
9
|
+
* casos ANTES de tocar la red, para poder avisar y saltar.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
12
|
+
// Espejo de `backend/utils/filenames.js`: lista negra, no lista blanca. El
|
|
13
|
+
// backend acepta cualquier carácter que no rompa una ruta —acentos, espacios,
|
|
14
|
+
// kanji—, así que restringir acá sólo hacía que un repo perfectamente válido
|
|
15
|
+
// se subiera incompleto.
|
|
16
|
+
const ILLEGAL_CHARS = /[<>:"/\\|?*]/;
|
|
17
|
+
|
|
18
|
+
// Nombres de dispositivo reservados de Windows, con o sin extensión.
|
|
19
|
+
const WIN_RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
|
|
20
|
+
|
|
15
21
|
const MAX_NAME_LEN = 255;
|
|
16
22
|
const MAX_FILE_BYTES = 26214400; // 25 MB, igual que el backend
|
|
17
23
|
const MAX_DEPTH = 10; // profundidad máxima de carpetas
|
|
@@ -32,27 +38,49 @@ const BINARY_EXTENSIONS = new Set([
|
|
|
32
38
|
'ttf', 'otf', 'woff', 'woff2', 'eot', 'psd', 'ai', 'sketch', 'db', 'sqlite',
|
|
33
39
|
]);
|
|
34
40
|
|
|
41
|
+
// Los bytes de control se chequean por código y no con un rango en el regex:
|
|
42
|
+
// escritos literalmente el archivo deja de ser texto plano para git y grep, y
|
|
43
|
+
// escritos como escape son invisibles al leer el código. Un bucle es explícito.
|
|
44
|
+
function hasControlChars(str) {
|
|
45
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
46
|
+
const code = str.charCodeAt(i);
|
|
47
|
+
if (code < 32 || code === 127) return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
/**
|
|
36
53
|
* Valida un nombre individual (archivo o carpeta) contra las reglas de Shilo.
|
|
54
|
+
* Espejo de `validateFileName` en `backend/utils/filenames.js`: mismos rechazos,
|
|
55
|
+
* mismo orden, con el mensaje en el idioma del CLI.
|
|
37
56
|
* Devuelve `{ ok: true }` o `{ ok: false, reason }`.
|
|
38
57
|
*/
|
|
39
|
-
function validateName(
|
|
58
|
+
function validateName(rawName) {
|
|
59
|
+
// El backend guarda el nombre normalizado y sin espacios al borde: validamos
|
|
60
|
+
// sobre esa misma forma para no aceptar acá algo que allá se transforma.
|
|
61
|
+
const name = typeof rawName === 'string' ? rawName.normalize('NFC').trim() : '';
|
|
62
|
+
|
|
40
63
|
if (!name || name === '.' || name === '..') {
|
|
41
64
|
return { ok: false, reason: 'nombre reservado (. o ..)' };
|
|
42
65
|
}
|
|
43
66
|
if (name.length > MAX_NAME_LEN) {
|
|
44
67
|
return { ok: false, reason: `nombre demasiado largo (>${MAX_NAME_LEN})` };
|
|
45
68
|
}
|
|
46
|
-
if (
|
|
47
|
-
return { ok: false, reason: 'contiene separador de ruta' };
|
|
48
|
-
}
|
|
49
|
-
if (!VALID_NAME.test(name)) {
|
|
69
|
+
if (ILLEGAL_CHARS.test(name) || hasControlChars(name)) {
|
|
50
70
|
return {
|
|
51
71
|
ok: false,
|
|
52
|
-
reason: 'caracteres no permitidos (
|
|
72
|
+
reason: 'caracteres no permitidos (/ \\ : * ? " < > | o bytes de control)',
|
|
53
73
|
};
|
|
54
74
|
}
|
|
55
|
-
|
|
75
|
+
if (WIN_RESERVED.test(name)) {
|
|
76
|
+
return { ok: false, reason: 'nombre de dispositivo reservado en Windows (CON, NUL, COM1…)' };
|
|
77
|
+
}
|
|
78
|
+
// Windows descarta el punto final al crear el archivo, así que "informe." y
|
|
79
|
+
// "informe" terminarían siendo el mismo archivo en disco.
|
|
80
|
+
if (name.endsWith('.')) {
|
|
81
|
+
return { ok: false, reason: 'no puede terminar en punto' };
|
|
82
|
+
}
|
|
83
|
+
return { ok: true, name };
|
|
56
84
|
}
|
|
57
85
|
|
|
58
86
|
/**
|
|
@@ -118,7 +146,9 @@ function classifyContent(name, buffer) {
|
|
|
118
146
|
}
|
|
119
147
|
|
|
120
148
|
module.exports = {
|
|
121
|
-
|
|
149
|
+
ILLEGAL_CHARS,
|
|
150
|
+
WIN_RESERVED,
|
|
151
|
+
hasControlChars,
|
|
122
152
|
MAX_NAME_LEN,
|
|
123
153
|
MAX_FILE_BYTES,
|
|
124
154
|
MAX_DEPTH,
|