shilo-cli 0.5.0 → 0.6.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/bin/shilo.js CHANGED
@@ -138,9 +138,28 @@ async function main() {
138
138
  return handler(commandArgs);
139
139
  }
140
140
 
141
+ /**
142
+ * Termina con `code` sin llamar a `process.exit()` de inmediato.
143
+ *
144
+ * Llamarlo justo después de un `fetch` aborta el proceso en Windows con
145
+ * `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`: libuv cierra dos
146
+ * veces el handle del pool de conexiones de undici. El comando ya imprimió todo
147
+ * y hasta guardó en disco, pero el shell recibe 127 en vez de 0 — o sea que
148
+ * cualquier script o CI que chequee el resultado de `shilo push` lee basura.
149
+ *
150
+ * Dejar que el loop se vacíe solo evita el crash y no cuesta nada: las conexiones
151
+ * de undici no tienen ref, así que el proceso sale igual de rápido. El timer es
152
+ * el paracaídas por si algo queda colgado; sin ref, no mantiene vivo al proceso
153
+ * por sí mismo, sólo actúa si ya lo estaba.
154
+ */
155
+ function finish(code) {
156
+ process.exitCode = code;
157
+ setTimeout(() => process.exit(code), 5000).unref();
158
+ }
159
+
141
160
  main()
142
- .then((code) => process.exit(typeof code === 'number' ? code : 0))
161
+ .then((code) => finish(typeof code === 'number' ? code : 0))
143
162
  .catch((err) => {
144
163
  ui.error(err && err.message ? err.message : String(err));
145
- process.exit(1);
164
+ finish(1);
146
165
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shilo-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "CLI para sincronizar carpetas locales con repositorios de Shilo: push, pull, status, log y diff.",
5
5
  "keywords": [
6
6
  "shilo",
@@ -89,7 +89,7 @@ class ShiloClient {
89
89
  }
90
90
 
91
91
  /** Un intento único, sin reintentos. */
92
- async _fetchOnce(url, { method = 'GET', headers = {}, body = null } = {}) {
92
+ async _fetchOnce(url, { method = 'GET', headers = {}, body = null, raw = false } = {}) {
93
93
  const controller = new AbortController();
94
94
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
95
95
  let res;
@@ -97,7 +97,11 @@ class ShiloClient {
97
97
  res = await fetch(url, {
98
98
  method,
99
99
  headers,
100
- body: body != null ? JSON.stringify(body) : undefined,
100
+ // Un Buffer va tal cual: es el cuerpo de una subida a URL firmada, y
101
+ // pasarlo por JSON.stringify lo convertiría en el texto "{\"type\":
102
+ // \"Buffer\",\"data\":[...]}", que es exactamente la corrupción
103
+ // silenciosa que la subida directa vino a evitar.
104
+ body: body == null ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body),
101
105
  signal: controller.signal,
102
106
  });
103
107
  } catch (err) {
@@ -111,6 +115,14 @@ class ShiloClient {
111
115
  }
112
116
  clearTimeout(timer);
113
117
 
118
+ // `raw`: la respuesta son bytes (un binario bajado de una URL firmada) y
119
+ // leerla como texto la decodificaría como UTF-8, que es destruirla. Los
120
+ // errores tampoco se parsean acá: con `raw` el llamador mira `res.status`.
121
+ if (raw) {
122
+ const buffer = res.ok ? Buffer.from(await res.arrayBuffer()) : null;
123
+ return { res, data: buffer };
124
+ }
125
+
114
126
  const text = await res.text();
115
127
  let data = null;
116
128
  if (text) {
@@ -307,7 +319,7 @@ class ShiloClient {
307
319
  async _authFetch(url, credentials, { method = 'GET', body = null } = {}) {
308
320
  const validCreds = await this.ensureValidToken(credentials);
309
321
  const headers = { Authorization: `Bearer ${validCreds.jwt}` };
310
- if (body != null) headers['Content-Type'] = 'application/json';
322
+ if (body != null && !Buffer.isBuffer(body)) headers['Content-Type'] = 'application/json';
311
323
  const { res, data } = await this._fetch(url, { method, headers, body });
312
324
  return { res, data, credentials: validCreds };
313
325
  }
@@ -366,7 +378,18 @@ class ShiloClient {
366
378
  }
367
379
  const file = data && data.file ? data.file : {};
368
380
  const content = data && data.content !== undefined ? data.content : null;
369
- return { file: { ...file, content }, credentials: creds };
381
+ // Un binario no viene con contenido: viene con una URL firmada para bajarlo
382
+ // directo de Storage (ver `downloadBinary`). Los dos campos se pegan al
383
+ // `file` para que el llamador decida con un solo objeto en la mano.
384
+ return {
385
+ file: {
386
+ ...file,
387
+ content,
388
+ is_binary: Boolean(data && data.is_binary),
389
+ download_url: (data && data.download_url) || null,
390
+ },
391
+ credentials: creds,
392
+ };
370
393
  }
371
394
 
372
395
  /**
@@ -459,6 +482,113 @@ class ShiloClient {
459
482
  return { results, summary, credentials: creds };
460
483
  }
461
484
 
485
+ /**
486
+ * Baja los bytes de una URL firmada. Devuelve un Buffer.
487
+ *
488
+ * No lleva `Authorization`: la firma ES la autorización, y mandar el JWT a una
489
+ * URL de Storage sería filtrarlo a un origen que no lo necesita.
490
+ */
491
+ async downloadBinary(signedUrl) {
492
+ const { res, data } = await this._fetch(signedUrl, { method: 'GET', raw: true });
493
+ if (!res.ok || !data) {
494
+ throw new ShiloError(`No se pudo descargar el contenido: HTTP ${res.status}`, {
495
+ status: res.status,
496
+ });
497
+ }
498
+ return data;
499
+ }
500
+
501
+ /**
502
+ * Sube un archivo binario: URL firmada, tres pasos.
503
+ *
504
+ * El contenido NO puede viajar por `writeBatch` ni por `writeFile`: esos
505
+ * mandan el archivo como string dentro de un JSON, y el backend lo hashea con
506
+ * `Buffer.from(content, 'utf8')`. Un binario se corrompe ahí, ANTES de
507
+ * hashearse, y el grafo registra el resultado corrupto como válido — no hay
508
+ * verificador que lo note, porque para el motor no pasó nada raro. Por eso el
509
+ * byte va directo a Storage y nunca entra en un payload JSON.
510
+ *
511
+ * Los tres pasos:
512
+ * 1. `begin` — el servidor firma una URL contra la ruta por contenido del
513
+ * blob. Si ese contenido ya está en el proyecto responde
514
+ * `exists: true` y no hay nada que transferir: el archivo ya
515
+ * está ahí, aunque sea con otro nombre.
516
+ * 2. PUT — los bytes, directo a Storage. No pasa por la función
517
+ * serverless, así que el límite de payload de Vercel deja de
518
+ * aplicar.
519
+ * 3. `commit` — el servidor verifica y apunta la fila de `files` al blob.
520
+ *
521
+ * `entry`: { path, buffer, mimeType }.
522
+ * Devuelve `{ result, uploaded, credentials }`, donde `result` tiene la misma
523
+ * forma que un elemento de `writeBatch` (`ok`, `action`, `file`) y `uploaded`
524
+ * dice si hubo bytes en el aire.
525
+ */
526
+ async uploadBinary(projectId, entry, credentials) {
527
+ const url = `${this.baseURL}/api/files/${projectId}/direct`;
528
+ const payload = {
529
+ path: entry.path,
530
+ size: entry.buffer.length,
531
+ content_hash: entry.contentHash,
532
+ mime_type: entry.mimeType || 'application/octet-stream',
533
+ };
534
+
535
+ const begun = await this._authFetch(url, credentials, {
536
+ method: 'POST',
537
+ body: { action: 'begin', ...payload },
538
+ });
539
+
540
+ if (!begun.res.ok) {
541
+ throw new ShiloError(
542
+ `No se pudo preparar la subida de "${entry.path}": ${this._errMessage(begun.data, `HTTP ${begun.res.status}`)}`,
543
+ {
544
+ status: begun.res.status,
545
+ body: begun.data,
546
+ notSupported: begun.res.status === 404 || begun.res.status === 405,
547
+ }
548
+ );
549
+ }
550
+
551
+ let uploaded = false;
552
+ const upload = begun.data && begun.data.upload;
553
+
554
+ if (upload && upload.url) {
555
+ const { res } = await this._fetch(upload.url, {
556
+ method: 'PUT',
557
+ headers: {
558
+ 'Content-Type': entry.mimeType || 'application/octet-stream',
559
+ // `x-upsert: false` es el default de una URL firmada de subida y se
560
+ // deja explícito: si el objeto ya existiera, pisarlo reemplazaría el
561
+ // contenido de un blob conservando su identidad, que es justo el
562
+ // ataque que el direccionamiento por contenido tiene que impedir.
563
+ 'x-upsert': 'false',
564
+ },
565
+ body: entry.buffer,
566
+ });
567
+
568
+ if (!res.ok) {
569
+ throw new ShiloError(
570
+ `No se pudo subir el contenido de "${entry.path}": HTTP ${res.status}`,
571
+ { status: res.status }
572
+ );
573
+ }
574
+ uploaded = true;
575
+ }
576
+
577
+ const done = await this._authFetch(url, begun.credentials, {
578
+ method: 'POST',
579
+ body: { action: 'commit', ...payload },
580
+ });
581
+
582
+ if (!done.res.ok) {
583
+ throw new ShiloError(
584
+ `No se pudo registrar "${entry.path}": ${this._errMessage(done.data, `HTTP ${done.res.status}`)}`,
585
+ { status: done.res.status, body: done.data }
586
+ );
587
+ }
588
+
589
+ return { result: done.data, uploaded, credentials: done.credentials };
590
+ }
591
+
462
592
  /**
463
593
  * Crea una carpeta bajo `parentPath` (vacío = raíz). Un 409 significa que ya
464
594
  * existe: no es fatal, devolvemos `{ folder, existed: true }`.
@@ -30,6 +30,16 @@ function safeTarget(destDir, repoPath) {
30
30
  return { ok: true, target };
31
31
  }
32
32
 
33
+ /** Los bytes de un archivo local, o null si no existe o no se puede leer. */
34
+ function readLocalBytes(absPath) {
35
+ try {
36
+ if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) return null;
37
+ return fs.readFileSync(absPath);
38
+ } catch (_) {
39
+ return null;
40
+ }
41
+ }
42
+
33
43
  /**
34
44
  * shilo pull [carpeta] [--project <uuid>] [--base-url <url>] [--dry-run] [--yes] [--force]
35
45
  *
@@ -72,9 +82,13 @@ async function pull(args) {
72
82
  ui.warn(`no se pudo listar ${ui.c.dim(err.folderPath || '(raíz)')} — ${err.reason}`);
73
83
  }
74
84
 
75
- // 1) Separar texto (descargable) de lo que no se puede bajar sin corromper.
85
+ // 1) Descartar sólo lo que no se puede escribir con seguridad.
86
+ //
87
+ // Los binarios ya NO se saltean (Etapa 2 del PLAN_MOTOR_SHILO): el GET
88
+ // devuelve una URL firmada en vez de contenido, y se bajan como bytes. Lo
89
+ // que se saltaba antes no era una limitación del formato sino del camino:
90
+ // el contenido viajaba como texto y un PNG volvía mutilado.
76
91
  const candidates = [];
77
- const skippedBinary = [];
78
92
  const unsafe = [];
79
93
  for (const f of tree.files) {
80
94
  const safe = safeTarget(dir, f.repoPath);
@@ -82,15 +96,12 @@ async function pull(args) {
82
96
  unsafe.push({ repoPath: f.repoPath, reason: safe.reason });
83
97
  continue;
84
98
  }
85
- if (!sync.isTextMime(f.mime_type)) {
86
- skippedBinary.push({ repoPath: f.repoPath, reason: `mime no textual (${f.mime_type})` });
87
- continue;
88
- }
89
- candidates.push({ ...f, target: safe.target });
99
+ candidates.push({ ...f, target: safe.target, binary: !sync.isTextMime(f.mime_type) });
90
100
  }
91
101
 
92
102
  for (const u of unsafe) ui.warn(`saltado ${ui.c.dim(u.repoPath)} — ${u.reason}`);
93
- for (const s of skippedBinary) ui.warn(`saltado ${ui.c.dim(s.repoPath)} — ${s.reason}`);
103
+
104
+ const binaryCount = candidates.filter((f) => f.binary).length;
94
105
 
95
106
  // 2) Clasificar contra lo que hay en disco, sin bajar nada todavía.
96
107
  // `maybeSame` (mismo tamaño) se confirma recién al descargar el contenido.
@@ -116,7 +127,7 @@ async function pull(args) {
116
127
  ` ${ui.c.yellow('en conflicto:')} ${conflicting.length} ${ui.c.dim('(difieren en tamaño del local)')}\n` +
117
128
  ` ${ui.c.dim('posiblemente iguales:')} ${maybeSame.length}\n` +
118
129
  ` ${ui.c.dim('sólo locales:')} ${onlyLocal.length} ${ui.c.dim('(el pull no los toca)')}` +
119
- (skippedBinary.length ? `\n ${ui.c.dim('binarios saltados:')} ${skippedBinary.length}` : '') +
130
+ (binaryCount ? `\n ${ui.c.dim('binarios:')} ${binaryCount}` : '') +
120
131
  (unsafe.length ? `\n ${ui.c.red('inseguros:')} ${unsafe.length}` : '')
121
132
  );
122
133
  ui.info('');
@@ -186,6 +197,23 @@ async function pull(args) {
186
197
  const outcomes = await sync.runPool(toDownload, CONCURRENCY, async (f) => {
187
198
  const creds = await mgr.valid();
188
199
  const read = await client.readFile(projectId, f.repoPath, creds);
200
+
201
+ // Binario: el servidor no manda contenido, manda una URL firmada. Los bytes
202
+ // se escriben tal cual, sin encoding: pasarlos por 'utf8' es exactamente lo
203
+ // que corrompía los binarios antes de esta etapa.
204
+ if (read.file && read.file.is_binary && read.file.download_url) {
205
+ const bytes = await client.downloadBinary(read.file.download_url);
206
+
207
+ const currentBytes = readLocalBytes(f.target);
208
+ if (currentBytes !== null && currentBytes.equals(bytes)) {
209
+ return { repoPath: f.repoPath, skipped: true };
210
+ }
211
+
212
+ fs.mkdirSync(path.dirname(f.target), { recursive: true });
213
+ fs.writeFileSync(f.target, bytes);
214
+ return { repoPath: f.repoPath, skipped: false };
215
+ }
216
+
189
217
  const content = read.file && read.file.content != null ? read.file.content : '';
190
218
 
191
219
  const current = sync.readLocalText(f.target);
@@ -13,6 +13,7 @@ const { walk } = require('../walk');
13
13
  const {
14
14
  classifyContent,
15
15
  looksBinary,
16
+ binaryMimeType,
16
17
  extensionOf,
17
18
  BINARY_EXTENSIONS,
18
19
  MAX_FILE_BYTES,
@@ -54,7 +55,9 @@ const SKIP_LABELS = {
54
55
  symlink: 'symlinks (no soportados)',
55
56
  name: 'nombre no permitido por Shilo',
56
57
  depth: 'demasiado anidados',
57
- binary: 'binarios o mayores a 25 MB',
58
+ // Los binarios ya NO se saltean: desde la Etapa 2 suben por URL firmada. Este
59
+ // motivo quedó sólo para lo que supera el techo de tamaño.
60
+ size: `mayores a ${MAX_FILE_BYTES / (1024 * 1024)} MB`,
58
61
  };
59
62
 
60
63
  /** Lee como mucho `n` bytes del principio del archivo. */
@@ -78,6 +81,12 @@ function readHead(absPath, n) {
78
81
  *
79
82
  * Sólo se leen los primeros KB de cada archivo; el contenido completo se lee
80
83
  * recién cuando se sabe que ese archivo se sube.
84
+ *
85
+ * Desde la Etapa 2 esta fase ya no descarta binarios: los MARCA. Un archivo con
86
+ * `binary: true` sube por URL firmada (`client.uploadBinary`) en vez de por el
87
+ * lote JSON, porque el lote hashea el contenido como utf8 y ahí un binario se
88
+ * corrompe antes de que nadie pueda notarlo. Lo único que sigue siendo motivo de
89
+ * salteo es superar el techo de tamaño.
81
90
  */
82
91
  function preflight(dir) {
83
92
  const { files, skipped, ignored, gitDirs } = walk(dir);
@@ -89,41 +98,43 @@ function preflight(dir) {
89
98
  for (const f of files) {
90
99
  const name = path.basename(f.relPath);
91
100
 
92
- const ext = extensionOf(name);
93
- if (ext && BINARY_EXTENSIONS.has(ext)) {
94
- skips.push({ relPath: f.relPath, reason: `extensión binaria .${ext}`, code: 'binary' });
95
- continue;
96
- }
97
101
  if (f.size > MAX_FILE_BYTES) {
98
- skips.push({ relPath: f.relPath, reason: `supera 25 MB (${f.size} bytes)`, code: 'binary' });
99
- continue;
100
- }
101
-
102
- let head;
103
- try {
104
- head = readHead(f.absPath, HEAD_BYTES);
105
- } catch (e) {
106
- skips.push({ relPath: f.relPath, reason: `no se pudo leer: ${e.message}`, code: 'unreadable' });
107
- continue;
108
- }
109
-
110
- if (looksBinary(head)) {
111
102
  skips.push({
112
103
  relPath: f.relPath,
113
- reason: 'contenido binario (bytes no imprimibles)',
114
- code: 'binary',
104
+ reason: `supera ${MAX_FILE_BYTES / (1024 * 1024)} MB (${f.size} bytes)`,
105
+ code: 'size',
115
106
  });
116
107
  continue;
117
108
  }
118
109
 
119
- findings.push(...secrets.inspect(f.relPath, head.toString('utf8')));
110
+ const ext = extensionOf(name);
111
+ let binary = Boolean(ext && BINARY_EXTENSIONS.has(ext));
112
+ let head = null;
113
+
114
+ // Un archivo cuya extensión ya dice que es binario no hace falta leerlo para
115
+ // saberlo, y leerlo sólo serviría para escanearle secretos — que en un JPEG
116
+ // son ruido.
117
+ if (!binary) {
118
+ try {
119
+ head = readHead(f.absPath, HEAD_BYTES);
120
+ } catch (e) {
121
+ skips.push({ relPath: f.relPath, reason: `no se pudo leer: ${e.message}`, code: 'unreadable' });
122
+ continue;
123
+ }
124
+ binary = looksBinary(head);
125
+ }
126
+
127
+ if (!binary) {
128
+ findings.push(...secrets.inspect(f.relPath, head.toString('utf8')));
129
+ }
120
130
 
121
131
  uploads.push({
122
132
  relPath: f.relPath,
123
133
  absPath: f.absPath,
124
134
  size: f.size,
125
135
  mtimeMs: f.mtimeMs,
126
- mimeType: guessMime(f.relPath),
136
+ binary,
137
+ mimeType: binary ? binaryMimeType(name) : guessMime(f.relPath),
127
138
  content: null,
128
139
  });
129
140
  }
@@ -301,7 +312,7 @@ async function push(args) {
301
312
  }
302
313
 
303
314
  if (uploads.length === 0) {
304
- ui.info('No hay archivos de texto para subir.');
315
+ ui.info('No hay archivos para subir.');
305
316
  ui.info(ui.c.dim(`(${ignored} ignorados, ${skipped.length} salteados)`));
306
317
  return 0;
307
318
  }
@@ -323,7 +334,8 @@ async function push(args) {
323
334
 
324
335
  ui.info(
325
336
  `Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — ` +
326
- `${uploads.length} archivo(s) de texto, ${ignored} ignorado(s), ` +
337
+ `${uploads.length} archivo(s) (${uploads.filter((u) => u.binary).length} binario(s)), ` +
338
+ `${ignored} ignorado(s), ` +
327
339
  `${skipped.length} salteado(s).`
328
340
  );
329
341
 
@@ -457,6 +469,19 @@ async function push(args) {
457
469
  }
458
470
 
459
471
  const content = await loadContent(u);
472
+
473
+ // Un binario no se compara bajando el contenido: el GET devuelve una URL
474
+ // firmada, no bytes, justamente para no arrastrar el archivo por el
475
+ // payload de la función. Sin `content_hash` remoto no queda con qué
476
+ // comparar, así que se asume modificado. El servidor deduplica: si el
477
+ // contenido es el mismo, el blob ya existe y no se transfiere un byte.
478
+ if (u.binary) {
479
+ const creds = await mgr.valid();
480
+ const meta = await client.getFileMeta(projectId, u.relPath, creds);
481
+ if (!meta.exists) return { kind: 'added', u: { ...u, content } };
482
+ return { kind: 'modified', u: { ...u, content, remote: meta.file } };
483
+ }
484
+
460
485
  const creds = await mgr.valid();
461
486
  const read = await client.readFile(projectId, u.relPath, creds);
462
487
  if (read.file && read.file.content === content) {
@@ -666,13 +691,23 @@ async function push(args) {
666
691
 
667
692
  // Empaquetado por cantidad Y por tamaño. Un archivo más grande que el
668
693
  // presupuesto termina solo en su lote, y ahí se sube por el endpoint
669
- // individual, que admite hasta 25 MB.
694
+ // individual.
695
+ //
696
+ // Los lotes son HOMOGÉNEOS: o todo texto, o todo binario. Los dos grupos se
697
+ // escriben por caminos distintos —lote JSON contra URL firmada— y un lote
698
+ // mezclado obligaría a partirlo en el momento de escribir, justo donde después
699
+ // hay que volver a juntar los cambios para un solo commit. Separarlos acá
700
+ // deja esa parte sin ramas.
701
+ //
702
+ // El presupuesto de bytes no se le aplica a los binarios: su contenido nunca
703
+ // entra en el payload de un request, así que un lote de diez PDF de 30 MB son
704
+ // diez subidas directas y ningún JSON grande.
670
705
  const batches = [];
671
- {
706
+ for (const group of [toUpload.filter((e) => !e.u.binary), toUpload.filter((e) => e.u.binary)]) {
672
707
  let current = [];
673
708
  let currentBytes = 0;
674
- for (const e of toUpload) {
675
- const bytes = typeof e.u.size === 'number' ? e.u.size : 0;
709
+ for (const e of group) {
710
+ const bytes = e.u.binary ? 0 : typeof e.u.size === 'number' ? e.u.size : 0;
676
711
  const full =
677
712
  current.length >= BATCH_MAX_FILES || currentBytes + bytes > BATCH_MAX_BYTES;
678
713
  if (current.length > 0 && full) {
@@ -719,11 +754,63 @@ async function push(args) {
719
754
  // faltara caemos a lo que sabíamos del árbol remoto.
720
755
  change.previous_storage_path =
721
756
  f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
757
+ // El hash del contenido que estamos reemplazando lo sabe únicamente el
758
+ // cliente: cuando el commit llega al servidor, `files.content_hash` ya
759
+ // fue pisado con el contenido nuevo. Puede ser null en archivos
760
+ // anteriores a la migración 010.
761
+ if (e.u.remote && e.u.remote.content_hash) {
762
+ change.previous_content_hash = e.u.remote.content_hash;
763
+ }
722
764
  }
723
765
  return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
724
766
  });
725
767
  }
726
768
 
769
+ /**
770
+ * Camino de los binarios: URL firmada, un archivo a la vez y en paralelo.
771
+ *
772
+ * No se agrupan en un request como el texto porque no hay nada que agrupar:
773
+ * cada byte va directo a Storage. Lo que sí se comparte es el control de
774
+ * ritmo, que es el que evita que veinte subidas de 30 MB salgan juntas.
775
+ *
776
+ * El endpoint crea las carpetas intermedias por su cuenta, igual que el lote,
777
+ * así que acá no hace falta `ensureFolders()`.
778
+ */
779
+ async function writeViaDirect(entries) {
780
+ const results = await sync.runPool(entries, throttle, async (e) => {
781
+ const creds = await mgr.valid();
782
+ const { result } = await client.uploadBinary(
783
+ projectId,
784
+ {
785
+ path: e.u.relPath,
786
+ buffer: e.content,
787
+ contentHash: pushindex.hashContent(e.content),
788
+ mimeType: e.u.mimeType,
789
+ },
790
+ creds
791
+ );
792
+
793
+ const f = (result && result.file) || {};
794
+ const action = result && result.action === 'created' ? 'added' : 'modified';
795
+ const change = { file_id: f.id, action, new_storage_path: f.storage_path };
796
+
797
+ if (action === 'modified') {
798
+ change.previous_storage_path =
799
+ f.previous_storage_path || (e.u.remote && e.u.remote.storage_path) || f.storage_path;
800
+ // El hash de lo que estamos reemplazando: el servidor lo lee antes de
801
+ // pisar la fila, que es el único momento en que existe. Si no vino, lo
802
+ // sabemos por el árbol remoto.
803
+ const previous =
804
+ f.previous_content_hash || (e.u.remote && e.u.remote.content_hash) || null;
805
+ if (previous) change.previous_content_hash = previous;
806
+ }
807
+
808
+ return { ok: true, change, symbol: action === 'added' ? '+' : '~' };
809
+ });
810
+
811
+ return results.map((r) => (r && r.__error ? { ok: false, reason: r.__error } : r));
812
+ }
813
+
727
814
  /** Camino archivo-por-archivo: server sin /batch, o archivo demasiado grande. */
728
815
  async function writeViaSingle(entries) {
729
816
  await ensureFolders();
@@ -780,6 +867,10 @@ async function push(args) {
780
867
  action: 'modified',
781
868
  previous_storage_path: u.remote ? u.remote.storage_path : upd.file.storage_path,
782
869
  new_storage_path: upd.file.storage_path,
870
+ // Ver la nota en writeViaBatch: el hash previo sólo existe acá.
871
+ ...(u.remote && u.remote.content_hash
872
+ ? { previous_content_hash: u.remote.content_hash }
873
+ : {}),
783
874
  },
784
875
  };
785
876
  });
@@ -811,32 +902,50 @@ async function push(args) {
811
902
  }
812
903
  if (loaded.length === 0) continue;
813
904
 
814
- const oversize =
815
- loaded.length === 1 && Buffer.byteLength(loaded[0].content, 'utf8') > BATCH_MAX_BYTES;
816
- let outcomes = null;
905
+ // El lote se armó con lo que dijo el preflight, que sólo miró los primeros
906
+ // 8 KB. `loadContent` acaba de leer el archivo entero y pudo reclasificarlo,
907
+ // así que la partición se rehace ACÁ, sobre el contenido de verdad. Mandar
908
+ // un Buffer por el lote JSON lo convertiría en `{"type":"Buffer",...}`, que
909
+ // es la corrupción silenciosa que toda esta etapa existe para evitar.
910
+ const binaries = loaded.filter((e) => e.u.binary);
911
+ const texts = loaded.filter((e) => !e.u.binary);
912
+
913
+ const outcomeByEntry = new Map();
914
+
915
+ if (binaries.length > 0) {
916
+ // Un fallo acá es por archivo, no del lote: `writeViaDirect` devuelve un
917
+ // resultado por entrada y los errores ya vienen adentro.
918
+ const res = await writeViaDirect(binaries);
919
+ binaries.forEach((e, i) => outcomeByEntry.set(e, res[i]));
920
+ }
817
921
 
818
- if (batchSupported && !oversize) {
819
- try {
820
- outcomes = await writeViaBatch(loaded);
821
- } catch (err) {
822
- if (err.notSupported) {
823
- batchSupported = false;
824
- upProg.clear();
825
- ui.warn('El servidor no acepta escritura por lotes; se sube archivo por archivo.');
826
- } else {
827
- // Falla del request entero (red, 5xx, permisos): ningún archivo del
828
- // lote se escribió.
829
- for (const e of loaded) {
830
- failedWrites.push({ relPath: e.u.relPath, reason: err.message });
831
- logError(upProg, `${e.u.relPath} — ${err.message}`);
922
+ if (texts.length > 0) {
923
+ const oversize =
924
+ texts.length === 1 && Buffer.byteLength(texts[0].content, 'utf8') > BATCH_MAX_BYTES;
925
+ let res = null;
926
+
927
+ if (batchSupported && !oversize) {
928
+ try {
929
+ res = await writeViaBatch(texts);
930
+ } catch (err) {
931
+ if (err.notSupported) {
932
+ batchSupported = false;
933
+ upProg.clear();
934
+ ui.warn('El servidor no acepta escritura por lotes; se sube archivo por archivo.');
935
+ } else {
936
+ // Falla del request entero (red, 5xx, permisos): ningún archivo de
937
+ // texto del lote se escribió. Los binarios ya se resolvieron aparte
938
+ // y no los arrastra.
939
+ res = texts.map(() => ({ ok: false, reason: err.message }));
832
940
  }
833
- tickUp(loaded.length);
834
- continue;
835
941
  }
836
942
  }
943
+
944
+ if (!res) res = await writeViaSingle(texts);
945
+ texts.forEach((e, i) => outcomeByEntry.set(e, res[i]));
837
946
  }
838
947
 
839
- if (!outcomes) outcomes = await writeViaSingle(loaded);
948
+ const outcomes = loaded.map((e) => outcomeByEntry.get(e));
840
949
  config.persistCredsIfChanged(cfg, mgr.current);
841
950
 
842
951
  const batchChanges = [];
@@ -923,13 +1032,32 @@ async function push(args) {
923
1032
 
924
1033
  /**
925
1034
  * Lee el contenido completo de un archivo, cacheado en la propia entrada.
926
- * Re-valida que siga siendo texto: entre el preflight y la subida pudo cambiar.
1035
+ *
1036
+ * Devuelve un `string` para texto y un `Buffer` para binarios. Esa diferencia
1037
+ * es la que decide por qué camino sube el archivo, así que se re-clasifica acá y
1038
+ * no se confía en lo que dijo el preflight: entre una cosa y la otra el archivo
1039
+ * pudo cambiar, y el preflight sólo miró los primeros 8 KB.
1040
+ *
1041
+ * `u.binary` y `u.mimeType` se corrigen con lo que digan los bytes completos.
1042
+ * Un archivo que el preflight leyó como texto y resultó binario tiene que
1043
+ * cambiar de camino, no subirse mal por el que ya tenía asignado.
927
1044
  */
928
1045
  async function loadContent(u) {
929
1046
  if (u.content != null) return u.content;
1047
+
930
1048
  const buf = await fs.promises.readFile(u.absPath);
931
1049
  const cls = classifyContent(path.basename(u.relPath), buf);
1050
+
1051
+ if (cls.isBinary) {
1052
+ u.binary = true;
1053
+ u.mimeType = cls.mimeType;
1054
+ u.content = cls.buffer;
1055
+ return u.content;
1056
+ }
1057
+
932
1058
  if (!cls.isText) throw new Error(cls.reason);
1059
+
1060
+ u.binary = false;
933
1061
  u.content = cls.content;
934
1062
  return u.content;
935
1063
  }
package/src/pushindex.js CHANGED
@@ -40,7 +40,12 @@ function indexPath(dir) {
40
40
 
41
41
  /** SHA-256 hex de un contenido utf8. */
42
42
  function hashContent(content) {
43
- return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
43
+ // Un binario llega como Buffer y un archivo de texto como string. Se hashean
44
+ // los BYTES en los dos casos: el hash tiene que ser el mismo que calcula el
45
+ // backend sobre el contenido crudo (`files.content_hash`, migración 010), o
46
+ // el push reportaría "modificado" en cada corrida.
47
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8');
48
+ return crypto.createHash('sha256').update(buf).digest('hex');
44
49
  }
45
50
 
46
51
  /**
package/src/textguard.js CHANGED
@@ -1,12 +1,24 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Validación de nombres y detección de binarios para el push.
4
+ * Validación de nombres y clasificación de contenido para el push.
5
5
  *
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 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.
6
+ * Hasta la Etapa 2 del PLAN_MOTOR_SHILO, Shilo sólo aceptaba texto: el endpoint
7
+ * por ruta sube el body como utf8, así que un binario se corrompía. Ahora los
8
+ * binarios SÍ se suben, pero por otro camino —`POST /api/files/:id/direct`, con
9
+ * URL firmada, sin pasar por JSON— y por eso este módulo ya no los rechaza:
10
+ * los CLASIFICA, para que el push sepa por dónde mandar cada archivo.
11
+ *
12
+ * Lo que sigue rechazando, y no cambió: los nombres que rompen una ruta o no son
13
+ * portables, y los archivos que superan el techo de tamaño. El escaneo de
14
+ * secretos también se queda; vive en `secrets.js` y sólo corre sobre texto,
15
+ * porque buscar patrones de credenciales en un JPEG es ruido.
16
+ *
17
+ * Espejo de `backend/utils/binary.js`: mismas constantes, misma heurística. Son
18
+ * dos paquetes sin dependencia entre sí (el CLI se publica solo y no tiene un
19
+ * solo `dependencies`), así que la duplicación es deliberada — pero si cambia
20
+ * una tiene que cambiar la otra, o un archivo se clasifica de un modo en el
21
+ * cliente y de otro en el servidor.
10
22
  */
11
23
 
12
24
  // Espejo de `backend/utils/filenames.js`: lista negra, no lista blanca. El
@@ -19,7 +31,9 @@ const ILLEGAL_CHARS = /[<>:"/\\|?*]/;
19
31
  const WIN_RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
20
32
 
21
33
  const MAX_NAME_LEN = 255;
22
- const MAX_FILE_BYTES = 26214400; // 25 MB, igual que el backend
34
+ // Espejo de MAX_FILE_BYTES en backend/utils/binary.js y del CHECK
35
+ // `file_size_valid` de la migración 014. Los tres son el mismo número.
36
+ const MAX_FILE_BYTES = 52428800; // 50 MB, igual que el backend
23
37
  const MAX_DEPTH = 10; // profundidad máxima de carpetas
24
38
 
25
39
  // Extensiones típicamente binarias: pre-filtro rápido antes de leer bytes.
@@ -126,23 +140,58 @@ function looksBinary(buffer, sample = 8000) {
126
140
  }
127
141
 
128
142
  /**
129
- * Decide si un archivo es texto apto para subir. Recibe el nombre (para la
130
- * extensión) y el Buffer con su contenido. Devuelve:
131
- * { isText: true, content } -> subir `content` (utf8)
132
- * { isText: false, reason } -> saltar con aviso
143
+ * Mapa mínimo extensión -> mime. El backend vuelve a adivinar por su cuenta con
144
+ * `mime-types`, pero mandar el tipo desde acá es mejor: el cliente vio los bytes
145
+ * y el servidor, en una subida directa, sólo ve el nombre.
146
+ */
147
+ const MIME_BY_EXT = {
148
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
149
+ webp: 'image/webp', avif: 'image/avif', bmp: 'image/bmp', ico: 'image/x-icon',
150
+ tiff: 'image/tiff', tif: 'image/tiff', heic: 'image/heic',
151
+ pdf: 'application/pdf', zip: 'application/zip', gz: 'application/gzip',
152
+ tar: 'application/x-tar', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg',
153
+ mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
154
+ ttf: 'font/ttf', otf: 'font/otf', woff: 'font/woff', woff2: 'font/woff2',
155
+ doc: 'application/msword',
156
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
157
+ xls: 'application/vnd.ms-excel',
158
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
159
+ ppt: 'application/vnd.ms-powerpoint',
160
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
161
+ };
162
+
163
+ /** Tipo de contenido de un binario. `octet-stream` cuando no se sabe. */
164
+ function binaryMimeType(name) {
165
+ return MIME_BY_EXT[extensionOf(name)] || 'application/octet-stream';
166
+ }
167
+
168
+ /**
169
+ * Clasifica el contenido de un archivo. Recibe el nombre (para la extensión) y
170
+ * el Buffer con su contenido. Devuelve:
171
+ * { isText: true, content } -> sube por JSON (batch)
172
+ * { isText: false, isBinary: true, buffer, mimeType }
173
+ * -> sube por URL firmada (direct)
174
+ * { isText: false, isBinary: false, reason } -> saltar con aviso
175
+ *
176
+ * El techo de tamaño se comprueba PRIMERO, antes de decidir texto o binario: un
177
+ * archivo demasiado grande se saltea sea lo que sea, y ponerlo después haría que
178
+ * el mensaje dijera "binario" cuando el problema es el tamaño.
133
179
  */
134
180
  function classifyContent(name, buffer) {
135
- const ext = extensionOf(name);
136
- if (ext && BINARY_EXTENSIONS.has(ext)) {
137
- return { isText: false, reason: `extensión binaria .${ext}` };
138
- }
139
- if (looksBinary(buffer)) {
140
- return { isText: false, reason: 'contenido binario (bytes no imprimibles)' };
141
- }
142
181
  if (buffer.length > MAX_FILE_BYTES) {
143
- return { isText: false, reason: `supera 25 MB (${buffer.length} bytes)` };
182
+ return {
183
+ isText: false,
184
+ isBinary: false,
185
+ reason: `supera ${MAX_FILE_BYTES / (1024 * 1024)} MB (${buffer.length} bytes)`,
186
+ };
144
187
  }
145
- return { isText: true, content: buffer.toString('utf8') };
188
+
189
+ const ext = extensionOf(name);
190
+ if ((ext && BINARY_EXTENSIONS.has(ext)) || looksBinary(buffer)) {
191
+ return { isText: false, isBinary: true, buffer, mimeType: binaryMimeType(name) };
192
+ }
193
+
194
+ return { isText: true, isBinary: false, content: buffer.toString('utf8') };
146
195
  }
147
196
 
148
197
  module.exports = {
@@ -157,5 +206,7 @@ module.exports = {
157
206
  validatePath,
158
207
  looksBinary,
159
208
  classifyContent,
209
+ binaryMimeType,
210
+ MIME_BY_EXT,
160
211
  extensionOf,
161
212
  };