shilo-cli 0.3.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,12 +39,63 @@ shilo push -m "mi mensaje" # con mensaje de commit
39
39
  shilo push --dry-run # muestra qué haría sin subir
40
40
  shilo push --card ABC-123 # agrega "[card: ABC-123]" al mensaje del commit
41
41
  shilo push --yes # sin pedir confirmación
42
+ shilo push --verbose # lista archivo por archivo en vez de resumir
43
+ shilo push --concurrency 4 # requests en paralelo (1-32, default 8)
42
44
  shilo push ./otra-carpeta --project <uuid> # sin init, proyecto explícito
43
45
  ```
44
46
 
45
47
  Sólo se suben archivos de **texto**; binarios, oversize (>25 MB) y nombres con
46
48
  caracteres no permitidos se saltan con aviso. Los archivos sin cambios no se
47
- re-suben. Ignorá rutas con un `.shiloignore` (sintaxis estilo `.gitignore`).
49
+ re-suben.
50
+
51
+ Los nombres aceptan **espacios, acentos y Unicode** (`Notas Metrónomo.txt`, `日本語.md`):
52
+ sólo se rechaza lo que rompe una ruta o no es portable — `/ \ : * ? " < > |`, bytes de
53
+ control, `.`/`..`, nombres de dispositivo de Windows (`CON`, `NUL`, `COM1`…) y el punto
54
+ final. Los nombres se normalizan a NFC, igual que el backend, para que el mismo archivo
55
+ subido desde macOS y desde Windows sea uno solo.
56
+
57
+ La subida va **por lotes** (hasta 100 archivos o 4 MB por request) y **cada lote se
58
+ commitea apenas termina**: si el push se corta a la mitad, lo ya escrito queda en el
59
+ historial en vez de quedar suelto en storage. Contra un servidor viejo que no conoce el
60
+ endpoint de lotes, el CLI avisa y sigue archivo por archivo.
61
+
62
+ #### Qué se ignora
63
+
64
+ `push` respeta **`.gitignore` y `.shiloignore`**, incluidos los de subcarpetas (una
65
+ regla de `src/.gitignore` sólo alcanza a `src/`). `.shiloignore` se lee último, así que
66
+ podés re-incluir con `!` algo que git ignora. Por defecto ya se saltean `.git/`,
67
+ `node_modules/`, `.shilo/`, `.vercel/`, `.DS_Store` y `Thumbs.db`.
68
+
69
+ También se poda cualquier carpeta que **tenga forma de repo git** (`HEAD` + `objects/`
70
+ + `refs/`), aunque no se llame `.git`: un `.git-ORIGINAL-BACKUP/` trae todo el
71
+ historial, incluidos secretos de commits ya borrados del árbol de trabajo.
72
+
73
+ > **Limitación conocida:** la negación `!` se evalúa por regla, no por directorio. Como
74
+ > en git, `!` no puede re-incluir un archivo si su carpeta padre ya está excluida
75
+ > (`dist/` + `!dist/keep.txt` no funciona); excluí el contenido en vez de la carpeta
76
+ > (`dist/*` + `!dist/keep.txt`).
77
+
78
+ Lo salteado se reporta **agrupado por motivo** (binario, ilegible, symlink, nombre
79
+ inválido, profundidad) con unos pocos ejemplos; si una carpeta concentra muchos
80
+ descartes, el CLI sugiere la línea de `.shiloignore` que la cubre. Con `--verbose` ves
81
+ la lista completa.
82
+
83
+ #### Escaneo de secretos
84
+
85
+ Antes de tocar la red, `push` revisa nombres sospechosos (`.env`, `id_rsa`,
86
+ `*.pem`, credenciales de service account…) y los primeros 8 KB de cada archivo
87
+ buscando patrones de claves (AWS, JWT de Supabase, tokens de GitHub, claves privadas
88
+ PEM…). Si encuentra algo, **no sube nada** y sale con código 1, indicando archivo y
89
+ línea — nunca el valor detectado. Salidas: agregarlo a `.shiloignore`, sacar el
90
+ secreto, o `--allow-secrets` si es un falso positivo.
91
+
92
+ #### Índice local
93
+
94
+ `push` guarda un índice en `.shilo/index.json` (tamaño + mtime + hash de lo último
95
+ subido). Los archivos que no cambiaron se resuelven sin bajar el contenido remoto,
96
+ que es lo que hace rápido el segundo push de una carpeta grande. Se puede ignorar con
97
+ `--no-index` para recomparar todo contra el servidor. Agregá `.shilo/` a tu
98
+ `.gitignore` (el CLI ya no lo sube).
48
99
 
49
100
  En `--dry-run` (y antes de la confirmación interactiva) cada archivo modificado
50
101
  muestra **quién lo tocó por última vez y cuándo**, y se avisa explícitamente si vas
@@ -137,12 +188,25 @@ con AES-256-CBC y con permisos `0600`.
137
188
  Se almacena el JWT, el refresh token, la expiración y el email; **no** se guarda la
138
189
  contraseña.
139
190
 
191
+ ### Tokens de acceso personal (PAT)
192
+
193
+ Un token pasado como `--token abc123` queda en el historial del shell y en la lista de
194
+ procesos (`ps` lo muestra a cualquier usuario de la máquina). El CLI lo acepta —es la
195
+ forma que se copia desde la web— pero avisa. Alternativas:
196
+
197
+ ```bash
198
+ cat pat.txt | shilo login --token - # lee el token de stdin
199
+ SHILO_TOKEN=<pat> shilo init --project <uuid> # variable de entorno (lo natural en CI)
200
+ ```
201
+
140
202
  ## Variables de entorno
141
203
 
142
204
  | Variable | Efecto |
143
205
  | ----------------------- | ------------------------------------------------------------- |
144
206
  | `SHILO_CONFIG_DIR` | Directorio de config (default `~/.shilo`). |
145
207
  | `SHILO_ENCRYPTION_KEY` | Passphrase para cifrar credenciales (recomendado). |
208
+ | `SHILO_TOKEN` | Token de acceso personal (evita pasarlo por `--token`). |
209
+ | `SHILO_CONCURRENCY` | Requests en paralelo del push (1-32, default 8). |
146
210
  | `NO_COLOR` | Desactiva colores ANSI. |
147
211
 
148
212
  ## Roadmap
@@ -160,6 +224,13 @@ contraseña.
160
224
  de contenido + exit code 1), atribución de autor en `push --dry-run`, `pull` que
161
225
  resuelve conflictos por archivo en vez de abortar, `lastSync` en `.shilo.json` y
162
226
  `push --card <id>` para cruzar commits con un tablero externo (Metrónomo).
227
+ - **Fase 6 — robustez del push** *(hecho)*: `.gitignore` (incluidos los anidados),
228
+ poda de repos git por forma, escaneo de secretos con corte del push, preflight
229
+ 100 % offline antes de tocar la red, planificación con un recorrido del árbol
230
+ remoto (una request por carpeta en vez de una por archivo), índice local
231
+ `.shilo/index.json`, lectura perezosa del contenido, reintentos con backoff ante
232
+ 429/5xx, concurrencia adaptativa, barra de progreso y confirmación real en
233
+ `init --push`.
163
234
 
164
235
  ## Exit codes
165
236
 
@@ -168,4 +239,5 @@ contraseña.
168
239
  | `status` | 2 | El remoto tiene cambios que no tenés. |
169
240
  | `diff` | 1 | Hay diferencias entre local y remoto. |
170
241
  | `pull` | 1 | Quedaron conflictos sin bajar (o fallas). |
242
+ | `push` | 1 | Se detectaron posibles secretos (nada subido). |
171
243
  | cualquiera | 1 | Error. |
package/bin/shilo.js CHANGED
@@ -28,13 +28,15 @@ ${ui.c.bold('Opciones de login:')}
28
28
  --email <e> Email (si se omite, se pregunta).
29
29
  --password <p> Contraseña (si se omite, se pregunta oculta).
30
30
  --token <pat> Token de acceso personal (en vez de email + contraseña).
31
+ --token - Lee el token de stdin (no queda en el historial del shell).
31
32
  --base-url <url> Servidor Shilo (default: https://www.shilo.city).
33
+ ${ui.c.dim('También se lee de la variable de entorno SHILO_TOKEN.')}
32
34
 
33
35
  ${ui.c.bold('Opciones de init:')}
34
36
  --project <uuid> Id del proyecto de Shilo (requerido).
35
- --token <pat> Token de acceso personal (evita el "login" previo).
36
- --push Sube la carpeta apenas queda atada.
37
- --pull Baja la carpeta apenas queda atada.
37
+ --token <pat> Token de acceso personal (evita el "login" previo; o SHILO_TOKEN).
38
+ --push Sube la carpeta apenas queda atada (pregunta, salvo --yes).
39
+ --pull Baja la carpeta apenas queda atada (pregunta, salvo --yes).
38
40
  --base-url <url> Servidor Shilo.
39
41
  --force No verifica acceso ni pregunta antes de sobrescribir.
40
42
 
@@ -51,7 +53,12 @@ ${ui.c.bold('Opciones de push:')}
51
53
  --card <id> Id de tarjeta/tablero; se agrega al mensaje como [card: id].
52
54
  --dry-run Muestra qué haría (y quién tocó cada archivo), sin subir nada.
53
55
  --yes No pide confirmación.
56
+ --allow-secrets Sube igual los archivos donde se detectaron posibles secretos.
57
+ --concurrency <n> Requests en paralelo (1-32, default 8; o SHILO_CONCURRENCY).
58
+ --no-index Ignora el índice local (.shilo/index.json) y recompara todo.
59
+ --verbose Lista archivo por archivo en vez de resumir.
54
60
  --base-url <url> Servidor Shilo.
61
+ ${ui.c.dim('Respeta .gitignore y .shiloignore, incluidos los de subcarpetas.')}
55
62
 
56
63
  ${ui.c.bold('Opciones de pull:')}
57
64
  --project <uuid> Proyecto origen (pisa el .shilo.json de la carpeta).
@@ -97,7 +104,7 @@ async function main() {
97
104
  // valor de --push, perdiendo el positional de la carpeta.
98
105
  booleans: [
99
106
  'help', 'version', 'h', 'v', 'dry-run', 'yes', 'force', 'push', 'pull',
100
- 'json', 'short', 'stat', 'files',
107
+ 'json', 'short', 'stat', 'files', 'allow-secrets', 'no-index', 'verbose',
101
108
  ],
102
109
  aliases: { h: 'help', v: 'version', m: 'message' },
103
110
  });
@@ -131,9 +138,28 @@ async function main() {
131
138
  return handler(commandArgs);
132
139
  }
133
140
 
141
+ /**
142
+ * Termina con `code` sin llamar a `process.exit()` de inmediato.
143
+ *
144
+ * Llamarlo justo después de un `fetch` aborta el proceso en Windows con
145
+ * `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`: libuv cierra dos
146
+ * veces el handle del pool de conexiones de undici. El comando ya imprimió todo
147
+ * y hasta guardó en disco, pero el shell recibe 127 en vez de 0 — o sea que
148
+ * cualquier script o CI que chequee el resultado de `shilo push` lee basura.
149
+ *
150
+ * Dejar que el loop se vacíe solo evita el crash y no cuesta nada: las conexiones
151
+ * de undici no tienen ref, así que el proceso sale igual de rápido. El timer es
152
+ * el paracaídas por si algo queda colgado; sin ref, no mantiene vivo al proceso
153
+ * por sí mismo, sólo actúa si ya lo estaba.
154
+ */
155
+ function finish(code) {
156
+ process.exitCode = code;
157
+ setTimeout(() => process.exit(code), 5000).unref();
158
+ }
159
+
134
160
  main()
135
- .then((code) => process.exit(typeof code === 'number' ? code : 0))
161
+ .then((code) => finish(typeof code === 'number' ? code : 0))
136
162
  .catch((err) => {
137
163
  ui.error(err && err.message ? err.message : String(err));
138
- process.exit(1);
164
+ finish(1);
139
165
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shilo-cli",
3
- "version": "0.3.2",
3
+ "version": "0.6.0",
4
4
  "description": "CLI para sincronizar carpetas locales con repositorios de Shilo: push, pull, status, log y diff.",
5
5
  "keywords": [
6
6
  "shilo",
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Archivos y commits (Fase 2):
13
13
  * - listFiles / getFileMeta / readFile
14
- * - createFile / writeFile / createFolder / ensurePath
14
+ * - createFile / writeFile / writeBatch / createFolder / ensurePath
15
15
  * - createCommit
16
16
  *
17
17
  * Todos los métodos de red toman `credentials` y devuelven `{ ..., credentials }`
@@ -21,6 +21,36 @@
21
21
  const DEFAULT_BASE_URL = 'https://www.shilo.city';
22
22
  const DEFAULT_TIMEOUT_MS = 45000;
23
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
+
24
54
  /**
25
55
  * El ápex https://shilo.city responde 307 hacia https://www.shilo.city. Como
26
56
  * `fetch` sigue redirects pero descarta el header Authorization al cruzar de
@@ -33,11 +63,15 @@ function normalizeBaseURL(url) {
33
63
  }
34
64
 
35
65
  class ShiloError extends Error {
36
- constructor(message, { status = null, body = null } = {}) {
66
+ constructor(message, { status = null, body = null, retryable = false, notSupported = false } = {}) {
37
67
  super(message);
38
68
  this.name = 'ShiloError';
39
69
  this.status = status;
40
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;
41
75
  }
42
76
  }
43
77
 
@@ -48,9 +82,14 @@ class ShiloClient {
48
82
  // Opcionales: habilitan refresh de token sin re-login.
49
83
  this.supabaseUrl = options.supabaseUrl || null;
50
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;
51
89
  }
52
90
 
53
- async _fetch(url, { method = 'GET', headers = {}, body = null } = {}) {
91
+ /** Un intento único, sin reintentos. */
92
+ async _fetchOnce(url, { method = 'GET', headers = {}, body = null, raw = false } = {}) {
54
93
  const controller = new AbortController();
55
94
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
56
95
  let res;
@@ -58,18 +97,32 @@ class ShiloClient {
58
97
  res = await fetch(url, {
59
98
  method,
60
99
  headers,
61
- body: body != null ? JSON.stringify(body) : undefined,
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),
62
105
  signal: controller.signal,
63
106
  });
64
107
  } catch (err) {
65
108
  clearTimeout(timer);
66
109
  if (err.name === 'AbortError') {
67
- throw new ShiloError(`Timeout tras ${this.timeoutMs}ms: ${method} ${url}`);
110
+ throw new ShiloError(`Timeout tras ${this.timeoutMs}ms: ${method} ${url}`, {
111
+ retryable: true,
112
+ });
68
113
  }
69
- throw new ShiloError(`Fallo de red: ${err.message}`);
114
+ throw new ShiloError(`Fallo de red: ${err.message}`, { retryable: true });
70
115
  }
71
116
  clearTimeout(timer);
72
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
+
73
126
  const text = await res.text();
74
127
  let data = null;
75
128
  if (text) {
@@ -82,6 +135,43 @@ class ShiloClient {
82
135
  return { res, data };
83
136
  }
84
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
+
85
175
  _errMessage(data, fallback) {
86
176
  if (data && typeof data === 'object') {
87
177
  if (typeof data.error === 'string') return data.error;
@@ -229,7 +319,7 @@ class ShiloClient {
229
319
  async _authFetch(url, credentials, { method = 'GET', body = null } = {}) {
230
320
  const validCreds = await this.ensureValidToken(credentials);
231
321
  const headers = { Authorization: `Bearer ${validCreds.jwt}` };
232
- if (body != null) headers['Content-Type'] = 'application/json';
322
+ if (body != null && !Buffer.isBuffer(body)) headers['Content-Type'] = 'application/json';
233
323
  const { res, data } = await this._fetch(url, { method, headers, body });
234
324
  return { res, data, credentials: validCreds };
235
325
  }
@@ -288,7 +378,18 @@ class ShiloClient {
288
378
  }
289
379
  const file = data && data.file ? data.file : {};
290
380
  const content = data && data.content !== undefined ? data.content : null;
291
- return { file: { ...file, content }, credentials: creds };
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
+ };
292
393
  }
293
394
 
294
395
  /**
@@ -343,6 +444,151 @@ class ShiloClient {
343
444
  return { file, credentials: creds };
344
445
  }
345
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 url = `${this.baseURL}/api/files/${projectId}/direct`;
528
+ const payload = {
529
+ path: entry.path,
530
+ size: entry.buffer.length,
531
+ content_hash: entry.contentHash,
532
+ mime_type: entry.mimeType || 'application/octet-stream',
533
+ };
534
+
535
+ const begun = await this._authFetch(url, credentials, {
536
+ method: 'POST',
537
+ body: { action: 'begin', ...payload },
538
+ });
539
+
540
+ if (!begun.res.ok) {
541
+ throw new ShiloError(
542
+ `No se pudo preparar la subida de "${entry.path}": ${this._errMessage(begun.data, `HTTP ${begun.res.status}`)}`,
543
+ {
544
+ status: begun.res.status,
545
+ body: begun.data,
546
+ notSupported: begun.res.status === 404 || begun.res.status === 405,
547
+ }
548
+ );
549
+ }
550
+
551
+ let uploaded = false;
552
+ const upload = begun.data && begun.data.upload;
553
+
554
+ if (upload && upload.url) {
555
+ const { res } = await this._fetch(upload.url, {
556
+ method: 'PUT',
557
+ headers: {
558
+ 'Content-Type': entry.mimeType || 'application/octet-stream',
559
+ // `x-upsert: false` es el default de una URL firmada de subida y se
560
+ // deja explícito: si el objeto ya existiera, pisarlo reemplazaría el
561
+ // contenido de un blob conservando su identidad, que es justo el
562
+ // ataque que el direccionamiento por contenido tiene que impedir.
563
+ 'x-upsert': 'false',
564
+ },
565
+ body: entry.buffer,
566
+ });
567
+
568
+ if (!res.ok) {
569
+ throw new ShiloError(
570
+ `No se pudo subir el contenido de "${entry.path}": HTTP ${res.status}`,
571
+ { status: res.status }
572
+ );
573
+ }
574
+ uploaded = true;
575
+ }
576
+
577
+ const done = await this._authFetch(url, begun.credentials, {
578
+ method: 'POST',
579
+ body: { action: 'commit', ...payload },
580
+ });
581
+
582
+ if (!done.res.ok) {
583
+ throw new ShiloError(
584
+ `No se pudo registrar "${entry.path}": ${this._errMessage(done.data, `HTTP ${done.res.status}`)}`,
585
+ { status: done.res.status, body: done.data }
586
+ );
587
+ }
588
+
589
+ return { result: done.data, uploaded, credentials: done.credentials };
590
+ }
591
+
346
592
  /**
347
593
  * Crea una carpeta bajo `parentPath` (vacío = raíz). Un 409 significa que ya
348
594
  * existe: no es fatal, devolvemos `{ folder, existed: true }`.
package/src/args.js CHANGED
@@ -42,7 +42,8 @@ function parseArgs(argv, { booleans = [], aliases = {} } = {}) {
42
42
  out[key] = true;
43
43
  } else {
44
44
  const next = argv[i + 1];
45
- if (next !== undefined && !next.startsWith('-')) {
45
+ // `-` a secas es un valor legítimo ("leer de stdin"), no otra bandera.
46
+ if (next !== undefined && (next === '-' || !next.startsWith('-'))) {
46
47
  out[key] = next;
47
48
  i++;
48
49
  } else {
@@ -58,7 +59,8 @@ function parseArgs(argv, { booleans = [], aliases = {} } = {}) {
58
59
  out[key] = true;
59
60
  } else {
60
61
  const next = argv[i + 1];
61
- if (next !== undefined && !next.startsWith('-')) {
62
+ // `-` a secas es un valor legítimo ("leer de stdin"), no otra bandera.
63
+ if (next !== undefined && (next === '-' || !next.startsWith('-'))) {
62
64
  out[key] = next;
63
65
  i++;
64
66
  } else {
@@ -5,6 +5,7 @@ const fs = require('fs');
5
5
  const config = require('../config');
6
6
  const remote = require('../remote');
7
7
  const prompt = require('../prompt');
8
+ const tokens = require('../token');
8
9
  const ui = require('../ui');
9
10
  const { ShiloClient } = require('../ShiloClient');
10
11
 
@@ -43,8 +44,16 @@ async function init(args) {
43
44
 
44
45
  // --token: persistir credenciales antes del chequeo de sesión, para que el
45
46
  // comando de una línea copiado desde la web ande sin "shilo login" previo.
46
- const token = typeof args.token === 'string' ? args.token.trim() : '';
47
+ let token = '';
48
+ let tokenSource = null;
49
+ try {
50
+ ({ token, source: tokenSource } = await tokens.resolveToken(args));
51
+ } catch (e) {
52
+ ui.error(e.message);
53
+ return 1;
54
+ }
47
55
  if (token) {
56
+ tokens.warnIfExposed(tokenSource, ui);
48
57
  const existing = config.load() || {};
49
58
  config.save({
50
59
  baseURL: args['base-url'] || existing.baseURL || DEFAULT_BASE_URL,
@@ -98,14 +107,16 @@ async function init(args) {
98
107
  ui.success(`Carpeta atada al proyecto ${ui.c.bold(projectId)}.`);
99
108
  ui.info(ui.c.dim(`Remote guardado en ${written}`));
100
109
 
110
+ // --push/--pull no implican confirmación: el usuario pidió *encadenar* el
111
+ // comando, no saltearse su preflight. Sin --yes, push muestra el plan (y el
112
+ // resultado del escaneo de secretos) y pregunta como si se hubiera invocado
113
+ // a mano. `args.yes` viaja tal cual en el spread.
101
114
  if (args.push) {
102
- // El usuario ya confirmó al pasar --push: no volvemos a preguntar.
103
- return require('./push')({ ...args, _: [dir], yes: true });
115
+ return require('./push')({ ...args, _: [dir] });
104
116
  }
105
117
 
106
118
  if (args.pull) {
107
- // Espejo de --push: el usuario ya confirmó al pasar --pull.
108
- return require('./pull')({ ...args, _: [dir], yes: true });
119
+ return require('./pull')({ ...args, _: [dir] });
109
120
  }
110
121
 
111
122
  ui.info(`Ahora podés subir con: ${ui.c.bold('shilo push')}`);
@@ -2,6 +2,7 @@
2
2
 
3
3
  const config = require('../config');
4
4
  const prompt = require('../prompt');
5
+ const tokens = require('../token');
5
6
  const ui = require('../ui');
6
7
  const { ShiloClient } = require('../ShiloClient');
7
8
 
@@ -17,8 +18,18 @@ const DEFAULT_BASE_URL = 'https://www.shilo.city';
17
18
  async function login(args) {
18
19
  const baseURL = args['base-url'] || (config.load() || {}).baseURL || DEFAULT_BASE_URL;
19
20
 
20
- const token = typeof args.token === 'string' ? args.token.trim() : '';
21
- if (token) return loginWithToken(token, baseURL);
21
+ let token = '';
22
+ let source = null;
23
+ try {
24
+ ({ token, source } = await tokens.resolveToken(args));
25
+ } catch (e) {
26
+ ui.error(e.message);
27
+ return 1;
28
+ }
29
+ if (token) {
30
+ tokens.warnIfExposed(source, ui);
31
+ return loginWithToken(token, baseURL);
32
+ }
22
33
 
23
34
  let email = args.email;
24
35
  let pass = args.password;