shilo-cli 0.5.0 → 0.7.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/src/pushindex.js CHANGED
@@ -40,7 +40,12 @@ function indexPath(dir) {
40
40
 
41
41
  /** SHA-256 hex de un contenido utf8. */
42
42
  function hashContent(content) {
43
- return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
43
+ // Un binario llega como Buffer y un archivo de texto como string. Se hashean
44
+ // los BYTES en los dos casos: el hash tiene que ser el mismo que calcula el
45
+ // backend sobre el contenido crudo (`files.content_hash`, migración 010), o
46
+ // el push reportaría "modificado" en cada corrida.
47
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8');
48
+ return crypto.createHash('sha256').update(buf).digest('hex');
44
49
  }
45
50
 
46
51
  /**
package/src/remote.js CHANGED
@@ -11,13 +11,22 @@
11
11
  * {
12
12
  * "projectId": "<uuid>",
13
13
  * "baseURL": "https://www.shilo.city",
14
- * "lastSync": "2026-07-27T12:00:00.000Z"
14
+ * "lastSync": "2026-07-27T12:00:00.000Z",
15
+ * "base": "<oid de la versión actual que esta carpeta vio por última vez>"
15
16
  * }
16
17
  *
17
18
  * `lastSync` es la marca de la última vez que esta carpeta quedó alineada con
18
19
  * el remoto (fin de un push o pull exitoso). `status` la usa para distinguir
19
20
  * "el remoto cambió desde que sincronicé" de "esto lo subí yo".
20
21
  *
22
+ * `base` es lo mismo pero exacto: el punto de guardado del que parte esta
23
+ * carpeta. `push` lo manda con cada propuesta para que el servidor sepa contra
24
+ * qué comparar — un archivo que otra persona cambió después de esa base y que
25
+ * acá también se cambió es un conflicto, no una sobreescritura. Va acá y no en
26
+ * `.shilo/index.json` porque el índice es una optimización descartable
27
+ * (`--no-index` lo ignora) y la base es un dato: perderla no rompe nada, pero
28
+ * vuelve el push siguiente ciego a lo que cambiaron los demás.
29
+ *
21
30
  * NO contiene credenciales (esas están en ~/.shilo/config.json, cifradas), así
22
31
  * que es seguro commitearlo al repo si el usuario quiere.
23
32
  */
@@ -49,17 +58,19 @@ function read(dir) {
49
58
  projectId: raw.projectId || null,
50
59
  baseURL: raw.baseURL || null,
51
60
  lastSync: raw.lastSync || null,
61
+ base: typeof raw.base === 'string' && /^[0-9a-f]{64}$/.test(raw.base) ? raw.base : null,
52
62
  };
53
63
  }
54
64
 
55
65
  /**
56
66
  * Escribe `.shilo.json` en `dir`. Devuelve la ruta escrita.
57
67
  */
58
- function write(dir, { projectId, baseURL, lastSync }) {
68
+ function write(dir, { projectId, baseURL, lastSync, base }) {
59
69
  const p = remotePath(dir);
60
70
  const onDisk = { projectId };
61
71
  if (baseURL) onDisk.baseURL = baseURL;
62
72
  if (lastSync) onDisk.lastSync = lastSync;
73
+ if (base) onDisk.base = base;
63
74
  fs.writeFileSync(p, JSON.stringify(onDisk, null, 2) + '\n', 'utf8');
64
75
  return p;
65
76
  }
@@ -69,13 +80,14 @@ function write(dir, { projectId, baseURL, lastSync }) {
69
80
  * remote. No es fatal si falla (la carpeta puede ser de sólo lectura): devuelve
70
81
  * `false` y el caller decide si avisar.
71
82
  */
72
- function touchSync(dir, { projectId, baseURL } = {}) {
83
+ function touchSync(dir, { projectId, baseURL, base } = {}) {
73
84
  try {
74
85
  const current = read(dir) || {};
75
86
  write(dir, {
76
87
  projectId: projectId || current.projectId,
77
88
  baseURL: baseURL || current.baseURL,
78
89
  lastSync: new Date().toISOString(),
90
+ base: base || current.base || null,
79
91
  });
80
92
  return true;
81
93
  } catch (_) {
package/src/textguard.js CHANGED
@@ -1,12 +1,24 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Validación de nombres y detección de binarios para el push.
4
+ * Validación de nombres y clasificación de contenido para el push.
5
5
  *
6
- * Shilo sólo acepta contenido de texto (el endpoint path-based sube el body
7
- * como utf8, así que un binario se corrompería) y rechaza los nombres de
8
- * archivo/carpeta que rompen una ruta o no son portables. Aquí filtramos ambos
9
- * casos ANTES de tocar la red, para poder avisar y saltar.
6
+ * Hasta la Etapa 2 del PLAN_MOTOR_SHILO, Shilo sólo aceptaba texto: el endpoint
7
+ * por ruta sube el body como utf8, así que un binario se corrompía. Ahora los
8
+ * binarios SÍ se suben, pero por otro camino —`POST /api/files/:id/direct`, con
9
+ * URL firmada, sin pasar por JSON— y por eso este módulo ya no los rechaza:
10
+ * los CLASIFICA, para que el push sepa por dónde mandar cada archivo.
11
+ *
12
+ * Lo que sigue rechazando, y no cambió: los nombres que rompen una ruta o no son
13
+ * portables, y los archivos que superan el techo de tamaño. El escaneo de
14
+ * secretos también se queda; vive en `secrets.js` y sólo corre sobre texto,
15
+ * porque buscar patrones de credenciales en un JPEG es ruido.
16
+ *
17
+ * Espejo de `backend/utils/binary.js`: mismas constantes, misma heurística. Son
18
+ * dos paquetes sin dependencia entre sí (el CLI se publica solo y no tiene un
19
+ * solo `dependencies`), así que la duplicación es deliberada — pero si cambia
20
+ * una tiene que cambiar la otra, o un archivo se clasifica de un modo en el
21
+ * cliente y de otro en el servidor.
10
22
  */
11
23
 
12
24
  // Espejo de `backend/utils/filenames.js`: lista negra, no lista blanca. El
@@ -19,7 +31,9 @@ const ILLEGAL_CHARS = /[<>:"/\\|?*]/;
19
31
  const WIN_RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
20
32
 
21
33
  const MAX_NAME_LEN = 255;
22
- const MAX_FILE_BYTES = 26214400; // 25 MB, igual que el backend
34
+ // Espejo de MAX_FILE_BYTES en backend/utils/binary.js y del CHECK
35
+ // `file_size_valid` de la migración 014. Los tres son el mismo número.
36
+ const MAX_FILE_BYTES = 52428800; // 50 MB, igual que el backend
23
37
  const MAX_DEPTH = 10; // profundidad máxima de carpetas
24
38
 
25
39
  // Extensiones típicamente binarias: pre-filtro rápido antes de leer bytes.
@@ -126,23 +140,58 @@ function looksBinary(buffer, sample = 8000) {
126
140
  }
127
141
 
128
142
  /**
129
- * Decide si un archivo es texto apto para subir. Recibe el nombre (para la
130
- * extensión) y el Buffer con su contenido. Devuelve:
131
- * { isText: true, content } -> subir `content` (utf8)
132
- * { isText: false, reason } -> saltar con aviso
143
+ * Mapa mínimo extensión -> mime. El backend vuelve a adivinar por su cuenta con
144
+ * `mime-types`, pero mandar el tipo desde acá es mejor: el cliente vio los bytes
145
+ * y el servidor, en una subida directa, sólo ve el nombre.
146
+ */
147
+ const MIME_BY_EXT = {
148
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
149
+ webp: 'image/webp', avif: 'image/avif', bmp: 'image/bmp', ico: 'image/x-icon',
150
+ tiff: 'image/tiff', tif: 'image/tiff', heic: 'image/heic',
151
+ pdf: 'application/pdf', zip: 'application/zip', gz: 'application/gzip',
152
+ tar: 'application/x-tar', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg',
153
+ mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
154
+ ttf: 'font/ttf', otf: 'font/otf', woff: 'font/woff', woff2: 'font/woff2',
155
+ doc: 'application/msword',
156
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
157
+ xls: 'application/vnd.ms-excel',
158
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
159
+ ppt: 'application/vnd.ms-powerpoint',
160
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
161
+ };
162
+
163
+ /** Tipo de contenido de un binario. `octet-stream` cuando no se sabe. */
164
+ function binaryMimeType(name) {
165
+ return MIME_BY_EXT[extensionOf(name)] || 'application/octet-stream';
166
+ }
167
+
168
+ /**
169
+ * Clasifica el contenido de un archivo. Recibe el nombre (para la extensión) y
170
+ * el Buffer con su contenido. Devuelve:
171
+ * { isText: true, content } -> sube por JSON (batch)
172
+ * { isText: false, isBinary: true, buffer, mimeType }
173
+ * -> sube por URL firmada (direct)
174
+ * { isText: false, isBinary: false, reason } -> saltar con aviso
175
+ *
176
+ * El techo de tamaño se comprueba PRIMERO, antes de decidir texto o binario: un
177
+ * archivo demasiado grande se saltea sea lo que sea, y ponerlo después haría que
178
+ * el mensaje dijera "binario" cuando el problema es el tamaño.
133
179
  */
134
180
  function classifyContent(name, buffer) {
135
- const ext = extensionOf(name);
136
- if (ext && BINARY_EXTENSIONS.has(ext)) {
137
- return { isText: false, reason: `extensión binaria .${ext}` };
138
- }
139
- if (looksBinary(buffer)) {
140
- return { isText: false, reason: 'contenido binario (bytes no imprimibles)' };
141
- }
142
181
  if (buffer.length > MAX_FILE_BYTES) {
143
- return { isText: false, reason: `supera 25 MB (${buffer.length} bytes)` };
182
+ return {
183
+ isText: false,
184
+ isBinary: false,
185
+ reason: `supera ${MAX_FILE_BYTES / (1024 * 1024)} MB (${buffer.length} bytes)`,
186
+ };
144
187
  }
145
- return { isText: true, content: buffer.toString('utf8') };
188
+
189
+ const ext = extensionOf(name);
190
+ if ((ext && BINARY_EXTENSIONS.has(ext)) || looksBinary(buffer)) {
191
+ return { isText: false, isBinary: true, buffer, mimeType: binaryMimeType(name) };
192
+ }
193
+
194
+ return { isText: true, isBinary: false, content: buffer.toString('utf8') };
146
195
  }
147
196
 
148
197
  module.exports = {
@@ -157,5 +206,7 @@ module.exports = {
157
206
  validatePath,
158
207
  looksBinary,
159
208
  classifyContent,
209
+ binaryMimeType,
210
+ MIME_BY_EXT,
160
211
  extensionOf,
161
212
  };