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/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-six.vercel.app",
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-six.vercel.app';
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
- rl.question(query, (answer) => {
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
 
@@ -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-six.vercel.app",
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
- raw = JSON.parse(fs.readFileSync(p, 'utf8'));
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 < Math.min(concurrency, items.length); 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: [{ repoPath, name, id, size, mime_type, updated_at }]
108
- * errors: [{ folderPath, reason }]
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, CONCURRENCY, async (folderPath) => {
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 restringe los nombres de
8
- * archivo/carpeta a un charset acotado. Aquí filtramos ambos casos ANTES de
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
- // Charset permitido por el backend (ver backend/api/files/[projectId].js):
13
- // ^[a-zA-Z0-9._\-\[\]()@]+$ y ni "." ni ".." ni separadores de ruta.
14
- const VALID_NAME = /^[a-zA-Z0-9._\-[\]()@]+$/;
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(name) {
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 (name.includes('/') || name.includes('\\')) {
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 (usá solo A-Z a-z 0-9 . _ - [ ] ( ) @ y sin espacios/acentos)',
72
+ reason: 'caracteres no permitidos (/ \\ : * ? " < > | o bytes de control)',
53
73
  };
54
74
  }
55
- return { ok: true };
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
- VALID_NAME,
149
+ ILLEGAL_CHARS,
150
+ WIN_RESERVED,
151
+ hasControlChars,
122
152
  MAX_NAME_LEN,
123
153
  MAX_FILE_BYTES,
124
154
  MAX_DEPTH,