shilo-cli 0.2.0 → 0.3.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/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
+ };
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Diff de texto por líneas, estilo unified, sin dependencias.
5
+ *
6
+ * Usado por `shilo diff` y por el `--dry-run` de push/pull para mostrar qué
7
+ * cambia realmente entre la versión local y la remota de un archivo.
8
+ *
9
+ * El algoritmo es LCS con programación dinámica: simple y exacto, pero O(n·m)
10
+ * en memoria. Para archivos grandes eso explota, así que por encima de
11
+ * `MAX_CELLS` se degrada a un resumen ("N líneas locales vs M remotas") en vez
12
+ * de intentar el diff completo.
13
+ */
14
+
15
+ const MAX_CELLS = 4_000_000; // ~4M celdas: unos 2000x2000 líneas
16
+ const DEFAULT_CONTEXT = 3;
17
+
18
+ function splitLines(text) {
19
+ if (text === '' || text == null) return [];
20
+ const normalized = String(text).replace(/\r\n/g, '\n');
21
+ const lines = normalized.split('\n');
22
+ // Un texto terminado en \n produce un último elemento vacío que no es línea.
23
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
24
+ return lines;
25
+ }
26
+
27
+ /**
28
+ * Matriz de longitudes LCS entre dos arrays de líneas.
29
+ */
30
+ function lcsTable(a, b) {
31
+ const rows = a.length + 1;
32
+ const cols = b.length + 1;
33
+ const table = new Array(rows);
34
+ for (let i = 0; i < rows; i++) table[i] = new Uint32Array(cols);
35
+
36
+ for (let i = a.length - 1; i >= 0; i--) {
37
+ for (let j = b.length - 1; j >= 0; j--) {
38
+ table[i][j] =
39
+ a[i] === b[j]
40
+ ? table[i + 1][j + 1] + 1
41
+ : Math.max(table[i + 1][j], table[i][j + 1]);
42
+ }
43
+ }
44
+ return table;
45
+ }
46
+
47
+ /**
48
+ * Secuencia de operaciones para transformar `a` en `b`.
49
+ * Devuelve [{ type: 'equal'|'del'|'add', line }] o `null` si es demasiado grande.
50
+ */
51
+ function diffLines(a, b) {
52
+ if ((a.length + 1) * (b.length + 1) > MAX_CELLS) return null;
53
+
54
+ const table = lcsTable(a, b);
55
+ const ops = [];
56
+ let i = 0;
57
+ let j = 0;
58
+
59
+ while (i < a.length && j < b.length) {
60
+ if (a[i] === b[j]) {
61
+ ops.push({ type: 'equal', line: a[i] });
62
+ i++;
63
+ j++;
64
+ } else if (table[i + 1][j] >= table[i][j + 1]) {
65
+ ops.push({ type: 'del', line: a[i] });
66
+ i++;
67
+ } else {
68
+ ops.push({ type: 'add', line: b[j] });
69
+ j++;
70
+ }
71
+ }
72
+ while (i < a.length) ops.push({ type: 'del', line: a[i++] });
73
+ while (j < b.length) ops.push({ type: 'add', line: b[j++] });
74
+
75
+ return ops;
76
+ }
77
+
78
+ /**
79
+ * Agrupa las operaciones en hunks con `context` líneas de contexto, al estilo
80
+ * unified. Devuelve [{ aStart, aCount, bStart, bCount, ops }] (1-indexado).
81
+ */
82
+ function toHunks(ops, context = DEFAULT_CONTEXT) {
83
+ // Índice de línea (1-based) de cada op en cada lado.
84
+ const annotated = [];
85
+ let aLine = 1;
86
+ let bLine = 1;
87
+ for (const op of ops) {
88
+ annotated.push({ ...op, aLine, bLine });
89
+ if (op.type === 'equal') {
90
+ aLine++;
91
+ bLine++;
92
+ } else if (op.type === 'del') {
93
+ aLine++;
94
+ } else {
95
+ bLine++;
96
+ }
97
+ }
98
+
99
+ const changed = annotated
100
+ .map((op, idx) => (op.type === 'equal' ? -1 : idx))
101
+ .filter((idx) => idx >= 0);
102
+ if (changed.length === 0) return [];
103
+
104
+ // Agrupar índices cambiados que estén a <= 2*context de distancia.
105
+ const groups = [];
106
+ let start = changed[0];
107
+ let end = changed[0];
108
+ for (const idx of changed.slice(1)) {
109
+ if (idx - end <= context * 2) {
110
+ end = idx;
111
+ } else {
112
+ groups.push([start, end]);
113
+ start = idx;
114
+ end = idx;
115
+ }
116
+ }
117
+ groups.push([start, end]);
118
+
119
+ return groups.map(([gStart, gEnd]) => {
120
+ const from = Math.max(0, gStart - context);
121
+ const to = Math.min(annotated.length - 1, gEnd + context);
122
+ const slice = annotated.slice(from, to + 1);
123
+
124
+ let aCount = 0;
125
+ let bCount = 0;
126
+ for (const op of slice) {
127
+ if (op.type !== 'add') aCount++;
128
+ if (op.type !== 'del') bCount++;
129
+ }
130
+
131
+ return {
132
+ aStart: aCount ? slice[0].aLine : 0,
133
+ aCount,
134
+ bStart: bCount ? slice[0].bLine : 0,
135
+ bCount,
136
+ ops: slice,
137
+ };
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Diff completo entre dos textos.
143
+ * Devuelve:
144
+ * { identical: true }
145
+ * { tooBig: true, aLines, bLines }
146
+ * { hunks, added, removed, aLines, bLines }
147
+ */
148
+ function diffText(aText, bText, { context = DEFAULT_CONTEXT } = {}) {
149
+ const a = splitLines(aText);
150
+ const b = splitLines(bText);
151
+
152
+ if (aText === bText) return { identical: true, aLines: a.length, bLines: b.length };
153
+
154
+ const ops = diffLines(a, b);
155
+ if (!ops) return { tooBig: true, aLines: a.length, bLines: b.length };
156
+
157
+ const added = ops.filter((o) => o.type === 'add').length;
158
+ const removed = ops.filter((o) => o.type === 'del').length;
159
+ if (added === 0 && removed === 0) {
160
+ return { identical: true, aLines: a.length, bLines: b.length };
161
+ }
162
+
163
+ return { hunks: toHunks(ops, context), added, removed, aLines: a.length, bLines: b.length };
164
+ }
165
+
166
+ /**
167
+ * Render de un resultado de `diffText` a líneas de texto listas para imprimir.
168
+ * `paint` permite inyectar colores (`ui.c`); por defecto sale en plano.
169
+ */
170
+ function renderDiff(result, { paint = null } = {}) {
171
+ const color = paint || { green: (s) => s, red: (s) => s, cyan: (s) => s, dim: (s) => s };
172
+ if (result.identical) return [];
173
+ if (result.tooBig) {
174
+ return [
175
+ color.dim(
176
+ `(archivo demasiado grande para diff línea a línea: ` +
177
+ `${result.aLines} líneas de un lado, ${result.bLines} del otro)`
178
+ ),
179
+ ];
180
+ }
181
+
182
+ const out = [];
183
+ for (const hunk of result.hunks) {
184
+ out.push(
185
+ color.cyan(`@@ -${hunk.aStart},${hunk.aCount} +${hunk.bStart},${hunk.bCount} @@`)
186
+ );
187
+ for (const op of hunk.ops) {
188
+ if (op.type === 'equal') out.push(color.dim(` ${op.line}`));
189
+ else if (op.type === 'del') out.push(color.red(`-${op.line}`));
190
+ else out.push(color.green(`+${op.line}`));
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ module.exports = { splitLines, diffLines, diffText, renderDiff, toHunks, MAX_CELLS };