shilo-cli 0.2.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/config.js ADDED
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Gestión del archivo de configuración/credenciales del CLI.
5
+ *
6
+ * Ubicación: ~/.shilo/config.json (override con SHILO_CONFIG_DIR).
7
+ *
8
+ * Las credenciales se guardan cifradas con AES-256-CBC. La clave se deriva
9
+ * de SHILO_ENCRYPTION_KEY si está definida; si no, de un valor propio de la
10
+ * máquina (hostname + usuario del SO). En este segundo caso el cifrado es
11
+ * ofuscación-en-reposo (evita leer el token de un vistazo o copiar el archivo
12
+ * a otra máquina), NO secreto fuerte: para eso, exportá SHILO_ENCRYPTION_KEY.
13
+ * Además el archivo se escribe con permisos 0600.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const os = require('os');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+
21
+ const ALGORITHM = 'aes-256-cbc';
22
+
23
+ function configDir() {
24
+ if (process.env.SHILO_CONFIG_DIR) return process.env.SHILO_CONFIG_DIR;
25
+ return path.join(os.homedir(), '.shilo');
26
+ }
27
+
28
+ function configPath() {
29
+ return path.join(configDir(), 'config.json');
30
+ }
31
+
32
+ function deriveKey() {
33
+ const base =
34
+ process.env.SHILO_ENCRYPTION_KEY ||
35
+ `${os.hostname()}::${os.userInfo().username}::shilo-cli`;
36
+ return crypto.scryptSync(base, 'shilo-cli-salt', 32);
37
+ }
38
+
39
+ function encrypt(obj) {
40
+ const key = deriveKey();
41
+ const iv = crypto.randomBytes(16);
42
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
43
+ let data = cipher.update(JSON.stringify(obj), 'utf8', 'hex');
44
+ data += cipher.final('hex');
45
+ return { iv: iv.toString('hex'), data };
46
+ }
47
+
48
+ function decrypt(payload) {
49
+ const key = deriveKey();
50
+ const iv = Buffer.from(payload.iv, 'hex');
51
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
52
+ let out = decipher.update(payload.data, 'hex', 'utf8');
53
+ out += decipher.final('utf8');
54
+ return JSON.parse(out);
55
+ }
56
+
57
+ /**
58
+ * Estructura en disco:
59
+ * {
60
+ * version: 1,
61
+ * baseURL: "https://shilo-six.vercel.app",
62
+ * supabaseUrl?: "...", // opcional, habilita refresh de token
63
+ * supabaseAnonKey?: "...", // opcional (valor público)
64
+ * credentials: { iv, data } // cifrado -> { jwt, refreshToken, expiresAt, user }
65
+ * }
66
+ */
67
+
68
+ function load() {
69
+ const p = configPath();
70
+ if (!fs.existsSync(p)) return null;
71
+ let raw;
72
+ try {
73
+ raw = JSON.parse(fs.readFileSync(p, 'utf8'));
74
+ } catch (e) {
75
+ throw new Error(`Config corrupta en ${p}: ${e.message}`);
76
+ }
77
+ let credentials = null;
78
+ if (raw.credentials) {
79
+ try {
80
+ credentials = decrypt(raw.credentials);
81
+ } catch (e) {
82
+ throw new Error(
83
+ `No se pudieron descifrar las credenciales (¿cambió SHILO_ENCRYPTION_KEY o la máquina?). ` +
84
+ `Ejecutá "shilo login" de nuevo. Detalle: ${e.message}`
85
+ );
86
+ }
87
+ }
88
+ return {
89
+ version: raw.version || 1,
90
+ baseURL: raw.baseURL || null,
91
+ supabaseUrl: raw.supabaseUrl || null,
92
+ supabaseAnonKey: raw.supabaseAnonKey || null,
93
+ credentials,
94
+ };
95
+ }
96
+
97
+ function save(cfg) {
98
+ const dir = configDir();
99
+ fs.mkdirSync(dir, { recursive: true });
100
+ const onDisk = {
101
+ version: 1,
102
+ baseURL: cfg.baseURL || null,
103
+ };
104
+ if (cfg.supabaseUrl) onDisk.supabaseUrl = cfg.supabaseUrl;
105
+ if (cfg.supabaseAnonKey) onDisk.supabaseAnonKey = cfg.supabaseAnonKey;
106
+ if (cfg.credentials) onDisk.credentials = encrypt(cfg.credentials);
107
+
108
+ const p = configPath();
109
+ // Escribir con permisos restrictivos (0600). En Windows los modos POSIX se
110
+ // ignoran en gran medida, pero no hace daño.
111
+ fs.writeFileSync(p, JSON.stringify(onDisk, null, 2), { mode: 0o600 });
112
+ try {
113
+ fs.chmodSync(p, 0o600);
114
+ } catch (_) {
115
+ /* no-op en FS que no soportan chmod */
116
+ }
117
+ return p;
118
+ }
119
+
120
+ function clear() {
121
+ const p = configPath();
122
+ if (fs.existsSync(p)) {
123
+ fs.rmSync(p);
124
+ return true;
125
+ }
126
+ return false;
127
+ }
128
+
129
+ /**
130
+ * Re-guarda el config si el token cambió (p.ej. tras un refresh de JWT), y deja
131
+ * el `cfg` en memoria sincronizado para que la próxima comparación sea correcta.
132
+ * Los PAT nunca cambian, así que en la práctica es no-op para ellos.
133
+ */
134
+ function persistCredsIfChanged(cfg, credentials) {
135
+ if (!credentials || !cfg || !cfg.credentials) return;
136
+ if (credentials.jwt === cfg.credentials.jwt) return;
137
+ save({
138
+ baseURL: cfg.baseURL,
139
+ supabaseUrl: cfg.supabaseUrl,
140
+ supabaseAnonKey: cfg.supabaseAnonKey,
141
+ credentials,
142
+ });
143
+ cfg.credentials = credentials;
144
+ }
145
+
146
+ /**
147
+ * Arma las credenciales para un Personal Access Token. `kind: 'pat'` es lo que
148
+ * hace que ShiloClient.ensureValidToken no intente refrescarlas.
149
+ */
150
+ function patCredentials(token) {
151
+ return { jwt: token, kind: 'pat', refreshToken: null, expiresAt: null, user: null };
152
+ }
153
+
154
+ module.exports = {
155
+ configDir,
156
+ configPath,
157
+ load,
158
+ save,
159
+ clear,
160
+ encrypt,
161
+ decrypt,
162
+ persistCredsIfChanged,
163
+ patCredentials,
164
+ };
package/src/prompt.js ADDED
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Prompts interactivos mínimos sobre readline (sin dependencias).
5
+ */
6
+
7
+ const readline = require('readline');
8
+
9
+ function question(query) {
10
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
11
+ return new Promise((resolve) => {
12
+ rl.question(query, (answer) => {
13
+ rl.close();
14
+ resolve(answer.trim());
15
+ });
16
+ });
17
+ }
18
+
19
+ /**
20
+ * Lee input ocultando lo tecleado (para contraseñas).
21
+ */
22
+ function password(query) {
23
+ return new Promise((resolve) => {
24
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
25
+ const stdout = process.stdout;
26
+ // Mutar la salida: readline no expone masking nativo, así que interceptamos
27
+ // _writeToOutput para no imprimir los caracteres de la contraseña.
28
+ const originalWrite = rl._writeToOutput;
29
+ let muted = false;
30
+ rl._writeToOutput = function (str) {
31
+ if (muted) {
32
+ // Permitir el salto de línea final para no romper el layout.
33
+ if (str.includes('\n')) stdout.write('\n');
34
+ return;
35
+ }
36
+ originalWrite.call(rl, str);
37
+ };
38
+ rl.question(query, (answer) => {
39
+ rl.close();
40
+ resolve(answer.trim());
41
+ });
42
+ muted = true;
43
+ });
44
+ }
45
+
46
+ async function confirm(query, defaultYes = false) {
47
+ const suffix = defaultYes ? ' [Y/n] ' : ' [y/N] ';
48
+ const ans = (await question(query + suffix)).toLowerCase();
49
+ if (!ans) return defaultYes;
50
+ return ans === 'y' || ans === 'yes' || ans === 's' || ans === 'si' || ans === 'sí';
51
+ }
52
+
53
+ module.exports = { question, password, confirm };
package/src/remote.js ADDED
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Manejo del archivo de remote del proyecto: `.shilo.json`.
5
+ *
6
+ * Vive en la carpeta que se sube (no en ~/.shilo) y ata esa carpeta a un
7
+ * repositorio de Shilo. Es el equivalente conceptual a `.git/config` pero
8
+ * mínimo: sólo el id del proyecto y el servidor.
9
+ *
10
+ * Estructura en disco:
11
+ * {
12
+ * "projectId": "<uuid>",
13
+ * "baseURL": "https://shilo-six.vercel.app"
14
+ * }
15
+ *
16
+ * NO contiene credenciales (esas están en ~/.shilo/config.json, cifradas), así
17
+ * que es seguro commitearlo al repo si el usuario quiere.
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ const REMOTE_FILENAME = '.shilo.json';
24
+
25
+ function remotePath(dir) {
26
+ return path.join(dir, REMOTE_FILENAME);
27
+ }
28
+
29
+ /**
30
+ * Lee `.shilo.json` de `dir`. Devuelve el objeto o `null` si no existe.
31
+ * Lanza si existe pero está corrupto.
32
+ */
33
+ function read(dir) {
34
+ const p = remotePath(dir);
35
+ if (!fs.existsSync(p)) return null;
36
+ let raw;
37
+ try {
38
+ raw = JSON.parse(fs.readFileSync(p, 'utf8'));
39
+ } catch (e) {
40
+ throw new Error(`Remote corrupto en ${p}: ${e.message}`);
41
+ }
42
+ return {
43
+ projectId: raw.projectId || null,
44
+ baseURL: raw.baseURL || null,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Escribe `.shilo.json` en `dir`. Devuelve la ruta escrita.
50
+ */
51
+ function write(dir, { projectId, baseURL }) {
52
+ const p = remotePath(dir);
53
+ const onDisk = { projectId };
54
+ if (baseURL) onDisk.baseURL = baseURL;
55
+ fs.writeFileSync(p, JSON.stringify(onDisk, null, 2) + '\n', 'utf8');
56
+ return p;
57
+ }
58
+
59
+ module.exports = { REMOTE_FILENAME, remotePath, read, write };
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Validación de nombres y detección de binarios para el push.
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.
10
+ */
11
+
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._\-[\]()@]+$/;
15
+ const MAX_NAME_LEN = 255;
16
+ const MAX_FILE_BYTES = 26214400; // 25 MB, igual que el backend
17
+ const MAX_DEPTH = 10; // profundidad máxima de carpetas
18
+
19
+ // Extensiones típicamente binarias: pre-filtro rápido antes de leer bytes.
20
+ const BINARY_EXTENSIONS = new Set([
21
+ // imágenes
22
+ 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'tiff', 'tif', 'heic', 'avif',
23
+ // audio/video
24
+ 'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv',
25
+ // comprimidos / paquetes
26
+ 'zip', 'gz', 'tar', 'rar', '7z', 'bz2', 'xz', 'jar', 'war', 'apk', 'dmg', 'iso',
27
+ // ejecutables / libs
28
+ 'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'a', 'obj', 'class', 'wasm', 'node',
29
+ // documentos binarios
30
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
31
+ // fuentes / otros
32
+ 'ttf', 'otf', 'woff', 'woff2', 'eot', 'psd', 'ai', 'sketch', 'db', 'sqlite',
33
+ ]);
34
+
35
+ /**
36
+ * Valida un nombre individual (archivo o carpeta) contra las reglas de Shilo.
37
+ * Devuelve `{ ok: true }` o `{ ok: false, reason }`.
38
+ */
39
+ function validateName(name) {
40
+ if (!name || name === '.' || name === '..') {
41
+ return { ok: false, reason: 'nombre reservado (. o ..)' };
42
+ }
43
+ if (name.length > MAX_NAME_LEN) {
44
+ return { ok: false, reason: `nombre demasiado largo (>${MAX_NAME_LEN})` };
45
+ }
46
+ if (name.includes('/') || name.includes('\\')) {
47
+ return { ok: false, reason: 'contiene separador de ruta' };
48
+ }
49
+ if (!VALID_NAME.test(name)) {
50
+ return {
51
+ ok: false,
52
+ reason: 'caracteres no permitidos (usá solo A-Z a-z 0-9 . _ - [ ] ( ) @ y sin espacios/acentos)',
53
+ };
54
+ }
55
+ return { ok: true };
56
+ }
57
+
58
+ /**
59
+ * Valida cada segmento de una ruta relativa (POSIX, separada por `/`).
60
+ * Devuelve `{ ok, reason?, segment? }`.
61
+ */
62
+ function validatePath(relPath) {
63
+ const segments = relPath.split('/').filter(Boolean);
64
+ if (segments.length > MAX_DEPTH + 1) {
65
+ // +1 porque el último segmento es el archivo, no una carpeta.
66
+ return { ok: false, reason: `profundidad de carpetas > ${MAX_DEPTH}` };
67
+ }
68
+ for (const seg of segments) {
69
+ const r = validateName(seg);
70
+ if (!r.ok) return { ok: false, reason: r.reason, segment: seg };
71
+ }
72
+ return { ok: true };
73
+ }
74
+
75
+ function extensionOf(name) {
76
+ const dot = name.lastIndexOf('.');
77
+ if (dot <= 0) return '';
78
+ return name.slice(dot + 1).toLowerCase();
79
+ }
80
+
81
+ /**
82
+ * Heurística de binario sobre un Buffer:
83
+ * - un byte NUL (0x00) es señal casi segura de binario;
84
+ * - una proporción alta de bytes de control no imprimibles también.
85
+ * Analiza como mucho los primeros `sample` bytes.
86
+ */
87
+ function looksBinary(buffer, sample = 8000) {
88
+ const len = Math.min(buffer.length, sample);
89
+ if (len === 0) return false; // archivo vacío = texto válido
90
+ let suspicious = 0;
91
+ for (let i = 0; i < len; i++) {
92
+ const b = buffer[i];
93
+ if (b === 0) return true; // NUL → binario
94
+ // Permitir tab(9), LF(10), CR(13), y todo lo >= 32. El resto es control.
95
+ if (b < 9 || (b > 13 && b < 32)) suspicious++;
96
+ }
97
+ return suspicious / len > 0.3;
98
+ }
99
+
100
+ /**
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
105
+ */
106
+ 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
+ if (buffer.length > MAX_FILE_BYTES) {
115
+ return { isText: false, reason: `supera 25 MB (${buffer.length} bytes)` };
116
+ }
117
+ return { isText: true, content: buffer.toString('utf8') };
118
+ }
119
+
120
+ module.exports = {
121
+ VALID_NAME,
122
+ MAX_NAME_LEN,
123
+ MAX_FILE_BYTES,
124
+ MAX_DEPTH,
125
+ BINARY_EXTENSIONS,
126
+ validateName,
127
+ validatePath,
128
+ looksBinary,
129
+ classifyContent,
130
+ extensionOf,
131
+ };
package/src/ui.js ADDED
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Helpers de salida por consola. Colores ANSI que se auto-desactivan si no
5
+ * hay TTY o si se define NO_COLOR.
6
+ */
7
+
8
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
9
+
10
+ function paint(code, s) {
11
+ return useColor ? `\x1b[${code}m${s}\x1b[0m` : s;
12
+ }
13
+
14
+ const c = {
15
+ bold: (s) => paint('1', s),
16
+ dim: (s) => paint('2', s),
17
+ red: (s) => paint('31', s),
18
+ green: (s) => paint('32', s),
19
+ yellow: (s) => paint('33', s),
20
+ cyan: (s) => paint('36', s),
21
+ };
22
+
23
+ function info(msg) {
24
+ console.log(msg);
25
+ }
26
+ function success(msg) {
27
+ console.log(`${c.green('✓')} ${msg}`);
28
+ }
29
+ function warn(msg) {
30
+ console.warn(`${c.yellow('!')} ${msg}`);
31
+ }
32
+ function error(msg) {
33
+ console.error(`${c.red('✗')} ${msg}`);
34
+ }
35
+
36
+ module.exports = { c, info, success, warn, error };
package/src/walk.js ADDED
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Recorrido de un directorio local para el push.
5
+ *
6
+ * Responsabilidades (todo offline, sin red):
7
+ * - aplicar ignores por defecto (.git, node_modules, etc.) + `.shiloignore`;
8
+ * - descartar nombres inválidos para Shilo (poda subárboles con carpeta inválida);
9
+ * - respetar la profundidad máxima de carpetas;
10
+ * - NO leer el contenido completo (eso lo hace el push al clasificar binarios),
11
+ * sólo `stat` para el tamaño.
12
+ *
13
+ * Devuelve rutas relativas en formato POSIX (con `/`), que es lo que espera la
14
+ * API de Shilo.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { validateName, MAX_DEPTH } = require('./textguard');
20
+
21
+ // Ignores por defecto (nombres de carpeta/archivo, a cualquier profundidad).
22
+ const DEFAULT_IGNORES = [
23
+ '.git/',
24
+ 'node_modules/',
25
+ '.shilo/',
26
+ '.shilo.json',
27
+ '.shiloignore',
28
+ '.DS_Store',
29
+ 'Thumbs.db',
30
+ '.vercel/',
31
+ ];
32
+
33
+ /**
34
+ * Compila una línea de patrón estilo .gitignore (subconjunto práctico) a una
35
+ * regla { negate, dirOnly, anchored, re }.
36
+ */
37
+ function compileRule(raw) {
38
+ let line = raw.replace(/\r$/, '');
39
+ // Ignorar comentarios y líneas vacías.
40
+ if (!line.trim() || line.trim().startsWith('#')) return null;
41
+
42
+ let negate = false;
43
+ if (line.startsWith('!')) {
44
+ negate = true;
45
+ line = line.slice(1);
46
+ }
47
+ // Espacios finales no escapados se ignoran (simplificado: trim al final).
48
+ line = line.replace(/\s+$/, '');
49
+ if (!line) return null;
50
+
51
+ let dirOnly = false;
52
+ if (line.endsWith('/')) {
53
+ dirOnly = true;
54
+ line = line.slice(0, -1);
55
+ }
56
+
57
+ // Un patrón con `/` interno o inicial se ancla a la raíz; si no, matchea por
58
+ // basename a cualquier profundidad.
59
+ const hasSlash = line.includes('/');
60
+ let anchored = hasSlash;
61
+ if (line.startsWith('/')) {
62
+ line = line.slice(1);
63
+ anchored = true;
64
+ }
65
+
66
+ const re = globToRegExp(line);
67
+ return { negate, dirOnly, anchored, re };
68
+ }
69
+
70
+ function globToRegExp(glob) {
71
+ let out = '^';
72
+ for (let i = 0; i < glob.length; i++) {
73
+ const ch = glob[i];
74
+ if (ch === '*') {
75
+ if (glob[i + 1] === '*') {
76
+ out += '.*'; // ** cruza separadores
77
+ i++;
78
+ if (glob[i + 1] === '/') i++; // consumir la barra que sigue a **/
79
+ } else {
80
+ out += '[^/]*'; // * no cruza separadores
81
+ }
82
+ } else if (ch === '?') {
83
+ out += '[^/]';
84
+ } else if ('\\^$.|+()[]{}'.includes(ch)) {
85
+ out += '\\' + ch;
86
+ } else {
87
+ out += ch;
88
+ }
89
+ }
90
+ out += '$';
91
+ return new RegExp(out);
92
+ }
93
+
94
+ /**
95
+ * Crea el matcher de ignores a partir de reglas compiladas. Devuelve
96
+ * `(relPath, isDir) => boolean` (true = ignorar).
97
+ */
98
+ function makeMatcher(rules) {
99
+ return function isIgnored(relPath, isDir) {
100
+ const base = relPath.split('/').pop();
101
+ let ignored = false;
102
+ for (const rule of rules) {
103
+ if (!rule) continue;
104
+ if (rule.dirOnly && !isDir) continue;
105
+ const target = rule.anchored ? relPath : base;
106
+ if (rule.re.test(target)) {
107
+ ignored = !rule.negate;
108
+ }
109
+ }
110
+ return ignored;
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Lee y compila reglas de un `.shiloignore` en la raíz (si existe), combinadas
116
+ * con los ignores por defecto.
117
+ */
118
+ function loadIgnoreRules(rootDir, extraLines = []) {
119
+ const lines = [...DEFAULT_IGNORES, ...extraLines];
120
+ const ignorePath = path.join(rootDir, '.shiloignore');
121
+ if (fs.existsSync(ignorePath)) {
122
+ try {
123
+ const text = fs.readFileSync(ignorePath, 'utf8');
124
+ lines.push(...text.split('\n'));
125
+ } catch (_) {
126
+ /* si no se puede leer, seguimos con los defaults */
127
+ }
128
+ }
129
+ return lines.map(compileRule).filter(Boolean);
130
+ }
131
+
132
+ /**
133
+ * Recorre `rootDir` y devuelve:
134
+ * {
135
+ * files: [{ relPath, absPath, size }], // candidatos válidos a subir
136
+ * skipped: [{ relPath, reason }], // descartados (nombre/profundidad)
137
+ * ignored: <número de entradas ignoradas por reglas>
138
+ * }
139
+ *
140
+ * No sigue symlinks (se reportan como skipped).
141
+ */
142
+ function walk(rootDir, { extraIgnore = [] } = {}) {
143
+ const rules = loadIgnoreRules(rootDir, extraIgnore);
144
+ const isIgnored = makeMatcher(rules);
145
+
146
+ const files = [];
147
+ const skipped = [];
148
+ let ignored = 0;
149
+
150
+ function recurse(absDir, relBase, folderDepth) {
151
+ let entries;
152
+ try {
153
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
154
+ } catch (err) {
155
+ skipped.push({ relPath: relBase || '.', reason: `no se pudo leer: ${err.message}` });
156
+ return;
157
+ }
158
+
159
+ // Orden estable (carpetas y archivos alfabético) para salida determinista.
160
+ entries.sort((a, b) => a.name.localeCompare(b.name));
161
+
162
+ for (const entry of entries) {
163
+ const relPath = relBase ? `${relBase}/${entry.name}` : entry.name;
164
+ const absPath = path.join(absDir, entry.name);
165
+ const isDir = entry.isDirectory();
166
+
167
+ if (entry.isSymbolicLink()) {
168
+ skipped.push({ relPath, reason: 'symlink (no soportado)' });
169
+ continue;
170
+ }
171
+
172
+ if (isIgnored(relPath, isDir)) {
173
+ ignored++;
174
+ continue;
175
+ }
176
+
177
+ const nameCheck = validateName(entry.name);
178
+ if (!nameCheck.ok) {
179
+ skipped.push({ relPath, reason: nameCheck.reason });
180
+ continue; // si es carpeta, se poda todo el subárbol
181
+ }
182
+
183
+ if (isDir) {
184
+ if (folderDepth + 1 > MAX_DEPTH) {
185
+ skipped.push({ relPath, reason: `profundidad de carpetas > ${MAX_DEPTH}` });
186
+ continue;
187
+ }
188
+ recurse(absPath, relPath, folderDepth + 1);
189
+ } else if (entry.isFile()) {
190
+ let size = 0;
191
+ try {
192
+ size = fs.statSync(absPath).size;
193
+ } catch (_) {
194
+ /* si falla el stat, dejamos size en 0; el push lo detecta al leer */
195
+ }
196
+ files.push({ relPath, absPath, size });
197
+ }
198
+ // otros tipos (fifo, socket, device) se ignoran silenciosamente
199
+ }
200
+ }
201
+
202
+ recurse(rootDir, '', 0);
203
+ return { files, skipped, ignored };
204
+ }
205
+
206
+ module.exports = {
207
+ walk,
208
+ loadIgnoreRules,
209
+ makeMatcher,
210
+ compileRule,
211
+ globToRegExp,
212
+ DEFAULT_IGNORES,
213
+ };