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/src/textguard.js CHANGED
@@ -1,19 +1,39 @@
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 restringe los nombres de
8
- * archivo/carpeta a un charset acotado. Aquí filtramos ambos casos ANTES de
9
- * 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
- // Charset permitido por el backend (ver backend/api/files/[projectId].js):
13
- // ^[a-zA-Z0-9._\-\[\]()@]+$ y ni "." ni ".." ni separadores de ruta.
14
- const VALID_NAME = /^[a-zA-Z0-9._\-[\]()@]+$/;
24
+ // Espejo de `backend/utils/filenames.js`: lista negra, no lista blanca. El
25
+ // backend acepta cualquier carácter que no rompa una ruta —acentos, espacios,
26
+ // kanji—, así que restringir acá sólo hacía que un repo perfectamente válido
27
+ // se subiera incompleto.
28
+ const ILLEGAL_CHARS = /[<>:"/\\|?*]/;
29
+
30
+ // Nombres de dispositivo reservados de Windows, con o sin extensión.
31
+ const WIN_RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
32
+
15
33
  const MAX_NAME_LEN = 255;
16
- 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
17
37
  const MAX_DEPTH = 10; // profundidad máxima de carpetas
18
38
 
19
39
  // Extensiones típicamente binarias: pre-filtro rápido antes de leer bytes.
@@ -32,27 +52,49 @@ const BINARY_EXTENSIONS = new Set([
32
52
  'ttf', 'otf', 'woff', 'woff2', 'eot', 'psd', 'ai', 'sketch', 'db', 'sqlite',
33
53
  ]);
34
54
 
55
+ // Los bytes de control se chequean por código y no con un rango en el regex:
56
+ // escritos literalmente el archivo deja de ser texto plano para git y grep, y
57
+ // escritos como escape son invisibles al leer el código. Un bucle es explícito.
58
+ function hasControlChars(str) {
59
+ for (let i = 0; i < str.length; i += 1) {
60
+ const code = str.charCodeAt(i);
61
+ if (code < 32 || code === 127) return true;
62
+ }
63
+ return false;
64
+ }
65
+
35
66
  /**
36
67
  * Valida un nombre individual (archivo o carpeta) contra las reglas de Shilo.
68
+ * Espejo de `validateFileName` en `backend/utils/filenames.js`: mismos rechazos,
69
+ * mismo orden, con el mensaje en el idioma del CLI.
37
70
  * Devuelve `{ ok: true }` o `{ ok: false, reason }`.
38
71
  */
39
- function validateName(name) {
72
+ function validateName(rawName) {
73
+ // El backend guarda el nombre normalizado y sin espacios al borde: validamos
74
+ // sobre esa misma forma para no aceptar acá algo que allá se transforma.
75
+ const name = typeof rawName === 'string' ? rawName.normalize('NFC').trim() : '';
76
+
40
77
  if (!name || name === '.' || name === '..') {
41
78
  return { ok: false, reason: 'nombre reservado (. o ..)' };
42
79
  }
43
80
  if (name.length > MAX_NAME_LEN) {
44
81
  return { ok: false, reason: `nombre demasiado largo (>${MAX_NAME_LEN})` };
45
82
  }
46
- if (name.includes('/') || name.includes('\\')) {
47
- return { ok: false, reason: 'contiene separador de ruta' };
48
- }
49
- if (!VALID_NAME.test(name)) {
83
+ if (ILLEGAL_CHARS.test(name) || hasControlChars(name)) {
50
84
  return {
51
85
  ok: false,
52
- reason: 'caracteres no permitidos (usá solo A-Z a-z 0-9 . _ - [ ] ( ) @ y sin espacios/acentos)',
86
+ reason: 'caracteres no permitidos (/ \\ : * ? " < > | o bytes de control)',
53
87
  };
54
88
  }
55
- return { ok: true };
89
+ if (WIN_RESERVED.test(name)) {
90
+ return { ok: false, reason: 'nombre de dispositivo reservado en Windows (CON, NUL, COM1…)' };
91
+ }
92
+ // Windows descarta el punto final al crear el archivo, así que "informe." y
93
+ // "informe" terminarían siendo el mismo archivo en disco.
94
+ if (name.endsWith('.')) {
95
+ return { ok: false, reason: 'no puede terminar en punto' };
96
+ }
97
+ return { ok: true, name };
56
98
  }
57
99
 
58
100
  /**
@@ -98,27 +140,64 @@ function looksBinary(buffer, sample = 8000) {
98
140
  }
99
141
 
100
142
  /**
101
- * Decide si un archivo es texto apto para subir. Recibe el nombre (para la
102
- * extensión) y el Buffer con su contenido. Devuelve:
103
- * { isText: true, content } -> subir `content` (utf8)
104
- * { 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.
105
179
  */
106
180
  function classifyContent(name, buffer) {
107
- const ext = extensionOf(name);
108
- if (ext && BINARY_EXTENSIONS.has(ext)) {
109
- return { isText: false, reason: `extensión binaria .${ext}` };
110
- }
111
- if (looksBinary(buffer)) {
112
- return { isText: false, reason: 'contenido binario (bytes no imprimibles)' };
113
- }
114
181
  if (buffer.length > MAX_FILE_BYTES) {
115
- 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
+ };
116
187
  }
117
- 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') };
118
195
  }
119
196
 
120
197
  module.exports = {
121
- VALID_NAME,
198
+ ILLEGAL_CHARS,
199
+ WIN_RESERVED,
200
+ hasControlChars,
122
201
  MAX_NAME_LEN,
123
202
  MAX_FILE_BYTES,
124
203
  MAX_DEPTH,
@@ -127,5 +206,7 @@ module.exports = {
127
206
  validatePath,
128
207
  looksBinary,
129
208
  classifyContent,
209
+ binaryMimeType,
210
+ MIME_BY_EXT,
130
211
  extensionOf,
131
212
  };
package/src/token.js ADDED
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Resolución del token de acceso personal (PAT).
5
+ *
6
+ * Un PAT pasado como `--token abc123` queda en el historial del shell, en la
7
+ * lista de procesos (`ps` lo muestra a cualquier usuario de la máquina) y en
8
+ * los logs de CI. Por eso se aceptan dos formas más discretas:
9
+ *
10
+ * --token - lee el token de stdin (`cat pat.txt | shilo login --token -`)
11
+ * SHILO_TOKEN=<pat> variable de entorno (lo natural en CI)
12
+ *
13
+ * La forma inline sigue funcionando —es la que se copia desde la web— pero
14
+ * avisa una vez.
15
+ */
16
+
17
+ const STDIN_SENTINEL = '-';
18
+
19
+ /** Lee stdin completo (hasta EOF) y devuelve la primera línea no vacía. */
20
+ function readStdin() {
21
+ return new Promise((resolve, reject) => {
22
+ if (process.stdin.isTTY) {
23
+ reject(new Error('--token - espera el token por stdin, pero la entrada es una terminal.'));
24
+ return;
25
+ }
26
+ let data = '';
27
+ process.stdin.setEncoding('utf8');
28
+ process.stdin.on('data', (chunk) => {
29
+ data += chunk;
30
+ });
31
+ process.stdin.on('end', () => resolve(data));
32
+ process.stdin.on('error', reject);
33
+ });
34
+ }
35
+
36
+ function firstLine(text) {
37
+ for (const line of String(text).split(/\r?\n/)) {
38
+ const t = line.trim();
39
+ if (t) return t;
40
+ }
41
+ return '';
42
+ }
43
+
44
+ /**
45
+ * Devuelve `{ token, source }` con source ∈ 'flag' | 'stdin' | 'env' | null.
46
+ * Lanza si `--token -` no encuentra nada que leer.
47
+ */
48
+ async function resolveToken(args) {
49
+ const raw = typeof args.token === 'string' ? args.token.trim() : '';
50
+
51
+ if (raw === STDIN_SENTINEL) {
52
+ const token = firstLine(await readStdin());
53
+ if (!token) throw new Error('No llegó ningún token por stdin.');
54
+ return { token, source: 'stdin' };
55
+ }
56
+
57
+ if (raw) return { token: raw, source: 'flag' };
58
+
59
+ const env = (process.env.SHILO_TOKEN || '').trim();
60
+ if (env) return { token: env, source: 'env' };
61
+
62
+ return { token: '', source: null };
63
+ }
64
+
65
+ /** Aviso (una sola línea) cuando el token viajó de la forma más expuesta. */
66
+ function warnIfExposed(source, ui) {
67
+ if (source !== 'flag') return;
68
+ ui.warn('El token quedó en el historial del shell y en la lista de procesos.');
69
+ ui.info(ui.c.dim('Alternativas: SHILO_TOKEN=<pat> o `cat pat.txt | shilo login --token -`.'));
70
+ }
71
+
72
+ module.exports = { resolveToken, warnIfExposed, readStdin, firstLine, STDIN_SENTINEL };
package/src/ui.js CHANGED
@@ -33,4 +33,64 @@ function error(msg) {
33
33
  console.error(`${c.red('✗')} ${msg}`);
34
34
  }
35
35
 
36
- module.exports = { c, info, success, warn, error };
36
+ /** ¿La salida es una terminal interactiva? (redirigida a archivo/CI: no) */
37
+ const isTTY = !!process.stdout.isTTY;
38
+
39
+ function formatEta(secs) {
40
+ if (!Number.isFinite(secs) || secs < 0) return '';
41
+ if (secs < 60) return `${Math.max(1, Math.round(secs))}s`;
42
+ const mins = Math.floor(secs / 60);
43
+ const rest = Math.round(secs % 60);
44
+ return `${mins}m ${rest}s`;
45
+ }
46
+
47
+ /**
48
+ * Contador de progreso de una sola línea.
49
+ *
50
+ * En una terminal reescribe la misma línea con porcentaje y ETA; sin TTY no
51
+ * dibuja nada (una barra con `\r` en un log de CI es ruido ilegible) y en su
52
+ * lugar `line()` emite una línea por ítem. Así el mismo código sirve para el
53
+ * uso interactivo y para el registro.
54
+ *
55
+ * El repintado se limita a ~10 fps: con cientos de archivos, escribir en cada
56
+ * uno cuesta más que el trabajo que reporta.
57
+ */
58
+ function progress(total, label = '') {
59
+ const start = Date.now();
60
+ let lastLen = 0;
61
+ let lastDraw = 0;
62
+
63
+ return {
64
+ tty: isTTY,
65
+ update(done) {
66
+ if (!isTTY || total <= 0) return;
67
+ const now = Date.now();
68
+ if (done < total && now - lastDraw < 100) return;
69
+ lastDraw = now;
70
+
71
+ const pct = Math.round((done / total) * 100);
72
+ let eta = '';
73
+ if (done > 0 && done < total) {
74
+ const elapsed = (now - start) / 1000;
75
+ const left = formatEta((elapsed / done) * (total - done));
76
+ if (left) eta = `, faltan ~${left}`;
77
+ }
78
+ const line = ` ${label} ${done}/${total} (${pct}%${eta})`;
79
+ const pad = Math.max(0, lastLen - line.length);
80
+ process.stdout.write(`\r${c.dim(line)}${' '.repeat(pad)}`);
81
+ lastLen = line.length;
82
+ },
83
+ /** Borra la línea de progreso para que el mensaje siguiente arranque limpio. */
84
+ clear() {
85
+ if (!isTTY || lastLen === 0) return;
86
+ process.stdout.write(`\r${' '.repeat(lastLen)}\r`);
87
+ lastLen = 0;
88
+ },
89
+ /** Línea por ítem: sólo cuando no hay barra que la pisaría. */
90
+ line(msg) {
91
+ if (!isTTY) console.log(msg);
92
+ },
93
+ };
94
+ }
95
+
96
+ module.exports = { c, info, success, warn, error, isTTY, progress, formatEta };
package/src/walk.js CHANGED
@@ -4,7 +4,13 @@
4
4
  * Recorrido de un directorio local para el push.
5
5
  *
6
6
  * Responsabilidades (todo offline, sin red):
7
- * - aplicar ignores por defecto (.git, node_modules, etc.) + `.shiloignore`;
7
+ * - aplicar ignores por defecto (.git, node_modules, etc.) + `.gitignore` +
8
+ * `.shiloignore`, incluyendo los anidados de cada subcarpeta;
9
+ * - podar repositorios git detectados por su FORMA (HEAD + objects/ + refs/),
10
+ * no por el nombre `.git`: un `.git-ORIGINAL-BACKUP/` trae todo el historial
11
+ * y renombrarlo no lo vuelve inocuo;
12
+ * - descartar nombres reservados de Windows (`nul`, `con`, `com1`…), que en
13
+ * esa plataforma son dispositivos y no archivos;
8
14
  * - descartar nombres inválidos para Shilo (poda subárboles con carpeta inválida);
9
15
  * - respetar la profundidad máxima de carpetas;
10
16
  * - NO leer el contenido completo (eso lo hace el push al clasificar binarios),
@@ -16,7 +22,7 @@
16
22
 
17
23
  const fs = require('fs');
18
24
  const path = require('path');
19
- const { validateName, MAX_DEPTH } = require('./textguard');
25
+ const { validateName, MAX_DEPTH, WIN_RESERVED } = require('./textguard');
20
26
 
21
27
  // Ignores por defecto (nombres de carpeta/archivo, a cualquier profundidad).
22
28
  const DEFAULT_IGNORES = [
@@ -30,11 +36,40 @@ const DEFAULT_IGNORES = [
30
36
  '.vercel/',
31
37
  ];
32
38
 
39
+ // Archivos de ignores que se leen en cada directorio, en orden de precedencia:
40
+ // `.shiloignore` va último para poder re-incluir con `!` algo que git ignora.
41
+ const IGNORE_FILES = ['.gitignore', '.shiloignore'];
42
+
43
+ // `WIN_RESERVED` (CON, NUL, COM1…) vive en textguard porque el backend también
44
+ // los rechaza. Acá se usan además para PODAR: abrir `nul` no devuelve un archivo
45
+ // sino el dispositivo nulo (`readFileSync` da vacío, `statSync` es inconsistente),
46
+ // así que se saltean en toda plataforma y no sólo en Windows.
47
+
48
+ /**
49
+ * ¿Este directorio es un repositorio git (bare o no), sin importar cómo se llame?
50
+ * Un `.git-ORIGINAL-BACKUP/`, `.git.bak/` o un submódulo tienen la misma forma.
51
+ */
52
+ function looksLikeGitDir(absDir) {
53
+ try {
54
+ return (
55
+ fs.existsSync(path.join(absDir, 'HEAD')) &&
56
+ fs.existsSync(path.join(absDir, 'objects')) &&
57
+ fs.existsSync(path.join(absDir, 'refs'))
58
+ );
59
+ } catch (_) {
60
+ return false;
61
+ }
62
+ }
63
+
33
64
  /**
34
65
  * Compila una línea de patrón estilo .gitignore (subconjunto práctico) a una
35
- * regla { negate, dirOnly, anchored, re }.
66
+ * regla { negate, dirOnly, anchored, base, re }.
67
+ *
68
+ * `base` es el prefijo de ruta (POSIX, con `/` final) del directorio donde vive
69
+ * el archivo de ignores: una regla de `public/.gitignore` sólo aplica bajo
70
+ * `public/`.
36
71
  */
37
- function compileRule(raw) {
72
+ function compileRule(raw, base = '') {
38
73
  let line = raw.replace(/\r$/, '');
39
74
  // Ignorar comentarios y líneas vacías.
40
75
  if (!line.trim() || line.trim().startsWith('#')) return null;
@@ -63,8 +98,10 @@ function compileRule(raw) {
63
98
  anchored = true;
64
99
  }
65
100
 
66
- const re = globToRegExp(line);
67
- return { negate, dirOnly, anchored, re };
101
+ // Los anclados se comparan contra la ruta completa, así que llevan el base
102
+ // incorporado; los de basename se filtran por `base` en el matcher.
103
+ const re = globToRegExp(anchored ? base + line : line);
104
+ return { negate, dirOnly, anchored, base, re };
68
105
  }
69
106
 
70
107
  function globToRegExp(glob) {
@@ -97,12 +134,14 @@ function globToRegExp(glob) {
97
134
  */
98
135
  function makeMatcher(rules) {
99
136
  return function isIgnored(relPath, isDir) {
100
- const base = relPath.split('/').pop();
137
+ const name = relPath.split('/').pop();
101
138
  let ignored = false;
102
139
  for (const rule of rules) {
103
140
  if (!rule) continue;
104
141
  if (rule.dirOnly && !isDir) continue;
105
- const target = rule.anchored ? relPath : base;
142
+ // Una regla de un ignore anidado sólo alcanza a su propio subárbol.
143
+ if (rule.base && !relPath.startsWith(rule.base)) continue;
144
+ const target = rule.anchored ? relPath : name;
106
145
  if (rule.re.test(target)) {
107
146
  ignored = !rule.negate;
108
147
  }
@@ -112,47 +151,86 @@ function makeMatcher(rules) {
112
151
  }
113
152
 
114
153
  /**
115
- * Lee y compila reglas de un `.shiloignore` en la raíz (si existe), combinadas
116
- * con los ignores por defecto.
154
+ * Compila las reglas de los archivos de ignores presentes en `absDir`.
155
+ * `relBase` es la ruta POSIX del directorio relativa a la raíz ('' para la raíz).
117
156
  */
118
- function loadIgnoreRules(rootDir, extraLines = []) {
119
- const lines = [...DEFAULT_IGNORES, ...extraLines];
120
- const ignorePath = path.join(rootDir, '.shiloignore');
121
- if (fs.existsSync(ignorePath)) {
157
+ function loadDirRules(absDir, relBase = '') {
158
+ const base = relBase ? `${relBase}/` : '';
159
+ const rules = [];
160
+ for (const fileName of IGNORE_FILES) {
161
+ const ignorePath = path.join(absDir, fileName);
162
+ let text;
122
163
  try {
123
- const text = fs.readFileSync(ignorePath, 'utf8');
124
- lines.push(...text.split('\n'));
164
+ if (!fs.existsSync(ignorePath)) continue;
165
+ // Sin sacar el BOM (Notepad, PowerShell) el primer patrón del archivo se
166
+ // compila con un caracter invisible al frente y nunca matchea.
167
+ text = fs.readFileSync(ignorePath, 'utf8').replace(/^/, '');
125
168
  } catch (_) {
126
- /* si no se puede leer, seguimos con los defaults */
169
+ continue; // si no se puede leer, seguimos con lo que haya
170
+ }
171
+ for (const line of text.split('\n')) {
172
+ const rule = compileRule(line, base);
173
+ if (rule) rules.push(rule);
127
174
  }
128
175
  }
129
- return lines.map(compileRule).filter(Boolean);
176
+ return rules;
177
+ }
178
+
179
+ /**
180
+ * Reglas de la raíz: defaults + `.gitignore` + `.shiloignore` + extras.
181
+ * Se mantiene exportada porque es la forma de inspeccionar qué se va a ignorar
182
+ * sin recorrer nada.
183
+ */
184
+ function loadIgnoreRules(rootDir, extraLines = []) {
185
+ const rules = [];
186
+ for (const line of DEFAULT_IGNORES) {
187
+ const rule = compileRule(line);
188
+ if (rule) rules.push(rule);
189
+ }
190
+ rules.push(...loadDirRules(rootDir, ''));
191
+ for (const line of extraLines) {
192
+ const rule = compileRule(line);
193
+ if (rule) rules.push(rule);
194
+ }
195
+ return rules;
130
196
  }
131
197
 
132
198
  /**
133
199
  * Recorre `rootDir` y devuelve:
134
200
  * {
135
- * files: [{ relPath, absPath, size }], // candidatos válidos a subir
136
- * skipped: [{ relPath, reason }], // descartados (nombre/profundidad)
201
+ * files: [{ relPath, absPath, size }], // candidatos válidos a subir
202
+ * skipped: [{ relPath, reason, code }], // descartados
137
203
  * ignored: <número de entradas ignoradas por reglas>
204
+ * gitDirs: [<rutas de repos git podados>]
138
205
  * }
139
206
  *
140
207
  * No sigue symlinks (se reportan como skipped).
141
208
  */
142
209
  function walk(rootDir, { extraIgnore = [] } = {}) {
143
- const rules = loadIgnoreRules(rootDir, extraIgnore);
144
- const isIgnored = makeMatcher(rules);
210
+ const rootRules = loadIgnoreRules(rootDir, extraIgnore);
145
211
 
146
212
  const files = [];
147
213
  const skipped = [];
214
+ const gitDirs = [];
148
215
  let ignored = 0;
149
216
 
150
- function recurse(absDir, relBase, folderDepth) {
217
+ function recurse(absDir, relBase, folderDepth, inheritedRules) {
218
+ // Los ignores de este directorio se suman a los heredados y valen sólo de
219
+ // acá para abajo: por eso se copia el array en vez de mutarlo.
220
+ const rules = relBase
221
+ ? inheritedRules.concat(loadDirRules(absDir, relBase))
222
+ : inheritedRules;
223
+ const isIgnored = makeMatcher(rules);
224
+
151
225
  let entries;
152
226
  try {
153
227
  entries = fs.readdirSync(absDir, { withFileTypes: true });
154
228
  } catch (err) {
155
- skipped.push({ relPath: relBase || '.', reason: `no se pudo leer: ${err.message}` });
229
+ skipped.push({
230
+ relPath: relBase || '.',
231
+ reason: `no se pudo leer: ${err.message}`,
232
+ code: 'unreadable',
233
+ });
156
234
  return;
157
235
  }
158
236
 
@@ -160,12 +238,24 @@ function walk(rootDir, { extraIgnore = [] } = {}) {
160
238
  entries.sort((a, b) => a.name.localeCompare(b.name));
161
239
 
162
240
  for (const entry of entries) {
163
- const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
241
+ // El nombre lógico (el que viaja al repo) se normaliza igual que en el
242
+ // backend —NFC y sin espacios al borde— porque es la forma que se guarda:
243
+ // macOS devuelve NFD, y sin esto "café.md" local y "café.md" remoto no se
244
+ // reconocen como el mismo archivo. `absPath` usa el nombre tal cual lo dio
245
+ // el SO, que es el que hay que abrir.
246
+ const logicalName = entry.name.normalize('NFC').trim();
247
+ const relPath = relBase ? `${relBase}/${logicalName}` : logicalName;
164
248
  const absPath = path.join(absDir, entry.name);
165
249
  const isDir = entry.isDirectory();
166
250
 
167
251
  if (entry.isSymbolicLink()) {
168
- skipped.push({ relPath, reason: 'symlink (no soportado)' });
252
+ skipped.push({ relPath, reason: 'symlink (no soportado)', code: 'symlink' });
253
+ continue;
254
+ }
255
+
256
+ if (WIN_RESERVED.test(entry.name)) {
257
+ // No es un archivo real en Windows; leerlo devolvería el dispositivo nulo.
258
+ ignored++;
169
259
  continue;
170
260
  }
171
261
 
@@ -174,40 +264,58 @@ function walk(rootDir, { extraIgnore = [] } = {}) {
174
264
  continue;
175
265
  }
176
266
 
177
- const nameCheck = validateName(entry.name);
267
+ if (isDir && looksLikeGitDir(absPath)) {
268
+ // Todo el historial del repo, incluidos secretos de commits ya purgados
269
+ // del árbol de trabajo. Se poda entero y se avisa una sola vez.
270
+ gitDirs.push(relPath);
271
+ continue;
272
+ }
273
+
274
+ const nameCheck = validateName(logicalName);
178
275
  if (!nameCheck.ok) {
179
- skipped.push({ relPath, reason: nameCheck.reason });
276
+ skipped.push({ relPath, reason: nameCheck.reason, code: 'name' });
180
277
  continue; // si es carpeta, se poda todo el subárbol
181
278
  }
182
279
 
183
280
  if (isDir) {
184
281
  if (folderDepth + 1 > MAX_DEPTH) {
185
- skipped.push({ relPath, reason: `profundidad de carpetas > ${MAX_DEPTH}` });
282
+ skipped.push({
283
+ relPath,
284
+ reason: `profundidad de carpetas > ${MAX_DEPTH}`,
285
+ code: 'depth',
286
+ });
186
287
  continue;
187
288
  }
188
- recurse(absPath, relPath, folderDepth + 1);
289
+ recurse(absPath, relPath, folderDepth + 1, rules);
189
290
  } else if (entry.isFile()) {
190
291
  let size = 0;
292
+ let mtimeMs = 0;
191
293
  try {
192
- size = fs.statSync(absPath).size;
294
+ const st = fs.statSync(absPath);
295
+ size = st.size;
296
+ mtimeMs = st.mtimeMs;
193
297
  } catch (_) {
194
298
  /* si falla el stat, dejamos size en 0; el push lo detecta al leer */
195
299
  }
196
- files.push({ relPath, absPath, size });
300
+ files.push({ relPath, absPath, size, mtimeMs });
197
301
  }
198
302
  // otros tipos (fifo, socket, device) se ignoran silenciosamente
199
303
  }
200
304
  }
201
305
 
202
- recurse(rootDir, '', 0);
203
- return { files, skipped, ignored };
306
+ recurse(rootDir, '', 0, rootRules);
307
+ return { files, skipped, ignored, gitDirs };
204
308
  }
205
309
 
206
310
  module.exports = {
207
311
  walk,
208
312
  loadIgnoreRules,
313
+ loadDirRules,
209
314
  makeMatcher,
210
315
  compileRule,
211
316
  globToRegExp,
317
+ looksLikeGitDir,
212
318
  DEFAULT_IGNORES,
319
+ IGNORE_FILES,
320
+ WIN_RESERVED,
213
321
  };