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/src/textguard.js
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Validación de nombres y
|
|
4
|
+
* Validación de nombres y clasificación de contenido para el push.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* como utf8, así que un binario se
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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 (
|
|
86
|
+
reason: 'caracteres no permitidos (/ \\ : * ? " < > | o bytes de control)',
|
|
53
87
|
};
|
|
54
88
|
}
|
|
55
|
-
|
|
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
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
|
|
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 {
|
|
182
|
+
return {
|
|
183
|
+
isText: false,
|
|
184
|
+
isBinary: false,
|
|
185
|
+
reason: `supera ${MAX_FILE_BYTES / (1024 * 1024)} MB (${buffer.length} bytes)`,
|
|
186
|
+
};
|
|
116
187
|
}
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.) + `.
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
116
|
-
*
|
|
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
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
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 }],
|
|
136
|
-
* skipped: [{ relPath, reason }],
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
};
|