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 +20 -8
- package/bin/shilo.js +21 -2
- package/package.json +1 -1
- package/src/ShiloClient.js +265 -4
- package/src/commands/pull.js +56 -12
- package/src/commands/push.js +695 -338
- package/src/pushindex.js +6 -1
- package/src/remote.js +15 -3
- package/src/textguard.js +70 -19
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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) =>
|
|
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
|
-
|
|
164
|
+
finish(1);
|
|
146
165
|
});
|
package/package.json
CHANGED
package/src/ShiloClient.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|
package/src/commands/pull.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
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}`);
|