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.
@@ -0,0 +1,239 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const config = require('../config');
6
+ const remote = require('../remote');
7
+ const prompt = require('../prompt');
8
+ const ui = require('../ui');
9
+ const sync = require('../sync');
10
+ const { validatePath } = require('../textguard');
11
+ const { resolveContext } = require('../context');
12
+
13
+ const CONCURRENCY = sync.CONCURRENCY;
14
+
15
+ /**
16
+ * Resuelve la ruta absoluta de escritura para un `repoPath`, blindando contra
17
+ * path traversal. Valida cada segmento con las mismas reglas del backend y
18
+ * exige que el destino final quede dentro de `destDir`.
19
+ * Devuelve `{ ok: true, target }` o `{ ok: false, reason }`.
20
+ */
21
+ function safeTarget(destDir, repoPath) {
22
+ const check = validatePath(repoPath);
23
+ if (!check.ok) return { ok: false, reason: check.reason };
24
+
25
+ const target = path.resolve(destDir, repoPath);
26
+ const rel = path.relative(destDir, target);
27
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
28
+ return { ok: false, reason: 'la ruta escaparía de la carpeta destino' };
29
+ }
30
+ return { ok: true, target };
31
+ }
32
+
33
+ /**
34
+ * shilo pull [carpeta] [--project <uuid>] [--base-url <url>] [--dry-run] [--yes] [--force]
35
+ *
36
+ * Baja un proyecto de Shilo a una carpeta local y deja `.shilo.json` listo para
37
+ * un `push` posterior.
38
+ *
39
+ * Seguridad ante trabajo local sin guardar: el pull NO pisa un archivo local que
40
+ * difiera del remoto salvo con `--force`. Los archivos que sólo existen en el
41
+ * remoto, y los que ya coinciden byte a byte, se resuelven solos. Un archivo que
42
+ * sólo existe localmente nunca se toca (el pull no borra).
43
+ */
44
+ async function pull(args) {
45
+ const ctx = resolveContext(args, { requireDir: false });
46
+ if (!ctx.ok) return ctx.code;
47
+
48
+ const { dir, projectId, baseURL, cfg, client, mgr } = ctx;
49
+
50
+ // La carpeta destino puede no existir todavía: la creamos al escribir. Si
51
+ // existe pero es un archivo, es un error del usuario.
52
+ if (fs.existsSync(dir) && !fs.statSync(dir).isDirectory()) {
53
+ ui.error(`No es una carpeta válida: ${dir}`);
54
+ return 1;
55
+ }
56
+
57
+ const dryRun = !!args['dry-run'];
58
+
59
+ ui.info(`Proyecto ${ui.c.bold(projectId)} en ${ui.c.cyan(baseURL)} — recorriendo árbol ...`);
60
+
61
+ let tree;
62
+ try {
63
+ tree = await sync.walkRemote(client, projectId, mgr);
64
+ } catch (e) {
65
+ ui.error(`No se pudo recorrer el proyecto: ${e.message}`);
66
+ return 1;
67
+ } finally {
68
+ config.persistCredsIfChanged(cfg, mgr.current);
69
+ }
70
+
71
+ for (const err of tree.errors) {
72
+ ui.warn(`no se pudo listar ${ui.c.dim(err.folderPath || '(raíz)')} — ${err.reason}`);
73
+ }
74
+
75
+ // 1) Separar texto (descargable) de lo que no se puede bajar sin corromper.
76
+ const candidates = [];
77
+ const skippedBinary = [];
78
+ const unsafe = [];
79
+ for (const f of tree.files) {
80
+ const safe = safeTarget(dir, f.repoPath);
81
+ if (!safe.ok) {
82
+ unsafe.push({ repoPath: f.repoPath, reason: safe.reason });
83
+ continue;
84
+ }
85
+ if (!sync.isTextMime(f.mime_type)) {
86
+ skippedBinary.push({ repoPath: f.repoPath, reason: `mime no textual (${f.mime_type})` });
87
+ continue;
88
+ }
89
+ candidates.push({ ...f, target: safe.target });
90
+ }
91
+
92
+ for (const u of unsafe) ui.warn(`saltado ${ui.c.dim(u.repoPath)} — ${u.reason}`);
93
+ for (const s of skippedBinary) ui.warn(`saltado ${ui.c.dim(s.repoPath)} — ${s.reason}`);
94
+
95
+ // 2) Clasificar contra lo que hay en disco, sin bajar nada todavía.
96
+ // `maybeSame` (mismo tamaño) se confirma recién al descargar el contenido.
97
+ const local = sync.walkLocal(dir);
98
+ const localByPath = new Map(local.files.map((f) => [f.repoPath, f]));
99
+
100
+ const newFiles = []; // sólo en el remoto: bajar sin riesgo
101
+ const maybeSame = []; // existe local con el mismo tamaño
102
+ const conflicting = []; // existe local con distinto tamaño: pisaría trabajo
103
+ for (const f of candidates) {
104
+ const localFile = localByPath.get(f.repoPath);
105
+ if (!localFile) newFiles.push(f);
106
+ else if (typeof f.size === 'number' && f.size !== localFile.size) conflicting.push(f);
107
+ else maybeSame.push(f);
108
+ }
109
+
110
+ const remotePaths = new Set(candidates.map((f) => f.repoPath));
111
+ const onlyLocal = local.files.filter((f) => !remotePaths.has(f.repoPath));
112
+
113
+ ui.info('');
114
+ ui.info(
115
+ ` ${ui.c.green('nuevos:')} ${newFiles.length}\n` +
116
+ ` ${ui.c.yellow('en conflicto:')} ${conflicting.length} ${ui.c.dim('(difieren en tamaño del local)')}\n` +
117
+ ` ${ui.c.dim('posiblemente iguales:')} ${maybeSame.length}\n` +
118
+ ` ${ui.c.dim('sólo locales:')} ${onlyLocal.length} ${ui.c.dim('(el pull no los toca)')}` +
119
+ (skippedBinary.length ? `\n ${ui.c.dim('binarios saltados:')} ${skippedBinary.length}` : '') +
120
+ (unsafe.length ? `\n ${ui.c.red('inseguros:')} ${unsafe.length}` : '')
121
+ );
122
+ ui.info('');
123
+
124
+ // 3) --dry-run: mostrar el plan contra el estado local y salir sin tocar disco.
125
+ if (dryRun) {
126
+ for (const f of newFiles) ui.info(` ${ui.c.green('↓')} ${f.repoPath} ${ui.c.dim('(nuevo)')}`);
127
+ for (const f of conflicting) {
128
+ const localFile = localByPath.get(f.repoPath);
129
+ const delta = f.size - localFile.size;
130
+ ui.info(
131
+ ` ${ui.c.yellow('~')} ${f.repoPath} ` +
132
+ ui.c.dim(`(sobrescribe tu versión local, ${delta > 0 ? '+' : ''}${delta} bytes)`)
133
+ );
134
+ }
135
+ for (const f of onlyLocal) ui.info(` ${ui.c.dim('·')} ${ui.c.dim(`${f.repoPath} (sólo local, se conserva)`)}`);
136
+ ui.info('');
137
+ if (conflicting.length > 0) {
138
+ ui.warn(`${conflicting.length} archivo(s) locales se sobrescribirían: hace falta --force.`);
139
+ ui.info(ui.c.dim('Mirá "shilo diff <archivo>" antes de bajarlos.'));
140
+ }
141
+ ui.info(
142
+ ui.c.dim(
143
+ '(--dry-run: no se bajó nada. Los "posiblemente iguales" se confirman al descargar.)'
144
+ )
145
+ );
146
+ return 0;
147
+ }
148
+
149
+ // 4) Sin --force, los conflictos quedan afuera en vez de abortar todo el pull:
150
+ // lo nuevo se baja igual y el usuario decide qué hacer con el resto.
151
+ let toDownload = [...newFiles, ...maybeSame];
152
+ if (conflicting.length > 0) {
153
+ if (args.force) {
154
+ toDownload = toDownload.concat(conflicting);
155
+ } else {
156
+ ui.warn(`${conflicting.length} archivo(s) difieren de tu copia local y NO se van a bajar:`);
157
+ for (const f of conflicting) ui.info(` ${ui.c.yellow('~')} ${f.repoPath}`);
158
+ ui.info(ui.c.dim('Usá --force para sobrescribirlos, o "shilo diff <archivo>" para comparar.'));
159
+ ui.info('');
160
+ }
161
+ }
162
+
163
+ if (toDownload.length === 0) {
164
+ ui.info('No hay archivos para bajar.');
165
+ return conflicting.length > 0 && !args.force ? 1 : 0;
166
+ }
167
+
168
+ // 5) Confirmar (salvo --yes).
169
+ if (!args.yes) {
170
+ const overwrites = toDownload.filter((f) => fs.existsSync(f.target)).length;
171
+ const label = overwrites
172
+ ? `¿Bajar ${toDownload.length} archivo(s) a "${dir}" (${overwrites} ya existen)?`
173
+ : `¿Bajar ${toDownload.length} archivo(s) a "${dir}"?`;
174
+ const ok = await prompt.confirm(label, true);
175
+ if (!ok) {
176
+ ui.info('Cancelado.');
177
+ return 0;
178
+ }
179
+ }
180
+
181
+ // 6) Descargar y escribir, con pool. Recién acá se sabe si un archivo del
182
+ // mismo tamaño era realmente idéntico: si lo era, no se reescribe.
183
+ const written = [];
184
+ const unchanged = [];
185
+ const failed = [];
186
+ const outcomes = await sync.runPool(toDownload, CONCURRENCY, async (f) => {
187
+ const creds = await mgr.valid();
188
+ const read = await client.readFile(projectId, f.repoPath, creds);
189
+ const content = read.file && read.file.content != null ? read.file.content : '';
190
+
191
+ const current = sync.readLocalText(f.target);
192
+ if (current !== null && current === content) {
193
+ return { repoPath: f.repoPath, skipped: true };
194
+ }
195
+
196
+ fs.mkdirSync(path.dirname(f.target), { recursive: true });
197
+ fs.writeFileSync(f.target, content, 'utf8');
198
+ return { repoPath: f.repoPath, skipped: false };
199
+ });
200
+
201
+ for (const res of outcomes) {
202
+ if (res && res.__error) {
203
+ failed.push({ repoPath: res.item.repoPath, reason: res.__error });
204
+ ui.error(`${res.item.repoPath} — ${res.__error}`);
205
+ } else if (res.skipped) {
206
+ unchanged.push(res.repoPath);
207
+ } else {
208
+ written.push(res.repoPath);
209
+ ui.success(`${ui.c.green('↓')} ${res.repoPath}`);
210
+ }
211
+ }
212
+
213
+ config.persistCredsIfChanged(cfg, mgr.current);
214
+
215
+ // 7) Binding + marca de sincronización para el `status` siguiente. Sólo si el
216
+ // pull cubrió todo: con conflictos pendientes la carpeta NO quedó alineada.
217
+ const complete = failed.length === 0 && (args.force || conflicting.length === 0);
218
+ try {
219
+ if (complete) {
220
+ remote.write(dir, { projectId, baseURL, lastSync: new Date().toISOString() });
221
+ } else if (written.length > 0) {
222
+ remote.write(dir, { projectId, baseURL });
223
+ }
224
+ } catch (e) {
225
+ ui.warn(`No se pudo escribir ${remote.REMOTE_FILENAME}: ${e.message}`);
226
+ }
227
+
228
+ // 8) Resumen.
229
+ ui.info('');
230
+ const tail = unchanged.length ? ` (${unchanged.length} ya estaban al día)` : '';
231
+ if (failed.length === 0) {
232
+ ui.success(`Pull completo: ${written.length} archivo(s) actualizados en "${dir}"${tail}.`);
233
+ return conflicting.length > 0 && !args.force ? 1 : 0;
234
+ }
235
+ ui.warn(`Pull con problemas: ${failed.length} archivo(s) fallaron, ${written.length} bajados${tail}.`);
236
+ return 1;
237
+ }
238
+
239
+ module.exports = pull;
@@ -6,6 +6,7 @@ const config = require('../config');
6
6
  const remote = require('../remote');
7
7
  const prompt = require('../prompt');
8
8
  const ui = require('../ui');
9
+ const sync = require('../sync');
9
10
  const { walk } = require('../walk');
10
11
  const { classifyContent } = require('../textguard');
11
12
  const { ShiloClient } = require('../ShiloClient');
@@ -69,6 +70,12 @@ async function push(args) {
69
70
  const dryRun = !!args['dry-run'];
70
71
  const message =
71
72
  typeof args.message === 'string' && args.message.trim() ? args.message.trim() : null;
73
+ const card = typeof args.card === 'string' && args.card.trim() ? args.card.trim() : null;
74
+
75
+ if (card && card.length > 100) {
76
+ ui.error('El id de tarjeta (--card) no puede superar 100 caracteres.');
77
+ return 1;
78
+ }
72
79
 
73
80
  // 3) Recorrer la carpeta.
74
81
  const { files, skipped, ignored } = walk(dir);
@@ -166,6 +173,32 @@ async function push(args) {
166
173
 
167
174
  config.persistCredsIfChanged(cfg, creds);
168
175
 
176
+ // 5b) Atribución: para los archivos que vamos a pisar, quién los tocó último.
177
+ // Es lo que distingue "esto lo subí yo antes" de "esto lo va a perder un
178
+ // compañero". Best-effort: si el historial no se puede leer, seguimos.
179
+ let attribution = { index: new Map() };
180
+ const myId = cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : null;
181
+ if (plan.modified.length > 0) {
182
+ const mgr = sync.makeCredsManager(client, cfg);
183
+ attribution = await sync.buildAuthorIndex(client, projectId, mgr, { commits: 50 });
184
+ config.persistCredsIfChanged(cfg, mgr.current);
185
+ }
186
+
187
+ /** Etiqueta " — última edición de X, fecha" para un archivo del plan. */
188
+ const authorNote = (u) => {
189
+ const meta = u.remote && attribution.index.get(u.remote.id);
190
+ if (!meta) return '';
191
+ const mine = myId && meta.authorId && meta.authorId === myId;
192
+ const who = mine ? ui.c.dim('vos') : ui.c.bold(meta.author);
193
+ return ui.c.dim(' — última edición de ') + who + ui.c.dim(`, ${sync.formatDate(meta.date)}`);
194
+ };
195
+
196
+ /** ¿El último que tocó este archivo fue otra persona? */
197
+ const touchedByOthers = plan.modified.filter((u) => {
198
+ const meta = u.remote && attribution.index.get(u.remote.id);
199
+ return meta && meta.authorId && myId && meta.authorId !== myId;
200
+ });
201
+
169
202
  // 6) Reporte del plan.
170
203
  ui.info('');
171
204
  ui.info(` ${ui.c.green('nuevos:')} ${plan.added.length}`);
@@ -188,14 +221,38 @@ async function push(args) {
188
221
 
189
222
  if (dryRun) {
190
223
  for (const u of plan.added) ui.info(` ${ui.c.green('+')} ${u.relPath}`);
191
- for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}`);
224
+ for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}${authorNote(u)}`);
192
225
  for (const f of plan.failed) ui.info(` ${ui.c.red('!')} ${f.relPath} — ${f.reason}`);
193
226
  ui.info('');
227
+ if (!myId && plan.modified.length > 0 && attribution.index.size > 0) {
228
+ ui.info(
229
+ ui.c.dim(
230
+ '(con token de acceso personal no sabemos tu id de usuario: ' +
231
+ 'el autor se muestra, pero no se marca cuál sos vos)'
232
+ )
233
+ );
234
+ ui.info('');
235
+ }
236
+ if (touchedByOthers.length > 0) {
237
+ ui.warn(
238
+ `${touchedByOthers.length} de esos archivos los editó otra persona: ` +
239
+ `este push pisaría su versión.`
240
+ );
241
+ ui.info(ui.c.dim('Revisalos con "shilo diff <archivo>" o traelos con "shilo pull".'));
242
+ ui.info('');
243
+ }
194
244
  ui.info(ui.c.dim('(--dry-run: no se subió nada)'));
195
245
  return plan.failed.length ? 1 : 0;
196
246
  }
197
247
 
198
- // 7) Confirmar (salvo --yes).
248
+ // 7) Confirmar (salvo --yes). Si vamos a pisar trabajo ajeno, lo decimos
249
+ // antes de preguntar: es el caso que motivó todo este flujo.
250
+ if (touchedByOthers.length > 0) {
251
+ ui.warn(`Vas a pisar ${touchedByOthers.length} archivo(s) editado(s) por otra persona:`);
252
+ for (const u of touchedByOthers) ui.info(` ${ui.c.red('!')} ${u.relPath}${authorNote(u)}`);
253
+ ui.info('');
254
+ }
255
+
199
256
  if (!args.yes) {
200
257
  const ok = await prompt.confirm(`¿Subir ${toWrite} archivo(s) al proyecto?`, true);
201
258
  if (!ok) {
@@ -269,7 +326,7 @@ async function push(args) {
269
326
  for (let i = 0; i < total; i += COMMIT_BATCH) {
270
327
  const batch = changes.slice(i, i + COMMIT_BATCH);
271
328
  const partNo = Math.floor(i / COMMIT_BATCH) + 1;
272
- const msg = buildMessage(message, batch.length, total, partNo, nBatches);
329
+ const msg = buildMessage(message, batch.length, total, partNo, nBatches, card);
273
330
  try {
274
331
  const result = await client.createCommit(projectId, msg, batch, creds);
275
332
  creds = result.credentials;
@@ -284,7 +341,13 @@ async function push(args) {
284
341
 
285
342
  config.persistCredsIfChanged(cfg, creds);
286
343
 
287
- // 10) Resumen final.
344
+ // 10) Anotar la sincronización: `status` la usa después para saber qué del
345
+ // remoto es posterior a lo último que subimos.
346
+ if (failedWrites.length === 0) {
347
+ remote.touchSync(dir, { projectId, baseURL });
348
+ }
349
+
350
+ // 11) Resumen final.
288
351
  ui.info('');
289
352
  const nCommits = Math.ceil(changes.length / COMMIT_BATCH);
290
353
  if (failedWrites.length === 0) {
@@ -297,10 +360,21 @@ async function push(args) {
297
360
  return 1;
298
361
  }
299
362
 
300
- function buildMessage(userMsg, batchCount, total, partNo, nBatches) {
301
- const base = userMsg || `shilo push: ${total} archivo(s)`;
302
- if (nBatches > 1) return `${base} (${partNo}/${nBatches})`;
303
- return base;
363
+ /**
364
+ * Arma el mensaje del commit. Si vino `--card`, se agrega como trailer
365
+ * `[card: <id>]` al final: queda visible en el historial y es fácil de grepear
366
+ * para cruzar un cambio con la tarjeta que lo pidió (Metrónomo u otro tablero).
367
+ * El backend corta en 500 caracteres, así que recortamos el mensaje del usuario
368
+ * antes que el trailer.
369
+ */
370
+ function buildMessage(userMsg, batchCount, total, partNo, nBatches, card) {
371
+ let base = userMsg || `shilo push: ${total} archivo(s)`;
372
+ if (nBatches > 1) base = `${base} (${partNo}/${nBatches})`;
373
+ if (!card) return base.slice(0, 500);
374
+
375
+ const trailer = ` [card: ${card}]`;
376
+ const room = 500 - trailer.length;
377
+ return `${base.slice(0, room)}${trailer}`;
304
378
  }
305
379
 
306
380
  module.exports = push;
@@ -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 };