shilo-cli 0.3.2 → 0.5.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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shilo-cli",
3
- "version": "0.3.2",
3
+ "version": "0.5.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 } = {}) {
54
93
  const controller = new AbortController();
55
94
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
56
95
  let res;
@@ -64,9 +103,11 @@ class ShiloClient {
64
103
  } catch (err) {
65
104
  clearTimeout(timer);
66
105
  if (err.name === 'AbortError') {
67
- throw new ShiloError(`Timeout tras ${this.timeoutMs}ms: ${method} ${url}`);
106
+ throw new ShiloError(`Timeout tras ${this.timeoutMs}ms: ${method} ${url}`, {
107
+ retryable: true,
108
+ });
68
109
  }
69
- throw new ShiloError(`Fallo de red: ${err.message}`);
110
+ throw new ShiloError(`Fallo de red: ${err.message}`, { retryable: true });
70
111
  }
71
112
  clearTimeout(timer);
72
113
 
@@ -82,6 +123,43 @@ class ShiloClient {
82
123
  return { res, data };
83
124
  }
84
125
 
126
+ /**
127
+ * `_fetchOnce` + reintentos con backoff exponencial y jitter. Un 502
128
+ * transitorio o un microcorte de red dejaban de forma permanente ese archivo
129
+ * afuera del push —y si pasaba en la fase de commit, quedaban archivos
130
+ * escritos sin commit que los registrara.
131
+ */
132
+ async _fetch(url, options = {}) {
133
+ const maxAttempts = Math.max(1, this.retries);
134
+ let nextDelay = 0;
135
+
136
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
137
+ const isLast = attempt === maxAttempts - 1;
138
+ if (attempt > 0) await sleep(nextDelay);
139
+
140
+ let result;
141
+ try {
142
+ result = await this._fetchOnce(url, options);
143
+ } catch (err) {
144
+ if (!err.retryable || isLast) throw err;
145
+ nextDelay = backoffDelay(attempt);
146
+ continue;
147
+ }
148
+
149
+ const { res } = result;
150
+ const throttled = res.status === 429;
151
+ if (throttled && this.onThrottle) this.onThrottle();
152
+
153
+ if ((!throttled && res.status < 500) || isLast) return result;
154
+
155
+ const hinted = retryAfterMs(res);
156
+ nextDelay = hinted != null ? hinted : backoffDelay(attempt);
157
+ }
158
+
159
+ /* istanbul ignore next — el bucle siempre sale por return o throw */
160
+ throw new ShiloError(`No se pudo completar la petición a ${url}`);
161
+ }
162
+
85
163
  _errMessage(data, fallback) {
86
164
  if (data && typeof data === 'object') {
87
165
  if (typeof data.error === 'string') return data.error;
@@ -343,6 +421,44 @@ class ShiloClient {
343
421
  return { file, credentials: creds };
344
422
  }
345
423
 
424
+ /**
425
+ * Escribe varios archivos en una sola llamada (`POST /api/files/:id/batch`).
426
+ * Crea las carpetas intermedias por su cuenta, así que no hace falta un
427
+ * `ensurePath` previo.
428
+ *
429
+ * `files`: [{ path, content, mimeType? }] — máximo 100 y 5 MB por lote.
430
+ *
431
+ * El endpoint responde 200 aunque algunos archivos fallen: el resultado viene
432
+ * POR ARCHIVO, en el mismo orden que se mandó. Los `ok:true` traen
433
+ * `file.previous_storage_path`, que es lo que después pide el commit.
434
+ * Devuelve `{ results, summary, credentials }`.
435
+ *
436
+ * Un servidor viejo (sin el endpoint) responde 404/405: se marca en el error
437
+ * con `notSupported` para que el caller pueda caer al camino de a un archivo.
438
+ */
439
+ async writeBatch(projectId, files, credentials) {
440
+ const url = `${this.baseURL}/api/files/${projectId}/batch`;
441
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials, {
442
+ method: 'POST',
443
+ body: { files },
444
+ });
445
+
446
+ if (!res.ok) {
447
+ throw new ShiloError(
448
+ `No se pudo escribir el lote: ${this._errMessage(data, `HTTP ${res.status}`)}`,
449
+ { status: res.status, body: data, notSupported: res.status === 404 || res.status === 405 }
450
+ );
451
+ }
452
+
453
+ const results = data && Array.isArray(data.results) ? data.results : [];
454
+ const summary = (data && data.summary) || {
455
+ total: results.length,
456
+ succeeded: results.filter((r) => r && r.ok).length,
457
+ failed: results.filter((r) => r && !r.ok).length,
458
+ };
459
+ return { results, summary, credentials: creds };
460
+ }
461
+
346
462
  /**
347
463
  * Crea una carpeta bajo `parentPath` (vacío = raíz). Un 409 significa que ya
348
464
  * 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;