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 +73 -1
- package/bin/shilo.js +32 -6
- package/package.json +1 -1
- package/src/ShiloClient.js +254 -8
- package/src/args.js +4 -2
- package/src/commands/init.js +16 -5
- package/src/commands/login.js +13 -2
- package/src/commands/pull.js +37 -9
- package/src/commands/push.js +841 -166
- package/src/prompt.js +10 -2
- package/src/pushindex.js +156 -0
- package/src/remote.js +2 -1
- package/src/secrets.js +168 -0
- package/src/sync.js +86 -10
- package/src/textguard.js +111 -30
- package/src/token.js +72 -0
- package/src/ui.js +61 -1
- package/src/walk.js +142 -34
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.
|
|
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) =>
|
|
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
|
-
|
|
164
|
+
finish(1);
|
|
139
165
|
});
|
package/package.json
CHANGED
package/src/ShiloClient.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
103
|
-
return require('./push')({ ...args, _: [dir], yes: true });
|
|
115
|
+
return require('./push')({ ...args, _: [dir] });
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
if (args.pull) {
|
|
107
|
-
|
|
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')}`);
|
package/src/commands/login.js
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
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;
|