shilo-cli 0.6.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/package.json +1 -1
- package/src/ShiloClient.js +144 -13
- package/src/commands/pull.js +19 -3
- package/src/commands/push.js +617 -388
- package/src/remote.js +15 -3
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/package.json
CHANGED
package/src/ShiloClient.js
CHANGED
|
@@ -524,6 +524,58 @@ class ShiloClient {
|
|
|
524
524
|
* dice si hubo bytes en el aire.
|
|
525
525
|
*/
|
|
526
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) {
|
|
527
579
|
const url = `${this.baseURL}/api/files/${projectId}/direct`;
|
|
528
580
|
const payload = {
|
|
529
581
|
path: entry.path,
|
|
@@ -574,19 +626,7 @@ class ShiloClient {
|
|
|
574
626
|
uploaded = true;
|
|
575
627
|
}
|
|
576
628
|
|
|
577
|
-
|
|
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 };
|
|
629
|
+
return { url, payload, uploaded, credentials: begun.credentials };
|
|
590
630
|
}
|
|
591
631
|
|
|
592
632
|
/**
|
|
@@ -701,6 +741,97 @@ class ShiloClient {
|
|
|
701
741
|
const commit = (data && data.commit) || data;
|
|
702
742
|
return { commit, credentials: creds };
|
|
703
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
|
+
}
|
|
704
835
|
}
|
|
705
836
|
|
|
706
837
|
module.exports = { ShiloClient, ShiloError };
|
package/src/commands/pull.js
CHANGED
|
@@ -55,7 +55,7 @@ async function pull(args) {
|
|
|
55
55
|
const ctx = resolveContext(args, { requireDir: false });
|
|
56
56
|
if (!ctx.ok) return ctx.code;
|
|
57
57
|
|
|
58
|
-
const { dir, projectId, baseURL, cfg, client, mgr } = ctx;
|
|
58
|
+
const { dir, projectId, baseURL, cfg, client, mgr, savedRemote } = ctx;
|
|
59
59
|
|
|
60
60
|
// La carpeta destino puede no existir todavía: la creamos al escribir. Si
|
|
61
61
|
// existe pero es un archivo, es un error del usuario.
|
|
@@ -68,6 +68,19 @@ async function pull(args) {
|
|
|
68
68
|
|
|
69
69
|
ui.info(`Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — recorriendo árbol ...`);
|
|
70
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
|
+
|
|
71
84
|
let tree;
|
|
72
85
|
try {
|
|
73
86
|
tree = await sync.walkRemote(client, projectId, mgr);
|
|
@@ -245,9 +258,12 @@ async function pull(args) {
|
|
|
245
258
|
const complete = failed.length === 0 && (args.force || conflicting.length === 0);
|
|
246
259
|
try {
|
|
247
260
|
if (complete) {
|
|
248
|
-
remote.write(dir, { projectId, baseURL, lastSync: new Date().toISOString() });
|
|
261
|
+
remote.write(dir, { projectId, baseURL, lastSync: new Date().toISOString(), base: main });
|
|
249
262
|
} else if (written.length > 0) {
|
|
250
|
-
|
|
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 });
|
|
251
267
|
}
|
|
252
268
|
} catch (e) {
|
|
253
269
|
ui.warn(`No se pudo escribir ${remote.REMOTE_FILENAME}: ${e.message}`);
|