shilo-cli 0.7.0 → 0.9.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/args.js CHANGED
@@ -10,14 +10,32 @@
10
10
  * -m "msg" -> alias corto (ver `aliases`)
11
11
  * positionals -> array de argumentos sueltos
12
12
  *
13
+ * Las claves de `multiple` acumulan en un array en vez de pisarse, y quedan
14
+ * SIEMPRE como array (aunque haya una sola aparición, o ninguna): así el
15
+ * consumidor no tiene que distinguir "uno" de "varios", que es justo donde se
16
+ * cuelan los bugs de `--only`.
17
+ *
13
18
  * Devuelve { _: [positionals...], ...flags }.
14
19
  */
15
- function parseArgs(argv, { booleans = [], aliases = {} } = {}) {
20
+ function parseArgs(argv, { booleans = [], aliases = {}, multiple = [] } = {}) {
16
21
  const out = { _: [] };
17
22
  const boolSet = new Set(booleans);
23
+ const multiSet = new Set(multiple);
18
24
 
19
25
  const canonical = (k) => aliases[k] || k;
20
26
 
27
+ const set = (key, value) => {
28
+ if (!multiSet.has(key)) {
29
+ out[key] = value;
30
+ return;
31
+ }
32
+ // `--only` sin valor (`--only --yes`) no es una lista de nada: se descarta
33
+ // en vez de meter un `true` que el consumidor tendría que filtrar.
34
+ if (value === true) return;
35
+ if (!Array.isArray(out[key])) out[key] = [];
36
+ out[key].push(value);
37
+ };
38
+
21
39
  for (let i = 0; i < argv.length; i++) {
22
40
  let token = argv[i];
23
41
 
@@ -37,17 +55,17 @@ function parseArgs(argv, { booleans = [], aliases = {} } = {}) {
37
55
  }
38
56
  key = canonical(key);
39
57
  if (value !== undefined) {
40
- out[key] = value;
58
+ set(key, value);
41
59
  } else if (boolSet.has(key)) {
42
- out[key] = true;
60
+ set(key, true);
43
61
  } else {
44
62
  const next = argv[i + 1];
45
63
  // `-` a secas es un valor legítimo ("leer de stdin"), no otra bandera.
46
64
  if (next !== undefined && (next === '-' || !next.startsWith('-'))) {
47
- out[key] = next;
65
+ set(key, next);
48
66
  i++;
49
67
  } else {
50
- out[key] = true;
68
+ set(key, true);
51
69
  }
52
70
  }
53
71
  continue;
@@ -56,15 +74,15 @@ function parseArgs(argv, { booleans = [], aliases = {} } = {}) {
56
74
  if (token.startsWith('-') && token.length > 1) {
57
75
  const key = canonical(token.slice(1));
58
76
  if (boolSet.has(key)) {
59
- out[key] = true;
77
+ set(key, true);
60
78
  } else {
61
79
  const next = argv[i + 1];
62
80
  // `-` a secas es un valor legítimo ("leer de stdin"), no otra bandera.
63
81
  if (next !== undefined && (next === '-' || !next.startsWith('-'))) {
64
- out[key] = next;
82
+ set(key, next);
65
83
  i++;
66
84
  } else {
67
- out[key] = true;
85
+ set(key, true);
68
86
  }
69
87
  }
70
88
  continue;
@@ -73,6 +91,12 @@ function parseArgs(argv, { booleans = [], aliases = {} } = {}) {
73
91
  out._.push(token);
74
92
  }
75
93
 
94
+ // Normalización: una clave `multiple` que no apareció igual llega como array
95
+ // vacío, para que el consumidor pueda hacer `.length` sin chequear antes.
96
+ for (const key of multiSet) {
97
+ if (!Array.isArray(out[key])) out[key] = [];
98
+ }
99
+
76
100
  return out;
77
101
  }
78
102
 
package/src/checks.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Chequeos de propuesta (EP-S2, MU-93).
3
+ *
4
+ * Un proyecto declara en `shilo.config.json` los comandos que tienen que pasar
5
+ * antes de proponer (`"chequeos": ["npm test"]`). `shilo push` los corre en la
6
+ * carpeta, uno por uno y en orden, con la salida a la vista, y el resultado
7
+ * viaja con la propuesta: la web la muestra verificada o fallida, y aplicar una
8
+ * fallida exige forzar.
9
+ *
10
+ * Se corta en el primero que falla, como un hook de git: lo que viene después
11
+ * suele depender de lo anterior (instalar, compilar, probar) y correrlo sólo
12
+ * hace más largo el error. El resultado registra lo que se corrió.
13
+ *
14
+ * `run` se inyecta para probarlo sin lanzar procesos.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const childProcess = require('child_process');
20
+
21
+ // Un chequeo colgado no puede dejar colgado el push: a los diez minutos se corta
22
+ // y cuenta como fallido.
23
+ const CHECK_TIMEOUT_MS = 10 * 60 * 1000;
24
+
25
+ function spawnCheck(comando, dir) {
26
+ const r = childProcess.spawnSync(comando, {
27
+ cwd: dir,
28
+ shell: true,
29
+ stdio: 'inherit',
30
+ timeout: CHECK_TIMEOUT_MS,
31
+ windowsHide: true,
32
+ });
33
+ // Sin `status` = no llegó a terminar (no se pudo lanzar, o se cortó por tiempo).
34
+ return { codigo: typeof r.status === 'number' ? r.status : null };
35
+ }
36
+
37
+ /**
38
+ * @param {string} dir - la carpeta donde se corren
39
+ * @param {string[]} comandos
40
+ * @param {{ run?: Function, now?: Function, onStart?: Function }} [opts]
41
+ * @returns {{ resultado: 'ok'|'fallido', comandos: Array<{ comando, ok, codigo?, ms }> }}
42
+ */
43
+ function runChecks(dir, comandos, { run = spawnCheck, now = Date.now, onStart = () => {} } = {}) {
44
+ const out = [];
45
+ for (const comando of comandos) {
46
+ onStart(comando, out.length + 1, comandos.length);
47
+ const t0 = now();
48
+ const { codigo } = run(comando, dir);
49
+ const entry = { comando, ok: codigo === 0, ms: Math.max(0, Math.round(now() - t0)) };
50
+ if (codigo !== null) entry.codigo = codigo;
51
+ out.push(entry);
52
+ if (!entry.ok) break;
53
+ }
54
+ return { resultado: out.every((c) => c.ok) ? 'ok' : 'fallido', comandos: out };
55
+ }
56
+
57
+ module.exports = { runChecks, CHECK_TIMEOUT_MS };
@@ -0,0 +1,105 @@
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 = 10;
9
+
10
+ const STATUS = {
11
+ queued: (s) => ui.c.dim(s),
12
+ running: (s) => ui.c.cyan(s),
13
+ uploading: (s) => ui.c.cyan(s),
14
+ building: (s) => ui.c.cyan(s),
15
+ ready: (s) => ui.c.green(s),
16
+ error: (s) => ui.c.red(s),
17
+ canceled: (s) => ui.c.dim(s),
18
+ superseded: (s) => ui.c.dim(s),
19
+ };
20
+
21
+ const AUTO = {
22
+ off: 'sólo manual',
23
+ preview: 'preview en cada guardado',
24
+ production: 'producción en cada guardado',
25
+ };
26
+
27
+ /**
28
+ * shilo deploys [-n <N>] [--project <uuid>] [--json]
29
+ *
30
+ * Destino e historial de deploys a Vercel del proyecto (PLAN_DEPLOY_VERCEL,
31
+ * Etapa 4.2). Sólo lectura. El servidor devuelve como mucho los últimos 20.
32
+ */
33
+ async function deploys(args) {
34
+ const ctx = resolveContext({ ...args, _: [] });
35
+ if (!ctx.ok) return ctx.code;
36
+
37
+ const { projectId, baseURL, cfg, client, mgr } = ctx;
38
+
39
+ const limit = parseLimit(args.n || args.number);
40
+ if (limit === null) {
41
+ ui.error('El valor de -n debe ser un entero entre 1 y 20.');
42
+ return 1;
43
+ }
44
+
45
+ let info;
46
+ try {
47
+ info = await client.getDeploys(projectId, await mgr.valid());
48
+ } catch (e) {
49
+ ui.error(e.notSupported ? 'Este servidor de Shilo todavía no tiene deploys.' : e.message);
50
+ return 1;
51
+ } finally {
52
+ config.persistCredsIfChanged(cfg, mgr.current);
53
+ }
54
+
55
+ const list = info.deployments.slice(0, limit);
56
+
57
+ if (args.json) {
58
+ console.log(JSON.stringify({ projectId, connected: info.connected, target: info.target, deployments: list }, null, 2));
59
+ return 0;
60
+ }
61
+
62
+ ui.info(`Deploys del proyecto — ${ui.c.cyan(baseURL)}`);
63
+ if (!info.target) {
64
+ ui.info('');
65
+ ui.info('Este proyecto no despliega a ningún lado. El owner lo configura en la');
66
+ ui.info('pestaña "Deploys" del proyecto en la web.');
67
+ return 0;
68
+ }
69
+
70
+ const t = info.target;
71
+ ui.info(`Vercel: ${ui.c.bold(t.vercel_project_name || t.vercel_project)} · ${AUTO[t.auto_target] || t.auto_target}`);
72
+ if (!info.connected) {
73
+ ui.warn('La conexión con Vercel no está activa: el owner tiene que volver a conectar.');
74
+ }
75
+ ui.info('');
76
+
77
+ if (list.length === 0) {
78
+ ui.info('Todavía no hubo deploys.');
79
+ return 0;
80
+ }
81
+
82
+ for (const d of list) {
83
+ const paint = STATUS[d.status] || ((s) => s);
84
+ const commit = d.commit_oid ? String(d.commit_oid).slice(0, 8) : '--------';
85
+ const target = d.target === 'production' ? ui.c.bold('prod ') : 'preview';
86
+ const card = d.card ? ui.c.dim(` [card: ${d.card}]`) : '';
87
+ ui.info(`${paint(pad(d.status, 10))} ${target} ${ui.c.yellow(commit)} ${ui.c.dim(sync.formatDate(d.created_at))}${card}`);
88
+ if (d.url) ui.info(` ${d.url}`);
89
+ if (d.error) ui.info(` ${ui.c.red(d.error)}`);
90
+ }
91
+ return 0;
92
+ }
93
+
94
+ function pad(s, n) {
95
+ return String(s).padEnd(n);
96
+ }
97
+
98
+ function parseLimit(raw) {
99
+ if (raw === undefined || raw === null || raw === '') return DEFAULT_LIMIT;
100
+ const n = Number(raw);
101
+ if (!Number.isInteger(n) || n < 1 || n > 20) return null;
102
+ return n;
103
+ }
104
+
105
+ module.exports = deploys;
@@ -126,17 +126,33 @@ async function diffAll({ dir, projectId, baseURL, cfg, client, mgr }) {
126
126
  config.persistCredsIfChanged(cfg, mgr.current);
127
127
  }
128
128
 
129
- const cmp = sync.compareTrees(local.files, tree.files);
130
- const total = cmp.onlyLocal.length + cmp.onlyRemote.length + cmp.differing.length;
129
+ const cmp = sync.refineByHash(sync.compareTrees(local.files, tree.files), { dir, projectId, baseURL });
130
+
131
+ // Un archivo ignorado localmente no es una diferencia que haya que resolver:
132
+ // el push no lo sube y el pull no lo baja. Se nombra aparte, no como "sólo
133
+ // remoto", que invita a un pull que igual no lo traería.
134
+ const isIgnoredPath = sync.makePathMatcher(dir);
135
+ const onlyRemote = cmp.onlyRemote.filter((i) => !isIgnoredPath(i.repoPath));
136
+ const ignoredRemote = cmp.onlyRemote.filter((i) => isIgnoredPath(i.repoPath));
137
+
138
+ const total = cmp.onlyLocal.length + onlyRemote.length + cmp.differing.length;
131
139
 
132
140
  ui.info('');
133
141
  if (total === 0) {
134
142
  ui.success('Sin diferencias de tamaño ni de archivos entre local y remoto.');
143
+ if (ignoredRemote.length > 0) {
144
+ ui.info(
145
+ ui.c.dim(`(${ignoredRemote.length} archivo(s) del remoto están ignorados localmente.)`)
146
+ );
147
+ }
135
148
  return 0;
136
149
  }
137
150
 
138
151
  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)')}`);
152
+ for (const item of onlyRemote) ui.info(` ${ui.c.red('-')} ${item.repoPath} ${ui.c.dim('(sólo remoto)')}`);
153
+ for (const item of ignoredRemote) {
154
+ ui.info(` ${ui.c.dim('·')} ${ui.c.dim(`${item.repoPath} (sólo remoto, ignorado localmente)`)}`);
155
+ }
140
156
  for (const item of cmp.differing) {
141
157
  const delta = item.local.size - item.remote.size;
142
158
  const sign = delta > 0 ? `+${delta}` : String(delta);
@@ -0,0 +1,226 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const config = require('../config');
5
+ const remote = require('../remote');
6
+ const ui = require('../ui');
7
+ const sync = require('../sync');
8
+ const { ShiloClient } = require('../ShiloClient');
9
+ const { DEFAULT_BASE_URL } = require('../context');
10
+ const { exportarProyecto, leerMapa } = require('../exportgit');
11
+
12
+ /**
13
+ * shilo export <carpeta> [--project <uuid>] [--base-url <url>] [--json]
14
+ *
15
+ * Escribe la historia del proyecto como un repositorio Git en `carpeta`, con
16
+ * `main` y `refs/notes/shilo`. La primera vez la carpeta tiene que estar vacía
17
+ * o no existir; después, repetir el comando sobre la misma carpeta trae sólo
18
+ * lo nuevo. No toca el proyecto: es de sólo lectura.
19
+ *
20
+ * El proyecto sale de `--project`, de la exportación anterior que haya en la
21
+ * carpeta, o del `.shilo.json` de la carpeta actual, en ese orden. Un proyecto
22
+ * público se exporta sin sesión.
23
+ */
24
+ async function exportCmd(args) {
25
+ const destinoArg = typeof args._[0] === 'string' ? args._[0] : null;
26
+ if (!destinoArg) {
27
+ ui.error('Falta la carpeta de destino: shilo export <carpeta>.');
28
+ return 1;
29
+ }
30
+ const destino = path.resolve(destinoArg);
31
+ const asJson = !!args.json;
32
+
33
+ let previo = null;
34
+ let atada = null;
35
+ try {
36
+ previo = leerMapa(path.join(destino, '.git'));
37
+ atada = remote.read(process.cwd());
38
+ } catch (e) {
39
+ ui.error(e.message);
40
+ return 1;
41
+ }
42
+
43
+ const pedido = typeof args.project === 'string' && args.project.trim() ? args.project.trim() : null;
44
+ if (pedido && previo && previo.projectId !== pedido) {
45
+ ui.error(`${destino} ya tiene la exportación de otro proyecto (${previo.projectId}).`);
46
+ ui.info('Exportá a una carpeta nueva.');
47
+ return 1;
48
+ }
49
+ const projectId = pedido || (previo && previo.projectId) || (atada && atada.projectId) || null;
50
+ if (!projectId) {
51
+ ui.error('No sé qué proyecto exportar.');
52
+ ui.info('Pasá --project <uuid>, o corré el comando dentro de una carpeta atada con "shilo init".');
53
+ return 1;
54
+ }
55
+
56
+ const cfg = config.load() || {};
57
+ const baseURL = args['base-url'] || (previo && previo.baseURL) || (atada && atada.baseURL) || cfg.baseURL || DEFAULT_BASE_URL;
58
+ const client = new ShiloClient({
59
+ baseURL,
60
+ supabaseUrl: cfg.supabaseUrl || null,
61
+ supabaseAnonKey: cfg.supabaseAnonKey || null,
62
+ });
63
+
64
+ // Sin sesión se intenta igual: un proyecto público se exporta sin cuenta.
65
+ let creds = null;
66
+ if (cfg.credentials) {
67
+ try {
68
+ creds = await sync.makeCredsManager(client, cfg).valid();
69
+ } catch (e) {
70
+ ui.error(`La sesión guardada no sirve: ${e.message}`);
71
+ ui.info('Corré "shilo login" de nuevo.');
72
+ return 1;
73
+ }
74
+ }
75
+
76
+ let progreso = null;
77
+ if (!asJson) {
78
+ ui.info(`${previo ? 'Actualizando la exportación' : 'Exportando'} de ${ui.c.bold(projectId)} en ${ui.c.bold(destino)}`);
79
+ ui.info(ui.c.dim(`Servidor: ${baseURL}`));
80
+ }
81
+
82
+ let informe;
83
+ try {
84
+ informe = await exportarProyecto({
85
+ client,
86
+ projectId,
87
+ creds,
88
+ destino,
89
+ baseURL,
90
+ onEtapa: (nombre, detalle) => {
91
+ if (asJson) return;
92
+ if (nombre === 'archivos' && detalle > 0) {
93
+ ui.info(`Bajando ${detalle} archivo(s)…`);
94
+ progreso = ui.progress(detalle, 'archivos');
95
+ }
96
+ },
97
+ onProgreso: (hechos) => { if (progreso) progreso.update(hechos); },
98
+ });
99
+ } catch (e) {
100
+ if (progreso) progreso.clear();
101
+ return explicarError(e, { destino, projectId, conSesion: Boolean(creds), asJson });
102
+ } finally {
103
+ if (creds) config.persistCredsIfChanged(cfg, creds);
104
+ }
105
+ if (progreso) progreso.clear();
106
+ if (informe.credentials) config.persistCredsIfChanged(cfg, informe.credentials);
107
+
108
+ if (asJson) {
109
+ const { credentials, ...resto } = informe;
110
+ console.log(JSON.stringify({ projectId, ...resto }, null, 2));
111
+ return 0;
112
+ }
113
+
114
+ mostrarInforme(informe);
115
+ return 0;
116
+ }
117
+
118
+ function mostrarInforme(inf) {
119
+ ui.info('');
120
+ if (inf.incremental && inf.mainAntes === inf.main) {
121
+ ui.success('Ya estaba al día: no hay puntos de guardado nuevos.');
122
+ } else if (inf.incremental) {
123
+ ui.success(`${inf.nuevos} punto(s) de guardado nuevo(s); ${inf.commits} en total.`);
124
+ } else {
125
+ ui.success(`${inf.commits} punto(s) de guardado exportado(s).`);
126
+ }
127
+ ui.info(` main ${ui.c.yellow(inf.main.slice(0, 12))}`);
128
+ if (inf.notas) ui.info(` procedencia ${ui.c.yellow(inf.notas.slice(0, 12))} ${ui.c.dim('(refs/notes/shilo)')}`);
129
+ ui.info(ui.c.dim(` ${inf.archivos} archivo(s) y ${inf.carpetas} carpeta(s) bajados, ${formatoBytes(inf.bytes)}.`));
130
+
131
+ if (inf.vacias.length > 0) {
132
+ ui.info('');
133
+ ui.warn(`Git no guarda carpetas vacías; quedaron afuera ${inf.vacias.length}:`);
134
+ for (const r of inf.vacias.slice(0, 20)) ui.info(` ${r}/`);
135
+ if (inf.vacias.length > 20) ui.info(ui.c.dim(` … y ${inf.vacias.length - 20} más.`));
136
+ }
137
+
138
+ if (!inf.carpetaActualizada) {
139
+ ui.info('');
140
+ ui.info(ui.c.dim('La carpeta no está en main: se movió la rama pero no se tocaron tus archivos.'));
141
+ }
142
+
143
+ ui.info('');
144
+ ui.info('Para subirlo a otro servidor Git:');
145
+ ui.info(ui.c.cyan(` git -C "${inf.destino}" push <remoto> main`));
146
+ if (inf.notas) {
147
+ // La ref de notas se rehace entera en cada exportación (no tiene padre),
148
+ // así que un push normal la rechaza a partir de la segunda: va con `+`.
149
+ ui.info(ui.c.cyan(` git -C "${inf.destino}" push <remoto> +refs/notes/shilo`));
150
+ if (inf.notasReemplazadas) {
151
+ ui.info(ui.c.dim(' La procedencia se reemplazó entera; por eso el + en el push.'));
152
+ }
153
+ }
154
+ }
155
+
156
+ function explicarError(e, { destino, projectId, conSesion, asJson }) {
157
+ const code = e && e.code;
158
+ if (asJson) {
159
+ console.log(JSON.stringify({
160
+ ok: false, code: code || null, status: (e && e.status) || null, message: e.message, path: e.path || null,
161
+ }, null, 2));
162
+ return 1;
163
+ }
164
+
165
+ ui.error(e.message);
166
+ switch (code) {
167
+ case 'SIN_GIT':
168
+ ui.info('Instalá Git (https://git-scm.com) y volvé a correr el comando.');
169
+ break;
170
+ case 'DESTINO_OCUPADO':
171
+ ui.info('La primera exportación va a una carpeta vacía o que no existe.');
172
+ break;
173
+ case 'OTRO_PROYECTO':
174
+ ui.info('Exportá a una carpeta nueva.');
175
+ break;
176
+ case 'MAIN_AJENA':
177
+ ui.info('Hay puntos de guardado en main que no vienen de Shilo. Llevalos a otra rama');
178
+ ui.info('(git branch <nombre> main) y devolvé main a lo exportado, o exportá a una carpeta nueva.');
179
+ break;
180
+ case 'CAMBIOS_LOCALES':
181
+ ui.info('Guardalos o descartalos (git stash) antes de actualizar la exportación.');
182
+ break;
183
+ case 'MAPA_ILEGIBLE':
184
+ case 'REPO_INCOMPLETO':
185
+ ui.info('La exportación anterior está dañada. Exportá a una carpeta nueva.');
186
+ break;
187
+ case 'SIN_IDENTIDADES':
188
+ ui.info('Hay que aplicar la migración 018 en el servidor antes de exportar.');
189
+ break;
190
+ case 'PROYECTO_VACIO':
191
+ ui.info('Guardá una versión primero.');
192
+ break;
193
+ case 'NOMBRE_INVALIDO':
194
+ ui.info(`Git no acepta «${e.path || e.name}». Renombralo en Shilo y exportá de nuevo.`);
195
+ break;
196
+ case 'SHA1_MOVIDO':
197
+ ui.info('Lo ya exportado daría otro resultado: es un error de Shilo, no de tu carpeta. Avisanos.');
198
+ break;
199
+ case 'GRAFO_INCOMPLETO':
200
+ case 'BLOB_CORRUPTO':
201
+ case 'ARBOL_CORRUPTO':
202
+ case 'FALTA_OBJETO':
203
+ ui.info('A la historia del proyecto le falta algo en el servidor. No se escribió ninguna rama.');
204
+ break;
205
+ default:
206
+ if (e && e.status === 404) {
207
+ ui.info(conSesion
208
+ ? `El proyecto ${projectId} no existe o no tenés acceso.`
209
+ : 'Si el proyecto es privado, iniciá sesión con "shilo login".');
210
+ } else if (e && e.status === 401) {
211
+ ui.info('Corré "shilo login" de nuevo.');
212
+ }
213
+ }
214
+ if (code && code !== 'DESTINO_OCUPADO' && code !== 'SIN_GIT' && code !== 'OTRO_PROYECTO') {
215
+ ui.info(ui.c.dim(`Carpeta: ${destino}`));
216
+ }
217
+ return 1;
218
+ }
219
+
220
+ function formatoBytes(n) {
221
+ if (n < 1024) return `${n} B`;
222
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
223
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
224
+ }
225
+
226
+ module.exports = exportCmd;
@@ -106,6 +106,14 @@ async function init(args) {
106
106
  const written = remote.write(dir, { projectId, baseURL });
107
107
  ui.success(`Carpeta atada al proyecto ${ui.c.bold(projectId)}.`);
108
108
  ui.info(ui.c.dim(`Remote guardado en ${written}`));
109
+ // Se lee siempre (ver walk.js), pero no decirlo hacía que la gente copiara
110
+ // a mano cada línea del .gitignore al .shiloignore.
111
+ if (fs.existsSync(path.join(dir, '.gitignore'))) {
112
+ ui.info(
113
+ ui.c.dim('Se respeta tu .gitignore: lo que git ignora tampoco se sube. ') +
114
+ ui.c.dim('.shiloignore es sólo para exclusiones extra.')
115
+ );
116
+ }
109
117
 
110
118
  // --push/--pull no implican confirmación: el usuario pidió *encadenar* el
111
119
  // comando, no saltearse su preflight. Sin --yes, push muestra el plan (y el
@@ -4,6 +4,7 @@ const config = require('../config');
4
4
  const ui = require('../ui');
5
5
  const sync = require('../sync');
6
6
  const { resolveContext } = require('../context');
7
+ const { marcaDeRevision } = require('./review');
7
8
 
8
9
  const DEFAULT_LIMIT = 20;
9
10
 
@@ -90,6 +91,7 @@ async function log(args) {
90
91
  message: c.message,
91
92
  author: sync.describeAuthor(c.users),
92
93
  date: c.created_at,
94
+ reviews: Array.isArray(c.reviews) ? c.reviews : [],
93
95
  files: (c.files || []).map((cf) => ({
94
96
  name: cf.files ? cf.files.name : null,
95
97
  action: cf.action,
@@ -122,6 +124,8 @@ async function log(args) {
122
124
  const hash = commit.hash ? String(commit.hash).slice(0, 8) : '(sin hash)';
123
125
  ui.info(`${ui.c.yellow(hash)} ${ui.c.bold(sync.describeAuthor(commit.users))} ${ui.c.dim(sync.formatDate(commit.created_at))}`);
124
126
  ui.info(` ${commit.message}`);
127
+ const marca = marcaDeRevision(commit.reviews);
128
+ if (marca) ui.info(` ${ui.c.dim('revisión:')} ${marca}`);
125
129
  if (showFiles && Array.isArray(commit.files) && commit.files.length > 0) {
126
130
  const shown = repoPath
127
131
  ? commit.files.filter((cf) => cf.files && cf.files.id === targetId)