shilo-cli 0.6.0 → 0.8.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 +319 -243
- package/bin/shilo.js +197 -165
- package/package.json +1 -1
- package/src/ShiloClient.js +1027 -706
- package/src/args.js +32 -8
- package/src/commands/diff.js +18 -2
- package/src/commands/export.js +226 -0
- package/src/commands/log.js +4 -0
- package/src/commands/pull.js +105 -5
- package/src/commands/push.js +848 -394
- package/src/commands/review.js +101 -0
- package/src/commands/status.js +36 -0
- package/src/exportgit.js +417 -0
- package/src/gitobjects.js +532 -0
- package/src/remote.js +15 -3
- package/src/sync.js +4 -1
- package/src/walk.js +30 -0
package/src/ShiloClient.js
CHANGED
|
@@ -1,706 +1,1027 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Cliente HTTP para la API de Shilo. Portado del ShiloClient de
|
|
5
|
-
* Bnei-Yerushalaim-Cloud, adaptado a `fetch` nativo (Node >=18, cero deps).
|
|
6
|
-
*
|
|
7
|
-
* Autenticación (Fase 1):
|
|
8
|
-
* - authenticate(email, password)
|
|
9
|
-
* - refreshToken(refreshToken) (requiere supabaseUrl + anonKey)
|
|
10
|
-
* - ensureValidToken(credentials)
|
|
11
|
-
*
|
|
12
|
-
* Archivos y commits (Fase 2):
|
|
13
|
-
* - listFiles / getFileMeta / readFile
|
|
14
|
-
* - createFile / writeFile / writeBatch / createFolder / ensurePath
|
|
15
|
-
* - createCommit
|
|
16
|
-
*
|
|
17
|
-
* Todos los métodos de red toman `credentials` y devuelven `{ ..., credentials }`
|
|
18
|
-
* con las credenciales posiblemente refrescadas, para que el caller las persista.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
this.
|
|
71
|
-
this.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
this.
|
|
84
|
-
|
|
85
|
-
this.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
data = text;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
*
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
// ---------------------------------------------------------------------------
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
*
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
.
|
|
309
|
-
.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
*
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
*
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
* `
|
|
451
|
-
*
|
|
452
|
-
* `
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
* `
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
};
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
uploaded
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
)
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cliente HTTP para la API de Shilo. Portado del ShiloClient de
|
|
5
|
+
* Bnei-Yerushalaim-Cloud, adaptado a `fetch` nativo (Node >=18, cero deps).
|
|
6
|
+
*
|
|
7
|
+
* Autenticación (Fase 1):
|
|
8
|
+
* - authenticate(email, password)
|
|
9
|
+
* - refreshToken(refreshToken) (requiere supabaseUrl + anonKey)
|
|
10
|
+
* - ensureValidToken(credentials)
|
|
11
|
+
*
|
|
12
|
+
* Archivos y commits (Fase 2):
|
|
13
|
+
* - listFiles / getFileMeta / readFile
|
|
14
|
+
* - createFile / writeFile / writeBatch / createFolder / ensurePath
|
|
15
|
+
* - createCommit
|
|
16
|
+
*
|
|
17
|
+
* Todos los métodos de red toman `credentials` y devuelven `{ ..., credentials }`
|
|
18
|
+
* con las credenciales posiblemente refrescadas, para que el caller las persista.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { leerArbolShilo } = require('./gitobjects');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_BASE_URL = 'https://www.shilo.city';
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 45000;
|
|
25
|
+
|
|
26
|
+
// Reintentos: sólo para fallos que pueden desaparecer solos (red, timeout, 429,
|
|
27
|
+
// 5xx). Un 4xx que no sea 429 es un error del cliente —ruta inválida, permisos,
|
|
28
|
+
// body mal formado— y repetirlo sólo gasta tiempo.
|
|
29
|
+
const DEFAULT_RETRIES = 3;
|
|
30
|
+
const RETRY_BASE_MS = 250;
|
|
31
|
+
const RETRY_MAX_MS = 8000;
|
|
32
|
+
|
|
33
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Backoff exponencial con jitter completo. El jitter importa: sin él, N
|
|
37
|
+
* requests que chocan con el mismo 429 reintentan todos en el mismo
|
|
38
|
+
* milisegundo y vuelven a chocar.
|
|
39
|
+
*/
|
|
40
|
+
function backoffDelay(attempt) {
|
|
41
|
+
const ceiling = Math.min(RETRY_BASE_MS * Math.pow(4, attempt), RETRY_MAX_MS);
|
|
42
|
+
return Math.round(ceiling * (0.5 + Math.random() * 0.5));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Lee `Retry-After` (segundos o fecha HTTP). Devuelve ms, o null. */
|
|
46
|
+
function retryAfterMs(res) {
|
|
47
|
+
const raw = res && res.headers && res.headers.get && res.headers.get('retry-after');
|
|
48
|
+
if (!raw) return null;
|
|
49
|
+
const secs = Number(raw);
|
|
50
|
+
if (Number.isFinite(secs)) return Math.max(0, Math.min(secs * 1000, 60000));
|
|
51
|
+
const when = Date.parse(raw);
|
|
52
|
+
if (!Number.isNaN(when)) return Math.max(0, Math.min(when - Date.now(), 60000));
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* El ápex https://shilo.city responde 307 hacia https://www.shilo.city. Como
|
|
58
|
+
* `fetch` sigue redirects pero descarta el header Authorization al cruzar de
|
|
59
|
+
* origen, apuntar al ápex haría fallar con 401 todo lo autenticado (push, pull,
|
|
60
|
+
* projects). Normalizamos a www para que nunca ocurra ese salto.
|
|
61
|
+
*/
|
|
62
|
+
function normalizeBaseURL(url) {
|
|
63
|
+
const clean = String(url).replace(/\/+$/, '');
|
|
64
|
+
return clean.replace(/^(https?:\/\/)shilo\.city(?=$|\/)/i, '$1www.shilo.city');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class ShiloError extends Error {
|
|
68
|
+
constructor(message, { status = null, body = null, retryable = false, notSupported = false } = {}) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = 'ShiloError';
|
|
71
|
+
this.status = status;
|
|
72
|
+
this.body = body;
|
|
73
|
+
this.retryable = retryable;
|
|
74
|
+
// El servidor no conoce el endpoint (deploy viejo): el caller puede usar
|
|
75
|
+
// el camino alternativo en vez de abortar.
|
|
76
|
+
this.notSupported = notSupported;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class ShiloClient {
|
|
81
|
+
constructor(options = {}) {
|
|
82
|
+
this.baseURL = normalizeBaseURL(options.baseURL || DEFAULT_BASE_URL);
|
|
83
|
+
this.timeoutMs = Number(options.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
84
|
+
// Opcionales: habilitan refresh de token sin re-login.
|
|
85
|
+
this.supabaseUrl = options.supabaseUrl || null;
|
|
86
|
+
this.supabaseAnonKey = options.supabaseAnonKey || null;
|
|
87
|
+
this.retries = options.retries != null ? Number(options.retries) : DEFAULT_RETRIES;
|
|
88
|
+
// Se avisa de cada 429 para que el caller pueda bajar la concurrencia en vez
|
|
89
|
+
// de seguir martillando al servidor con la misma cadencia.
|
|
90
|
+
this.onThrottle = typeof options.onThrottle === 'function' ? options.onThrottle : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Un intento único, sin reintentos. */
|
|
94
|
+
async _fetchOnce(url, { method = 'GET', headers = {}, body = null, raw = false } = {}) {
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
97
|
+
let res;
|
|
98
|
+
try {
|
|
99
|
+
res = await fetch(url, {
|
|
100
|
+
method,
|
|
101
|
+
headers,
|
|
102
|
+
// Un Buffer va tal cual: es el cuerpo de una subida a URL firmada, y
|
|
103
|
+
// pasarlo por JSON.stringify lo convertiría en el texto "{\"type\":
|
|
104
|
+
// \"Buffer\",\"data\":[...]}", que es exactamente la corrupción
|
|
105
|
+
// silenciosa que la subida directa vino a evitar.
|
|
106
|
+
body: body == null ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body),
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
if (err.name === 'AbortError') {
|
|
112
|
+
throw new ShiloError(`Timeout tras ${this.timeoutMs}ms: ${method} ${url}`, {
|
|
113
|
+
retryable: true,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
throw new ShiloError(`Fallo de red: ${err.message}`, { retryable: true });
|
|
117
|
+
}
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
|
|
120
|
+
// `raw`: la respuesta son bytes (un binario bajado de una URL firmada) y
|
|
121
|
+
// leerla como texto la decodificaría como UTF-8, que es destruirla. Los
|
|
122
|
+
// errores tampoco se parsean acá: con `raw` el llamador mira `res.status`.
|
|
123
|
+
if (raw) {
|
|
124
|
+
const buffer = res.ok ? Buffer.from(await res.arrayBuffer()) : null;
|
|
125
|
+
return { res, data: buffer };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const text = await res.text();
|
|
129
|
+
let data = null;
|
|
130
|
+
if (text) {
|
|
131
|
+
try {
|
|
132
|
+
data = JSON.parse(text);
|
|
133
|
+
} catch (_) {
|
|
134
|
+
data = text;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { res, data };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `_fetchOnce` + reintentos con backoff exponencial y jitter. Un 502
|
|
142
|
+
* transitorio o un microcorte de red dejaban de forma permanente ese archivo
|
|
143
|
+
* afuera del push —y si pasaba en la fase de commit, quedaban archivos
|
|
144
|
+
* escritos sin commit que los registrara.
|
|
145
|
+
*/
|
|
146
|
+
async _fetch(url, options = {}) {
|
|
147
|
+
const maxAttempts = Math.max(1, this.retries);
|
|
148
|
+
let nextDelay = 0;
|
|
149
|
+
|
|
150
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
151
|
+
const isLast = attempt === maxAttempts - 1;
|
|
152
|
+
if (attempt > 0) await sleep(nextDelay);
|
|
153
|
+
|
|
154
|
+
let result;
|
|
155
|
+
try {
|
|
156
|
+
result = await this._fetchOnce(url, options);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (!err.retryable || isLast) throw err;
|
|
159
|
+
nextDelay = backoffDelay(attempt);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const { res } = result;
|
|
164
|
+
const throttled = res.status === 429;
|
|
165
|
+
if (throttled && this.onThrottle) this.onThrottle();
|
|
166
|
+
|
|
167
|
+
if ((!throttled && res.status < 500) || isLast) return result;
|
|
168
|
+
|
|
169
|
+
const hinted = retryAfterMs(res);
|
|
170
|
+
nextDelay = hinted != null ? hinted : backoffDelay(attempt);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* istanbul ignore next — el bucle siempre sale por return o throw */
|
|
174
|
+
throw new ShiloError(`No se pudo completar la petición a ${url}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
_errMessage(data, fallback) {
|
|
178
|
+
if (data && typeof data === 'object') {
|
|
179
|
+
if (typeof data.error === 'string') return data.error;
|
|
180
|
+
if (typeof data.message === 'string') return data.message;
|
|
181
|
+
return JSON.stringify(data);
|
|
182
|
+
}
|
|
183
|
+
if (typeof data === 'string' && data) return data;
|
|
184
|
+
return fallback;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Autentica contra /api/auth/login. Devuelve credenciales normalizadas.
|
|
189
|
+
*/
|
|
190
|
+
async authenticate(email, password) {
|
|
191
|
+
const { res, data } = await this._fetch(`${this.baseURL}/api/auth/login`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: { 'Content-Type': 'application/json' },
|
|
194
|
+
body: { email, password },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
throw new ShiloError(
|
|
199
|
+
`Autenticación fallida: ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
200
|
+
{ status: res.status, body: data }
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const session = data && data.data && data.data.session;
|
|
205
|
+
const user = data && data.data && data.data.user;
|
|
206
|
+
if (!session || !session.access_token) {
|
|
207
|
+
throw new ShiloError('Respuesta de login inesperada (sin session.access_token)', {
|
|
208
|
+
status: res.status,
|
|
209
|
+
body: data,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const expiresAt = new Date(Date.now() + (session.expires_in || 3600) * 1000).toISOString();
|
|
214
|
+
return {
|
|
215
|
+
jwt: session.access_token,
|
|
216
|
+
refreshToken: session.refresh_token || null,
|
|
217
|
+
expiresAt,
|
|
218
|
+
user: user ? { id: user.id, email: user.email } : null,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Refresca el JWT con el refresh_token vía Supabase. Requiere supabaseUrl +
|
|
224
|
+
* supabaseAnonKey configurados; si no, lanza para que el caller pida re-login.
|
|
225
|
+
*/
|
|
226
|
+
async refreshToken(refreshToken) {
|
|
227
|
+
if (!this.supabaseUrl || !this.supabaseAnonKey) {
|
|
228
|
+
throw new ShiloError(
|
|
229
|
+
'Refresh no configurado (falta supabaseUrl/anonKey). Ejecutá "shilo login" de nuevo.'
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const { res, data } = await this._fetch(
|
|
233
|
+
`${this.supabaseUrl.replace(/\/+$/, '')}/auth/v1/token?grant_type=refresh_token`,
|
|
234
|
+
{
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: {
|
|
237
|
+
apikey: this.supabaseAnonKey,
|
|
238
|
+
'Content-Type': 'application/json',
|
|
239
|
+
},
|
|
240
|
+
body: { refresh_token: refreshToken },
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
if (!res.ok || !data || !data.access_token) {
|
|
244
|
+
throw new ShiloError('No se pudo refrescar el token. Re-autenticación requerida.', {
|
|
245
|
+
status: res.status,
|
|
246
|
+
body: data,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const expiresAt = new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString();
|
|
250
|
+
return {
|
|
251
|
+
jwt: data.access_token,
|
|
252
|
+
refreshToken: data.refresh_token || refreshToken,
|
|
253
|
+
expiresAt,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Devuelve credenciales con JWT válido, refrescando si faltan <5min.
|
|
259
|
+
* Si el refresh no está disponible/falla, propaga para que el CLI pida login.
|
|
260
|
+
*
|
|
261
|
+
* Un PAT no tiene refresh ni expiración local: la validez la decide el
|
|
262
|
+
* servidor en cada request, así que se devuelve tal cual.
|
|
263
|
+
*/
|
|
264
|
+
async ensureValidToken(credentials) {
|
|
265
|
+
if (credentials && credentials.kind === 'pat') return credentials;
|
|
266
|
+
|
|
267
|
+
if (!credentials || !credentials.jwt) {
|
|
268
|
+
throw new ShiloError('No hay sesión. Ejecutá "shilo login".');
|
|
269
|
+
}
|
|
270
|
+
const msLeft = new Date(credentials.expiresAt).getTime() - Date.now();
|
|
271
|
+
if (msLeft >= 5 * 60 * 1000) return credentials;
|
|
272
|
+
|
|
273
|
+
if (!credentials.refreshToken) {
|
|
274
|
+
throw new ShiloError('Sesión expirada. Ejecutá "shilo login" de nuevo.');
|
|
275
|
+
}
|
|
276
|
+
const fresh = await this.refreshToken(credentials.refreshToken);
|
|
277
|
+
return { ...credentials, ...fresh };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Verifica que unas credenciales sean aceptadas por el servidor, listando los
|
|
282
|
+
* proyectos del usuario. Sirve tanto para JWT como para PAT.
|
|
283
|
+
* Devuelve `{ ok: <bool>, status, error }` — no lanza por credenciales malas.
|
|
284
|
+
*/
|
|
285
|
+
async verifyCredentials(credentials) {
|
|
286
|
+
const headers = { Authorization: `Bearer ${credentials.jwt}` };
|
|
287
|
+
const { res, data } = await this._fetch(`${this.baseURL}/api/projects`, { headers });
|
|
288
|
+
if (res.ok) return { ok: true, status: res.status, error: null };
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
status: res.status,
|
|
292
|
+
error: this._errMessage(data, `HTTP ${res.status}`),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Helpers de archivos
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Codifica una ruta de repo (segmentos separados por `/`) para la URL,
|
|
302
|
+
* escapando cada segmento pero conservando los separadores. Los nombres
|
|
303
|
+
* válidos en Shilo pueden incluir `[ ] ( ) @`, que conviene escapar.
|
|
304
|
+
*/
|
|
305
|
+
_encodePath(repoPath) {
|
|
306
|
+
if (!repoPath) return '';
|
|
307
|
+
return repoPath
|
|
308
|
+
.split('/')
|
|
309
|
+
.filter(Boolean)
|
|
310
|
+
.map((seg) => encodeURIComponent(seg))
|
|
311
|
+
.join('/');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
_fileUrl(projectId, repoPath = '') {
|
|
315
|
+
const enc = this._encodePath(repoPath);
|
|
316
|
+
return enc
|
|
317
|
+
? `${this.baseURL}/api/files/${projectId}/${enc}`
|
|
318
|
+
: `${this.baseURL}/api/files/${projectId}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async _authFetch(url, credentials, { method = 'GET', body = null } = {}) {
|
|
322
|
+
const validCreds = await this.ensureValidToken(credentials);
|
|
323
|
+
const headers = { Authorization: `Bearer ${validCreds.jwt}` };
|
|
324
|
+
if (body != null && !Buffer.isBuffer(body)) headers['Content-Type'] = 'application/json';
|
|
325
|
+
const { res, data } = await this._fetch(url, { method, headers, body });
|
|
326
|
+
return { res, data, credentials: validCreds };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Lista el contenido directo de una carpeta (no recursa). Raíz responde
|
|
331
|
+
* `{ files }`, una carpeta responde `{ folder, files }`.
|
|
332
|
+
* Devuelve `{ files: [...], credentials }`.
|
|
333
|
+
*/
|
|
334
|
+
async listFiles(projectId, credentials, folderPath = '') {
|
|
335
|
+
const url = this._fileUrl(projectId, folderPath);
|
|
336
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials);
|
|
337
|
+
if (!res.ok) {
|
|
338
|
+
throw new ShiloError(
|
|
339
|
+
`No se pudieron listar archivos: ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
340
|
+
{ status: res.status, body: data }
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const files = data && Array.isArray(data.files) ? data.files : Array.isArray(data) ? data : [];
|
|
344
|
+
return { files, credentials: creds };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Obtiene la metadata de un archivo/carpeta por ruta SIN descargar contenido.
|
|
349
|
+
* Devuelve `{ file: <obj|null>, exists: <bool>, credentials }`.
|
|
350
|
+
* Un 404 no es error: significa que la ruta no existe todavía (archivo nuevo).
|
|
351
|
+
*/
|
|
352
|
+
async getFileMeta(projectId, filePath, credentials) {
|
|
353
|
+
const url = this._fileUrl(projectId, filePath);
|
|
354
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials);
|
|
355
|
+
if (res.status === 404) {
|
|
356
|
+
return { file: null, exists: false, credentials: creds };
|
|
357
|
+
}
|
|
358
|
+
if (!res.ok) {
|
|
359
|
+
throw new ShiloError(
|
|
360
|
+
`No se pudo consultar "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
361
|
+
{ status: res.status, body: data }
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
const file = data && data.file ? data.file : data;
|
|
365
|
+
return { file: file || null, exists: !!file, credentials: creds };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Lee metadata + contenido de un archivo (`?content=true`).
|
|
370
|
+
* Devuelve `{ file: { ...meta, content }, credentials }`.
|
|
371
|
+
*/
|
|
372
|
+
async readFile(projectId, filePath, credentials) {
|
|
373
|
+
const url = `${this._fileUrl(projectId, filePath)}?content=true`;
|
|
374
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials);
|
|
375
|
+
if (!res.ok) {
|
|
376
|
+
throw new ShiloError(
|
|
377
|
+
`No se pudo leer "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
378
|
+
{ status: res.status, body: data }
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
const file = data && data.file ? data.file : {};
|
|
382
|
+
const content = data && data.content !== undefined ? data.content : null;
|
|
383
|
+
// Un binario no viene con contenido: viene con una URL firmada para bajarlo
|
|
384
|
+
// directo de Storage (ver `downloadBinary`). Los dos campos se pegan al
|
|
385
|
+
// `file` para que el llamador decida con un solo objeto en la mano.
|
|
386
|
+
return {
|
|
387
|
+
file: {
|
|
388
|
+
...file,
|
|
389
|
+
content,
|
|
390
|
+
is_binary: Boolean(data && data.is_binary),
|
|
391
|
+
download_url: (data && data.download_url) || null,
|
|
392
|
+
},
|
|
393
|
+
credentials: creds,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Crea un archivo nuevo (POST a la carpeta padre). Idempotente en el server:
|
|
399
|
+
* si ya existe, responde 409 con el objeto existente. Aquí tratamos el 409
|
|
400
|
+
* como "ya existía" y devolvemos `{ file, created: false }` para que el caller
|
|
401
|
+
* decida (normalmente, hacer un PUT).
|
|
402
|
+
* Devuelve `{ file, created: <bool>, credentials }`.
|
|
403
|
+
*/
|
|
404
|
+
async createFile(projectId, filePath, content, mimeType, credentials) {
|
|
405
|
+
const parts = filePath.split('/').filter(Boolean);
|
|
406
|
+
const fileName = parts.pop();
|
|
407
|
+
const parentPath = parts.join('/');
|
|
408
|
+
const url = this._fileUrl(projectId, parentPath);
|
|
409
|
+
|
|
410
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
|
|
411
|
+
method: 'POST',
|
|
412
|
+
body: { name: fileName, type: 'file', content, mimeType },
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
if (res.status === 409) {
|
|
416
|
+
const existing = (data && (data.file || data.folder)) || null;
|
|
417
|
+
return { file: existing, created: false, credentials: creds };
|
|
418
|
+
}
|
|
419
|
+
if (!res.ok) {
|
|
420
|
+
throw new ShiloError(
|
|
421
|
+
`No se pudo crear "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
422
|
+
{ status: res.status, body: data }
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const file = (data && (data.file || data)) || null;
|
|
426
|
+
return { file, created: true, credentials: creds };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Actualiza el contenido de un archivo existente (PUT). Devuelve el archivo
|
|
431
|
+
* con su NUEVO `storage_path`. `{ file, credentials }`.
|
|
432
|
+
*/
|
|
433
|
+
async writeFile(projectId, filePath, content, mimeType, credentials) {
|
|
434
|
+
const url = this._fileUrl(projectId, filePath);
|
|
435
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
|
|
436
|
+
method: 'PUT',
|
|
437
|
+
body: { content, mimeType },
|
|
438
|
+
});
|
|
439
|
+
if (!res.ok) {
|
|
440
|
+
throw new ShiloError(
|
|
441
|
+
`No se pudo actualizar "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
442
|
+
{ status: res.status, body: data }
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
const file = (data && (data.file || data)) || null;
|
|
446
|
+
return { file, credentials: creds };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Escribe varios archivos en una sola llamada (`POST /api/files/:id/batch`).
|
|
451
|
+
* Crea las carpetas intermedias por su cuenta, así que no hace falta un
|
|
452
|
+
* `ensurePath` previo.
|
|
453
|
+
*
|
|
454
|
+
* `files`: [{ path, content, mimeType? }] — máximo 100 y 5 MB por lote.
|
|
455
|
+
*
|
|
456
|
+
* El endpoint responde 200 aunque algunos archivos fallen: el resultado viene
|
|
457
|
+
* POR ARCHIVO, en el mismo orden que se mandó. Los `ok:true` traen
|
|
458
|
+
* `file.previous_storage_path`, que es lo que después pide el commit.
|
|
459
|
+
* Devuelve `{ results, summary, credentials }`.
|
|
460
|
+
*
|
|
461
|
+
* Un servidor viejo (sin el endpoint) responde 404/405: se marca en el error
|
|
462
|
+
* con `notSupported` para que el caller pueda caer al camino de a un archivo.
|
|
463
|
+
*/
|
|
464
|
+
async writeBatch(projectId, files, credentials) {
|
|
465
|
+
const url = `${this.baseURL}/api/files/${projectId}/batch`;
|
|
466
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
|
|
467
|
+
method: 'POST',
|
|
468
|
+
body: { files },
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
if (!res.ok) {
|
|
472
|
+
throw new ShiloError(
|
|
473
|
+
`No se pudo escribir el lote: ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
474
|
+
{ status: res.status, body: data, notSupported: res.status === 404 || res.status === 405 }
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const results = data && Array.isArray(data.results) ? data.results : [];
|
|
479
|
+
const summary = (data && data.summary) || {
|
|
480
|
+
total: results.length,
|
|
481
|
+
succeeded: results.filter((r) => r && r.ok).length,
|
|
482
|
+
failed: results.filter((r) => r && !r.ok).length,
|
|
483
|
+
};
|
|
484
|
+
return { results, summary, credentials: creds };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Baja los bytes de una URL firmada. Devuelve un Buffer.
|
|
489
|
+
*
|
|
490
|
+
* No lleva `Authorization`: la firma ES la autorización, y mandar el JWT a una
|
|
491
|
+
* URL de Storage sería filtrarlo a un origen que no lo necesita.
|
|
492
|
+
*/
|
|
493
|
+
async downloadBinary(signedUrl) {
|
|
494
|
+
const { res, data } = await this._fetch(signedUrl, { method: 'GET', raw: true });
|
|
495
|
+
if (!res.ok || !data) {
|
|
496
|
+
throw new ShiloError(`No se pudo descargar el contenido: HTTP ${res.status}`, {
|
|
497
|
+
status: res.status,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
return data;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Sube un archivo binario: URL firmada, tres pasos.
|
|
505
|
+
*
|
|
506
|
+
* El contenido NO puede viajar por `writeBatch` ni por `writeFile`: esos
|
|
507
|
+
* mandan el archivo como string dentro de un JSON, y el backend lo hashea con
|
|
508
|
+
* `Buffer.from(content, 'utf8')`. Un binario se corrompe ahí, ANTES de
|
|
509
|
+
* hashearse, y el grafo registra el resultado corrupto como válido — no hay
|
|
510
|
+
* verificador que lo note, porque para el motor no pasó nada raro. Por eso el
|
|
511
|
+
* byte va directo a Storage y nunca entra en un payload JSON.
|
|
512
|
+
*
|
|
513
|
+
* Los tres pasos:
|
|
514
|
+
* 1. `begin` — el servidor firma una URL contra la ruta por contenido del
|
|
515
|
+
* blob. Si ese contenido ya está en el proyecto responde
|
|
516
|
+
* `exists: true` y no hay nada que transferir: el archivo ya
|
|
517
|
+
* está ahí, aunque sea con otro nombre.
|
|
518
|
+
* 2. PUT — los bytes, directo a Storage. No pasa por la función
|
|
519
|
+
* serverless, así que el límite de payload de Vercel deja de
|
|
520
|
+
* aplicar.
|
|
521
|
+
* 3. `commit` — el servidor verifica y apunta la fila de `files` al blob.
|
|
522
|
+
*
|
|
523
|
+
* `entry`: { path, buffer, mimeType }.
|
|
524
|
+
* Devuelve `{ result, uploaded, credentials }`, donde `result` tiene la misma
|
|
525
|
+
* forma que un elemento de `writeBatch` (`ok`, `action`, `file`) y `uploaded`
|
|
526
|
+
* dice si hubo bytes en el aire.
|
|
527
|
+
*/
|
|
528
|
+
async uploadBinary(projectId, entry, credentials) {
|
|
529
|
+
const staged = await this._stageBlob(projectId, entry, credentials);
|
|
530
|
+
const done = await this._authFetch(staged.url, staged.credentials, {
|
|
531
|
+
method: 'POST',
|
|
532
|
+
body: { action: 'commit', ...staged.payload },
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
if (!done.res.ok) {
|
|
536
|
+
throw new ShiloError(
|
|
537
|
+
`No se pudo registrar "${entry.path}": ${this._errMessage(done.data, `HTTP ${done.res.status}`)}`,
|
|
538
|
+
{ status: done.res.status, body: done.data }
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return { result: done.data, uploaded: staged.uploaded, credentials: done.credentials };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Deja el contenido de un archivo en el proyecto SIN tocar la versión actual:
|
|
547
|
+
* los mismos pasos que `uploadBinary` pero el tercero es `register`, que
|
|
548
|
+
* verifica e inventaria el blob y no escribe ninguna fila de `files`. Es
|
|
549
|
+
* como un binario (o un texto demasiado grande para ir inline) entra a una
|
|
550
|
+
* propuesta: por su oid.
|
|
551
|
+
*
|
|
552
|
+
* Devuelve `{ oid, uploaded, credentials }`.
|
|
553
|
+
*/
|
|
554
|
+
async registerBlob(projectId, entry, credentials) {
|
|
555
|
+
const staged = await this._stageBlob(projectId, entry, credentials);
|
|
556
|
+
const done = await this._authFetch(staged.url, staged.credentials, {
|
|
557
|
+
method: 'POST',
|
|
558
|
+
body: { action: 'register', ...staged.payload },
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
if (!done.res.ok) {
|
|
562
|
+
throw new ShiloError(
|
|
563
|
+
`No se pudo registrar el contenido de "${entry.path}": ${this._errMessage(done.data, `HTTP ${done.res.status}`)}`,
|
|
564
|
+
{
|
|
565
|
+
status: done.res.status,
|
|
566
|
+
body: done.data,
|
|
567
|
+
notSupported: done.res.status === 404 || done.res.status === 405,
|
|
568
|
+
}
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
oid: (done.data && done.data.oid) || entry.contentHash,
|
|
574
|
+
uploaded: staged.uploaded,
|
|
575
|
+
credentials: done.credentials,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Pasos 1 y 2 de la subida directa: `begin` y, si hace falta, el PUT. */
|
|
580
|
+
async _stageBlob(projectId, entry, credentials) {
|
|
581
|
+
const url = `${this.baseURL}/api/files/${projectId}/direct`;
|
|
582
|
+
const payload = {
|
|
583
|
+
path: entry.path,
|
|
584
|
+
size: entry.buffer.length,
|
|
585
|
+
content_hash: entry.contentHash,
|
|
586
|
+
mime_type: entry.mimeType || 'application/octet-stream',
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const begun = await this._authFetch(url, credentials, {
|
|
590
|
+
method: 'POST',
|
|
591
|
+
body: { action: 'begin', ...payload },
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
if (!begun.res.ok) {
|
|
595
|
+
throw new ShiloError(
|
|
596
|
+
`No se pudo preparar la subida de "${entry.path}": ${this._errMessage(begun.data, `HTTP ${begun.res.status}`)}`,
|
|
597
|
+
{
|
|
598
|
+
status: begun.res.status,
|
|
599
|
+
body: begun.data,
|
|
600
|
+
notSupported: begun.res.status === 404 || begun.res.status === 405,
|
|
601
|
+
}
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
let uploaded = false;
|
|
606
|
+
const upload = begun.data && begun.data.upload;
|
|
607
|
+
|
|
608
|
+
if (upload && upload.url) {
|
|
609
|
+
const { res } = await this._fetch(upload.url, {
|
|
610
|
+
method: 'PUT',
|
|
611
|
+
headers: {
|
|
612
|
+
'Content-Type': entry.mimeType || 'application/octet-stream',
|
|
613
|
+
// `x-upsert: false` es el default de una URL firmada de subida y se
|
|
614
|
+
// deja explícito: si el objeto ya existiera, pisarlo reemplazaría el
|
|
615
|
+
// contenido de un blob conservando su identidad, que es justo el
|
|
616
|
+
// ataque que el direccionamiento por contenido tiene que impedir.
|
|
617
|
+
'x-upsert': 'false',
|
|
618
|
+
},
|
|
619
|
+
body: entry.buffer,
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
if (!res.ok) {
|
|
623
|
+
throw new ShiloError(
|
|
624
|
+
`No se pudo subir el contenido de "${entry.path}": HTTP ${res.status}`,
|
|
625
|
+
{ status: res.status }
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
uploaded = true;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
return { url, payload, uploaded, credentials: begun.credentials };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Crea una carpeta bajo `parentPath` (vacío = raíz). Un 409 significa que ya
|
|
636
|
+
* existe: no es fatal, devolvemos `{ folder, existed: true }`.
|
|
637
|
+
* `{ folder, existed: <bool>, credentials }`.
|
|
638
|
+
*/
|
|
639
|
+
async createFolder(projectId, folderName, parentPath = '', credentials) {
|
|
640
|
+
const url = this._fileUrl(projectId, parentPath);
|
|
641
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
|
|
642
|
+
method: 'POST',
|
|
643
|
+
body: { name: folderName, type: 'folder' },
|
|
644
|
+
});
|
|
645
|
+
if (res.status === 409) {
|
|
646
|
+
const existing = (data && (data.folder || data.file)) || { name: folderName };
|
|
647
|
+
return { folder: existing, existed: true, credentials: creds };
|
|
648
|
+
}
|
|
649
|
+
if (!res.ok) {
|
|
650
|
+
throw new ShiloError(
|
|
651
|
+
`No se pudo crear la carpeta "${folderName}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
652
|
+
{ status: res.status, body: data }
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
const folder = (data && (data.file || data)) || null;
|
|
656
|
+
return { folder, existed: false, credentials: creds };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Asegura que existan todas las carpetas del path de un archivo.
|
|
661
|
+
* Estrategia anti-duplicados: Shilo puede no devolver 409 fiable, así que
|
|
662
|
+
* listamos el padre antes de crear y reutilizamos la carpeta si ya existe.
|
|
663
|
+
* `ensuredCache` (Set de rutas completas) evita re-listar dentro de una corrida.
|
|
664
|
+
* Devuelve las credenciales (posiblemente refrescadas).
|
|
665
|
+
*/
|
|
666
|
+
async ensurePath(projectId, filePath, credentials, ensuredCache = null) {
|
|
667
|
+
const parts = filePath.split('/').filter(Boolean);
|
|
668
|
+
parts.pop(); // quitar el nombre del archivo
|
|
669
|
+
if (parts.length === 0) return credentials;
|
|
670
|
+
|
|
671
|
+
let currentCreds = credentials;
|
|
672
|
+
let parentPath = '';
|
|
673
|
+
|
|
674
|
+
for (const folderName of parts) {
|
|
675
|
+
const fullPath = parentPath ? `${parentPath}/${folderName}` : folderName;
|
|
676
|
+
|
|
677
|
+
if (ensuredCache && ensuredCache.has(fullPath)) {
|
|
678
|
+
parentPath = fullPath;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
let exists = false;
|
|
683
|
+
try {
|
|
684
|
+
const listResult = await this.listFiles(projectId, currentCreds, parentPath);
|
|
685
|
+
currentCreds = listResult.credentials;
|
|
686
|
+
exists = listResult.files.some((e) => e && e.type === 'folder' && e.name === folderName);
|
|
687
|
+
} catch (_) {
|
|
688
|
+
// El padre puede no existir aún: dejamos que createFolder lo cree.
|
|
689
|
+
exists = false;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (!exists) {
|
|
693
|
+
const result = await this.createFolder(projectId, folderName, parentPath, currentCreds);
|
|
694
|
+
currentCreds = result.credentials;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (ensuredCache) ensuredCache.add(fullPath);
|
|
698
|
+
parentPath = fullPath;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return currentCreds;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Lista el historial de commits del proyecto (más nuevo primero).
|
|
706
|
+
* `includeFiles` pide además los `commit_files` de cada commit, que es lo que
|
|
707
|
+
* permite saber quién tocó cada archivo por última vez.
|
|
708
|
+
* Devuelve `{ commits, hasMore, credentials }`.
|
|
709
|
+
*/
|
|
710
|
+
async listCommits(projectId, credentials, { limit = 20, page = 1, includeFiles = false } = {}) {
|
|
711
|
+
const params = new URLSearchParams({ limit: String(limit), page: String(page) });
|
|
712
|
+
if (includeFiles) params.set('include', 'files');
|
|
713
|
+
const url = `${this.baseURL}/api/commits/${projectId}?${params.toString()}`;
|
|
714
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials);
|
|
715
|
+
if (!res.ok) {
|
|
716
|
+
throw new ShiloError(
|
|
717
|
+
`No se pudo listar el historial: ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
718
|
+
{ status: res.status, body: data }
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
const commits = data && Array.isArray(data.commits) ? data.commits : [];
|
|
722
|
+
const hasMore = !!(data && data.pagination && data.pagination.hasMore);
|
|
723
|
+
return { commits, hasMore, credentials: creds };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Crea un commit con la lista de cambios de archivos (máx 100 por lote).
|
|
728
|
+
* `changes`: [{ file_id, action, previous_storage_path?, new_storage_path? }].
|
|
729
|
+
* Devuelve `{ commit, credentials }`.
|
|
730
|
+
*/
|
|
731
|
+
async createCommit(projectId, message, changes, credentials, { card } = {}) {
|
|
732
|
+
const url = `${this.baseURL}/api/commits/${projectId}`;
|
|
733
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
|
|
734
|
+
method: 'POST',
|
|
735
|
+
body: { message, files: changes, ...(card ? { card } : {}) },
|
|
736
|
+
});
|
|
737
|
+
if (!res.ok) {
|
|
738
|
+
throw new ShiloError(
|
|
739
|
+
`No se pudo crear el commit: ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
740
|
+
{ status: res.status, body: data }
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
const commit = (data && data.commit) || data;
|
|
744
|
+
return { commit, credentials: creds };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Revisión humana (Etapa 4). `version` es `actual`, un oid o un prefijo de al
|
|
749
|
+
* menos 7 caracteres (lo que imprime `shilo log`). El servidor la resuelve y
|
|
750
|
+
* devuelve el oid completo. Devuelve `{ version, reviews, credentials }`.
|
|
751
|
+
*/
|
|
752
|
+
async listReviews(projectId, version, credentials) {
|
|
753
|
+
const params = new URLSearchParams({ reviews: version || 'actual' });
|
|
754
|
+
const url = `${this.baseURL}/api/commits/${projectId}?${params.toString()}`;
|
|
755
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials);
|
|
756
|
+
if (!res.ok) {
|
|
757
|
+
throw new ShiloError(
|
|
758
|
+
`No se pudieron leer las revisiones: ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
759
|
+
{ status: res.status, body: data }
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
return {
|
|
763
|
+
version: data && data.version,
|
|
764
|
+
reviews: data && Array.isArray(data.reviews) ? data.reviews : [],
|
|
765
|
+
credentials: creds,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Deja constancia de una revisión. `verdict`: aprobado | con_reparos |
|
|
771
|
+
* rechazado; la nota es obligatoria salvo en `aprobado`.
|
|
772
|
+
* Devuelve `{ version, review, reviews, credentials }`.
|
|
773
|
+
*/
|
|
774
|
+
async review(projectId, { version, verdict, note }, credentials) {
|
|
775
|
+
const url = `${this.baseURL}/api/commits/${projectId}`;
|
|
776
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
|
|
777
|
+
method: 'POST',
|
|
778
|
+
body: { review: { version: version || 'actual', verdict, ...(note ? { note } : {}) } },
|
|
779
|
+
});
|
|
780
|
+
if (!res.ok) {
|
|
781
|
+
throw new ShiloError(
|
|
782
|
+
`No se pudo registrar la revisión: ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
783
|
+
{ status: res.status, body: data }
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
return {
|
|
787
|
+
version: data && data.version,
|
|
788
|
+
review: data && data.review,
|
|
789
|
+
reviews: data && Array.isArray(data.reviews) ? data.reviews : [],
|
|
790
|
+
credentials: creds,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Propuestas (Etapa 3). Guardar es proponer: el cliente dice de qué versión
|
|
796
|
+
* partió (`base`, el oid de la versión actual que vio) y qué cambió, y el
|
|
797
|
+
* servidor decide si lo aplica solo o si lo deja abierto porque alguien más
|
|
798
|
+
* cambió los mismos archivos. Es lo que hace que un push nunca pise trabajo
|
|
799
|
+
* ajeno.
|
|
800
|
+
*/
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Lista las propuestas del proyecto y, de paso, la versión actual (`main`).
|
|
804
|
+
* Devuelve `{ main, proposals, credentials }`. Un servidor sin la Etapa 3
|
|
805
|
+
* responde 404: se marca `notSupported`.
|
|
806
|
+
*/
|
|
807
|
+
async listProposals(projectId, credentials, { state = null } = {}) {
|
|
808
|
+
const params = new URLSearchParams({ projectId });
|
|
809
|
+
if (state) params.set('state', state);
|
|
810
|
+
const url = `${this.baseURL}/api/projects/proposals?${params.toString()}`;
|
|
811
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials);
|
|
812
|
+
if (!res.ok) {
|
|
813
|
+
throw new ShiloError(
|
|
814
|
+
`No se pudo leer la versión actual: ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
815
|
+
{ status: res.status, body: data, notSupported: res.status === 404 || res.status === 405 }
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
return {
|
|
819
|
+
main: (data && data.main) || null,
|
|
820
|
+
proposals: data && Array.isArray(data.proposals) ? data.proposals : [],
|
|
821
|
+
credentials: creds,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Abre una propuesta y deja que el servidor intente aplicarla.
|
|
827
|
+
*
|
|
828
|
+
* `changes`: [{ path, content, mime_type? } | { path, oid } | { path, delete: true }].
|
|
829
|
+
*
|
|
830
|
+
* `agent` declara que esto no lo escribió una persona: `{ name, model,
|
|
831
|
+
* session_id, tool }`. `prompt` es el texto que se lo pidió, y sube sólo si
|
|
832
|
+
* quien llama lo pasa — nunca se infiere del entorno, porque un prompt puede
|
|
833
|
+
* llevar adentro cualquier cosa que se haya pegado en la terminal.
|
|
834
|
+
*
|
|
835
|
+
* `card` es la tarjeta del tablero que pidió el cambio. Va además del trailer
|
|
836
|
+
* `[card: …]` del mensaje, que se queda porque es el formato de todo lo ya
|
|
837
|
+
* guardado; la clave es la que se puede consultar.
|
|
838
|
+
*
|
|
839
|
+
* Devuelve `{ applied, empty, proposal, conflicts, blockedBy, main, message,
|
|
840
|
+
* warnings, credentials }`. Los 200 y 202 son respuestas normales (aplicada /
|
|
841
|
+
* quedó abierta); cualquier otro estado es error.
|
|
842
|
+
*/
|
|
843
|
+
async propose(projectId, { base, title, summary, changes, agent, prompt, card }, credentials) {
|
|
844
|
+
const url = `${this.baseURL}/api/projects/proposals`;
|
|
845
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
|
|
846
|
+
method: 'POST',
|
|
847
|
+
body: {
|
|
848
|
+
projectId,
|
|
849
|
+
base,
|
|
850
|
+
title,
|
|
851
|
+
summary,
|
|
852
|
+
changes,
|
|
853
|
+
...(agent ? { agent } : {}),
|
|
854
|
+
...(prompt ? { prompt } : {}),
|
|
855
|
+
...(card ? { card } : {}),
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
if (!res.ok && res.status !== 202) {
|
|
859
|
+
throw new ShiloError(
|
|
860
|
+
`No se pudo proponer el cambio: ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
861
|
+
{ status: res.status, body: data, notSupported: res.status === 404 || res.status === 405 }
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
return {
|
|
865
|
+
applied: !!(data && data.applied),
|
|
866
|
+
empty: !!(data && data.empty),
|
|
867
|
+
proposal: (data && data.proposal) || null,
|
|
868
|
+
conflicts: data && Array.isArray(data.conflicts) ? data.conflicts : [],
|
|
869
|
+
blockedBy: data && Array.isArray(data.blocked_by) ? data.blocked_by : [],
|
|
870
|
+
main: (data && data.main) || null,
|
|
871
|
+
message: (data && data.message) || '',
|
|
872
|
+
warnings: data && Array.isArray(data.warnings) ? data.warnings : [],
|
|
873
|
+
credentials: creds,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Resuelve una propuesta abierta: `apply` (dueño) o `discard` (dueño o
|
|
879
|
+
* autor). Devuelve `{ applied, proposal, conflicts, main, credentials }`; con
|
|
880
|
+
* `apply` un 409 significa que sigue en conflicto y viene con los datos.
|
|
881
|
+
*/
|
|
882
|
+
async resolveProposal(projectId, id, action, credentials) {
|
|
883
|
+
const url = `${this.baseURL}/api/projects/proposals`;
|
|
884
|
+
const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
|
|
885
|
+
method: 'POST',
|
|
886
|
+
body: { projectId, id, action },
|
|
887
|
+
});
|
|
888
|
+
const stillOpen = action === 'apply' && res.status === 409 && data && data.proposal;
|
|
889
|
+
if (!res.ok && !stillOpen) {
|
|
890
|
+
throw new ShiloError(
|
|
891
|
+
`No se pudo resolver la propuesta: ${this._errMessage(data, `HTTP ${res.status}`)}`,
|
|
892
|
+
{ status: res.status, body: data }
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
return {
|
|
896
|
+
applied: !!(data && data.applied),
|
|
897
|
+
proposal: (data && data.proposal) || null,
|
|
898
|
+
conflicts: data && Array.isArray(data.conflicts) ? data.conflicts : [],
|
|
899
|
+
main: (data && data.main) || null,
|
|
900
|
+
message: (data && data.message) || '',
|
|
901
|
+
credentials: creds,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// -------------------------------------------------------------------------
|
|
906
|
+
// Lectura del grafo por oid (Etapa 5, B1) — lo que baja `shilo export`.
|
|
907
|
+
// -------------------------------------------------------------------------
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* GET de lectura del grafo. Sin `credentials` va sin sesión: un proyecto
|
|
911
|
+
* público se exporta sin cuenta, igual que se mira sin cuenta.
|
|
912
|
+
*/
|
|
913
|
+
async _graphGet(projectId, params, credentials) {
|
|
914
|
+
const query = new URLSearchParams({ projectId, ...params });
|
|
915
|
+
const url = `${this.baseURL}/api/projects/graph?${query.toString()}`;
|
|
916
|
+
if (credentials) return this._authFetch(url, credentials);
|
|
917
|
+
const { res, data } = await this._fetch(url, { method: 'GET' });
|
|
918
|
+
return { res, data, credentials: null };
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
_graphError(que, res, data) {
|
|
922
|
+
return new ShiloError(`No se pudo leer ${que}: ${this._errMessage(data, `HTTP ${res.status}`)}`, {
|
|
923
|
+
status: res.status,
|
|
924
|
+
body: data,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* La historia alcanzable desde `refs` (por defecto `main`), menos lo
|
|
930
|
+
* alcanzable desde `desde`, padres primero. Pide todas las páginas y fija
|
|
931
|
+
* las cabezas de la primera en las siguientes, así un guardado ajeno en el
|
|
932
|
+
* medio no mezcla dos historias. Devuelve
|
|
933
|
+
* `{ refs, heads, commits, identities, credentials }`.
|
|
934
|
+
*
|
|
935
|
+
* `identities` junta las identidades congeladas de los autores de cada
|
|
936
|
+
* página (B2, migración 018). Si alguna página vino sin ellas —el servidor
|
|
937
|
+
* no tiene la 018 o falló al congelar— es `null`: una firma a medias daría
|
|
938
|
+
* SHA-1 que no se repiten, así que el que exporta tiene que cortar.
|
|
939
|
+
*/
|
|
940
|
+
async graphHistory(projectId, credentials, { refs = ['main'], desde = [], limit = 1000 } = {}) {
|
|
941
|
+
const base = { objects: 'history', limit: String(limit) };
|
|
942
|
+
if (desde.length > 0) base.desde = desde.join(',');
|
|
943
|
+
|
|
944
|
+
let creds = credentials;
|
|
945
|
+
let primera = null;
|
|
946
|
+
const commits = [];
|
|
947
|
+
let identities = {};
|
|
948
|
+
let offset = 0;
|
|
949
|
+
|
|
950
|
+
while (offset !== null) {
|
|
951
|
+
const params = primera
|
|
952
|
+
? { ...base, heads: primera.heads.join(','), offset: String(offset) }
|
|
953
|
+
: { ...base, refs: refs.join(',') };
|
|
954
|
+
// eslint-disable-next-line no-await-in-loop
|
|
955
|
+
const { res, data, credentials: c } = await this._graphGet(projectId, params, creds);
|
|
956
|
+
if (!res.ok) throw this._graphError('la historia', res, data);
|
|
957
|
+
creds = c || creds;
|
|
958
|
+
if (!primera) primera = data;
|
|
959
|
+
commits.push(...(data.commits || []));
|
|
960
|
+
identities = identities && data.identities ? { ...identities, ...data.identities } : null;
|
|
961
|
+
// Una historia vacía no tiene cabezas que fijar: no hay segunda página.
|
|
962
|
+
offset = primera.heads.length > 0 ? data.next_offset : null;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return { refs: primera.refs || {}, heads: primera.heads, commits, identities, credentials: creds };
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Árboles por oid, ya verificados contra su oid y parseados. Parte los
|
|
970
|
+
* pedidos de a 100 y vuelve a pedir los `pendientes` que el servidor dejó
|
|
971
|
+
* afuera por tamaño. Devuelve `{ trees: {oid: entradas}, faltan, credentials }`.
|
|
972
|
+
*/
|
|
973
|
+
async graphTrees(projectId, oids, credentials) {
|
|
974
|
+
const trees = {};
|
|
975
|
+
const faltan = [];
|
|
976
|
+
let creds = credentials;
|
|
977
|
+
let cola = [...new Set(oids)];
|
|
978
|
+
|
|
979
|
+
while (cola.length > 0) {
|
|
980
|
+
const tanda = cola.slice(0, 100);
|
|
981
|
+
cola = cola.slice(100);
|
|
982
|
+
// eslint-disable-next-line no-await-in-loop
|
|
983
|
+
const { res, data, credentials: c } = await this._graphGet(
|
|
984
|
+
projectId, { objects: 'trees', oids: tanda.join(',') }, creds
|
|
985
|
+
);
|
|
986
|
+
if (!res.ok) throw this._graphError('los árboles', res, data);
|
|
987
|
+
creds = c || creds;
|
|
988
|
+
|
|
989
|
+
for (const [oid, texto] of Object.entries(data.trees || {})) trees[oid] = leerArbolShilo(oid, texto);
|
|
990
|
+
faltan.push(...(data.faltan || []));
|
|
991
|
+
const pendientes = data.pendientes || [];
|
|
992
|
+
// Si ni uno entró, el servidor no está avanzando: cortar antes que girar.
|
|
993
|
+
if (pendientes.length === tanda.length) throw this._graphError('los árboles', res, { error: 'sin avance' });
|
|
994
|
+
cola = [...pendientes, ...cola];
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return { trees, faltan, credentials: creds };
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* URLs firmadas de blobs por oid, de a 100. No baja nada: los bytes se bajan
|
|
1002
|
+
* con `downloadBinary`, y el sha256 se verifica al convertir. Devuelve
|
|
1003
|
+
* `{ blobs: {oid: {size, url}}, faltan, credentials }`; el blob vacío viene
|
|
1004
|
+
* con `url: null`.
|
|
1005
|
+
*/
|
|
1006
|
+
async graphBlobs(projectId, oids, credentials) {
|
|
1007
|
+
const blobs = {};
|
|
1008
|
+
const faltan = [];
|
|
1009
|
+
let creds = credentials;
|
|
1010
|
+
const lista = [...new Set(oids)];
|
|
1011
|
+
|
|
1012
|
+
for (let i = 0; i < lista.length; i += 100) {
|
|
1013
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1014
|
+
const { res, data, credentials: c } = await this._graphGet(
|
|
1015
|
+
projectId, { objects: 'blobs', oids: lista.slice(i, i + 100).join(',') }, creds
|
|
1016
|
+
);
|
|
1017
|
+
if (!res.ok) throw this._graphError('los archivos', res, data);
|
|
1018
|
+
creds = c || creds;
|
|
1019
|
+
Object.assign(blobs, data.blobs || {});
|
|
1020
|
+
faltan.push(...(data.faltan || []));
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
return { blobs, faltan, credentials: creds };
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
module.exports = { ShiloClient, ShiloError };
|