shilo-cli 0.2.0 → 0.3.1

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.
@@ -0,0 +1,187 @@
1
+ 'use strict';
2
+
3
+ const config = require('../config');
4
+ const ui = require('../ui');
5
+ const sync = require('../sync');
6
+ const remote = require('../remote');
7
+ const { resolveContext } = require('../context');
8
+
9
+ /**
10
+ * shilo status [carpeta] [--project <uuid>] [--base-url <url>] [--short] [--json]
11
+ *
12
+ * Comando de sólo lectura: compara la carpeta local contra el remoto SIN bajar
13
+ * contenido y sin escribir nada, ni local ni remoto. Contesta las dos preguntas
14
+ * que uno se hace antes de arrancar a trabajar:
15
+ *
16
+ * - ¿el remoto tiene cambios que no tengo? (y de quién son)
17
+ * - ¿tengo cambios locales sin subir?
18
+ *
19
+ * La comparación es por ruta + tamaño (un listado por carpeta, cero descargas).
20
+ * Dos archivos del mismo tamaño se reportan como "iguales" aunque su contenido
21
+ * pudiera diferir: para confirmar byte a byte están `shilo diff` y `push`.
22
+ */
23
+ async function status(args) {
24
+ const ctx = resolveContext(args);
25
+ if (!ctx.ok) return ctx.code;
26
+
27
+ const { dir, projectId, baseURL, cfg, client, mgr, savedRemote } = ctx;
28
+ const asJson = !!args.json;
29
+ const short = !!args.short;
30
+ const lastSync = savedRemote && savedRemote.lastSync;
31
+
32
+ if (!asJson) {
33
+ ui.info(
34
+ `Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — comparando ${ui.c.dim(dir)} ...`
35
+ );
36
+ }
37
+
38
+ // 1) Árbol local (offline) y remoto (sólo listados).
39
+ const local = sync.walkLocal(dir);
40
+
41
+ let tree;
42
+ try {
43
+ tree = await sync.walkRemote(client, projectId, mgr);
44
+ } catch (e) {
45
+ ui.error(`No se pudo consultar el proyecto: ${e.message}`);
46
+ return 1;
47
+ } finally {
48
+ config.persistCredsIfChanged(cfg, mgr.current);
49
+ }
50
+
51
+ // 2) Atribución: quién tocó cada archivo por última vez (best-effort).
52
+ const attribution = await sync.buildAuthorIndex(client, projectId, mgr, { commits: 50 });
53
+ config.persistCredsIfChanged(cfg, mgr.current);
54
+
55
+ const me = cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : null;
56
+ const cmp = sync.compareTrees(local.files, tree.files);
57
+
58
+ // 3) "Detrás del remoto" = lo que otro subió y yo no tengo igual: archivos
59
+ // que faltan localmente, o que difieren y cambiaron después de mi lastSync.
60
+ const behind = [];
61
+ for (const item of cmp.onlyRemote) {
62
+ behind.push({ ...item, kind: 'falta local' });
63
+ }
64
+ for (const item of cmp.differing) {
65
+ if (sync.changedSinceSync(item.remote, lastSync) !== false) {
66
+ behind.push({ ...item, kind: 'difiere' });
67
+ }
68
+ }
69
+ behind.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
70
+
71
+ // 4) "Adelante" = lo que yo tengo y el remoto no refleja.
72
+ const ahead = [
73
+ ...cmp.onlyLocal.map((i) => ({ ...i, kind: 'nuevo local' })),
74
+ ...cmp.differing.map((i) => ({ ...i, kind: 'modificado local' })),
75
+ ].sort((a, b) => a.repoPath.localeCompare(b.repoPath));
76
+
77
+ const decorate = (item) => {
78
+ const meta = item.remote && attribution.index.get(item.remote.id);
79
+ if (!meta) return null;
80
+ return {
81
+ author: meta.author,
82
+ mine: !!(me && meta.authorId && meta.authorId === me),
83
+ date: meta.date,
84
+ message: meta.message,
85
+ };
86
+ };
87
+
88
+ if (asJson) {
89
+ console.log(
90
+ JSON.stringify(
91
+ {
92
+ projectId,
93
+ baseURL,
94
+ dir,
95
+ lastSync: lastSync || null,
96
+ remoteFiles: tree.files.length,
97
+ localFiles: local.files.length,
98
+ behind: behind.map((i) => ({ path: i.repoPath, kind: i.kind, lastCommit: decorate(i) })),
99
+ ahead: ahead.map((i) => ({ path: i.repoPath, kind: i.kind })),
100
+ unchanged: cmp.same.length,
101
+ listErrors: tree.errors,
102
+ },
103
+ null,
104
+ 2
105
+ )
106
+ );
107
+ return behind.length > 0 ? 2 : 0;
108
+ }
109
+
110
+ // 5) Reporte legible.
111
+ for (const err of tree.errors) {
112
+ ui.warn(`no se pudo listar ${ui.c.dim(err.folderPath || '(raíz)')} — ${err.reason}`);
113
+ }
114
+
115
+ ui.info('');
116
+ if (lastSync) {
117
+ ui.info(ui.c.dim(`Última sincronización de esta carpeta: ${sync.formatDate(lastSync)}`));
118
+ } else {
119
+ ui.info(
120
+ ui.c.dim(
121
+ `Esta carpeta no registra una sincronización previa ` +
122
+ `(hacé un pull o push para que ${remote.REMOTE_FILENAME} la anote).`
123
+ )
124
+ );
125
+ }
126
+ ui.info('');
127
+
128
+ if (behind.length === 0 && ahead.length === 0) {
129
+ ui.success(`Al día: ${cmp.same.length} archivo(s) coinciden con el remoto.`);
130
+ return 0;
131
+ }
132
+
133
+ if (behind.length > 0) {
134
+ ui.info(ui.c.bold(`El remoto tiene ${behind.length} cambio(s) que no tenés localmente:`));
135
+ for (const item of behind) {
136
+ const mark = item.kind === 'falta local' ? ui.c.green('↓') : ui.c.yellow('≠');
137
+ const meta = decorate(item);
138
+ let suffix = '';
139
+ if (meta) {
140
+ const who = meta.mine ? ui.c.dim('vos') : ui.c.bold(meta.author);
141
+ suffix = ui.c.dim(` — último commit de `) + who + ui.c.dim(`, ${sync.formatDate(meta.date)}`);
142
+ } else if (item.remote && item.remote.updated_at) {
143
+ suffix = ui.c.dim(` — actualizado ${sync.formatDate(item.remote.updated_at)}`);
144
+ }
145
+ ui.info(` ${mark} ${item.repoPath}${short ? '' : suffix}`);
146
+ }
147
+ ui.info('');
148
+ }
149
+
150
+ if (ahead.length > 0) {
151
+ ui.info(ui.c.bold(`Tenés ${ahead.length} cambio(s) local(es) sin subir:`));
152
+ for (const item of ahead) {
153
+ const mark = item.kind === 'nuevo local' ? ui.c.green('+') : ui.c.yellow('~');
154
+ ui.info(` ${mark} ${item.repoPath}`);
155
+ }
156
+ ui.info('');
157
+ }
158
+
159
+ ui.info(
160
+ ui.c.dim(
161
+ ` ${cmp.same.length} sin cambios (por tamaño) ` +
162
+ `${local.ignored} ignorados ${local.skipped.length} saltados`
163
+ )
164
+ );
165
+
166
+ // Los archivos que aparecen en ambas listas son el caso peligroso: el push
167
+ // los pisaría sin traerse antes lo del otro.
168
+ const conflicting = ahead.filter((a) => behind.some((b) => b.repoPath === a.repoPath));
169
+ ui.info('');
170
+ if (conflicting.length > 0) {
171
+ ui.warn(
172
+ `${conflicting.length} archivo(s) cambiaron de los dos lados: un push los pisaría.`
173
+ );
174
+ for (const item of conflicting) ui.info(` ${ui.c.red('!')} ${item.repoPath}`);
175
+ ui.info(ui.c.dim('Mirá "shilo diff <archivo>" antes de decidir.'));
176
+ } else if (behind.length > 0) {
177
+ ui.info(ui.c.dim('Sugerido: "shilo pull --dry-run" para ver qué bajaría.'));
178
+ } else {
179
+ ui.info(ui.c.dim('Sugerido: "shilo push --dry-run" para ver qué subiría.'));
180
+ }
181
+
182
+ // Exit code 2 = "estás desactualizado". Útil para encadenar en scripts:
183
+ // `shilo status || echo "bajá antes de trabajar"`.
184
+ return behind.length > 0 ? 2 : 0;
185
+ }
186
+
187
+ module.exports = status;
package/src/context.js ADDED
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Resolución del contexto común a los comandos que hablan con un proyecto:
5
+ * carpeta -> remote (.shilo.json) -> sesión -> cliente HTTP.
6
+ *
7
+ * Los comandos de sólo lectura (`status`, `log`, `diff`) comparten exactamente
8
+ * este preámbulo; tenerlo en un lugar evita que cada uno resuelva el proyecto o
9
+ * el baseURL con reglas ligeramente distintas.
10
+ */
11
+
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+ const config = require('./config');
15
+ const remote = require('./remote');
16
+ const ui = require('./ui');
17
+ const sync = require('./sync');
18
+ const { ShiloClient } = require('./ShiloClient');
19
+
20
+ const DEFAULT_BASE_URL = 'https://shilo-six.vercel.app';
21
+
22
+ /**
23
+ * Devuelve `{ ok: true, dir, projectId, baseURL, cfg, client, mgr, savedRemote }`
24
+ * o `{ ok: false, code }` habiendo ya impreso el error.
25
+ *
26
+ * `requireDir`: exige que la carpeta exista (todos menos `pull`, que la crea).
27
+ */
28
+ function resolveContext(args, { requireDir = true } = {}) {
29
+ const dir = path.resolve(args._[0] || '.');
30
+
31
+ if (requireDir && (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())) {
32
+ ui.error(`No es una carpeta válida: ${dir}`);
33
+ return { ok: false, code: 1 };
34
+ }
35
+
36
+ let savedRemote = null;
37
+ try {
38
+ savedRemote = remote.read(dir);
39
+ } catch (e) {
40
+ ui.error(e.message);
41
+ return { ok: false, code: 1 };
42
+ }
43
+
44
+ const projectId =
45
+ (typeof args.project === 'string' && args.project.trim()) ||
46
+ (savedRemote && savedRemote.projectId) ||
47
+ null;
48
+
49
+ if (!projectId) {
50
+ ui.error('No hay proyecto configurado en esta carpeta.');
51
+ ui.info('Pasá --project <uuid> o corré "shilo init --project <uuid>" primero.');
52
+ return { ok: false, code: 1 };
53
+ }
54
+
55
+ const cfg = config.load();
56
+ if (!cfg || !cfg.credentials) {
57
+ ui.error('No hay sesión activa. Ejecutá "shilo login" primero.');
58
+ return { ok: false, code: 1 };
59
+ }
60
+
61
+ const baseURL =
62
+ args['base-url'] || (savedRemote && savedRemote.baseURL) || cfg.baseURL || DEFAULT_BASE_URL;
63
+
64
+ const client = new ShiloClient({
65
+ baseURL,
66
+ supabaseUrl: cfg.supabaseUrl || null,
67
+ supabaseAnonKey: cfg.supabaseAnonKey || null,
68
+ });
69
+
70
+ return {
71
+ ok: true,
72
+ dir,
73
+ projectId,
74
+ baseURL,
75
+ cfg,
76
+ client,
77
+ mgr: sync.makeCredsManager(client, cfg),
78
+ savedRemote,
79
+ };
80
+ }
81
+
82
+ module.exports = { resolveContext, DEFAULT_BASE_URL };
package/src/remote.js CHANGED
@@ -10,9 +10,14 @@
10
10
  * Estructura en disco:
11
11
  * {
12
12
  * "projectId": "<uuid>",
13
- * "baseURL": "https://shilo-six.vercel.app"
13
+ * "baseURL": "https://shilo-six.vercel.app",
14
+ * "lastSync": "2026-07-27T12:00:00.000Z"
14
15
  * }
15
16
  *
17
+ * `lastSync` es la marca de la última vez que esta carpeta quedó alineada con
18
+ * el remoto (fin de un push o pull exitoso). `status` la usa para distinguir
19
+ * "el remoto cambió desde que sincronicé" de "esto lo subí yo".
20
+ *
16
21
  * NO contiene credenciales (esas están en ~/.shilo/config.json, cifradas), así
17
22
  * que es seguro commitearlo al repo si el usuario quiere.
18
23
  */
@@ -42,18 +47,39 @@ function read(dir) {
42
47
  return {
43
48
  projectId: raw.projectId || null,
44
49
  baseURL: raw.baseURL || null,
50
+ lastSync: raw.lastSync || null,
45
51
  };
46
52
  }
47
53
 
48
54
  /**
49
55
  * Escribe `.shilo.json` en `dir`. Devuelve la ruta escrita.
50
56
  */
51
- function write(dir, { projectId, baseURL }) {
57
+ function write(dir, { projectId, baseURL, lastSync }) {
52
58
  const p = remotePath(dir);
53
59
  const onDisk = { projectId };
54
60
  if (baseURL) onDisk.baseURL = baseURL;
61
+ if (lastSync) onDisk.lastSync = lastSync;
55
62
  fs.writeFileSync(p, JSON.stringify(onDisk, null, 2) + '\n', 'utf8');
56
63
  return p;
57
64
  }
58
65
 
59
- module.exports = { REMOTE_FILENAME, remotePath, read, write };
66
+ /**
67
+ * Marca la carpeta como sincronizada ahora mismo, conservando el resto del
68
+ * remote. No es fatal si falla (la carpeta puede ser de sólo lectura): devuelve
69
+ * `false` y el caller decide si avisar.
70
+ */
71
+ function touchSync(dir, { projectId, baseURL } = {}) {
72
+ try {
73
+ const current = read(dir) || {};
74
+ write(dir, {
75
+ projectId: projectId || current.projectId,
76
+ baseURL: baseURL || current.baseURL,
77
+ lastSync: new Date().toISOString(),
78
+ });
79
+ return true;
80
+ } catch (_) {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ module.exports = { REMOTE_FILENAME, remotePath, read, write, touchSync };
package/src/sync.js ADDED
@@ -0,0 +1,344 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Piezas compartidas entre `pull`, `status`, `diff`, `log` y `push`:
5
+ * recorrido del árbol remoto, comparación local↔remoto y atribución de
6
+ * cambios a partir del historial de commits.
7
+ *
8
+ * Todo lo de acá es de sólo lectura: ninguna función escribe en disco ni en el
9
+ * servidor. La idea es que un `shilo status` pueda contestar "¿estoy
10
+ * desactualizado?" sin bajar contenido.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const config = require('./config');
16
+ const { walk } = require('./walk');
17
+
18
+ const CONCURRENCY = 8; // pool para el N+1 de listar carpetas (el backend no tiene árbol recursivo)
19
+
20
+ // Mime types que sabemos que vuelven como texto legible. El backend siempre
21
+ // devuelve el contenido vía `data.text()`, así que un binario (p.ej. image/png
22
+ // subido desde la web) volvería corrupto: esos se saltean.
23
+ const TEXT_MIME_EXTRA = new Set([
24
+ 'application/json', 'application/xml', 'application/javascript',
25
+ 'application/typescript', 'application/yaml', 'application/x-yaml',
26
+ 'image/svg+xml',
27
+ ]);
28
+
29
+ function isTextMime(mime) {
30
+ if (!mime) return true; // el backend default para archivos es text/plain
31
+ const m = String(mime).toLowerCase().split(';')[0].trim();
32
+ return m.startsWith('text/') || TEXT_MIME_EXTRA.has(m);
33
+ }
34
+
35
+ /**
36
+ * Gestor de credenciales con refresh serializado.
37
+ *
38
+ * El recorrido remoto dispara muchos requests en paralelo (pool). Si dos toparan
39
+ * con el JWT casi vencido a la vez, cada uno intentaría refrescar con el mismo
40
+ * refresh_token —que Supabase invalida tras el primer uso— y la sesión se
41
+ * rompería. Este gestor centraliza el refresh en una sola promesa compartida y
42
+ * entrega credenciales ya válidas, de modo que el `ensureValidToken` interno de
43
+ * cada request sea no-op.
44
+ */
45
+ function makeCredsManager(client, cfg) {
46
+ let creds = cfg.credentials;
47
+ let refreshing = null;
48
+
49
+ return {
50
+ get current() {
51
+ return creds;
52
+ },
53
+ async valid() {
54
+ if (creds && creds.kind === 'pat') return creds;
55
+ const msLeft = new Date(creds.expiresAt).getTime() - Date.now();
56
+ if (msLeft >= 5 * 60 * 1000) return creds;
57
+
58
+ if (!refreshing) {
59
+ refreshing = (async () => {
60
+ const fresh = await client.ensureValidToken(creds);
61
+ if (fresh !== creds) {
62
+ creds = fresh;
63
+ config.persistCredsIfChanged(cfg, creds);
64
+ }
65
+ return creds;
66
+ })().finally(() => {
67
+ refreshing = null;
68
+ });
69
+ }
70
+ return refreshing;
71
+ },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Corre `worker(item)` sobre `items` con concurrencia limitada. Preserva el
77
+ * orden de `items` en el array de resultados. Un worker que lanza deja el hueco
78
+ * como `{ __error: <msg>, item }` para que el caller decida.
79
+ */
80
+ async function runPool(items, concurrency, worker) {
81
+ const results = new Array(items.length);
82
+ let next = 0;
83
+
84
+ async function drain() {
85
+ while (next < items.length) {
86
+ const i = next++;
87
+ try {
88
+ results[i] = await worker(items[i], i);
89
+ } catch (e) {
90
+ results[i] = { __error: e.message, item: items[i] };
91
+ }
92
+ }
93
+ }
94
+
95
+ const runners = [];
96
+ for (let k = 0; k < Math.min(concurrency, items.length); k++) {
97
+ runners.push(drain());
98
+ }
99
+ await Promise.all(runners);
100
+ return results;
101
+ }
102
+
103
+ /**
104
+ * Recorre el árbol remoto de un proyecto listando carpeta por carpeta (BFS con
105
+ * pool). El backend no expone un endpoint recursivo, así que es N+1: un request
106
+ * por carpeta, pero ninguno baja contenido. Devuelve `{ files, errors }`:
107
+ * files: [{ repoPath, name, id, size, mime_type, updated_at }]
108
+ * errors: [{ folderPath, reason }]
109
+ */
110
+ async function walkRemote(client, projectId, mgr) {
111
+ const files = [];
112
+ const errors = [];
113
+ let frontier = ['']; // rutas de carpeta a listar; '' = raíz
114
+
115
+ while (frontier.length > 0) {
116
+ const listed = await runPool(frontier, CONCURRENCY, async (folderPath) => {
117
+ const creds = await mgr.valid();
118
+ const { files: entries } = await client.listFiles(projectId, creds, folderPath);
119
+ return { folderPath, entries };
120
+ });
121
+
122
+ const nextFrontier = [];
123
+ for (const res of listed) {
124
+ if (res && res.__error) {
125
+ errors.push({ folderPath: res.item, reason: res.__error });
126
+ continue;
127
+ }
128
+ const { folderPath, entries } = res;
129
+ for (const entry of entries) {
130
+ const repoPath = folderPath ? `${folderPath}/${entry.name}` : entry.name;
131
+ if (entry.type === 'folder') {
132
+ nextFrontier.push(repoPath);
133
+ } else {
134
+ files.push({
135
+ repoPath,
136
+ name: entry.name,
137
+ id: entry.id,
138
+ size: entry.size,
139
+ mime_type: entry.mime_type,
140
+ updated_at: entry.updated_at || entry.created_at || null,
141
+ });
142
+ }
143
+ }
144
+ }
145
+ frontier = nextFrontier;
146
+ }
147
+
148
+ files.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
149
+ return { files, errors };
150
+ }
151
+
152
+ /**
153
+ * Árbol local en el mismo formato que el remoto, aplicando los mismos ignores
154
+ * que usa `push` (para que la comparación sea manzanas con manzanas).
155
+ * Devuelve `{ files: [{ repoPath, absPath, size }], skipped, ignored }`.
156
+ */
157
+ function walkLocal(dir) {
158
+ const { files, skipped, ignored } = walk(dir);
159
+ return {
160
+ files: files
161
+ .map((f) => ({ repoPath: f.relPath, absPath: f.absPath, size: f.size }))
162
+ .sort((a, b) => a.repoPath.localeCompare(b.repoPath)),
163
+ skipped,
164
+ ignored,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Compara los dos árboles por ruta y tamaño. NO baja contenido: por eso un
170
+ * archivo con el mismo tamaño en ambos lados cae en `same` aunque su contenido
171
+ * pudiera diferir (mismo largo, distinto texto). Esa es la concesión que hace
172
+ * que `status` sea barato; `diff` y `push` sí confirman byte a byte.
173
+ *
174
+ * Devuelve:
175
+ * {
176
+ * onlyLocal: [{ repoPath, local }], // sin subir
177
+ * onlyRemote: [{ repoPath, remote }], // sin bajar
178
+ * differing: [{ repoPath, local, remote }], // tamaños distintos
179
+ * same: [{ repoPath, local, remote }], // mismo tamaño
180
+ * }
181
+ */
182
+ function compareTrees(localFiles, remoteFiles) {
183
+ const localByPath = new Map(localFiles.map((f) => [f.repoPath, f]));
184
+ const remoteByPath = new Map(remoteFiles.map((f) => [f.repoPath, f]));
185
+
186
+ const onlyLocal = [];
187
+ const onlyRemote = [];
188
+ const differing = [];
189
+ const same = [];
190
+
191
+ for (const [repoPath, local] of localByPath) {
192
+ const remote = remoteByPath.get(repoPath);
193
+ if (!remote) {
194
+ onlyLocal.push({ repoPath, local });
195
+ } else if (typeof remote.size === 'number' && remote.size !== local.size) {
196
+ differing.push({ repoPath, local, remote });
197
+ } else {
198
+ same.push({ repoPath, local, remote });
199
+ }
200
+ }
201
+
202
+ for (const [repoPath, remote] of remoteByPath) {
203
+ if (!localByPath.has(repoPath)) onlyRemote.push({ repoPath, remote });
204
+ }
205
+
206
+ const byPath = (a, b) => a.repoPath.localeCompare(b.repoPath);
207
+ onlyLocal.sort(byPath);
208
+ onlyRemote.sort(byPath);
209
+ differing.sort(byPath);
210
+ same.sort(byPath);
211
+
212
+ return { onlyLocal, onlyRemote, differing, same };
213
+ }
214
+
215
+ /**
216
+ * Baja las últimas páginas del historial con sus archivos y arma un índice
217
+ * `file_id -> { message, author, date, hash, action }` con el commit MÁS
218
+ * RECIENTE que tocó cada archivo. Sirve para responder "¿esto lo subí yo o me
219
+ * lo va a pisar otro?".
220
+ *
221
+ * Es best-effort: si el historial falla (permisos, red), devuelve un índice
222
+ * vacío en vez de romper el comando que lo pidió.
223
+ */
224
+ async function buildAuthorIndex(client, projectId, mgr, { commits = 50 } = {}) {
225
+ const index = new Map();
226
+ let history = [];
227
+ try {
228
+ const creds = await mgr.valid();
229
+ const result = await client.listCommits(projectId, creds, {
230
+ limit: Math.min(commits, 100),
231
+ includeFiles: true,
232
+ });
233
+ history = result.commits;
234
+ } catch (_) {
235
+ return { index, commits: [], ok: false };
236
+ }
237
+
238
+ // El historial viene del más nuevo al más viejo: el primero que menciona un
239
+ // archivo es el último que lo tocó.
240
+ for (const commit of history) {
241
+ const author = describeAuthor(commit.users);
242
+ for (const cf of commit.files || []) {
243
+ const file = cf.files || {};
244
+ if (!file.id || index.has(file.id)) continue;
245
+ index.set(file.id, {
246
+ message: commit.message,
247
+ author,
248
+ authorId: commit.users ? commit.users.id : null,
249
+ date: commit.created_at,
250
+ hash: commit.hash,
251
+ action: cf.action,
252
+ });
253
+ }
254
+ }
255
+
256
+ return { index, commits: history, ok: true };
257
+ }
258
+
259
+ function describeAuthor(user) {
260
+ if (!user) return 'desconocido';
261
+ return user.username || user.display_name || 'desconocido';
262
+ }
263
+
264
+ /**
265
+ * Fecha absoluta corta + relativa ("2026-07-27 14:03, hace 2 h").
266
+ */
267
+ function formatDate(iso) {
268
+ if (!iso) return 'sin fecha';
269
+ const d = new Date(iso);
270
+ if (isNaN(d.getTime())) return String(iso);
271
+ const pad = (n) => String(n).padStart(2, '0');
272
+ const abs =
273
+ `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
274
+ `${pad(d.getHours())}:${pad(d.getMinutes())}`;
275
+ return `${abs} (${relativeDate(d)})`;
276
+ }
277
+
278
+ function relativeDate(d) {
279
+ const secs = Math.round((Date.now() - d.getTime()) / 1000);
280
+ if (secs < 0) return 'en el futuro';
281
+ if (secs < 60) return 'recién';
282
+ const mins = Math.round(secs / 60);
283
+ if (mins < 60) return `hace ${mins} min`;
284
+ const hours = Math.round(mins / 60);
285
+ if (hours < 48) return `hace ${hours} h`;
286
+ const days = Math.round(hours / 24);
287
+ if (days < 60) return `hace ${days} d`;
288
+ return `hace ${Math.round(days / 30)} meses`;
289
+ }
290
+
291
+ /**
292
+ * ¿Un archivo remoto cambió después de la última sincronización local?
293
+ * Sin `lastSync` (carpeta atada a mano, o CLI viejo) devolvemos `null`:
294
+ * "no se sabe", que es distinto de "no cambió".
295
+ */
296
+ function changedSinceSync(remoteFile, lastSync) {
297
+ if (!lastSync || !remoteFile || !remoteFile.updated_at) return null;
298
+ const t = new Date(remoteFile.updated_at).getTime();
299
+ const s = new Date(lastSync).getTime();
300
+ if (isNaN(t) || isNaN(s)) return null;
301
+ return t > s;
302
+ }
303
+
304
+ /**
305
+ * Lee un archivo local como texto. Devuelve `null` si no existe o no se puede
306
+ * leer como utf8.
307
+ */
308
+ function readLocalText(absPath) {
309
+ try {
310
+ if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) return null;
311
+ return fs.readFileSync(absPath, 'utf8');
312
+ } catch (_) {
313
+ return null;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Normaliza una ruta que el usuario escribe en la terminal (puede venir con
319
+ * `\` de Windows, con `./` adelante, o absoluta) a una ruta de repo POSIX
320
+ * relativa a `dir`. Devuelve `null` si queda fuera de la carpeta.
321
+ */
322
+ function toRepoPath(dir, userPath) {
323
+ const abs = path.resolve(dir, userPath);
324
+ const rel = path.relative(dir, abs);
325
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
326
+ return rel.split(path.sep).join('/');
327
+ }
328
+
329
+ module.exports = {
330
+ CONCURRENCY,
331
+ isTextMime,
332
+ makeCredsManager,
333
+ runPool,
334
+ walkRemote,
335
+ walkLocal,
336
+ compareTrees,
337
+ buildAuthorIndex,
338
+ describeAuthor,
339
+ formatDate,
340
+ relativeDate,
341
+ changedSinceSync,
342
+ readLocalText,
343
+ toRepoPath,
344
+ };