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 CHANGED
@@ -3,8 +3,9 @@
3
3
  CLI para subir carpetas locales a repositorios de [Shilo](https://shilo-six.vercel.app),
4
4
  con una experiencia tipo git: `shilo login` una vez y después `shilo push`.
5
5
 
6
- > **Estado:** Fase 3 (push). Disponibles `login`, `whoami`, `logout`, `init`, `push`.
7
- > El flujo completo tipo git ya funciona: `shilo login` → `shilo init` → `shilo push`.
6
+ > **Estado:** Fase 5 (colaboración asíncrona). Disponibles `login`, `whoami`, `logout`,
7
+ > `init`, `push`, `pull`, `status`, `log`, `diff`.
8
+ > Ciclo completo: `shilo status` → `shilo pull` → trabajar local → `shilo push`.
8
9
 
9
10
  ## Requisitos
10
11
 
@@ -36,6 +37,7 @@ shilo init --project <uuid> # ata la carpeta al repo (escribe .shilo.json)
36
37
  shilo push # sube: crea/actualiza + commit
37
38
  shilo push -m "mi mensaje" # con mensaje de commit
38
39
  shilo push --dry-run # muestra qué haría sin subir
40
+ shilo push --card ABC-123 # agrega "[card: ABC-123]" al mensaje del commit
39
41
  shilo push --yes # sin pedir confirmación
40
42
  shilo push ./otra-carpeta --project <uuid> # sin init, proyecto explícito
41
43
  ```
@@ -44,6 +46,78 @@ Sólo se suben archivos de **texto**; binarios, oversize (>25 MB) y nombres con
44
46
  caracteres no permitidos se saltan con aviso. Los archivos sin cambios no se
45
47
  re-suben. Ignorá rutas con un `.shiloignore` (sintaxis estilo `.gitignore`).
46
48
 
49
+ En `--dry-run` (y antes de la confirmación interactiva) cada archivo modificado
50
+ muestra **quién lo tocó por última vez y cuándo**, y se avisa explícitamente si vas
51
+ a pisar el trabajo de otra persona. Con token de acceso personal el autor se muestra
52
+ igual, pero no se puede marcar cuál sos vos (el PAT no expone tu id de usuario).
53
+
54
+ ### ¿Estoy desactualizado? (status)
55
+
56
+ ```bash
57
+ shilo status # compara local vs remoto SIN bajar contenido
58
+ shilo status --short # sólo rutas
59
+ shilo status --json # para scripts
60
+ ```
61
+
62
+ Es el comando barato: usa sólo listados de carpeta (una request por carpeta, cero
63
+ descargas de contenido) y compara por **ruta + tamaño**. Informa qué tiene el remoto
64
+ que vos no (con autor y fecha del último commit de cada archivo), qué tenés local sin
65
+ subir, y marca aparte los archivos que cambiaron **de los dos lados** — el caso
66
+ peligroso. **Exit code 2** = estás atrasado respecto del remoto, útil en scripts.
67
+
68
+ Compensación consciente: dos archivos del mismo tamaño se reportan como iguales
69
+ aunque difieran byte a byte. `shilo diff` y `shilo push` sí comparan contenido.
70
+
71
+ La comparación con "desde la última vez que sincronicé" se apoya en `lastSync`, que
72
+ `push` y los `pull` completos escriben en `.shilo.json`. Si nunca sincronizaste desde
73
+ esta carpeta, no hay línea de base y los cambios remotos se reportan igual.
74
+
75
+ ### Historial (log)
76
+
77
+ ```bash
78
+ shilo log # últimos commits del proyecto
79
+ shilo log src/app.js # sólo los commits que tocaron ese archivo
80
+ shilo log -n 50 --files # más commits, listando archivos de cada uno
81
+ shilo log --json
82
+ ```
83
+
84
+ La paginación es **por commit**, no por archivo: si un archivo no aparece, subí `-n`.
85
+
86
+ ### Diferencias (diff)
87
+
88
+ ```bash
89
+ shilo diff # resumen de todo lo que difiere (sólo metadata)
90
+ shilo diff src/app.js # diff de contenido línea a línea
91
+ shilo diff src/app.js --context 6
92
+ shilo diff --stat
93
+ ```
94
+
95
+ El diff usa el remoto como base (`---`) y tu copia local como destino (`+++`), o sea
96
+ que las líneas `+` son lo que un `push` agregaría. **Exit code 1** = hay diferencias.
97
+
98
+ ### Bajar un proyecto (pull)
99
+
100
+ ```bash
101
+ shilo pull ./mi-carpeta --project <uuid> # baja el repo a una carpeta local
102
+ shilo pull --dry-run # muestra qué cambiaría localmente, sin bajar nada
103
+ shilo pull --yes # sin pedir confirmación
104
+ shilo pull --force # sobrescribe también los archivos que difieren
105
+ ```
106
+
107
+ `pull` recorre el árbol remoto (carpeta por carpeta), baja los archivos de texto
108
+ y deja `.shilo.json` listo para un `push` posterior sin `--project`.
109
+
110
+ Protección del trabajo local sin guardar: **no pisa** un archivo local que difiera del
111
+ remoto salvo con `--force`. Sin `--force` no aborta el pull entero — baja lo que es
112
+ seguro, deja afuera los conflictivos y sale con código 1 para que decidas (mirá
113
+ `shilo diff <archivo>`). Los archivos que sólo existen localmente nunca se tocan: el
114
+ pull no borra. `--dry-run` muestra el impacto sobre tu disco (nuevo / sobrescribe /
115
+ se conserva), no el árbol remoto crudo.
116
+
117
+ También bloquea nombres que intenten escribir fuera de la carpeta destino (path
118
+ traversal) y saltea binarios con aviso (volverían corruptos). Espejo de `push`:
119
+ `--project` pisa a `.shilo.json`. Con `shilo init --pull` se ata y baja en un solo paso.
120
+
47
121
  ### Servidor alternativo
48
122
 
49
123
  ```bash
@@ -78,3 +152,20 @@ contraseña.
78
152
  `ensurePath`/`commit`) + recorrido de árbol (`.shiloignore`) + detección de binarios.
79
153
  - **Fase 3 — push** *(hecho)*: `shilo init` + `shilo push` (crea/actualiza + commit,
80
154
  detección de cambios, `.shiloignore`, `--dry-run`).
155
+ - **Fase 4 — pull** *(hecho)*: `shilo pull` (recorrido del árbol remoto con pool,
156
+ guarda anti path-traversal, salto de binarios, colisiones, binding `.shilo.json`,
157
+ `--dry-run`). Cierra el ciclo `pull → trabajar local → push`.
158
+ - **Fase 5 — colaboración asíncrona** *(hecho)*: `status` (comparación barata local vs
159
+ remoto + exit code 2), `log` (historial por proyecto o archivo), `diff` (comparación
160
+ de contenido + exit code 1), atribución de autor en `push --dry-run`, `pull` que
161
+ resuelve conflictos por archivo en vez de abortar, `lastSync` en `.shilo.json` y
162
+ `push --card <id>` para cruzar commits con un tablero externo (Metrónomo).
163
+
164
+ ## Exit codes
165
+
166
+ | Comando | Código | Significado |
167
+ | -------- | ------ | -------------------------------------------- |
168
+ | `status` | 2 | El remoto tiene cambios que no tenés. |
169
+ | `diff` | 1 | Hay diferencias entre local y remoto. |
170
+ | `pull` | 1 | Quedaron conflictos sin bajar (o fallas). |
171
+ | cualquiera | 1 | Error. |
package/bin/shilo.js CHANGED
@@ -17,7 +17,11 @@ ${ui.c.bold('Comandos:')}
17
17
  whoami Muestra la sesión activa.
18
18
  logout Cierra sesión (borra credenciales locales).
19
19
  init [carpeta] Ata una carpeta a un repo de Shilo (--project <uuid>).
20
+ status [carpeta] Compara local vs remoto sin bajar nada (¿estoy desactualizado?).
20
21
  push [carpeta] Sube una carpeta al repo de Shilo (crea/actualiza + commit).
22
+ pull [carpeta] Baja un repo de Shilo a una carpeta local (deja .shilo.json).
23
+ log [archivo] Historial de commits del proyecto o de un archivo.
24
+ diff [archivo] Diferencias entre tu copia local y la remota.
21
25
  help Muestra esta ayuda.
22
26
 
23
27
  ${ui.c.bold('Opciones de login:')}
@@ -30,16 +34,44 @@ ${ui.c.bold('Opciones de init:')}
30
34
  --project <uuid> Id del proyecto de Shilo (requerido).
31
35
  --token <pat> Token de acceso personal (evita el "login" previo).
32
36
  --push Sube la carpeta apenas queda atada.
37
+ --pull Baja la carpeta apenas queda atada.
33
38
  --base-url <url> Servidor Shilo.
34
39
  --force No verifica acceso ni pregunta antes de sobrescribir.
35
40
 
41
+ ${ui.c.bold('Opciones de status:')}
42
+ --project <uuid> Proyecto a comparar (pisa el .shilo.json de la carpeta).
43
+ --short Sólo las rutas, sin autor ni fecha.
44
+ --json Salida JSON (para scripts).
45
+ --base-url <url> Servidor Shilo.
46
+ ${ui.c.dim('Exit code 2 = el remoto tiene cambios que no tenés.')}
47
+
36
48
  ${ui.c.bold('Opciones de push:')}
37
49
  --project <uuid> Proyecto destino (pisa el .shilo.json de la carpeta).
38
50
  -m, --message <m> Mensaje del commit.
39
- --dry-run Muestra qué haría, sin subir nada.
51
+ --card <id> Id de tarjeta/tablero; se agrega al mensaje como [card: id].
52
+ --dry-run Muestra qué haría (y quién tocó cada archivo), sin subir nada.
40
53
  --yes No pide confirmación.
41
54
  --base-url <url> Servidor Shilo.
42
55
 
56
+ ${ui.c.bold('Opciones de pull:')}
57
+ --project <uuid> Proyecto origen (pisa el .shilo.json de la carpeta).
58
+ --dry-run Muestra qué cambiaría localmente, sin bajar nada.
59
+ --yes No pide confirmación.
60
+ --force Sobrescribe también los archivos locales que difieren.
61
+ --base-url <url> Servidor Shilo.
62
+
63
+ ${ui.c.bold('Opciones de log:')}
64
+ -n <N> Cuántos commits mirar (1-100, default 20).
65
+ --files Lista los archivos de cada commit.
66
+ --json Salida JSON.
67
+ --project <uuid> Proyecto (pisa el .shilo.json de la carpeta).
68
+
69
+ ${ui.c.bold('Opciones de diff:')}
70
+ --context <n> Líneas de contexto (0-20, default 3).
71
+ --stat Sólo el resumen de líneas agregadas/quitadas.
72
+ --project <uuid> Proyecto (pisa el .shilo.json de la carpeta).
73
+ ${ui.c.dim('Sin archivo, resume todo lo que difiere. Exit code 1 = hay diferencias.')}
74
+
43
75
  ${ui.c.bold('Globales:')}
44
76
  -v, --version Versión del CLI.
45
77
  -h, --help Ayuda.
@@ -51,6 +83,10 @@ const COMMANDS = {
51
83
  logout: () => require('../src/commands/logout'),
52
84
  init: () => require('../src/commands/init'),
53
85
  push: () => require('../src/commands/push'),
86
+ pull: () => require('../src/commands/pull'),
87
+ status: () => require('../src/commands/status'),
88
+ log: () => require('../src/commands/log'),
89
+ diff: () => require('../src/commands/diff'),
54
90
  };
55
91
 
56
92
  async function main() {
@@ -59,7 +95,10 @@ async function main() {
59
95
  // 'push' tiene que estar acá: la lista de booleanos es global, y sin
60
96
  // registrarlo `shilo init --project X --push .` se comería el `.` como
61
97
  // valor de --push, perdiendo el positional de la carpeta.
62
- booleans: ['help', 'version', 'h', 'v', 'dry-run', 'yes', 'force', 'push'],
98
+ booleans: [
99
+ 'help', 'version', 'h', 'v', 'dry-run', 'yes', 'force', 'push', 'pull',
100
+ 'json', 'short', 'stat', 'files',
101
+ ],
63
102
  aliases: { h: 'help', v: 'version', m: 'message' },
64
103
  });
65
104
 
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "shilo-cli",
3
- "version": "0.2.0",
4
- "description": "CLI para subir carpetas locales a repositorios de Shilo (login + push tipo git).",
3
+ "version": "0.3.1",
4
+ "description": "CLI para sincronizar carpetas locales con repositorios de Shilo: push, pull, status, log y diff.",
5
5
  "keywords": [
6
6
  "shilo",
7
7
  "cli",
8
8
  "push",
9
+ "pull",
10
+ "status",
11
+ "diff",
12
+ "sync",
9
13
  "upload",
10
14
  "repositorios"
11
15
  ],
@@ -402,6 +402,28 @@ class ShiloClient {
402
402
  return currentCreds;
403
403
  }
404
404
 
405
+ /**
406
+ * Lista el historial de commits del proyecto (más nuevo primero).
407
+ * `includeFiles` pide además los `commit_files` de cada commit, que es lo que
408
+ * permite saber quién tocó cada archivo por última vez.
409
+ * Devuelve `{ commits, hasMore, credentials }`.
410
+ */
411
+ async listCommits(projectId, credentials, { limit = 20, page = 1, includeFiles = false } = {}) {
412
+ const params = new URLSearchParams({ limit: String(limit), page: String(page) });
413
+ if (includeFiles) params.set('include', 'files');
414
+ const url = `${this.baseURL}/api/commits/${projectId}?${params.toString()}`;
415
+ const { res, data, credentials: creds } = await this._authFetch(url, credentials);
416
+ if (!res.ok) {
417
+ throw new ShiloError(
418
+ `No se pudo listar el historial: ${this._errMessage(data, `HTTP ${res.status}`)}`,
419
+ { status: res.status, body: data }
420
+ );
421
+ }
422
+ const commits = data && Array.isArray(data.commits) ? data.commits : [];
423
+ const hasMore = !!(data && data.pagination && data.pagination.hasMore);
424
+ return { commits, hasMore, credentials: creds };
425
+ }
426
+
405
427
  /**
406
428
  * Crea un commit con la lista de cambios de archivos (máx 100 por lote).
407
429
  * `changes`: [{ file_id, action, previous_storage_path?, new_storage_path? }].
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ const config = require('../config');
4
+ const ui = require('../ui');
5
+ const sync = require('../sync');
6
+ const textdiff = require('../textdiff');
7
+ const { resolveContext } = require('../context');
8
+
9
+ /**
10
+ * shilo diff [archivo] [--context <n>] [--stat] [--project <uuid>]
11
+ *
12
+ * Compara la versión local con la remota. Sin argumento hace un resumen de todo
13
+ * lo que difiere (barato: sólo listados). Con un archivo baja SOLO ese archivo y
14
+ * muestra el diff línea a línea.
15
+ *
16
+ * Convención de signos: el remoto es la base, lo local es el cambio. O sea
17
+ * `+` es lo que un push agregaría al remoto, `-` lo que borraría. Así el diff se
18
+ * lee igual que el push que vendría después.
19
+ */
20
+ async function diff(args) {
21
+ const filePath = typeof args._[0] === 'string' ? args._[0] : null;
22
+ const ctx = resolveContext({ ...args, _: [] });
23
+ if (!ctx.ok) return ctx.code;
24
+
25
+ const { dir, projectId, baseURL, cfg, client, mgr } = ctx;
26
+
27
+ const context = parseContext(args.context);
28
+ if (context === null) {
29
+ ui.error('El valor de --context debe ser un entero entre 0 y 20.');
30
+ return 1;
31
+ }
32
+
33
+ return filePath
34
+ ? diffOne({ dir, projectId, cfg, client, mgr, filePath, context, stat: !!args.stat })
35
+ : diffAll({ dir, projectId, baseURL, cfg, client, mgr });
36
+ }
37
+
38
+ /**
39
+ * Diff de un archivo puntual: una sola descarga.
40
+ */
41
+ async function diffOne({ dir, projectId, cfg, client, mgr, filePath, context, stat }) {
42
+ const repoPath = sync.toRepoPath(dir, filePath);
43
+ if (!repoPath) {
44
+ ui.error(`La ruta queda fuera de la carpeta: ${filePath}`);
45
+ return 1;
46
+ }
47
+
48
+ const absPath = require('path').resolve(dir, repoPath);
49
+ const localText = sync.readLocalText(absPath);
50
+
51
+ let remoteText = null;
52
+ let exists = false;
53
+ try {
54
+ const creds = await mgr.valid();
55
+ const meta = await client.getFileMeta(projectId, repoPath, creds);
56
+ exists = meta.exists && meta.file && meta.file.type !== 'folder';
57
+ if (exists) {
58
+ if (!sync.isTextMime(meta.file.mime_type)) {
59
+ ui.error(`"${repoPath}" no es texto en el remoto (${meta.file.mime_type}): no se puede diffear.`);
60
+ return 1;
61
+ }
62
+ const read = await client.readFile(projectId, repoPath, await mgr.valid());
63
+ remoteText = read.file && read.file.content != null ? read.file.content : '';
64
+ }
65
+ } catch (e) {
66
+ ui.error(`No se pudo leer "${repoPath}" del remoto: ${e.message}`);
67
+ return 1;
68
+ } finally {
69
+ config.persistCredsIfChanged(cfg, mgr.current);
70
+ }
71
+
72
+ if (!exists && localText === null) {
73
+ ui.error(`"${repoPath}" no existe ni local ni remotamente.`);
74
+ return 1;
75
+ }
76
+ if (!exists) {
77
+ ui.info(`${ui.c.green('+')} ${repoPath} ${ui.c.dim('(sólo local: un push lo crearía)')}`);
78
+ remoteText = '';
79
+ }
80
+ if (localText === null) {
81
+ ui.info(`${ui.c.red('-')} ${repoPath} ${ui.c.dim('(sólo remoto: un pull lo bajaría)')}`);
82
+ }
83
+
84
+ const result = textdiff.diffText(remoteText || '', localText === null ? '' : localText, { context });
85
+
86
+ if (result.identical) {
87
+ ui.success(`Sin diferencias: ${repoPath}`);
88
+ return 0;
89
+ }
90
+
91
+ ui.info('');
92
+ ui.info(`${ui.c.dim('--- remoto')} ${ui.c.dim(repoPath)}`);
93
+ ui.info(`${ui.c.dim('+++ local')} ${ui.c.dim(absPath)}`);
94
+
95
+ if (stat || result.tooBig) {
96
+ for (const line of textdiff.renderDiff({ ...result, hunks: [] }, { paint: ui.c })) {
97
+ ui.info(line);
98
+ }
99
+ if (!result.tooBig) {
100
+ ui.info(` ${ui.c.green(`+${result.added}`)} / ${ui.c.red(`-${result.removed}`)} líneas`);
101
+ }
102
+ return 1;
103
+ }
104
+
105
+ for (const line of textdiff.renderDiff(result, { paint: ui.c })) ui.info(line);
106
+ ui.info('');
107
+ ui.info(` ${ui.c.green(`+${result.added}`)} / ${ui.c.red(`-${result.removed}`)} líneas`);
108
+ // Exit 1 = "hay diferencias", como `git diff --exit-code`.
109
+ return 1;
110
+ }
111
+
112
+ /**
113
+ * Resumen de diferencias de toda la carpeta, sin bajar contenido.
114
+ */
115
+ async function diffAll({ dir, projectId, baseURL, cfg, client, mgr }) {
116
+ ui.info(`Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — comparando ...`);
117
+
118
+ const local = sync.walkLocal(dir);
119
+ let tree;
120
+ try {
121
+ tree = await sync.walkRemote(client, projectId, mgr);
122
+ } catch (e) {
123
+ ui.error(`No se pudo consultar el proyecto: ${e.message}`);
124
+ return 1;
125
+ } finally {
126
+ config.persistCredsIfChanged(cfg, mgr.current);
127
+ }
128
+
129
+ const cmp = sync.compareTrees(local.files, tree.files);
130
+ const total = cmp.onlyLocal.length + cmp.onlyRemote.length + cmp.differing.length;
131
+
132
+ ui.info('');
133
+ if (total === 0) {
134
+ ui.success('Sin diferencias de tamaño ni de archivos entre local y remoto.');
135
+ return 0;
136
+ }
137
+
138
+ for (const item of cmp.onlyLocal) ui.info(` ${ui.c.green('+')} ${item.repoPath} ${ui.c.dim('(sólo local)')}`);
139
+ for (const item of cmp.onlyRemote) ui.info(` ${ui.c.red('-')} ${item.repoPath} ${ui.c.dim('(sólo remoto)')}`);
140
+ for (const item of cmp.differing) {
141
+ const delta = item.local.size - item.remote.size;
142
+ const sign = delta > 0 ? `+${delta}` : String(delta);
143
+ ui.info(` ${ui.c.yellow('~')} ${item.repoPath} ${ui.c.dim(`(${sign} bytes)`)}`);
144
+ }
145
+
146
+ ui.info('');
147
+ ui.info(ui.c.dim(`${total} archivo(s) con diferencias. Usá "shilo diff <archivo>" para ver el detalle.`));
148
+ return 1;
149
+ }
150
+
151
+ function parseContext(raw) {
152
+ if (raw === undefined || raw === null || raw === '') return 3;
153
+ const n = Number(raw);
154
+ if (!Number.isInteger(n) || n < 0 || n > 20) return null;
155
+ return n;
156
+ }
157
+
158
+ module.exports = diff;
@@ -23,7 +23,7 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
23
23
  *
24
24
  * Con --token guarda las credenciales antes de verificar, de modo que el
25
25
  * comando de una línea que se copia desde la web funcione sin "login" previo.
26
- * Con --push, sube la carpeta al terminar.
26
+ * Con --push, sube la carpeta al terminar; con --pull, la baja.
27
27
  */
28
28
  async function init(args) {
29
29
  const dir = path.resolve(args._[0] || '.');
@@ -103,6 +103,11 @@ async function init(args) {
103
103
  return require('./push')({ ...args, _: [dir], yes: true });
104
104
  }
105
105
 
106
+ if (args.pull) {
107
+ // Espejo de --push: el usuario ya confirmó al pasar --pull.
108
+ return require('./pull')({ ...args, _: [dir], yes: true });
109
+ }
110
+
106
111
  ui.info(`Ahora podés subir con: ${ui.c.bold('shilo push')}`);
107
112
  return 0;
108
113
  }
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ const config = require('../config');
4
+ const ui = require('../ui');
5
+ const sync = require('../sync');
6
+ const { resolveContext } = require('../context');
7
+
8
+ const DEFAULT_LIMIT = 20;
9
+
10
+ /**
11
+ * shilo log [archivo] [-n <N>] [--project <uuid>] [--files] [--json]
12
+ *
13
+ * Historial de commits del proyecto, o sólo los que tocaron `archivo`.
14
+ * Sólo lectura: no toca ni el disco ni el remoto.
15
+ *
16
+ * Ojo con el filtro por archivo: el backend pagina commits, no cambios, así que
17
+ * filtramos del lado del cliente sobre las últimas páginas. Si un archivo no se
18
+ * toca hace mucho, puede necesitar un `-n` más grande para aparecer; el comando
19
+ * avisa cuando se queda sin historial explorado.
20
+ */
21
+ async function log(args) {
22
+ // El positional de `log` es un archivo, no una carpeta: el contexto se
23
+ // resuelve siempre sobre el directorio actual.
24
+ const filePath = typeof args._[0] === 'string' ? args._[0] : null;
25
+ const ctx = resolveContext({ ...args, _: [] });
26
+ if (!ctx.ok) return ctx.code;
27
+
28
+ const { dir, projectId, baseURL, cfg, client, mgr } = ctx;
29
+ const asJson = !!args.json;
30
+ const showFiles = !!args.files || !!filePath;
31
+
32
+ const limit = parseLimit(args.n || args.number, DEFAULT_LIMIT);
33
+ if (limit === null) {
34
+ ui.error('El valor de -n debe ser un entero entre 1 y 100.');
35
+ return 1;
36
+ }
37
+
38
+ // Si se pidió un archivo puntual, primero resolvemos su id en el remoto.
39
+ let targetId = null;
40
+ let repoPath = null;
41
+ if (filePath) {
42
+ repoPath = sync.toRepoPath(dir, filePath);
43
+ if (!repoPath) {
44
+ ui.error(`La ruta queda fuera de la carpeta: ${filePath}`);
45
+ return 1;
46
+ }
47
+ try {
48
+ const creds = await mgr.valid();
49
+ const meta = await client.getFileMeta(projectId, repoPath, creds);
50
+ if (!meta.exists || !meta.file) {
51
+ ui.error(`No existe en el remoto: ${repoPath}`);
52
+ return 1;
53
+ }
54
+ targetId = meta.file.id;
55
+ } catch (e) {
56
+ ui.error(`No se pudo resolver "${repoPath}": ${e.message}`);
57
+ return 1;
58
+ } finally {
59
+ config.persistCredsIfChanged(cfg, mgr.current);
60
+ }
61
+ }
62
+
63
+ let result;
64
+ try {
65
+ const creds = await mgr.valid();
66
+ result = await client.listCommits(projectId, creds, {
67
+ limit,
68
+ includeFiles: showFiles,
69
+ });
70
+ } catch (e) {
71
+ ui.error(e.message);
72
+ return 1;
73
+ } finally {
74
+ config.persistCredsIfChanged(cfg, mgr.current);
75
+ }
76
+
77
+ let commits = result.commits;
78
+ if (targetId) {
79
+ commits = commits.filter((c) => (c.files || []).some((cf) => cf.files && cf.files.id === targetId));
80
+ }
81
+
82
+ if (asJson) {
83
+ console.log(
84
+ JSON.stringify(
85
+ {
86
+ projectId,
87
+ path: repoPath,
88
+ commits: commits.map((c) => ({
89
+ hash: c.hash,
90
+ message: c.message,
91
+ author: sync.describeAuthor(c.users),
92
+ date: c.created_at,
93
+ files: (c.files || []).map((cf) => ({
94
+ name: cf.files ? cf.files.name : null,
95
+ action: cf.action,
96
+ })),
97
+ })),
98
+ hasMore: result.hasMore,
99
+ },
100
+ null,
101
+ 2
102
+ )
103
+ );
104
+ return 0;
105
+ }
106
+
107
+ const scope = repoPath ? `de ${ui.c.bold(repoPath)}` : 'del proyecto';
108
+ ui.info(`Historial ${scope} — ${ui.c.cyan(baseURL)}`);
109
+ ui.info('');
110
+
111
+ if (commits.length === 0) {
112
+ ui.info(
113
+ repoPath
114
+ ? `Sin commits para "${repoPath}" en los últimos ${limit} del proyecto.`
115
+ : 'El proyecto todavía no tiene commits.'
116
+ );
117
+ if (result.hasMore) ui.info(ui.c.dim(`Probá con -n mayor (hay más historial).`));
118
+ return 0;
119
+ }
120
+
121
+ for (const commit of commits) {
122
+ const hash = commit.hash ? String(commit.hash).slice(0, 8) : '(sin hash)';
123
+ ui.info(`${ui.c.yellow(hash)} ${ui.c.bold(sync.describeAuthor(commit.users))} ${ui.c.dim(sync.formatDate(commit.created_at))}`);
124
+ ui.info(` ${commit.message}`);
125
+ if (showFiles && Array.isArray(commit.files) && commit.files.length > 0) {
126
+ const shown = repoPath
127
+ ? commit.files.filter((cf) => cf.files && cf.files.id === targetId)
128
+ : commit.files;
129
+ for (const cf of shown) {
130
+ ui.info(` ${ui.c.dim(`${actionMark(cf.action)} ${cf.files ? cf.files.name : '?'}`)}`);
131
+ }
132
+ }
133
+ ui.info('');
134
+ }
135
+
136
+ if (result.hasMore) {
137
+ ui.info(ui.c.dim(`(mostrando los últimos ${limit} commits del proyecto; hay más)`));
138
+ }
139
+ return 0;
140
+ }
141
+
142
+ function actionMark(action) {
143
+ if (action === 'added') return '+';
144
+ if (action === 'deleted') return '-';
145
+ return '~';
146
+ }
147
+
148
+ function parseLimit(raw, fallback) {
149
+ if (raw === undefined || raw === null || raw === '') return fallback;
150
+ const n = Number(raw);
151
+ if (!Number.isInteger(n) || n < 1 || n > 100) return null;
152
+ return n;
153
+ }
154
+
155
+ module.exports = log;