shilo-cli 0.5.0 → 0.7.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 CHANGED
@@ -54,10 +54,19 @@ control, `.`/`..`, nombres de dispositivo de Windows (`CON`, `NUL`, `COM1`…) y
54
54
  final. Los nombres se normalizan a NFC, igual que el backend, para que el mismo archivo
55
55
  subido desde macOS y desde Windows sea uno solo.
56
56
 
57
- La subida va **por lotes** (hasta 100 archivos o 4 MB por request) y **cada lote se
58
- commitea apenas termina**: si el push se corta a la mitad, lo ya escrito queda en el
59
- historial en vez de quedar suelto en storage. Contra un servidor viejo que no conoce el
60
- endpoint de lotes, el CLI avisa y sigue archivo por archivo.
57
+ Guardar es **proponer**: el push manda todo lo que cambió como una propuesta que parte
58
+ de la versión que esta carpeta vio por última vez (`base` en `.shilo.json`, que
59
+ `pull` y `push` van anotando). El servidor la aplica solo si combina limpio con la
60
+ versión actual. Si otra persona cambió alguno de los **mismos archivos** desde esa
61
+ base, la propuesta **queda abierta y no se pisa nada**: el CLI dice qué archivos son,
62
+ tus cambios quedan guardados en esa propuesta para que el dueño decida, y vos podés
63
+ traer la versión actual con `shilo pull`, ajustar y volver a `push`. Un push nuevo
64
+ reemplaza tus propuestas abiertas anteriores.
65
+
66
+ El texto viaja inline (hasta 500 archivos o 4 MB por propuesta; más que eso son varias
67
+ propuestas de la misma base); los binarios y los textos de más de 1 MB se suben por URL
68
+ firmada y entran por su identificador de contenido. Contra un servidor anterior a las
69
+ propuestas, el CLI avisa y cae al camino viejo (escribir + commit), que sí puede pisar.
61
70
 
62
71
  #### Qué se ignora
63
72
 
@@ -97,10 +106,9 @@ que es lo que hace rápido el segundo push de una carpeta grande. Se puede ignor
97
106
  `--no-index` para recomparar todo contra el servidor. Agregá `.shilo/` a tu
98
107
  `.gitignore` (el CLI ya no lo sube).
99
108
 
100
- En `--dry-run` (y antes de la confirmación interactiva) cada archivo modificado
101
- muestra **quién lo tocó por última vez y cuándo**, y se avisa explícitamente si vas
102
- a pisar el trabajo de otra persona. Con token de acceso personal el autor se muestra
103
- igual, pero no se puede marcar cuál sos vos (el PAT no expone tu id de usuario).
109
+ `--dry-run` lista qué se crearía y qué se actualizaría, sin tocar la red más que para
110
+ leer el árbol. Ya no hace falta adivinar si vas a pisar a alguien: eso lo decide el
111
+ servidor con la base exacta, y si pasa, no pisa.
104
112
 
105
113
  ### ¿Estoy desactualizado? (status)
106
114
 
@@ -231,6 +239,10 @@ SHILO_TOKEN=<pat> shilo init --project <uuid> # variable de entorno (lo natura
231
239
  `.shilo/index.json`, lectura perezosa del contenido, reintentos con backoff ante
232
240
  429/5xx, concurrencia adaptativa, barra de progreso y confirmación real en
233
241
  `init --push`.
242
+ - **Fase 7 — propuestas** *(hecho)*: `push` deja de escribir archivo por archivo y
243
+ manda una propuesta con la base exacta; el servidor la combina con la versión actual
244
+ y nunca pisa un archivo que otra persona cambió. `base` en `.shilo.json`,
245
+ `registerBlob` para binarios, camino viejo sólo como reserva.
234
246
 
235
247
  ## Exit codes
236
248
 
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.7.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,153 @@ 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 staged = await this._stageBlob(projectId, entry, credentials);
528
+ const done = await this._authFetch(staged.url, staged.credentials, {
529
+ method: 'POST',
530
+ body: { action: 'commit', ...staged.payload },
531
+ });
532
+
533
+ if (!done.res.ok) {
534
+ throw new ShiloError(
535
+ `No se pudo registrar "${entry.path}": ${this._errMessage(done.data, `HTTP ${done.res.status}`)}`,
536
+ { status: done.res.status, body: done.data }
537
+ );
538
+ }
539
+
540
+ return { result: done.data, uploaded: staged.uploaded, credentials: done.credentials };
541
+ }
542
+
543
+ /**
544
+ * Deja el contenido de un archivo en el proyecto SIN tocar la versión actual:
545
+ * los mismos pasos que `uploadBinary` pero el tercero es `register`, que
546
+ * verifica e inventaria el blob y no escribe ninguna fila de `files`. Es
547
+ * como un binario (o un texto demasiado grande para ir inline) entra a una
548
+ * propuesta: por su oid.
549
+ *
550
+ * Devuelve `{ oid, uploaded, credentials }`.
551
+ */
552
+ async registerBlob(projectId, entry, credentials) {
553
+ const staged = await this._stageBlob(projectId, entry, credentials);
554
+ const done = await this._authFetch(staged.url, staged.credentials, {
555
+ method: 'POST',
556
+ body: { action: 'register', ...staged.payload },
557
+ });
558
+
559
+ if (!done.res.ok) {
560
+ throw new ShiloError(
561
+ `No se pudo registrar el contenido de "${entry.path}": ${this._errMessage(done.data, `HTTP ${done.res.status}`)}`,
562
+ {
563
+ status: done.res.status,
564
+ body: done.data,
565
+ notSupported: done.res.status === 404 || done.res.status === 405,
566
+ }
567
+ );
568
+ }
569
+
570
+ return {
571
+ oid: (done.data && done.data.oid) || entry.contentHash,
572
+ uploaded: staged.uploaded,
573
+ credentials: done.credentials,
574
+ };
575
+ }
576
+
577
+ /** Pasos 1 y 2 de la subida directa: `begin` y, si hace falta, el PUT. */
578
+ async _stageBlob(projectId, entry, credentials) {
579
+ const url = `${this.baseURL}/api/files/${projectId}/direct`;
580
+ const payload = {
581
+ path: entry.path,
582
+ size: entry.buffer.length,
583
+ content_hash: entry.contentHash,
584
+ mime_type: entry.mimeType || 'application/octet-stream',
585
+ };
586
+
587
+ const begun = await this._authFetch(url, credentials, {
588
+ method: 'POST',
589
+ body: { action: 'begin', ...payload },
590
+ });
591
+
592
+ if (!begun.res.ok) {
593
+ throw new ShiloError(
594
+ `No se pudo preparar la subida de "${entry.path}": ${this._errMessage(begun.data, `HTTP ${begun.res.status}`)}`,
595
+ {
596
+ status: begun.res.status,
597
+ body: begun.data,
598
+ notSupported: begun.res.status === 404 || begun.res.status === 405,
599
+ }
600
+ );
601
+ }
602
+
603
+ let uploaded = false;
604
+ const upload = begun.data && begun.data.upload;
605
+
606
+ if (upload && upload.url) {
607
+ const { res } = await this._fetch(upload.url, {
608
+ method: 'PUT',
609
+ headers: {
610
+ 'Content-Type': entry.mimeType || 'application/octet-stream',
611
+ // `x-upsert: false` es el default de una URL firmada de subida y se
612
+ // deja explícito: si el objeto ya existiera, pisarlo reemplazaría el
613
+ // contenido de un blob conservando su identidad, que es justo el
614
+ // ataque que el direccionamiento por contenido tiene que impedir.
615
+ 'x-upsert': 'false',
616
+ },
617
+ body: entry.buffer,
618
+ });
619
+
620
+ if (!res.ok) {
621
+ throw new ShiloError(
622
+ `No se pudo subir el contenido de "${entry.path}": HTTP ${res.status}`,
623
+ { status: res.status }
624
+ );
625
+ }
626
+ uploaded = true;
627
+ }
628
+
629
+ return { url, payload, uploaded, credentials: begun.credentials };
630
+ }
631
+
462
632
  /**
463
633
  * Crea una carpeta bajo `parentPath` (vacío = raíz). Un 409 significa que ya
464
634
  * existe: no es fatal, devolvemos `{ folder, existed: true }`.
@@ -571,6 +741,97 @@ class ShiloClient {
571
741
  const commit = (data && data.commit) || data;
572
742
  return { commit, credentials: creds };
573
743
  }
744
+
745
+ /**
746
+ * Propuestas (Etapa 3). Guardar es proponer: el cliente dice de qué versión
747
+ * partió (`base`, el oid de la versión actual que vio) y qué cambió, y el
748
+ * servidor decide si lo aplica solo o si lo deja abierto porque alguien más
749
+ * cambió los mismos archivos. Es lo que hace que un push nunca pise trabajo
750
+ * ajeno.
751
+ */
752
+
753
+ /**
754
+ * Lista las propuestas del proyecto y, de paso, la versión actual (`main`).
755
+ * Devuelve `{ main, proposals, credentials }`. Un servidor sin la Etapa 3
756
+ * responde 404: se marca `notSupported`.
757
+ */
758
+ async listProposals(projectId, credentials, { state = null } = {}) {
759
+ const params = new URLSearchParams({ projectId });
760
+ if (state) params.set('state', state);
761
+ const url = `${this.baseURL}/api/projects/proposals?${params.toString()}`;
762
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials);
763
+ if (!res.ok) {
764
+ throw new ShiloError(
765
+ `No se pudo leer la versión actual: ${this._errMessage(data, `HTTP ${res.status}`)}`,
766
+ { status: res.status, body: data, notSupported: res.status === 404 || res.status === 405 }
767
+ );
768
+ }
769
+ return {
770
+ main: (data && data.main) || null,
771
+ proposals: data && Array.isArray(data.proposals) ? data.proposals : [],
772
+ credentials: creds,
773
+ };
774
+ }
775
+
776
+ /**
777
+ * Abre una propuesta y deja que el servidor intente aplicarla.
778
+ *
779
+ * `changes`: [{ path, content, mime_type? } | { path, oid } | { path, delete: true }].
780
+ * Devuelve `{ applied, empty, proposal, conflicts, blockedBy, main, message,
781
+ * credentials }`. Los 200 y 202 son respuestas normales (aplicada / quedó
782
+ * abierta); cualquier otro estado es error.
783
+ */
784
+ async propose(projectId, { base, title, summary, changes }, credentials) {
785
+ const url = `${this.baseURL}/api/projects/proposals`;
786
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
787
+ method: 'POST',
788
+ body: { projectId, base, title, summary, changes },
789
+ });
790
+ if (!res.ok && res.status !== 202) {
791
+ throw new ShiloError(
792
+ `No se pudo proponer el cambio: ${this._errMessage(data, `HTTP ${res.status}`)}`,
793
+ { status: res.status, body: data, notSupported: res.status === 404 || res.status === 405 }
794
+ );
795
+ }
796
+ return {
797
+ applied: !!(data && data.applied),
798
+ empty: !!(data && data.empty),
799
+ proposal: (data && data.proposal) || null,
800
+ conflicts: data && Array.isArray(data.conflicts) ? data.conflicts : [],
801
+ blockedBy: data && Array.isArray(data.blocked_by) ? data.blocked_by : [],
802
+ main: (data && data.main) || null,
803
+ message: (data && data.message) || '',
804
+ credentials: creds,
805
+ };
806
+ }
807
+
808
+ /**
809
+ * Resuelve una propuesta abierta: `apply` (dueño) o `discard` (dueño o
810
+ * autor). Devuelve `{ applied, proposal, conflicts, main, credentials }`; con
811
+ * `apply` un 409 significa que sigue en conflicto y viene con los datos.
812
+ */
813
+ async resolveProposal(projectId, id, action, credentials) {
814
+ const url = `${this.baseURL}/api/projects/proposals`;
815
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
816
+ method: 'POST',
817
+ body: { projectId, id, action },
818
+ });
819
+ const stillOpen = action === 'apply' && res.status === 409 && data && data.proposal;
820
+ if (!res.ok && !stillOpen) {
821
+ throw new ShiloError(
822
+ `No se pudo resolver la propuesta: ${this._errMessage(data, `HTTP ${res.status}`)}`,
823
+ { status: res.status, body: data }
824
+ );
825
+ }
826
+ return {
827
+ applied: !!(data && data.applied),
828
+ proposal: (data && data.proposal) || null,
829
+ conflicts: data && Array.isArray(data.conflicts) ? data.conflicts : [],
830
+ main: (data && data.main) || null,
831
+ message: (data && data.message) || '',
832
+ credentials: creds,
833
+ };
834
+ }
574
835
  }
575
836
 
576
837
  module.exports = { ShiloClient, ShiloError };
@@ -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
  *
@@ -45,7 +55,7 @@ async function pull(args) {
45
55
  const ctx = resolveContext(args, { requireDir: false });
46
56
  if (!ctx.ok) return ctx.code;
47
57
 
48
- const { dir, projectId, baseURL, cfg, client, mgr } = ctx;
58
+ const { dir, projectId, baseURL, cfg, client, mgr, savedRemote } = ctx;
49
59
 
50
60
  // La carpeta destino puede no existir todavía: la creamos al escribir. Si
51
61
  // existe pero es un archivo, es un error del usuario.
@@ -58,6 +68,19 @@ async function pull(args) {
58
68
 
59
69
  ui.info(`Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — recorriendo árbol ...`);
60
70
 
71
+ // La versión actual ANTES de recorrer el árbol: si alguien guarda en el
72
+ // medio, la base que anotamos queda más vieja que los archivos que bajamos,
73
+ // y eso sólo puede hacer que el push siguiente vea un conflicto de más, nunca
74
+ // uno de menos. Un servidor sin la Etapa 3 no la tiene; se sigue sin base.
75
+ let main = null;
76
+ try {
77
+ const creds = await mgr.valid();
78
+ const listed = await client.listProposals(projectId, creds, { state: 'abierta' });
79
+ main = listed.main;
80
+ } catch (e) {
81
+ if (!e.notSupported) ui.warn(`No se pudo leer la versión actual: ${e.message}`);
82
+ }
83
+
61
84
  let tree;
62
85
  try {
63
86
  tree = await sync.walkRemote(client, projectId, mgr);
@@ -72,9 +95,13 @@ async function pull(args) {
72
95
  ui.warn(`no se pudo listar ${ui.c.dim(err.folderPath || '(raíz)')} — ${err.reason}`);
73
96
  }
74
97
 
75
- // 1) Separar texto (descargable) de lo que no se puede bajar sin corromper.
98
+ // 1) Descartar sólo lo que no se puede escribir con seguridad.
99
+ //
100
+ // Los binarios ya NO se saltean (Etapa 2 del PLAN_MOTOR_SHILO): el GET
101
+ // devuelve una URL firmada en vez de contenido, y se bajan como bytes. Lo
102
+ // que se saltaba antes no era una limitación del formato sino del camino:
103
+ // el contenido viajaba como texto y un PNG volvía mutilado.
76
104
  const candidates = [];
77
- const skippedBinary = [];
78
105
  const unsafe = [];
79
106
  for (const f of tree.files) {
80
107
  const safe = safeTarget(dir, f.repoPath);
@@ -82,15 +109,12 @@ async function pull(args) {
82
109
  unsafe.push({ repoPath: f.repoPath, reason: safe.reason });
83
110
  continue;
84
111
  }
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 });
112
+ candidates.push({ ...f, target: safe.target, binary: !sync.isTextMime(f.mime_type) });
90
113
  }
91
114
 
92
115
  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}`);
116
+
117
+ const binaryCount = candidates.filter((f) => f.binary).length;
94
118
 
95
119
  // 2) Clasificar contra lo que hay en disco, sin bajar nada todavía.
96
120
  // `maybeSame` (mismo tamaño) se confirma recién al descargar el contenido.
@@ -116,7 +140,7 @@ async function pull(args) {
116
140
  ` ${ui.c.yellow('en conflicto:')} ${conflicting.length} ${ui.c.dim('(difieren en tamaño del local)')}\n` +
117
141
  ` ${ui.c.dim('posiblemente iguales:')} ${maybeSame.length}\n` +
118
142
  ` ${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}` : '') +
143
+ (binaryCount ? `\n ${ui.c.dim('binarios:')} ${binaryCount}` : '') +
120
144
  (unsafe.length ? `\n ${ui.c.red('inseguros:')} ${unsafe.length}` : '')
121
145
  );
122
146
  ui.info('');
@@ -186,6 +210,23 @@ async function pull(args) {
186
210
  const outcomes = await sync.runPool(toDownload, CONCURRENCY, async (f) => {
187
211
  const creds = await mgr.valid();
188
212
  const read = await client.readFile(projectId, f.repoPath, creds);
213
+
214
+ // Binario: el servidor no manda contenido, manda una URL firmada. Los bytes
215
+ // se escriben tal cual, sin encoding: pasarlos por 'utf8' es exactamente lo
216
+ // que corrompía los binarios antes de esta etapa.
217
+ if (read.file && read.file.is_binary && read.file.download_url) {
218
+ const bytes = await client.downloadBinary(read.file.download_url);
219
+
220
+ const currentBytes = readLocalBytes(f.target);
221
+ if (currentBytes !== null && currentBytes.equals(bytes)) {
222
+ return { repoPath: f.repoPath, skipped: true };
223
+ }
224
+
225
+ fs.mkdirSync(path.dirname(f.target), { recursive: true });
226
+ fs.writeFileSync(f.target, bytes);
227
+ return { repoPath: f.repoPath, skipped: false };
228
+ }
229
+
189
230
  const content = read.file && read.file.content != null ? read.file.content : '';
190
231
 
191
232
  const current = sync.readLocalText(f.target);
@@ -217,9 +258,12 @@ async function pull(args) {
217
258
  const complete = failed.length === 0 && (args.force || conflicting.length === 0);
218
259
  try {
219
260
  if (complete) {
220
- remote.write(dir, { projectId, baseURL, lastSync: new Date().toISOString() });
261
+ remote.write(dir, { projectId, baseURL, lastSync: new Date().toISOString(), base: main });
221
262
  } else if (written.length > 0) {
222
- remote.write(dir, { projectId, baseURL });
263
+ // Carpeta a medias: se conserva la base anterior. Lo que sí se bajó ya
264
+ // coincide con la versión actual, así que en el próximo push no cuenta
265
+ // como cambio propio.
266
+ remote.write(dir, { projectId, baseURL, base: (savedRemote && savedRemote.base) || null });
223
267
  }
224
268
  } catch (e) {
225
269
  ui.warn(`No se pudo escribir ${remote.REMOTE_FILENAME}: ${e.message}`);