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.
- package/README.md +93 -2
- package/bin/shilo.js +41 -2
- package/package.json +6 -2
- package/src/ShiloClient.js +22 -0
- package/src/commands/diff.js +158 -0
- package/src/commands/init.js +6 -1
- package/src/commands/log.js +155 -0
- package/src/commands/pull.js +239 -0
- package/src/commands/push.js +190 -75
- package/src/commands/status.js +187 -0
- package/src/context.js +82 -0
- package/src/remote.js +29 -3
- package/src/sync.js +344 -0
- package/src/textdiff.js +196 -0
|
@@ -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
|
-
|
|
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
|
+
};
|