shilo-cli 0.8.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.
@@ -8,7 +8,10 @@ const prompt = require('../prompt');
8
8
  const ui = require('../ui');
9
9
  const sync = require('../sync');
10
10
  const secrets = require('../secrets');
11
+ const safelist = require('../safelist');
11
12
  const pushindex = require('../pushindex');
13
+ const projectconfig = require('../projectconfig');
14
+ const { runChecks } = require('../checks');
12
15
  const { walk } = require('../walk');
13
16
  const {
14
17
  classifyContent,
@@ -266,23 +269,98 @@ function suggestIgnore(skips) {
266
269
  }
267
270
  }
268
271
 
269
- /** Muestra los hallazgos de secretos. Nunca imprime el valor detectado. */
270
- function reportSecrets(findings) {
271
- const groups = secrets.groupByFile(findings);
272
- ui.error(`Posibles secretos en ${groups.length} archivo(s):`);
273
- for (const g of groups) {
274
- if (g.name) {
275
- ui.info(` ${ui.c.red('!')} ${g.relPath} ${ui.c.dim(`(coincide con "${g.name}")`)}`);
276
- }
277
- for (const hit of g.content) {
278
- ui.info(` ${ui.c.red('!')} ${g.relPath}:${hit.line} ${ui.c.dim(`— parece ${hit.label}`)}`);
272
+ /**
273
+ * Decide qué hacer con los posibles secretos de lo que se va a subir. Nunca
274
+ * imprime el valor detectado. Devuelve true si el push puede seguir.
275
+ *
276
+ * Dos tipos de hallazgo, dos salidas distintas:
277
+ * - por NOMBRE (`.env`, `*.pem`…): el archivo entero es de contraseñas, así
278
+ * que lo que corresponde es no subirlo → se ofrece sumarlo a `.shiloignore`.
279
+ * - por CONTENIDO (una línea parece una clave): puede ser un falso positivo →
280
+ * se ofrece marcar esas líneas como seguras, y la decisión queda anotada en
281
+ * `.shilo/` para no volver a preguntar mientras la línea no cambie.
282
+ *
283
+ * Sin terminal interactiva (o con --yes / --dry-run) no se pregunta nada: se
284
+ * explica y se frena, como antes.
285
+ */
286
+ async function resolveSecrets(dir, findings, { interactive }) {
287
+ const { pending, accepted } = safelist.partition(dir, findings);
288
+ if (accepted.length > 0) {
289
+ ui.info(
290
+ ui.c.dim(
291
+ `${accepted.length} línea(s) con posibles claves ya las marcaste como seguras antes: se suben.`
292
+ )
293
+ );
294
+ }
295
+ if (pending.length === 0) return true;
296
+
297
+ const byName = pending.filter((f) => f.kind === 'name');
298
+ const byContent = pending.filter((f) => f.kind === 'content');
299
+ const nameFiles = [...new Set(byName.map((f) => f.relPath))];
300
+
301
+ ui.info('');
302
+ ui.warn('Antes de subir: encontramos algo que podría ser una contraseña o clave.');
303
+ if (nameFiles.length > 0) {
304
+ ui.info('');
305
+ ui.info('Estos archivos suelen guardar contraseñas, y no conviene subirlos:');
306
+ for (const p of nameFiles) ui.info(` ${ui.c.red('!')} ${p}`);
307
+ }
308
+ if (byContent.length > 0) {
309
+ ui.info('');
310
+ ui.info('Estas líneas parecen claves:');
311
+ for (const g of secrets.groupByFile(byContent)) {
312
+ for (const hit of g.content) {
313
+ ui.info(` ${ui.c.red('!')} ${g.relPath}:${hit.line} ${ui.c.dim(`— parece ${hit.label}`)}`);
314
+ }
279
315
  }
280
316
  }
281
317
  ui.info('');
282
- ui.info('No se subió nada. Opciones:');
283
- ui.info(' · agregar esos archivos a .shiloignore (lo más común)');
284
- ui.info(' · sacar el secreto del contenido y volver a intentar');
285
- ui.info(' · si es un falso positivo, repetir con --allow-secrets');
318
+
319
+ const canAsk = interactive && process.stdin.isTTY;
320
+ if (!canAsk) {
321
+ ui.info('No se subió nada. Para seguir:');
322
+ if (nameFiles.length > 0) {
323
+ ui.info(` · Agregá ${nameFiles.length === 1 ? 'ese archivo' : 'esos archivos'} a .shiloignore para no subirlos.`);
324
+ }
325
+ if (byContent.length > 0) {
326
+ ui.info(' · Si esas claves son públicas (están hechas para verse), corré "shilo push" en una');
327
+ ui.info(' terminal y respondé que sí cuando pregunte: la decisión queda guardada.');
328
+ ui.info(' · Si son secretas, sacalas del archivo antes de subir.');
329
+ }
330
+ return false;
331
+ }
332
+
333
+ if (nameFiles.length > 0) {
334
+ const ignore = await prompt.confirm(
335
+ `¿Dejamos ${nameFiles.length === 1 ? 'ese archivo' : `esos ${nameFiles.length} archivos`} afuera ` +
336
+ '(se agregan a .shiloignore y no se suben nunca)?',
337
+ true
338
+ );
339
+ if (!ignore) {
340
+ ui.info('No se subió nada. Si de verdad hay que subirlos, usá --allow-secrets (no recomendado).');
341
+ return false;
342
+ }
343
+ if (!safelist.addToShiloignore(dir, nameFiles)) {
344
+ ui.error('No pudimos escribir .shiloignore. No se subió nada.');
345
+ return false;
346
+ }
347
+ ui.success(`Listo: ${nameFiles.length} archivo(s) agregados a .shiloignore. Volvé a correr "shilo push".`);
348
+ // El plan ya está armado con esos archivos adentro: rearmarlo acá sería
349
+ // duplicar el paso 5 entero. Más simple y más claro: frenar y repetir.
350
+ return false;
351
+ }
352
+
353
+ ui.info('Algunas claves son públicas a propósito (por ejemplo, las que usa una página web para');
354
+ ui.info('conectarse a su base de datos). Otras dan acceso total y nunca deberían subirse.');
355
+ const safe = await prompt.confirm('¿Estas son públicas y es seguro subirlas?', false);
356
+ if (!safe) {
357
+ ui.info('No se subió nada. Sacá esas claves del archivo (o ignoralo en .shiloignore) y volvé a intentar.');
358
+ return false;
359
+ }
360
+ if (safelist.remember(dir, byContent)) {
361
+ ui.info(ui.c.dim('Anotado: no te lo volvemos a preguntar por estas líneas mientras no cambien.'));
362
+ }
363
+ return true;
286
364
  }
287
365
 
288
366
  /** Todas las carpetas ancestro de un conjunto de rutas de archivo. */
@@ -303,6 +381,7 @@ function neededFolders(relPaths) {
303
381
  /**
304
382
  * shilo push [carpeta] [--project <uuid>] [-m <msg>] [--dry-run] [--yes]
305
383
  * [--allow-secrets] [--concurrency <n>] [--no-index] [--verbose]
384
+ * [--allow-failing-checks]
306
385
  *
307
386
  * Replica una carpeta local a un repo de Shilo: crea archivos nuevos y actualiza
308
387
  * los que cambiaron. Desde la Etapa 3 lo hace como PROPUESTA: el servidor la
@@ -352,6 +431,15 @@ async function push(args) {
352
431
  return 1;
353
432
  }
354
433
 
434
+ // --deploy sólo agrega el trailer [deploy: …]; quien decide es el servidor
435
+ // (PLAN_DEPLOY_VERCEL, Etapa 2). Sin --deploy, manda el destino automático.
436
+ const deploy = parseDeploy(args.deploy);
437
+ if (deploy === undefined) {
438
+ ui.error('--deploy tiene que ser prod, preview u off.');
439
+ return 1;
440
+ }
441
+ const trailers = [card && `[card: ${card}]`, deploy && `[deploy: ${deploy}]`].filter(Boolean);
442
+
355
443
  // Procedencia (Etapa 4, punto 1). Sin --agent no se manda bloque alguno y el
356
444
  // guardado queda como escrito por una persona, que es la verdad.
357
445
  const agent = buildAgent(args);
@@ -386,17 +474,9 @@ async function push(args) {
386
474
 
387
475
  reportSkipped(skipped, { verbose });
388
476
 
389
- if (findings.length > 0 && !args['allow-secrets']) {
390
- ui.info('');
391
- reportSecrets(findings);
392
- return 1;
393
- }
394
- if (findings.length > 0) {
395
- ui.warn(
396
- `${secrets.groupByFile(findings).length} archivo(s) con posibles secretos ` +
397
- `se van a subir igual (--allow-secrets).`
398
- );
399
- }
477
+ // Los hallazgos de secretos se deciden después de planificar (ver 6b): sólo
478
+ // importan los archivos que este push va a escribir. Uno que ya está en el
479
+ // proyecto tal cual no gana nada con frenar el push.
400
480
 
401
481
  if (allUploads.length === 0) {
402
482
  ui.info('No hay archivos para subir.');
@@ -602,9 +682,44 @@ async function push(args) {
602
682
 
603
683
  config.persistCredsIfChanged(cfg, mgr.current);
604
684
 
605
- // Con token de acceso personal no sabemos el id del usuario; sólo importa
606
- // para reconocer propuestas propias que este push reemplaza.
607
- const myId = cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : null;
685
+ // Quién soy importa para reconocer propuestas propias que este push
686
+ // reemplaza. Con token de acceso personal la sesión no trae el id: lo dice el
687
+ // listado de propuestas (`viewer`). Sin eso, cada push repetido dejaba una
688
+ // propuesta duplicada al lado de la anterior.
689
+ let myId = cfg.credentials && cfg.credentials.user ? cfg.credentials.user.id : null;
690
+ let viewer = null;
691
+ let projectInfo = null;
692
+ const noteListing = (listed) => {
693
+ if (listed.viewer) {
694
+ viewer = listed.viewer;
695
+ if (!myId) myId = listed.viewer.id || null;
696
+ }
697
+ if (listed.project) projectInfo = listed.project;
698
+ };
699
+
700
+ /**
701
+ * Si la carpeta coincide entera con la versión actual, anotarlo: fecha de
702
+ * sincronización y `base`. Sin esto, después de aprobar una propuesta en la
703
+ * web la carpeta seguía diciendo "última sincronización hace 2 meses" y, peor,
704
+ * el push siguiente partía de una base vieja y veía como conflicto lo que
705
+ * eran tus propios cambios ya aplicados.
706
+ */
707
+ async function markAlignedIfClean() {
708
+ if (dryRun || !treeComplete || leftOut.length > 0) return;
709
+ const isIgnoredPath = sync.makePathMatcher(dir);
710
+ const localPaths = new Set(allUploads.map((u) => u.relPath));
711
+ const missing = tree.files.some(
712
+ (f) => !localPaths.has(f.repoPath) && !isIgnoredPath(f.repoPath)
713
+ );
714
+ if (missing) return; // el proyecto tiene cosas que acá no están: no es "igual"
715
+ try {
716
+ const listed = await client.listProposals(projectId, await mgr.valid());
717
+ config.persistCredsIfChanged(cfg, mgr.current);
718
+ if (listed.main) remote.touchSync(dir, { projectId, baseURL, base: listed.main });
719
+ } catch (_) {
720
+ // Best-effort: servidor viejo o sin red. No cambia el resultado del push.
721
+ }
722
+ }
608
723
 
609
724
  // 6) Reporte del plan.
610
725
  ui.info('');
@@ -623,11 +738,26 @@ async function push(args) {
623
738
  return 1;
624
739
  }
625
740
  if (useIndex && !dryRun) saveIndex(dir, index, plan, allUploads);
741
+ await markAlignedIfClean();
626
742
  ui.success('Todo al día. Nada para subir.');
627
743
  reportLeftOut(leftOut, remoteByPath);
628
744
  return 0;
629
745
  }
630
746
 
747
+ // 6b) Secretos: sólo en lo que se va a escribir, y sin lo que la persona ya
748
+ // marcó como seguro en esta carpeta.
749
+ const writing = new Set([...plan.added, ...plan.modified].map((u) => u.relPath));
750
+ const relevant = findings.filter((f) => writing.has(f.relPath));
751
+ if (relevant.length > 0 && !args['allow-secrets']) {
752
+ const ok = await resolveSecrets(dir, relevant, { interactive: !dryRun && !args.yes });
753
+ if (!ok) return 1;
754
+ } else if (relevant.length > 0) {
755
+ ui.warn(
756
+ `${secrets.groupByFile(relevant).length} archivo(s) con posibles secretos ` +
757
+ `se van a subir igual (--allow-secrets).`
758
+ );
759
+ }
760
+
631
761
  if (dryRun) {
632
762
  for (const u of plan.added) ui.info(` ${ui.c.green('+')} ${u.relPath}`);
633
763
  for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}`);
@@ -649,6 +779,20 @@ async function push(args) {
649
779
  }
650
780
  }
651
781
 
782
+ // 7b) Chequeos del proyecto (MU-93): los de `shilo.config.json` de esta
783
+ // carpeta. Sin chequeos declarados no se corre nada y el push es el de
784
+ // siempre. Si uno falla, no se propone nada salvo --allow-failing-checks:
785
+ // entonces la propuesta viaja marcada como fallida y aplicarla exige forzar.
786
+ const checks = runProjectChecks(dir);
787
+ if (checks && checks.resultado !== 'ok') {
788
+ if (!args['allow-failing-checks']) {
789
+ ui.error('Un chequeo del proyecto falló: no se subió nada.');
790
+ ui.info(ui.c.dim('Arreglalo y volvé a subir, o usá --allow-failing-checks para proponer igual (queda marcada como fallida).'));
791
+ return 1;
792
+ }
793
+ ui.warn('Un chequeo falló: se propone igual (--allow-failing-checks) y queda marcada como fallida.');
794
+ }
795
+
652
796
  const pushedAt = Date.now();
653
797
 
654
798
  /** Log por archivo: en TTY lo tapa la barra de progreso, así que va sólo con --verbose. */
@@ -685,6 +829,7 @@ async function push(args) {
685
829
  throw e;
686
830
  }
687
831
  config.persistCredsIfChanged(cfg, mgr.current);
832
+ noteListing(listed);
688
833
 
689
834
  const knownBase = savedRemote && savedRemote.base ? savedRemote.base : null;
690
835
  let base = knownBase || listed.main;
@@ -733,6 +878,7 @@ async function push(args) {
733
878
  const failedWrites = [];
734
879
  const uploaded = [];
735
880
  const open = [];
881
+ const lineMerged = [];
736
882
  let applied = 0;
737
883
  let appliedCount = 0;
738
884
  let stale = 0;
@@ -802,12 +948,12 @@ async function push(args) {
802
948
  upProg.update(upDone);
803
949
  if (changes.length === 0) continue;
804
950
 
805
- const title = buildMessage(message, changes.length, toSend.length, bi + 1, batches.length, card);
951
+ const title = buildMessage(message, changes.length, toSend.length, bi + 1, batches.length, trailers);
806
952
  let result;
807
953
  try {
808
954
  result = await client.propose(
809
955
  projectId,
810
- { base, title, summary: '', changes, agent, prompt: promptText, card },
956
+ { base, title, summary: '', changes, agent, prompt: promptText, card, chequeos: checks },
811
957
  await mgr.valid()
812
958
  );
813
959
  for (const w of result.warnings || []) ui.warn(w);
@@ -836,6 +982,7 @@ async function push(args) {
836
982
  } else if (result.applied) {
837
983
  applied += sent.length;
838
984
  appliedCount += 1;
985
+ lineMerged.push(...(result.lineas || []));
839
986
  for (const u of sent) {
840
987
  logFile(upProg, `${u.remote ? ui.c.yellow('~') : ui.c.green('+')} ${u.relPath}`);
841
988
  }
@@ -847,7 +994,7 @@ async function push(args) {
847
994
  }
848
995
  upProg.clear();
849
996
 
850
- return { failedWrites, committed: applied, commitCount: appliedCount, uploaded, open, stale, main, base };
997
+ return { failedWrites, committed: applied, commitCount: appliedCount, uploaded, open, stale, main, base, lineMerged };
851
998
  }
852
999
 
853
1000
  /**
@@ -1223,7 +1370,7 @@ async function push(args) {
1223
1370
  toUpload.length,
1224
1371
  partNo,
1225
1372
  batches.length,
1226
- card
1373
+ trailers
1227
1374
  );
1228
1375
  try {
1229
1376
  const creds = await mgr.valid();
@@ -1259,10 +1406,35 @@ async function push(args) {
1259
1406
  );
1260
1407
  outcome = await pushLegacy();
1261
1408
  }
1262
- const { failedWrites, committed, commitCount, uploaded, open, stale, main } = outcome;
1409
+ const { failedWrites, commitCount, uploaded, open, stale } = outcome;
1410
+ let { committed, main } = outcome;
1411
+ const lineMerged = outcome.lineMerged || [];
1263
1412
 
1264
1413
  config.persistCredsIfChanged(cfg, mgr.current);
1265
1414
 
1415
+ // 9b) Si quien sube es el dueño, lo que quedó abierto lo puede aplicar ahora
1416
+ // mismo en vez de ir a la web a aprobarse a sí mismo.
1417
+ const failures = failedWrites.length + plan.failed.length;
1418
+ let openShown = false;
1419
+ if (open.length > 0 && failures === 0 && viewer && viewer.is_owner && !args.yes && process.stdin.isTTY) {
1420
+ const res = await offerOwnerApply(open);
1421
+ if (res.applied.length > 0) {
1422
+ committed += res.applied.reduce((n, o) => n + o.paths.length, 0);
1423
+ if (res.main) main = res.main;
1424
+ for (const o of res.applied) open.splice(open.indexOf(o), 1);
1425
+ lineMerged.push(...res.lineas);
1426
+ }
1427
+ }
1428
+
1429
+ // 9c) Unión por líneas (MU-92): el servidor combinó tu versión con la de otra
1430
+ // persona. Tu carpeta tiene que quedar con la combinada ANTES de mover la
1431
+ // base: si no, el push siguiente propondría tu versión sobre la nueva base
1432
+ // y desharía en silencio el cambio ajeno. Sólo se pisa un archivo que
1433
+ // sigue byte a byte como se subió; si lo tocaste mientras tanto, queda
1434
+ // como está y la base no se mueve, así el próximo push lo vuelve a combinar.
1435
+ const broughtIn = await bringLineMerged(lineMerged, uploaded, { client, projectId, mgr, dir });
1436
+ config.persistCredsIfChanged(cfg, mgr.current);
1437
+
1266
1438
  // 10) Índice local: lo que subimos bien y lo que ya estaba igual. El próximo
1267
1439
  // push arranca sabiendo qué no hace falta ni leer. Lo que quedó en una
1268
1440
  // propuesta abierta también se anota: su hash no coincide con el de la
@@ -1271,22 +1443,27 @@ async function push(args) {
1271
1443
  for (const u of uploaded) {
1272
1444
  pushindex.record(index, u, pushedAt);
1273
1445
  }
1446
+ for (const u of broughtIn.written) {
1447
+ pushindex.record(index, u, pushedAt);
1448
+ }
1274
1449
  saveIndex(dir, index, plan, allUploads, pushedAt);
1275
1450
  }
1276
1451
 
1277
1452
  // 11) Anotar la sincronización y la base nueva. Sólo si TODO quedó en la
1278
1453
  // versión actual: con archivos fallados o propuestas abiertas, la carpeta
1279
1454
  // no está alineada, y mover la base haría que el push siguiente proponga
1280
- // esos mismos archivos como si nadie más los hubiera tocado.
1281
- const clean = failedWrites.length === 0 && plan.failed.length === 0 && open.length === 0;
1282
- if (clean) {
1455
+ // esos mismos archivos como si nadie más los hubiera tocado. Lo mismo con
1456
+ // una versión combinada que no se pudo traer (9c).
1457
+ const clean = failures === 0 && open.length === 0;
1458
+ reportLineMerged(broughtIn);
1459
+ if (clean && broughtIn.left.length === 0) {
1283
1460
  remote.touchSync(dir, { projectId, baseURL, base: legacy ? null : main });
1284
1461
  }
1285
1462
 
1286
1463
  // 12) Resumen final.
1287
1464
  ui.info('');
1288
1465
  reportLeftOut(leftOut, remoteByPath);
1289
- for (const o of open) reportOpen(o);
1466
+ if (!openShown) for (const o of open) reportOpen(o);
1290
1467
  if (stale > 0) {
1291
1468
  ui.warn(
1292
1469
  `${stale} archivo(s) ya no cambian nada: la versión actual avanzó y lo que tenés ` +
@@ -1301,18 +1478,91 @@ async function push(args) {
1301
1478
  } else {
1302
1479
  ui.success(`Push completo: ${committed} archivo(s) ya son parte de la versión actual.`);
1303
1480
  }
1481
+ if (committed > 0 && deploy !== 'off') await reportDeploy(client, projectId, mgr, cfg);
1304
1482
  return 0;
1305
1483
  }
1306
1484
  for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
1307
- if (failedWrites.length + plan.failed.length > 0) {
1308
- ui.warn(`Push con problemas: ${failedWrites.length + plan.failed.length} archivo(s) fallaron.`);
1309
- } else {
1310
- ui.warn(
1311
- `${committed} archivo(s) subieron; ${open.reduce((n, o) => n + o.paths.length, 0)} quedaron en ` +
1312
- `${open.length} propuesta(s) abierta(s).`
1313
- );
1485
+ if (failures > 0) {
1486
+ ui.warn(`Push con problemas: ${failures} archivo(s) fallaron.`);
1487
+ return 1;
1488
+ }
1489
+
1490
+ // Quedó todo guardado, pero falta una aprobación. No es un error, y el
1491
+ // mensaje empieza por eso: "0 archivo(s) subieron" se leía como un fallo.
1492
+ const pendingFiles = open.reduce((n, o) => n + o.paths.length, 0);
1493
+ const owner = projectInfo && projectInfo.owner;
1494
+ const ownerName = owner ? owner.display_name || owner.username : null;
1495
+ const approver = viewer && viewer.is_owner ? 'tu aprobación' : ownerName ? `la aprobación de ${ownerName}` : 'la aprobación del dueño del proyecto';
1496
+ ui.success(
1497
+ `Tus ${pendingFiles} cambio(s) están guardados en Shilo y esperan ${approver}.` +
1498
+ (committed > 0 ? ` (Otros ${committed} ya son parte de la versión actual.)` : '')
1499
+ );
1500
+ for (const o of open) {
1501
+ const link = proposalLink(baseURL, projectInfo, o.proposal);
1502
+ if (link) ui.info(` Revisalos acá: ${ui.c.cyan(link)}`);
1503
+ }
1504
+ // Exit 3 = "guardado, falta aprobar": distinto de 1 (falló algo) para que un
1505
+ // script pueda distinguirlos, y distinto de 0 porque todavía no es la versión actual.
1506
+ return 3;
1507
+
1508
+ /**
1509
+ * Ofrece al dueño aplicar lo que quedó abierto. En los archivos en conflicto
1510
+ * se queda la versión de esta carpeta (`propuesta`), y se dice antes de
1511
+ * preguntar. Devuelve `{ applied: [open...], main }`.
1512
+ */
1513
+ async function offerOwnerApply(list) {
1514
+ const files = list.reduce((n, o) => n + o.paths.length, 0);
1515
+ const conflicted = list.reduce((n, o) => n + o.conflicts.length, 0);
1516
+ ui.info('');
1517
+ for (const o of list) reportOpen(o);
1518
+ openShown = true;
1519
+ ui.info(`Sos el dueño del proyecto: podés aplicar estos ${files} cambio(s) ahora, sin pasar por la web.`);
1520
+ if (conflicted > 0) {
1521
+ ui.info(
1522
+ `En ${conflicted} archivo(s) que también cambiaron en el proyecto se va a quedar ` +
1523
+ `${ui.c.bold('la versión de esta carpeta')}. Lo anterior queda en el historial.`
1524
+ );
1525
+ }
1526
+ const failedChecks = list.some(needsForce);
1527
+ if (failedChecks) {
1528
+ ui.info(`Sus chequeos o piezas ${ui.c.red('fallaron')}: aplicarlos ahora es forzarlos, sabiendo eso.`);
1529
+ }
1530
+ const ok = await prompt.confirm('¿Aplicarlos ahora?', conflicted === 0 && !failedChecks);
1531
+ const out = { applied: [], main: null, lineas: [] };
1532
+ if (!ok) return out;
1533
+ for (const o of list) {
1534
+ if (!o.proposal) continue;
1535
+ const resolutions =
1536
+ o.conflicts.length > 0 ? Object.fromEntries(o.conflicts.map((c) => [c.path, 'propuesta'])) : null;
1537
+ try {
1538
+ const r = await client.resolveProposal(projectId, o.proposal.id, 'apply', await mgr.valid(), {
1539
+ resolutions,
1540
+ force: needsForce(o),
1541
+ });
1542
+ config.persistCredsIfChanged(cfg, mgr.current);
1543
+ if (r.applied) {
1544
+ out.applied.push(o);
1545
+ if (r.main) out.main = r.main;
1546
+ out.lineas.push(...(r.lineas || []));
1547
+ } else {
1548
+ ui.warn(`No se pudo aplicar la propuesta ${o.proposal.id.slice(0, 8)}: ${r.message || 'sigue abierta'}.`);
1549
+ }
1550
+ } catch (e) {
1551
+ ui.warn(`No se pudo aplicar la propuesta ${o.proposal.id.slice(0, 8)}: ${e.message}`);
1552
+ }
1553
+ }
1554
+ ui.info('');
1555
+ return out;
1314
1556
  }
1315
- return 1;
1557
+ }
1558
+
1559
+ /**
1560
+ * Link web a una propuesta: `<base>/<usuario>/<proyecto>?propuesta=<id>`.
1561
+ * null si el servidor no dijo la ruta del proyecto (servidor anterior).
1562
+ */
1563
+ function proposalLink(baseURL, projectInfo, proposal) {
1564
+ if (!projectInfo || !projectInfo.path || !proposal) return null;
1565
+ return `${baseURL.replace(/\/+$/, '')}${projectInfo.path}?propuesta=${proposal.id.slice(0, 8)}`;
1316
1566
  }
1317
1567
 
1318
1568
  /**
@@ -1337,6 +1587,10 @@ function reportOpen(o) {
1337
1587
  : 'las dos versiones son distintas';
1338
1588
  ui.info(` ${ui.c.red('!')} ${c.path} ${ui.c.dim(`— ${why}`)}`);
1339
1589
  }
1590
+ } else if (checksFailed(o)) {
1591
+ ui.warn('Los chequeos del proyecto fallaron: la propuesta no se aplica sola, hace falta que el dueño la fuerce.');
1592
+ } else if (piecesFailed(o)) {
1593
+ reportPieces(o.piezas);
1340
1594
  } else if (o.blockedBy.length > 0) {
1341
1595
  ui.warn('Otra propuesta abierta toca los mismos archivos:');
1342
1596
  for (const b of o.blockedBy) {
@@ -1345,15 +1599,104 @@ function reportOpen(o) {
1345
1599
  } else {
1346
1600
  ui.warn(o.message || 'La propuesta quedó abierta.');
1347
1601
  }
1348
- ui.info(
1349
- ` Tus ${o.paths.length} archivo(s) no se perdieron: quedaron como propuesta ${ui.c.bold(id)}, ` +
1350
- 'abierta para que el dueño del proyecto decida.'
1351
- );
1352
- ui.info(
1353
- ui.c.dim(' Para resolverlo vos: "shilo pull" trae la versión actual (no pisa lo tuyo sin --force), ') +
1354
- ui.c.dim('ajustá y volvé a "shilo push".')
1355
- );
1602
+ ui.info(` Tus ${o.paths.length} archivo(s) no se perdieron: quedaron guardados como propuesta ${ui.c.bold(id)}.`);
1603
+ ui.info('');
1604
+ }
1605
+
1606
+ const checksFailed = (o) => Boolean(o.chequeos && o.chequeos.resultado === 'fallido');
1607
+ const piecesFailed = (o) => Array.isArray(o.piezas) && o.piezas.length > 0;
1608
+ /** Lo que el servidor sólo aplica si el dueño fuerza (MU-93, MU-94). */
1609
+ const needsForce = (o) => checksFailed(o) || piecesFailed(o);
1610
+
1611
+ /**
1612
+ * Las piezas USEE que dejarían de pasar el Nivel 1 (MU-94), con sus errores.
1613
+ * El detalle completo lo da `verificar.js` sobre la carpeta de la pieza.
1614
+ */
1615
+ function reportPieces(piezas) {
1616
+ ui.warn('Estas piezas USEE no pasarían el Nivel 1; la propuesta no se aplica sola, hace falta que el dueño la fuerce:');
1617
+ for (const p of piezas) {
1618
+ const donde = p.raiz || '(raíz del proyecto)';
1619
+ ui.info(` ${ui.c.red('✗')} ${donde}${p.nueva ? ui.c.dim(' — pieza nueva') : ''}`);
1620
+ for (const e of p.errores) ui.info(ui.c.dim(` (${e.codigo}) ${e.mensaje}`));
1621
+ }
1622
+ }
1623
+
1624
+ /**
1625
+ * Corre los chequeos de `shilo.config.json` de la carpeta (MU-93). Devuelve el
1626
+ * resultado que viaja con la propuesta, o null si el proyecto no declara
1627
+ * ninguno (y entonces el push es el de siempre).
1628
+ */
1629
+ function runProjectChecks(dir) {
1630
+ let text = null;
1631
+ try {
1632
+ text = fs.readFileSync(path.join(dir, projectconfig.CONFIG_PATH), 'utf8');
1633
+ } catch (_) {
1634
+ return null;
1635
+ }
1636
+ const cfgFile = projectconfig.parseProjectConfig(text);
1637
+ for (const aviso of cfgFile.avisos) ui.warn(aviso);
1638
+ if (cfgFile.chequeos.length === 0) return null;
1639
+
1356
1640
  ui.info('');
1641
+ const result = runChecks(dir, cfgFile.chequeos, {
1642
+ onStart: (cmd, i, n) => ui.info(ui.c.bold(`Chequeo ${i}/${n}: ${cmd}`)),
1643
+ });
1644
+ ui.info('');
1645
+ for (const c of result.comandos) {
1646
+ const mark = c.ok ? ui.c.green('✓') : ui.c.red('✗');
1647
+ const code = c.ok || c.codigo === undefined ? '' : ` (salió con ${c.codigo})`;
1648
+ ui.info(` ${mark} ${c.comando}${code} ${ui.c.dim(`${(c.ms / 1000).toFixed(1)} s`)}`);
1649
+ }
1650
+ const notRun = cfgFile.chequeos.length - result.comandos.length;
1651
+ if (notRun > 0) ui.info(ui.c.dim(` ${notRun} chequeo(s) más no se corrieron.`));
1652
+ return result;
1653
+ }
1654
+
1655
+ /**
1656
+ * Trae a la carpeta las versiones que el servidor combinó por líneas (MU-92).
1657
+ *
1658
+ * Un archivo se pisa sólo si sigue siendo exactamente lo que se subió: la
1659
+ * combinada contiene esos cambios, así que no se pierde nada. Si se tocó
1660
+ * durante el push, o no se pudo leer, queda en `left` y no se escribe.
1661
+ *
1662
+ * @returns {Promise<{ written: Array<{relPath,size,mtimeMs,hash}>, left: string[] }>}
1663
+ */
1664
+ async function bringLineMerged(paths, uploaded, { client, projectId, mgr, dir }) {
1665
+ const out = { written: [], left: [] };
1666
+ for (const relPath of [...new Set(paths)]) {
1667
+ const u = uploaded.find((x) => x.relPath === relPath);
1668
+ const absPath = u ? u.absPath : path.join(dir, ...relPath.split('/'));
1669
+ try {
1670
+ const now = fs.readFileSync(absPath);
1671
+ if (!u || pushindex.hashContent(now) !== u.hash) throw new Error('cambió durante el push');
1672
+ const read = await client.readFile(projectId, relPath, await mgr.valid());
1673
+ const content = read.file && read.file.content != null ? read.file.content : null;
1674
+ if (content === null || read.file.is_binary) throw new Error('el servidor no devolvió el texto');
1675
+ fs.writeFileSync(absPath, content, 'utf8');
1676
+ const st = fs.statSync(absPath);
1677
+ out.written.push({ relPath, size: st.size, mtimeMs: st.mtimeMs, hash: pushindex.hashContent(content) });
1678
+ } catch (_) {
1679
+ out.left.push(relPath);
1680
+ }
1681
+ }
1682
+ return out;
1683
+ }
1684
+
1685
+ function reportLineMerged({ written, left }) {
1686
+ if (written.length > 0) {
1687
+ ui.info(
1688
+ `${written.length} archivo(s) también los había cambiado otra persona, en otras líneas: ` +
1689
+ 'se combinaron y tu carpeta ya tiene la versión combinada.'
1690
+ );
1691
+ for (const u of written) ui.info(` ${ui.c.cyan('≈')} ${u.relPath}`);
1692
+ }
1693
+ if (left.length > 0) {
1694
+ ui.warn(
1695
+ `${left.length} archivo(s) se combinaron con cambios de otra persona, pero no se pudieron traer ` +
1696
+ 'a tu carpeta. Tu versión sigue intacta; revisalos con "shilo diff" y el próximo push los vuelve a combinar.'
1697
+ );
1698
+ for (const p of left) ui.info(` ${ui.c.yellow('≈')} ${p}`);
1699
+ }
1357
1700
  }
1358
1701
 
1359
1702
  /**
@@ -1526,25 +1869,56 @@ function readPrompt(args, agent) {
1526
1869
  }
1527
1870
 
1528
1871
  /**
1529
- * Arma el mensaje del commit. Si vino `--card`, se agrega como trailer
1530
- * `[card: <id>]` al final: queda visible en el historial y es fácil de grepear
1531
- * para cruzar un cambio con la tarjeta que lo pidió (Metrónomo u otro tablero).
1872
+ * Arma el mensaje del commit. Los trailers (`[card: <id>]` de `--card`,
1873
+ * `[deploy: …]` de `--deploy`) van al final: quedan visibles en el historial,
1874
+ * se grepean fácil para cruzar un cambio con la tarjeta que lo pidió, y el
1875
+ * servidor lee `[deploy: …]` para elegir el destino del deploy.
1532
1876
  * El backend corta en 500 caracteres, así que recortamos el mensaje del usuario
1533
- * antes que el trailer.
1877
+ * antes que los trailers.
1534
1878
  *
1535
1879
  * Desde la Etapa 4 la tarjeta viaja ADEMÁS aparte, en `provenance.card`, que es
1536
1880
  * lo que se puede consultar (`GET /api/commits/<id>?card=…`). El trailer no se
1537
1881
  * saca ni se va a sacar: es el formato de todo lo ya guardado, y la procedencia
1538
1882
  * entra en el oid del commit, así que lo viejo no se puede completar.
1539
1883
  */
1540
- function buildMessage(userMsg, batchCount, total, partNo, nBatches, card) {
1884
+ function buildMessage(userMsg, batchCount, total, partNo, nBatches, trailers = []) {
1541
1885
  let base = userMsg || `shilo push: ${total} archivo(s)`;
1542
1886
  if (nBatches > 1) base = `${base} (${partNo}/${nBatches})`;
1543
- if (!card) return base.slice(0, 500);
1887
+ if (!trailers.length) return base.slice(0, 500);
1888
+
1889
+ const tail = ` ${trailers.join(' ')}`;
1890
+ const room = 500 - tail.length;
1891
+ return `${base.slice(0, room)}${tail}`;
1892
+ }
1544
1893
 
1545
- const trailer = ` [card: ${card}]`;
1546
- const room = 500 - trailer.length;
1547
- return `${base.slice(0, room)}${trailer}`;
1894
+ /** `--deploy` → 'prod' | 'preview' | 'off'; null si no vino; undefined si es inválido. */
1895
+ function parseDeploy(raw) {
1896
+ if (raw === undefined || raw === null || raw === false) return null;
1897
+ const v = String(raw).trim().toLowerCase();
1898
+ if (v === 'prod' || v === 'production') return 'prod';
1899
+ if (v === 'preview' || v === 'off') return v;
1900
+ return undefined;
1901
+ }
1902
+
1903
+ /**
1904
+ * Después de un push que cambió algo: si el proyecto despliega a Vercel, avisa
1905
+ * que el deploy quedó en cola. Nunca hace fallar el push — un servidor sin
1906
+ * deploys o un error de red sólo se callan.
1907
+ */
1908
+ async function reportDeploy(client, projectId, mgr, cfg) {
1909
+ try {
1910
+ const info = await client.getDeploys(projectId, await mgr.valid());
1911
+ if (info.target && info.connected) {
1912
+ ui.info(ui.c.dim('Deploy en cola → "shilo deploys" para ver el estado.'));
1913
+ }
1914
+ } catch {
1915
+ // sin deploys en el servidor, o sin red: no es asunto del push
1916
+ } finally {
1917
+ config.persistCredsIfChanged(cfg, mgr.current);
1918
+ }
1548
1919
  }
1549
1920
 
1550
1921
  module.exports = push;
1922
+ // Para los tests de backend/utils/__tests__/pushLineMerged.test.js.
1923
+ module.exports.bringLineMerged = bringLineMerged;
1924
+ module.exports.runProjectChecks = runProjectChecks;