shilo-cli 0.2.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 ADDED
@@ -0,0 +1,80 @@
1
+ # shilo-cli
2
+
3
+ CLI para subir carpetas locales a repositorios de [Shilo](https://shilo-six.vercel.app),
4
+ con una experiencia tipo git: `shilo login` una vez y después `shilo push`.
5
+
6
+ > **Estado:** Fase 3 (push). Disponibles `login`, `whoami`, `logout`, `init`, `push`.
7
+ > El flujo completo tipo git ya funciona: `shilo login` → `shilo init` → `shilo push`.
8
+
9
+ ## Requisitos
10
+
11
+ - Node.js **>= 18** (usa `fetch` y `crypto` nativos; sin dependencias de runtime).
12
+
13
+ ## Instalación (local, durante desarrollo)
14
+
15
+ ```bash
16
+ cd cli
17
+ npm link # expone el comando `shilo` global
18
+ # o, sin link:
19
+ node bin/shilo.js <comando>
20
+ ```
21
+
22
+ ## Uso
23
+
24
+ ```bash
25
+ shilo login # pregunta email y contraseña (oculta)
26
+ shilo login --email you@example.com
27
+ shilo whoami # muestra la sesión activa
28
+ shilo logout # borra las credenciales locales
29
+ ```
30
+
31
+ ### Subir una carpeta (flujo tipo git)
32
+
33
+ ```bash
34
+ cd mi-carpeta
35
+ shilo init --project <uuid> # ata la carpeta al repo (escribe .shilo.json)
36
+ shilo push # sube: crea/actualiza + commit
37
+ shilo push -m "mi mensaje" # con mensaje de commit
38
+ shilo push --dry-run # muestra qué haría sin subir
39
+ shilo push --yes # sin pedir confirmación
40
+ shilo push ./otra-carpeta --project <uuid> # sin init, proyecto explícito
41
+ ```
42
+
43
+ Sólo se suben archivos de **texto**; binarios, oversize (>25 MB) y nombres con
44
+ caracteres no permitidos se saltan con aviso. Los archivos sin cambios no se
45
+ re-suben. Ignorá rutas con un `.shiloignore` (sintaxis estilo `.gitignore`).
46
+
47
+ ### Servidor alternativo
48
+
49
+ ```bash
50
+ shilo login --base-url https://mi-shilo.vercel.app
51
+ ```
52
+
53
+ ## Credenciales
54
+
55
+ Se guardan en `~/.shilo/config.json` (override con `SHILO_CONFIG_DIR`), **cifradas**
56
+ con AES-256-CBC y con permisos `0600`.
57
+
58
+ - Por defecto la clave de cifrado se deriva de la máquina (hostname + usuario).
59
+ Esto es **ofuscación en reposo**, no secreto fuerte.
60
+ - Para cifrado fuerte, exportá `SHILO_ENCRYPTION_KEY` (una passphrase propia) antes
61
+ de `shilo login`; necesitarás la misma variable para futuras ejecuciones.
62
+
63
+ Se almacena el JWT, el refresh token, la expiración y el email; **no** se guarda la
64
+ contraseña.
65
+
66
+ ## Variables de entorno
67
+
68
+ | Variable | Efecto |
69
+ | ----------------------- | ------------------------------------------------------------- |
70
+ | `SHILO_CONFIG_DIR` | Directorio de config (default `~/.shilo`). |
71
+ | `SHILO_ENCRYPTION_KEY` | Passphrase para cifrar credenciales (recomendado). |
72
+ | `NO_COLOR` | Desactiva colores ANSI. |
73
+
74
+ ## Roadmap
75
+
76
+ - **Fase 1 — auth** *(hecho)*: `login`, `whoami`, `logout` + config cifrada.
77
+ - **Fase 2 — motor** *(hecho)*: cliente de archivos (`list`/`getMeta`/`create`/`write`/
78
+ `ensurePath`/`commit`) + recorrido de árbol (`.shiloignore`) + detección de binarios.
79
+ - **Fase 3 — push** *(hecho)*: `shilo init` + `shilo push` (crea/actualiza + commit,
80
+ detección de cambios, `.shiloignore`, `--dry-run`).
package/bin/shilo.js ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { parseArgs } = require('../src/args');
5
+ const ui = require('../src/ui');
6
+
7
+ const VERSION = require('../package.json').version;
8
+
9
+ const HELP = `
10
+ ${ui.c.bold('shilo')} — sube carpetas locales a repositorios de Shilo.
11
+
12
+ ${ui.c.bold('Uso:')}
13
+ shilo <comando> [opciones]
14
+
15
+ ${ui.c.bold('Comandos:')}
16
+ login Inicia sesión y guarda credenciales cifradas (~/.shilo/config.json).
17
+ whoami Muestra la sesión activa.
18
+ logout Cierra sesión (borra credenciales locales).
19
+ init [carpeta] Ata una carpeta a un repo de Shilo (--project <uuid>).
20
+ push [carpeta] Sube una carpeta al repo de Shilo (crea/actualiza + commit).
21
+ help Muestra esta ayuda.
22
+
23
+ ${ui.c.bold('Opciones de login:')}
24
+ --email <e> Email (si se omite, se pregunta).
25
+ --password <p> Contraseña (si se omite, se pregunta oculta).
26
+ --token <pat> Token de acceso personal (en vez de email + contraseña).
27
+ --base-url <url> Servidor Shilo (default: https://shilo-six.vercel.app).
28
+
29
+ ${ui.c.bold('Opciones de init:')}
30
+ --project <uuid> Id del proyecto de Shilo (requerido).
31
+ --token <pat> Token de acceso personal (evita el "login" previo).
32
+ --push Sube la carpeta apenas queda atada.
33
+ --base-url <url> Servidor Shilo.
34
+ --force No verifica acceso ni pregunta antes de sobrescribir.
35
+
36
+ ${ui.c.bold('Opciones de push:')}
37
+ --project <uuid> Proyecto destino (pisa el .shilo.json de la carpeta).
38
+ -m, --message <m> Mensaje del commit.
39
+ --dry-run Muestra qué haría, sin subir nada.
40
+ --yes No pide confirmación.
41
+ --base-url <url> Servidor Shilo.
42
+
43
+ ${ui.c.bold('Globales:')}
44
+ -v, --version Versión del CLI.
45
+ -h, --help Ayuda.
46
+ `;
47
+
48
+ const COMMANDS = {
49
+ login: () => require('../src/commands/login'),
50
+ whoami: () => require('../src/commands/whoami'),
51
+ logout: () => require('../src/commands/logout'),
52
+ init: () => require('../src/commands/init'),
53
+ push: () => require('../src/commands/push'),
54
+ };
55
+
56
+ async function main() {
57
+ const argv = process.argv.slice(2);
58
+ const parsed = parseArgs(argv, {
59
+ // 'push' tiene que estar acá: la lista de booleanos es global, y sin
60
+ // registrarlo `shilo init --project X --push .` se comería el `.` como
61
+ // valor de --push, perdiendo el positional de la carpeta.
62
+ booleans: ['help', 'version', 'h', 'v', 'dry-run', 'yes', 'force', 'push'],
63
+ aliases: { h: 'help', v: 'version', m: 'message' },
64
+ });
65
+
66
+ const cmd = parsed._[0];
67
+
68
+ if (parsed.version && !cmd) {
69
+ console.log(VERSION);
70
+ return 0;
71
+ }
72
+ if (!cmd || cmd === 'help' || (parsed.help && !cmd)) {
73
+ console.log(HELP);
74
+ return 0;
75
+ }
76
+
77
+ const loader = COMMANDS[cmd];
78
+ if (!loader) {
79
+ ui.error(`Comando desconocido: "${cmd}".`);
80
+ console.log(HELP);
81
+ return 1;
82
+ }
83
+
84
+ if (parsed.help) {
85
+ console.log(HELP);
86
+ return 0;
87
+ }
88
+
89
+ // Los positionals del comando (sin el nombre del comando).
90
+ const commandArgs = { ...parsed, _: parsed._.slice(1) };
91
+ const handler = loader();
92
+ return handler(commandArgs);
93
+ }
94
+
95
+ main()
96
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
97
+ .catch((err) => {
98
+ ui.error(err && err.message ? err.message : String(err));
99
+ process.exit(1);
100
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "shilo-cli",
3
+ "version": "0.2.0",
4
+ "description": "CLI para subir carpetas locales a repositorios de Shilo (login + push tipo git).",
5
+ "keywords": [
6
+ "shilo",
7
+ "cli",
8
+ "push",
9
+ "upload",
10
+ "repositorios"
11
+ ],
12
+ "homepage": "https://shilo-six.vercel.app",
13
+ "bin": {
14
+ "shilo": "bin/shilo.js"
15
+ },
16
+ "type": "commonjs",
17
+ "main": "src/index.js",
18
+ "files": [
19
+ "bin/",
20
+ "src/",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "start": "node bin/shilo.js"
25
+ },
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "license": "MIT"
30
+ }
@@ -0,0 +1,427 @@
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 / 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://shilo-six.vercel.app';
22
+ const DEFAULT_TIMEOUT_MS = 45000;
23
+
24
+ class ShiloError extends Error {
25
+ constructor(message, { status = null, body = null } = {}) {
26
+ super(message);
27
+ this.name = 'ShiloError';
28
+ this.status = status;
29
+ this.body = body;
30
+ }
31
+ }
32
+
33
+ class ShiloClient {
34
+ constructor(options = {}) {
35
+ this.baseURL = (options.baseURL || DEFAULT_BASE_URL).replace(/\/+$/, '');
36
+ this.timeoutMs = Number(options.timeoutMs) || DEFAULT_TIMEOUT_MS;
37
+ // Opcionales: habilitan refresh de token sin re-login.
38
+ this.supabaseUrl = options.supabaseUrl || null;
39
+ this.supabaseAnonKey = options.supabaseAnonKey || null;
40
+ }
41
+
42
+ async _fetch(url, { method = 'GET', headers = {}, body = null } = {}) {
43
+ const controller = new AbortController();
44
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
45
+ let res;
46
+ try {
47
+ res = await fetch(url, {
48
+ method,
49
+ headers,
50
+ body: body != null ? JSON.stringify(body) : undefined,
51
+ signal: controller.signal,
52
+ });
53
+ } catch (err) {
54
+ clearTimeout(timer);
55
+ if (err.name === 'AbortError') {
56
+ throw new ShiloError(`Timeout tras ${this.timeoutMs}ms: ${method} ${url}`);
57
+ }
58
+ throw new ShiloError(`Fallo de red: ${err.message}`);
59
+ }
60
+ clearTimeout(timer);
61
+
62
+ const text = await res.text();
63
+ let data = null;
64
+ if (text) {
65
+ try {
66
+ data = JSON.parse(text);
67
+ } catch (_) {
68
+ data = text;
69
+ }
70
+ }
71
+ return { res, data };
72
+ }
73
+
74
+ _errMessage(data, fallback) {
75
+ if (data && typeof data === 'object') {
76
+ if (typeof data.error === 'string') return data.error;
77
+ if (typeof data.message === 'string') return data.message;
78
+ return JSON.stringify(data);
79
+ }
80
+ if (typeof data === 'string' && data) return data;
81
+ return fallback;
82
+ }
83
+
84
+ /**
85
+ * Autentica contra /api/auth/login. Devuelve credenciales normalizadas.
86
+ */
87
+ async authenticate(email, password) {
88
+ const { res, data } = await this._fetch(`${this.baseURL}/api/auth/login`, {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: { email, password },
92
+ });
93
+
94
+ if (!res.ok) {
95
+ throw new ShiloError(
96
+ `Autenticación fallida: ${this._errMessage(data, `HTTP ${res.status}`)}`,
97
+ { status: res.status, body: data }
98
+ );
99
+ }
100
+
101
+ const session = data && data.data && data.data.session;
102
+ const user = data && data.data && data.data.user;
103
+ if (!session || !session.access_token) {
104
+ throw new ShiloError('Respuesta de login inesperada (sin session.access_token)', {
105
+ status: res.status,
106
+ body: data,
107
+ });
108
+ }
109
+
110
+ const expiresAt = new Date(Date.now() + (session.expires_in || 3600) * 1000).toISOString();
111
+ return {
112
+ jwt: session.access_token,
113
+ refreshToken: session.refresh_token || null,
114
+ expiresAt,
115
+ user: user ? { id: user.id, email: user.email } : null,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Refresca el JWT con el refresh_token vía Supabase. Requiere supabaseUrl +
121
+ * supabaseAnonKey configurados; si no, lanza para que el caller pida re-login.
122
+ */
123
+ async refreshToken(refreshToken) {
124
+ if (!this.supabaseUrl || !this.supabaseAnonKey) {
125
+ throw new ShiloError(
126
+ 'Refresh no configurado (falta supabaseUrl/anonKey). Ejecutá "shilo login" de nuevo.'
127
+ );
128
+ }
129
+ const { res, data } = await this._fetch(
130
+ `${this.supabaseUrl.replace(/\/+$/, '')}/auth/v1/token?grant_type=refresh_token`,
131
+ {
132
+ method: 'POST',
133
+ headers: {
134
+ apikey: this.supabaseAnonKey,
135
+ 'Content-Type': 'application/json',
136
+ },
137
+ body: { refresh_token: refreshToken },
138
+ }
139
+ );
140
+ if (!res.ok || !data || !data.access_token) {
141
+ throw new ShiloError('No se pudo refrescar el token. Re-autenticación requerida.', {
142
+ status: res.status,
143
+ body: data,
144
+ });
145
+ }
146
+ const expiresAt = new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString();
147
+ return {
148
+ jwt: data.access_token,
149
+ refreshToken: data.refresh_token || refreshToken,
150
+ expiresAt,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Devuelve credenciales con JWT válido, refrescando si faltan <5min.
156
+ * Si el refresh no está disponible/falla, propaga para que el CLI pida login.
157
+ *
158
+ * Un PAT no tiene refresh ni expiración local: la validez la decide el
159
+ * servidor en cada request, así que se devuelve tal cual.
160
+ */
161
+ async ensureValidToken(credentials) {
162
+ if (credentials && credentials.kind === 'pat') return credentials;
163
+
164
+ if (!credentials || !credentials.jwt) {
165
+ throw new ShiloError('No hay sesión. Ejecutá "shilo login".');
166
+ }
167
+ const msLeft = new Date(credentials.expiresAt).getTime() - Date.now();
168
+ if (msLeft >= 5 * 60 * 1000) return credentials;
169
+
170
+ if (!credentials.refreshToken) {
171
+ throw new ShiloError('Sesión expirada. Ejecutá "shilo login" de nuevo.');
172
+ }
173
+ const fresh = await this.refreshToken(credentials.refreshToken);
174
+ return { ...credentials, ...fresh };
175
+ }
176
+
177
+ /**
178
+ * Verifica que unas credenciales sean aceptadas por el servidor, listando los
179
+ * proyectos del usuario. Sirve tanto para JWT como para PAT.
180
+ * Devuelve `{ ok: <bool>, status, error }` — no lanza por credenciales malas.
181
+ */
182
+ async verifyCredentials(credentials) {
183
+ const headers = { Authorization: `Bearer ${credentials.jwt}` };
184
+ const { res, data } = await this._fetch(`${this.baseURL}/api/projects`, { headers });
185
+ if (res.ok) return { ok: true, status: res.status, error: null };
186
+ return {
187
+ ok: false,
188
+ status: res.status,
189
+ error: this._errMessage(data, `HTTP ${res.status}`),
190
+ };
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Helpers de archivos
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Codifica una ruta de repo (segmentos separados por `/`) para la URL,
199
+ * escapando cada segmento pero conservando los separadores. Los nombres
200
+ * válidos en Shilo pueden incluir `[ ] ( ) @`, que conviene escapar.
201
+ */
202
+ _encodePath(repoPath) {
203
+ if (!repoPath) return '';
204
+ return repoPath
205
+ .split('/')
206
+ .filter(Boolean)
207
+ .map((seg) => encodeURIComponent(seg))
208
+ .join('/');
209
+ }
210
+
211
+ _fileUrl(projectId, repoPath = '') {
212
+ const enc = this._encodePath(repoPath);
213
+ return enc
214
+ ? `${this.baseURL}/api/files/${projectId}/${enc}`
215
+ : `${this.baseURL}/api/files/${projectId}`;
216
+ }
217
+
218
+ async _authFetch(url, credentials, { method = 'GET', body = null } = {}) {
219
+ const validCreds = await this.ensureValidToken(credentials);
220
+ const headers = { Authorization: `Bearer ${validCreds.jwt}` };
221
+ if (body != null) headers['Content-Type'] = 'application/json';
222
+ const { res, data } = await this._fetch(url, { method, headers, body });
223
+ return { res, data, credentials: validCreds };
224
+ }
225
+
226
+ /**
227
+ * Lista el contenido directo de una carpeta (no recursa). Raíz responde
228
+ * `{ files }`, una carpeta responde `{ folder, files }`.
229
+ * Devuelve `{ files: [...], credentials }`.
230
+ */
231
+ async listFiles(projectId, credentials, folderPath = '') {
232
+ const url = this._fileUrl(projectId, folderPath);
233
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials);
234
+ if (!res.ok) {
235
+ throw new ShiloError(
236
+ `No se pudieron listar archivos: ${this._errMessage(data, `HTTP ${res.status}`)}`,
237
+ { status: res.status, body: data }
238
+ );
239
+ }
240
+ const files = data && Array.isArray(data.files) ? data.files : Array.isArray(data) ? data : [];
241
+ return { files, credentials: creds };
242
+ }
243
+
244
+ /**
245
+ * Obtiene la metadata de un archivo/carpeta por ruta SIN descargar contenido.
246
+ * Devuelve `{ file: <obj|null>, exists: <bool>, credentials }`.
247
+ * Un 404 no es error: significa que la ruta no existe todavía (archivo nuevo).
248
+ */
249
+ async getFileMeta(projectId, filePath, credentials) {
250
+ const url = this._fileUrl(projectId, filePath);
251
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials);
252
+ if (res.status === 404) {
253
+ return { file: null, exists: false, credentials: creds };
254
+ }
255
+ if (!res.ok) {
256
+ throw new ShiloError(
257
+ `No se pudo consultar "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
258
+ { status: res.status, body: data }
259
+ );
260
+ }
261
+ const file = data && data.file ? data.file : data;
262
+ return { file: file || null, exists: !!file, credentials: creds };
263
+ }
264
+
265
+ /**
266
+ * Lee metadata + contenido de un archivo (`?content=true`).
267
+ * Devuelve `{ file: { ...meta, content }, credentials }`.
268
+ */
269
+ async readFile(projectId, filePath, credentials) {
270
+ const url = `${this._fileUrl(projectId, filePath)}?content=true`;
271
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials);
272
+ if (!res.ok) {
273
+ throw new ShiloError(
274
+ `No se pudo leer "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
275
+ { status: res.status, body: data }
276
+ );
277
+ }
278
+ const file = data && data.file ? data.file : {};
279
+ const content = data && data.content !== undefined ? data.content : null;
280
+ return { file: { ...file, content }, credentials: creds };
281
+ }
282
+
283
+ /**
284
+ * Crea un archivo nuevo (POST a la carpeta padre). Idempotente en el server:
285
+ * si ya existe, responde 409 con el objeto existente. Aquí tratamos el 409
286
+ * como "ya existía" y devolvemos `{ file, created: false }` para que el caller
287
+ * decida (normalmente, hacer un PUT).
288
+ * Devuelve `{ file, created: <bool>, credentials }`.
289
+ */
290
+ async createFile(projectId, filePath, content, mimeType, credentials) {
291
+ const parts = filePath.split('/').filter(Boolean);
292
+ const fileName = parts.pop();
293
+ const parentPath = parts.join('/');
294
+ const url = this._fileUrl(projectId, parentPath);
295
+
296
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
297
+ method: 'POST',
298
+ body: { name: fileName, type: 'file', content, mimeType },
299
+ });
300
+
301
+ if (res.status === 409) {
302
+ const existing = (data && (data.file || data.folder)) || null;
303
+ return { file: existing, created: false, credentials: creds };
304
+ }
305
+ if (!res.ok) {
306
+ throw new ShiloError(
307
+ `No se pudo crear "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
308
+ { status: res.status, body: data }
309
+ );
310
+ }
311
+ const file = (data && (data.file || data)) || null;
312
+ return { file, created: true, credentials: creds };
313
+ }
314
+
315
+ /**
316
+ * Actualiza el contenido de un archivo existente (PUT). Devuelve el archivo
317
+ * con su NUEVO `storage_path`. `{ file, credentials }`.
318
+ */
319
+ async writeFile(projectId, filePath, content, mimeType, credentials) {
320
+ const url = this._fileUrl(projectId, filePath);
321
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
322
+ method: 'PUT',
323
+ body: { content, mimeType },
324
+ });
325
+ if (!res.ok) {
326
+ throw new ShiloError(
327
+ `No se pudo actualizar "${filePath}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
328
+ { status: res.status, body: data }
329
+ );
330
+ }
331
+ const file = (data && (data.file || data)) || null;
332
+ return { file, credentials: creds };
333
+ }
334
+
335
+ /**
336
+ * Crea una carpeta bajo `parentPath` (vacío = raíz). Un 409 significa que ya
337
+ * existe: no es fatal, devolvemos `{ folder, existed: true }`.
338
+ * `{ folder, existed: <bool>, credentials }`.
339
+ */
340
+ async createFolder(projectId, folderName, parentPath = '', credentials) {
341
+ const url = this._fileUrl(projectId, parentPath);
342
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
343
+ method: 'POST',
344
+ body: { name: folderName, type: 'folder' },
345
+ });
346
+ if (res.status === 409) {
347
+ const existing = (data && (data.folder || data.file)) || { name: folderName };
348
+ return { folder: existing, existed: true, credentials: creds };
349
+ }
350
+ if (!res.ok) {
351
+ throw new ShiloError(
352
+ `No se pudo crear la carpeta "${folderName}": ${this._errMessage(data, `HTTP ${res.status}`)}`,
353
+ { status: res.status, body: data }
354
+ );
355
+ }
356
+ const folder = (data && (data.file || data)) || null;
357
+ return { folder, existed: false, credentials: creds };
358
+ }
359
+
360
+ /**
361
+ * Asegura que existan todas las carpetas del path de un archivo.
362
+ * Estrategia anti-duplicados: Shilo puede no devolver 409 fiable, así que
363
+ * listamos el padre antes de crear y reutilizamos la carpeta si ya existe.
364
+ * `ensuredCache` (Set de rutas completas) evita re-listar dentro de una corrida.
365
+ * Devuelve las credenciales (posiblemente refrescadas).
366
+ */
367
+ async ensurePath(projectId, filePath, credentials, ensuredCache = null) {
368
+ const parts = filePath.split('/').filter(Boolean);
369
+ parts.pop(); // quitar el nombre del archivo
370
+ if (parts.length === 0) return credentials;
371
+
372
+ let currentCreds = credentials;
373
+ let parentPath = '';
374
+
375
+ for (const folderName of parts) {
376
+ const fullPath = parentPath ? `${parentPath}/${folderName}` : folderName;
377
+
378
+ if (ensuredCache && ensuredCache.has(fullPath)) {
379
+ parentPath = fullPath;
380
+ continue;
381
+ }
382
+
383
+ let exists = false;
384
+ try {
385
+ const listResult = await this.listFiles(projectId, currentCreds, parentPath);
386
+ currentCreds = listResult.credentials;
387
+ exists = listResult.files.some((e) => e && e.type === 'folder' && e.name === folderName);
388
+ } catch (_) {
389
+ // El padre puede no existir aún: dejamos que createFolder lo cree.
390
+ exists = false;
391
+ }
392
+
393
+ if (!exists) {
394
+ const result = await this.createFolder(projectId, folderName, parentPath, currentCreds);
395
+ currentCreds = result.credentials;
396
+ }
397
+
398
+ if (ensuredCache) ensuredCache.add(fullPath);
399
+ parentPath = fullPath;
400
+ }
401
+
402
+ return currentCreds;
403
+ }
404
+
405
+ /**
406
+ * Crea un commit con la lista de cambios de archivos (máx 100 por lote).
407
+ * `changes`: [{ file_id, action, previous_storage_path?, new_storage_path? }].
408
+ * Devuelve `{ commit, credentials }`.
409
+ */
410
+ async createCommit(projectId, message, changes, credentials) {
411
+ const url = `${this.baseURL}/api/commits/${projectId}`;
412
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
413
+ method: 'POST',
414
+ body: { message, files: changes },
415
+ });
416
+ if (!res.ok) {
417
+ throw new ShiloError(
418
+ `No se pudo crear el commit: ${this._errMessage(data, `HTTP ${res.status}`)}`,
419
+ { status: res.status, body: data }
420
+ );
421
+ }
422
+ const commit = (data && data.commit) || data;
423
+ return { commit, credentials: creds };
424
+ }
425
+ }
426
+
427
+ module.exports = { ShiloClient, ShiloError };