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 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shilo-cli",
3
- "version": "0.6.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",
@@ -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
- 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 };
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 };
@@ -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
- 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 });
251
267
  }
252
268
  } catch (e) {
253
269
  ui.warn(`No se pudo escribir ${remote.REMOTE_FILENAME}: ${e.message}`);