shilo-cli 0.7.0 → 0.9.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.
@@ -1,837 +1,1066 @@
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 DEFAULT_BASE_URL = 'https://www.shilo.city';
22
- const DEFAULT_TIMEOUT_MS = 45000;
23
-
24
- // Reintentos: sólo para fallos que pueden desaparecer solos (red, timeout, 429,
25
- // 5xx). Un 4xx que no sea 429 es un error del cliente —ruta inválida, permisos,
26
- // body mal formado— y repetirlo sólo gasta tiempo.
27
- const DEFAULT_RETRIES = 3;
28
- const RETRY_BASE_MS = 250;
29
- const RETRY_MAX_MS = 8000;
30
-
31
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
-
33
- /**
34
- * Backoff exponencial con jitter completo. El jitter importa: sin él, N
35
- * requests que chocan con el mismo 429 reintentan todos en el mismo
36
- * milisegundo y vuelven a chocar.
37
- */
38
- function backoffDelay(attempt) {
39
- const ceiling = Math.min(RETRY_BASE_MS * Math.pow(4, attempt), RETRY_MAX_MS);
40
- return Math.round(ceiling * (0.5 + Math.random() * 0.5));
41
- }
42
-
43
- /** Lee `Retry-After` (segundos o fecha HTTP). Devuelve ms, o null. */
44
- function retryAfterMs(res) {
45
- const raw = res && res.headers && res.headers.get && res.headers.get('retry-after');
46
- if (!raw) return null;
47
- const secs = Number(raw);
48
- if (Number.isFinite(secs)) return Math.max(0, Math.min(secs * 1000, 60000));
49
- const when = Date.parse(raw);
50
- if (!Number.isNaN(when)) return Math.max(0, Math.min(when - Date.now(), 60000));
51
- return null;
52
- }
53
-
54
- /**
55
- * El ápex https://shilo.city responde 307 hacia https://www.shilo.city. Como
56
- * `fetch` sigue redirects pero descarta el header Authorization al cruzar de
57
- * origen, apuntar al ápex haría fallar con 401 todo lo autenticado (push, pull,
58
- * projects). Normalizamos a www para que nunca ocurra ese salto.
59
- */
60
- function normalizeBaseURL(url) {
61
- const clean = String(url).replace(/\/+$/, '');
62
- return clean.replace(/^(https?:\/\/)shilo\.city(?=$|\/)/i, '$1www.shilo.city');
63
- }
64
-
65
- class ShiloError extends Error {
66
- constructor(message, { status = null, body = null, retryable = false, notSupported = false } = {}) {
67
- super(message);
68
- this.name = 'ShiloError';
69
- this.status = status;
70
- this.body = body;
71
- this.retryable = retryable;
72
- // El servidor no conoce el endpoint (deploy viejo): el caller puede usar
73
- // el camino alternativo en vez de abortar.
74
- this.notSupported = notSupported;
75
- }
76
- }
77
-
78
- class ShiloClient {
79
- constructor(options = {}) {
80
- this.baseURL = normalizeBaseURL(options.baseURL || DEFAULT_BASE_URL);
81
- this.timeoutMs = Number(options.timeoutMs) || DEFAULT_TIMEOUT_MS;
82
- // Opcionales: habilitan refresh de token sin re-login.
83
- this.supabaseUrl = options.supabaseUrl || null;
84
- this.supabaseAnonKey = options.supabaseAnonKey || null;
85
- this.retries = options.retries != null ? Number(options.retries) : DEFAULT_RETRIES;
86
- // Se avisa de cada 429 para que el caller pueda bajar la concurrencia en vez
87
- // de seguir martillando al servidor con la misma cadencia.
88
- this.onThrottle = typeof options.onThrottle === 'function' ? options.onThrottle : null;
89
- }
90
-
91
- /** Un intento único, sin reintentos. */
92
- async _fetchOnce(url, { method = 'GET', headers = {}, body = null, raw = false } = {}) {
93
- const controller = new AbortController();
94
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
95
- let res;
96
- try {
97
- res = await fetch(url, {
98
- method,
99
- headers,
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),
105
- signal: controller.signal,
106
- });
107
- } catch (err) {
108
- clearTimeout(timer);
109
- if (err.name === 'AbortError') {
110
- throw new ShiloError(`Timeout tras ${this.timeoutMs}ms: ${method} ${url}`, {
111
- retryable: true,
112
- });
113
- }
114
- throw new ShiloError(`Fallo de red: ${err.message}`, { retryable: true });
115
- }
116
- clearTimeout(timer);
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
-
126
- const text = await res.text();
127
- let data = null;
128
- if (text) {
129
- try {
130
- data = JSON.parse(text);
131
- } catch (_) {
132
- data = text;
133
- }
134
- }
135
- return { res, data };
136
- }
137
-
138
- /**
139
- * `_fetchOnce` + reintentos con backoff exponencial y jitter. Un 502
140
- * transitorio o un microcorte de red dejaban de forma permanente ese archivo
141
- * afuera del push —y si pasaba en la fase de commit, quedaban archivos
142
- * escritos sin commit que los registrara.
143
- */
144
- async _fetch(url, options = {}) {
145
- const maxAttempts = Math.max(1, this.retries);
146
- let nextDelay = 0;
147
-
148
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
149
- const isLast = attempt === maxAttempts - 1;
150
- if (attempt > 0) await sleep(nextDelay);
151
-
152
- let result;
153
- try {
154
- result = await this._fetchOnce(url, options);
155
- } catch (err) {
156
- if (!err.retryable || isLast) throw err;
157
- nextDelay = backoffDelay(attempt);
158
- continue;
159
- }
160
-
161
- const { res } = result;
162
- const throttled = res.status === 429;
163
- if (throttled && this.onThrottle) this.onThrottle();
164
-
165
- if ((!throttled && res.status < 500) || isLast) return result;
166
-
167
- const hinted = retryAfterMs(res);
168
- nextDelay = hinted != null ? hinted : backoffDelay(attempt);
169
- }
170
-
171
- /* istanbul ignore next — el bucle siempre sale por return o throw */
172
- throw new ShiloError(`No se pudo completar la petición a ${url}`);
173
- }
174
-
175
- _errMessage(data, fallback) {
176
- if (data && typeof data === 'object') {
177
- if (typeof data.error === 'string') return data.error;
178
- if (typeof data.message === 'string') return data.message;
179
- return JSON.stringify(data);
180
- }
181
- if (typeof data === 'string' && data) return data;
182
- return fallback;
183
- }
184
-
185
- /**
186
- * Autentica contra /api/auth/login. Devuelve credenciales normalizadas.
187
- */
188
- async authenticate(email, password) {
189
- const { res, data } = await this._fetch(`${this.baseURL}/api/auth/login`, {
190
- method: 'POST',
191
- headers: { 'Content-Type': 'application/json' },
192
- body: { email, password },
193
- });
194
-
195
- if (!res.ok) {
196
- throw new ShiloError(
197
- `Autenticación fallida: ${this._errMessage(data, `HTTP ${res.status}`)}`,
198
- { status: res.status, body: data }
199
- );
200
- }
201
-
202
- const session = data && data.data && data.data.session;
203
- const user = data && data.data && data.data.user;
204
- if (!session || !session.access_token) {
205
- throw new ShiloError('Respuesta de login inesperada (sin session.access_token)', {
206
- status: res.status,
207
- body: data,
208
- });
209
- }
210
-
211
- const expiresAt = new Date(Date.now() + (session.expires_in || 3600) * 1000).toISOString();
212
- return {
213
- jwt: session.access_token,
214
- refreshToken: session.refresh_token || null,
215
- expiresAt,
216
- user: user ? { id: user.id, email: user.email } : null,
217
- };
218
- }
219
-
220
- /**
221
- * Refresca el JWT con el refresh_token vía Supabase. Requiere supabaseUrl +
222
- * supabaseAnonKey configurados; si no, lanza para que el caller pida re-login.
223
- */
224
- async refreshToken(refreshToken) {
225
- if (!this.supabaseUrl || !this.supabaseAnonKey) {
226
- throw new ShiloError(
227
- 'Refresh no configurado (falta supabaseUrl/anonKey). Ejecutá "shilo login" de nuevo.'
228
- );
229
- }
230
- const { res, data } = await this._fetch(
231
- `${this.supabaseUrl.replace(/\/+$/, '')}/auth/v1/token?grant_type=refresh_token`,
232
- {
233
- method: 'POST',
234
- headers: {
235
- apikey: this.supabaseAnonKey,
236
- 'Content-Type': 'application/json',
237
- },
238
- body: { refresh_token: refreshToken },
239
- }
240
- );
241
- if (!res.ok || !data || !data.access_token) {
242
- throw new ShiloError('No se pudo refrescar el token. Re-autenticación requerida.', {
243
- status: res.status,
244
- body: data,
245
- });
246
- }
247
- const expiresAt = new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString();
248
- return {
249
- jwt: data.access_token,
250
- refreshToken: data.refresh_token || refreshToken,
251
- expiresAt,
252
- };
253
- }
254
-
255
- /**
256
- * Devuelve credenciales con JWT válido, refrescando si faltan <5min.
257
- * Si el refresh no está disponible/falla, propaga para que el CLI pida login.
258
- *
259
- * Un PAT no tiene refresh ni expiración local: la validez la decide el
260
- * servidor en cada request, así que se devuelve tal cual.
261
- */
262
- async ensureValidToken(credentials) {
263
- if (credentials && credentials.kind === 'pat') return credentials;
264
-
265
- if (!credentials || !credentials.jwt) {
266
- throw new ShiloError('No hay sesión. Ejecutá "shilo login".');
267
- }
268
- const msLeft = new Date(credentials.expiresAt).getTime() - Date.now();
269
- if (msLeft >= 5 * 60 * 1000) return credentials;
270
-
271
- if (!credentials.refreshToken) {
272
- throw new ShiloError('Sesión expirada. Ejecutá "shilo login" de nuevo.');
273
- }
274
- const fresh = await this.refreshToken(credentials.refreshToken);
275
- return { ...credentials, ...fresh };
276
- }
277
-
278
- /**
279
- * Verifica que unas credenciales sean aceptadas por el servidor, listando los
280
- * proyectos del usuario. Sirve tanto para JWT como para PAT.
281
- * Devuelve `{ ok: <bool>, status, error }` — no lanza por credenciales malas.
282
- */
283
- async verifyCredentials(credentials) {
284
- const headers = { Authorization: `Bearer ${credentials.jwt}` };
285
- const { res, data } = await this._fetch(`${this.baseURL}/api/projects`, { headers });
286
- if (res.ok) return { ok: true, status: res.status, error: null };
287
- return {
288
- ok: false,
289
- status: res.status,
290
- error: this._errMessage(data, `HTTP ${res.status}`),
291
- };
292
- }
293
-
294
- // ---------------------------------------------------------------------------
295
- // Helpers de archivos
296
- // ---------------------------------------------------------------------------
297
-
298
- /**
299
- * Codifica una ruta de repo (segmentos separados por `/`) para la URL,
300
- * escapando cada segmento pero conservando los separadores. Los nombres
301
- * válidos en Shilo pueden incluir `[ ] ( ) @`, que conviene escapar.
302
- */
303
- _encodePath(repoPath) {
304
- if (!repoPath) return '';
305
- return repoPath
306
- .split('/')
307
- .filter(Boolean)
308
- .map((seg) => encodeURIComponent(seg))
309
- .join('/');
310
- }
311
-
312
- _fileUrl(projectId, repoPath = '') {
313
- const enc = this._encodePath(repoPath);
314
- return enc
315
- ? `${this.baseURL}/api/files/${projectId}/${enc}`
316
- : `${this.baseURL}/api/files/${projectId}`;
317
- }
318
-
319
- async _authFetch(url, credentials, { method = 'GET', body = null } = {}) {
320
- const validCreds = await this.ensureValidToken(credentials);
321
- const headers = { Authorization: `Bearer ${validCreds.jwt}` };
322
- if (body != null && !Buffer.isBuffer(body)) headers['Content-Type'] = 'application/json';
323
- const { res, data } = await this._fetch(url, { method, headers, body });
324
- return { res, data, credentials: validCreds };
325
- }
326
-
327
- /**
328
- * Lista el contenido directo de una carpeta (no recursa). Raíz responde
329
- * `{ files }`, una carpeta responde `{ folder, files }`.
330
- * Devuelve `{ files: [...], credentials }`.
331
- */
332
- async listFiles(projectId, credentials, folderPath = '') {
333
- const url = this._fileUrl(projectId, folderPath);
334
- const { res, data, credentials: creds } = await this._authFetch(url, credentials);
335
- if (!res.ok) {
336
- throw new ShiloError(
337
- `No se pudieron listar archivos: ${this._errMessage(data, `HTTP ${res.status}`)}`,
338
- { status: res.status, body: data }
339
- );
340
- }
341
- const files = data && Array.isArray(data.files) ? data.files : Array.isArray(data) ? data : [];
342
- return { files, credentials: creds };
343
- }
344
-
345
- /**
346
- * Obtiene la metadata de un archivo/carpeta por ruta SIN descargar contenido.
347
- * Devuelve `{ file: <obj|null>, exists: <bool>, credentials }`.
348
- * Un 404 no es error: significa que la ruta no existe todavía (archivo nuevo).
349
- */
350
- async getFileMeta(projectId, filePath, credentials) {
351
- const url = this._fileUrl(projectId, filePath);
352
- const { res, data, credentials: creds } = await this._authFetch(url, credentials);
353
- if (res.status === 404) {
354
- return { file: null, exists: false, credentials: creds };
355
- }
356
- if (!res.ok) {
357
- throw new ShiloError(
358
- `No se pudo consultar "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
359
- { status: res.status, body: data }
360
- );
361
- }
362
- const file = data && data.file ? data.file : data;
363
- return { file: file || null, exists: !!file, credentials: creds };
364
- }
365
-
366
- /**
367
- * Lee metadata + contenido de un archivo (`?content=true`).
368
- * Devuelve `{ file: { ...meta, content }, credentials }`.
369
- */
370
- async readFile(projectId, filePath, credentials) {
371
- const url = `${this._fileUrl(projectId, filePath)}?content=true`;
372
- const { res, data, credentials: creds } = await this._authFetch(url, credentials);
373
- if (!res.ok) {
374
- throw new ShiloError(
375
- `No se pudo leer "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
376
- { status: res.status, body: data }
377
- );
378
- }
379
- const file = data && data.file ? data.file : {};
380
- const content = data && data.content !== undefined ? data.content : null;
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
- };
393
- }
394
-
395
- /**
396
- * Crea un archivo nuevo (POST a la carpeta padre). Idempotente en el server:
397
- * si ya existe, responde 409 con el objeto existente. Aquí tratamos el 409
398
- * como "ya existía" y devolvemos `{ file, created: false }` para que el caller
399
- * decida (normalmente, hacer un PUT).
400
- * Devuelve `{ file, created: <bool>, credentials }`.
401
- */
402
- async createFile(projectId, filePath, content, mimeType, credentials) {
403
- const parts = filePath.split('/').filter(Boolean);
404
- const fileName = parts.pop();
405
- const parentPath = parts.join('/');
406
- const url = this._fileUrl(projectId, parentPath);
407
-
408
- const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
409
- method: 'POST',
410
- body: { name: fileName, type: 'file', content, mimeType },
411
- });
412
-
413
- if (res.status === 409) {
414
- const existing = (data && (data.file || data.folder)) || null;
415
- return { file: existing, created: false, credentials: creds };
416
- }
417
- if (!res.ok) {
418
- throw new ShiloError(
419
- `No se pudo crear "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
420
- { status: res.status, body: data }
421
- );
422
- }
423
- const file = (data && (data.file || data)) || null;
424
- return { file, created: true, credentials: creds };
425
- }
426
-
427
- /**
428
- * Actualiza el contenido de un archivo existente (PUT). Devuelve el archivo
429
- * con su NUEVO `storage_path`. `{ file, credentials }`.
430
- */
431
- async writeFile(projectId, filePath, content, mimeType, credentials) {
432
- const url = this._fileUrl(projectId, filePath);
433
- const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
434
- method: 'PUT',
435
- body: { content, mimeType },
436
- });
437
- if (!res.ok) {
438
- throw new ShiloError(
439
- `No se pudo actualizar "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
440
- { status: res.status, body: data }
441
- );
442
- }
443
- const file = (data && (data.file || data)) || null;
444
- return { file, credentials: creds };
445
- }
446
-
447
- /**
448
- * Escribe varios archivos en una sola llamada (`POST /api/files/:id/batch`).
449
- * Crea las carpetas intermedias por su cuenta, así que no hace falta un
450
- * `ensurePath` previo.
451
- *
452
- * `files`: [{ path, content, mimeType? }] — máximo 100 y 5 MB por lote.
453
- *
454
- * El endpoint responde 200 aunque algunos archivos fallen: el resultado viene
455
- * POR ARCHIVO, en el mismo orden que se mandó. Los `ok:true` traen
456
- * `file.previous_storage_path`, que es lo que después pide el commit.
457
- * Devuelve `{ results, summary, credentials }`.
458
- *
459
- * Un servidor viejo (sin el endpoint) responde 404/405: se marca en el error
460
- * con `notSupported` para que el caller pueda caer al camino de a un archivo.
461
- */
462
- async writeBatch(projectId, files, credentials) {
463
- const url = `${this.baseURL}/api/files/${projectId}/batch`;
464
- const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
465
- method: 'POST',
466
- body: { files },
467
- });
468
-
469
- if (!res.ok) {
470
- throw new ShiloError(
471
- `No se pudo escribir el lote: ${this._errMessage(data, `HTTP ${res.status}`)}`,
472
- { status: res.status, body: data, notSupported: res.status === 404 || res.status === 405 }
473
- );
474
- }
475
-
476
- const results = data && Array.isArray(data.results) ? data.results : [];
477
- const summary = (data && data.summary) || {
478
- total: results.length,
479
- succeeded: results.filter((r) => r && r.ok).length,
480
- failed: results.filter((r) => r && !r.ok).length,
481
- };
482
- return { results, summary, credentials: creds };
483
- }
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
-
632
- /**
633
- * Crea una carpeta bajo `parentPath` (vacío = raíz). Un 409 significa que ya
634
- * existe: no es fatal, devolvemos `{ folder, existed: true }`.
635
- * `{ folder, existed: <bool>, credentials }`.
636
- */
637
- async createFolder(projectId, folderName, parentPath = '', credentials) {
638
- const url = this._fileUrl(projectId, parentPath);
639
- const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
640
- method: 'POST',
641
- body: { name: folderName, type: 'folder' },
642
- });
643
- if (res.status === 409) {
644
- const existing = (data && (data.folder || data.file)) || { name: folderName };
645
- return { folder: existing, existed: true, credentials: creds };
646
- }
647
- if (!res.ok) {
648
- throw new ShiloError(
649
- `No se pudo crear la carpeta "${folderName}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
650
- { status: res.status, body: data }
651
- );
652
- }
653
- const folder = (data && (data.file || data)) || null;
654
- return { folder, existed: false, credentials: creds };
655
- }
656
-
657
- /**
658
- * Asegura que existan todas las carpetas del path de un archivo.
659
- * Estrategia anti-duplicados: Shilo puede no devolver 409 fiable, así que
660
- * listamos el padre antes de crear y reutilizamos la carpeta si ya existe.
661
- * `ensuredCache` (Set de rutas completas) evita re-listar dentro de una corrida.
662
- * Devuelve las credenciales (posiblemente refrescadas).
663
- */
664
- async ensurePath(projectId, filePath, credentials, ensuredCache = null) {
665
- const parts = filePath.split('/').filter(Boolean);
666
- parts.pop(); // quitar el nombre del archivo
667
- if (parts.length === 0) return credentials;
668
-
669
- let currentCreds = credentials;
670
- let parentPath = '';
671
-
672
- for (const folderName of parts) {
673
- const fullPath = parentPath ? `${parentPath}/${folderName}` : folderName;
674
-
675
- if (ensuredCache && ensuredCache.has(fullPath)) {
676
- parentPath = fullPath;
677
- continue;
678
- }
679
-
680
- let exists = false;
681
- try {
682
- const listResult = await this.listFiles(projectId, currentCreds, parentPath);
683
- currentCreds = listResult.credentials;
684
- exists = listResult.files.some((e) => e && e.type === 'folder' && e.name === folderName);
685
- } catch (_) {
686
- // El padre puede no existir aún: dejamos que createFolder lo cree.
687
- exists = false;
688
- }
689
-
690
- if (!exists) {
691
- const result = await this.createFolder(projectId, folderName, parentPath, currentCreds);
692
- currentCreds = result.credentials;
693
- }
694
-
695
- if (ensuredCache) ensuredCache.add(fullPath);
696
- parentPath = fullPath;
697
- }
698
-
699
- return currentCreds;
700
- }
701
-
702
- /**
703
- * Lista el historial de commits del proyecto (más nuevo primero).
704
- * `includeFiles` pide además los `commit_files` de cada commit, que es lo que
705
- * permite saber quién tocó cada archivo por última vez.
706
- * Devuelve `{ commits, hasMore, credentials }`.
707
- */
708
- async listCommits(projectId, credentials, { limit = 20, page = 1, includeFiles = false } = {}) {
709
- const params = new URLSearchParams({ limit: String(limit), page: String(page) });
710
- if (includeFiles) params.set('include', 'files');
711
- const url = `${this.baseURL}/api/commits/${projectId}?${params.toString()}`;
712
- const { res, data, credentials: creds } = await this._authFetch(url, credentials);
713
- if (!res.ok) {
714
- throw new ShiloError(
715
- `No se pudo listar el historial: ${this._errMessage(data, `HTTP ${res.status}`)}`,
716
- { status: res.status, body: data }
717
- );
718
- }
719
- const commits = data && Array.isArray(data.commits) ? data.commits : [];
720
- const hasMore = !!(data && data.pagination && data.pagination.hasMore);
721
- return { commits, hasMore, credentials: creds };
722
- }
723
-
724
- /**
725
- * Crea un commit con la lista de cambios de archivos (máx 100 por lote).
726
- * `changes`: [{ file_id, action, previous_storage_path?, new_storage_path? }].
727
- * Devuelve `{ commit, credentials }`.
728
- */
729
- async createCommit(projectId, message, changes, credentials) {
730
- const url = `${this.baseURL}/api/commits/${projectId}`;
731
- const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
732
- method: 'POST',
733
- body: { message, files: changes },
734
- });
735
- if (!res.ok) {
736
- throw new ShiloError(
737
- `No se pudo crear el commit: ${this._errMessage(data, `HTTP ${res.status}`)}`,
738
- { status: res.status, body: data }
739
- );
740
- }
741
- const commit = (data && data.commit) || data;
742
- return { commit, credentials: creds };
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
- }
835
- }
836
-
837
- module.exports = { ShiloClient, ShiloError };
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
+ * Destino e historial de deploys a Vercel (PLAN_DEPLOY_VERCEL). Devuelve
796
+ * `{ connected, can_manage, target, deployments, credentials }`. Un servidor
797
+ * sin deploys responde 404: se marca `notSupported`.
798
+ */
799
+ async getDeploys(projectId, credentials) {
800
+ const url = `${this.baseURL}/api/deploys/${encodeURIComponent(projectId)}`;
801
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials);
802
+ if (!res.ok) {
803
+ throw new ShiloError(
804
+ `No se pudieron leer los deploys: ${this._errMessage(data, `HTTP ${res.status}`)}`,
805
+ { status: res.status, body: data, notSupported: res.status === 404 || res.status === 405 }
806
+ );
807
+ }
808
+ return {
809
+ connected: !!(data && data.connected),
810
+ can_manage: !!(data && data.can_manage),
811
+ target: (data && data.target) || null,
812
+ deployments: data && Array.isArray(data.deployments) ? data.deployments : [],
813
+ credentials: creds,
814
+ };
815
+ }
816
+
817
+ /**
818
+ * Propuestas (Etapa 3). Guardar es proponer: el cliente dice de qué versión
819
+ * partió (`base`, el oid de la versión actual que vio) y qué cambió, y el
820
+ * servidor decide si lo aplica solo o si lo deja abierto porque alguien más
821
+ * cambió los mismos archivos. Es lo que hace que un push nunca pise trabajo
822
+ * ajeno.
823
+ */
824
+
825
+ /**
826
+ * Lista las propuestas del proyecto y, de paso, la versión actual (`main`).
827
+ * Devuelve `{ main, proposals, credentials }`. Un servidor sin la Etapa 3
828
+ * responde 404: se marca `notSupported`.
829
+ */
830
+ async listProposals(projectId, credentials, { state = null } = {}) {
831
+ const params = new URLSearchParams({ projectId });
832
+ if (state) params.set('state', state);
833
+ const url = `${this.baseURL}/api/projects/proposals?${params.toString()}`;
834
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials);
835
+ if (!res.ok) {
836
+ throw new ShiloError(
837
+ `No se pudo leer la versión actual: ${this._errMessage(data, `HTTP ${res.status}`)}`,
838
+ { status: res.status, body: data, notSupported: res.status === 404 || res.status === 405 }
839
+ );
840
+ }
841
+ return {
842
+ main: (data && data.main) || null,
843
+ proposals: data && Array.isArray(data.proposals) ? data.proposals : [],
844
+ // Servidores anteriores no los mandan: quien llama tiene que tolerar null.
845
+ viewer: (data && data.viewer) || null,
846
+ project: (data && data.project) || null,
847
+ credentials: creds,
848
+ };
849
+ }
850
+
851
+ /**
852
+ * Abre una propuesta y deja que el servidor intente aplicarla.
853
+ *
854
+ * `changes`: [{ path, content, mime_type? } | { path, oid } | { path, delete: true }].
855
+ *
856
+ * `agent` declara que esto no lo escribió una persona: `{ name, model,
857
+ * session_id, tool }`. `prompt` es el texto que se lo pidió, y sube sólo si
858
+ * quien llama lo pasa — nunca se infiere del entorno, porque un prompt puede
859
+ * llevar adentro cualquier cosa que se haya pegado en la terminal.
860
+ *
861
+ * `card` es la tarjeta del tablero que pidió el cambio. Va además del trailer
862
+ * `[card: …]` del mensaje, que se queda porque es el formato de todo lo ya
863
+ * guardado; la clave es la que se puede consultar.
864
+ *
865
+ * `chequeos` es el resultado de los chequeos del proyecto (MU-93,
866
+ * `checks.js`): la propuesta lo lleva y una fallida no se aplica sola.
867
+ *
868
+ * Devuelve `{ applied, empty, proposal, conflicts, blockedBy, lineas,
869
+ * chequeos, piezas, main, message, warnings, credentials }`; `lineas` son las rutas
870
+ * que el servidor combinó por líneas (MU-92) y `piezas`, las piezas USEE
871
+ * que dejarían de pasar el Nivel 1 (MU-94). Los 200 y 202 son respuestas normales (aplicada /
872
+ * quedó abierta); cualquier otro estado es error.
873
+ */
874
+ async propose(projectId, { base, title, summary, changes, agent, prompt, card, chequeos }, credentials) {
875
+ const url = `${this.baseURL}/api/projects/proposals`;
876
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
877
+ method: 'POST',
878
+ body: {
879
+ projectId,
880
+ base,
881
+ title,
882
+ summary,
883
+ changes,
884
+ ...(agent ? { agent } : {}),
885
+ ...(prompt ? { prompt } : {}),
886
+ ...(card ? { card } : {}),
887
+ ...(chequeos ? { chequeos } : {}),
888
+ },
889
+ });
890
+ if (!res.ok && res.status !== 202) {
891
+ throw new ShiloError(
892
+ `No se pudo proponer el cambio: ${this._errMessage(data, `HTTP ${res.status}`)}`,
893
+ { status: res.status, body: data, notSupported: res.status === 404 || res.status === 405 }
894
+ );
895
+ }
896
+ return {
897
+ applied: !!(data && data.applied),
898
+ empty: !!(data && data.empty),
899
+ proposal: (data && data.proposal) || null,
900
+ conflicts: data && Array.isArray(data.conflicts) ? data.conflicts : [],
901
+ blockedBy: data && Array.isArray(data.blocked_by) ? data.blocked_by : [],
902
+ lineas: data && Array.isArray(data.lineas) ? data.lineas : [],
903
+ chequeos: (data && data.chequeos) || null,
904
+ piezas: data && Array.isArray(data.piezas) ? data.piezas : [],
905
+ main: (data && data.main) || null,
906
+ message: (data && data.message) || '',
907
+ warnings: data && Array.isArray(data.warnings) ? data.warnings : [],
908
+ credentials: creds,
909
+ };
910
+ }
911
+
912
+ /**
913
+ * Resuelve una propuesta abierta: `apply` (dueño) o `discard` (dueño o
914
+ * autor). `resolutions` ({ ruta: 'actual' | 'propuesta' | 'ambas' }) decide
915
+ * los archivos en conflicto al aplicar; `force` aplica aunque sus chequeos
916
+ * o sus piezas USEE hayan fallado (MU-93, MU-94). Devuelve `{ applied, proposal, conflicts, main, credentials }`; con
917
+ * `apply` un 409 significa que sigue en conflicto y viene con los datos.
918
+ */
919
+ async resolveProposal(projectId, id, action, credentials, { resolutions = null, force = false } = {}) {
920
+ const url = `${this.baseURL}/api/projects/proposals`;
921
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
922
+ method: 'POST',
923
+ body: { projectId, id, action, ...(resolutions ? { resolutions } : {}), ...(force ? { force: true } : {}) },
924
+ });
925
+ const stillOpen = action === 'apply' && res.status === 409 && data && data.proposal;
926
+ if (!res.ok && !stillOpen) {
927
+ throw new ShiloError(
928
+ `No se pudo resolver la propuesta: ${this._errMessage(data, `HTTP ${res.status}`)}`,
929
+ { status: res.status, body: data }
930
+ );
931
+ }
932
+ return {
933
+ applied: !!(data && data.applied),
934
+ proposal: (data && data.proposal) || null,
935
+ conflicts: data && Array.isArray(data.conflicts) ? data.conflicts : [],
936
+ lineas: data && Array.isArray(data.lineas) ? data.lineas : [],
937
+ piezas: data && Array.isArray(data.piezas) ? data.piezas : [],
938
+ main: (data && data.main) || null,
939
+ message: (data && data.message) || '',
940
+ credentials: creds,
941
+ };
942
+ }
943
+
944
+ // -------------------------------------------------------------------------
945
+ // Lectura del grafo por oid (Etapa 5, B1) — lo que baja `shilo export`.
946
+ // -------------------------------------------------------------------------
947
+
948
+ /**
949
+ * GET de lectura del grafo. Sin `credentials` va sin sesión: un proyecto
950
+ * público se exporta sin cuenta, igual que se mira sin cuenta.
951
+ */
952
+ async _graphGet(projectId, params, credentials) {
953
+ const query = new URLSearchParams({ projectId, ...params });
954
+ const url = `${this.baseURL}/api/projects/graph?${query.toString()}`;
955
+ if (credentials) return this._authFetch(url, credentials);
956
+ const { res, data } = await this._fetch(url, { method: 'GET' });
957
+ return { res, data, credentials: null };
958
+ }
959
+
960
+ _graphError(que, res, data) {
961
+ return new ShiloError(`No se pudo leer ${que}: ${this._errMessage(data, `HTTP ${res.status}`)}`, {
962
+ status: res.status,
963
+ body: data,
964
+ });
965
+ }
966
+
967
+ /**
968
+ * La historia alcanzable desde `refs` (por defecto `main`), menos lo
969
+ * alcanzable desde `desde`, padres primero. Pide todas las páginas y fija
970
+ * las cabezas de la primera en las siguientes, así un guardado ajeno en el
971
+ * medio no mezcla dos historias. Devuelve
972
+ * `{ refs, heads, commits, identities, credentials }`.
973
+ *
974
+ * `identities` junta las identidades congeladas de los autores de cada
975
+ * página (B2, migración 018). Si alguna página vino sin ellas —el servidor
976
+ * no tiene la 018 o falló al congelar— es `null`: una firma a medias daría
977
+ * SHA-1 que no se repiten, así que el que exporta tiene que cortar.
978
+ */
979
+ async graphHistory(projectId, credentials, { refs = ['main'], desde = [], limit = 1000 } = {}) {
980
+ const base = { objects: 'history', limit: String(limit) };
981
+ if (desde.length > 0) base.desde = desde.join(',');
982
+
983
+ let creds = credentials;
984
+ let primera = null;
985
+ const commits = [];
986
+ let identities = {};
987
+ let offset = 0;
988
+
989
+ while (offset !== null) {
990
+ const params = primera
991
+ ? { ...base, heads: primera.heads.join(','), offset: String(offset) }
992
+ : { ...base, refs: refs.join(',') };
993
+ // eslint-disable-next-line no-await-in-loop
994
+ const { res, data, credentials: c } = await this._graphGet(projectId, params, creds);
995
+ if (!res.ok) throw this._graphError('la historia', res, data);
996
+ creds = c || creds;
997
+ if (!primera) primera = data;
998
+ commits.push(...(data.commits || []));
999
+ identities = identities && data.identities ? { ...identities, ...data.identities } : null;
1000
+ // Una historia vacía no tiene cabezas que fijar: no hay segunda página.
1001
+ offset = primera.heads.length > 0 ? data.next_offset : null;
1002
+ }
1003
+
1004
+ return { refs: primera.refs || {}, heads: primera.heads, commits, identities, credentials: creds };
1005
+ }
1006
+
1007
+ /**
1008
+ * Árboles por oid, ya verificados contra su oid y parseados. Parte los
1009
+ * pedidos de a 100 y vuelve a pedir los `pendientes` que el servidor dejó
1010
+ * afuera por tamaño. Devuelve `{ trees: {oid: entradas}, faltan, credentials }`.
1011
+ */
1012
+ async graphTrees(projectId, oids, credentials) {
1013
+ const trees = {};
1014
+ const faltan = [];
1015
+ let creds = credentials;
1016
+ let cola = [...new Set(oids)];
1017
+
1018
+ while (cola.length > 0) {
1019
+ const tanda = cola.slice(0, 100);
1020
+ cola = cola.slice(100);
1021
+ // eslint-disable-next-line no-await-in-loop
1022
+ const { res, data, credentials: c } = await this._graphGet(
1023
+ projectId, { objects: 'trees', oids: tanda.join(',') }, creds
1024
+ );
1025
+ if (!res.ok) throw this._graphError('los árboles', res, data);
1026
+ creds = c || creds;
1027
+
1028
+ for (const [oid, texto] of Object.entries(data.trees || {})) trees[oid] = leerArbolShilo(oid, texto);
1029
+ faltan.push(...(data.faltan || []));
1030
+ const pendientes = data.pendientes || [];
1031
+ // Si ni uno entró, el servidor no está avanzando: cortar antes que girar.
1032
+ if (pendientes.length === tanda.length) throw this._graphError('los árboles', res, { error: 'sin avance' });
1033
+ cola = [...pendientes, ...cola];
1034
+ }
1035
+
1036
+ return { trees, faltan, credentials: creds };
1037
+ }
1038
+
1039
+ /**
1040
+ * URLs firmadas de blobs por oid, de a 100. No baja nada: los bytes se bajan
1041
+ * con `downloadBinary`, y el sha256 se verifica al convertir. Devuelve
1042
+ * `{ blobs: {oid: {size, url}}, faltan, credentials }`; el blob vacío viene
1043
+ * con `url: null`.
1044
+ */
1045
+ async graphBlobs(projectId, oids, credentials) {
1046
+ const blobs = {};
1047
+ const faltan = [];
1048
+ let creds = credentials;
1049
+ const lista = [...new Set(oids)];
1050
+
1051
+ for (let i = 0; i < lista.length; i += 100) {
1052
+ // eslint-disable-next-line no-await-in-loop
1053
+ const { res, data, credentials: c } = await this._graphGet(
1054
+ projectId, { objects: 'blobs', oids: lista.slice(i, i + 100).join(',') }, creds
1055
+ );
1056
+ if (!res.ok) throw this._graphError('los archivos', res, data);
1057
+ creds = c || creds;
1058
+ Object.assign(blobs, data.blobs || {});
1059
+ faltan.push(...(data.faltan || []));
1060
+ }
1061
+
1062
+ return { blobs, faltan, credentials: creds };
1063
+ }
1064
+ }
1065
+
1066
+ module.exports = { ShiloClient, ShiloError };