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.
@@ -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,
@@ -36,6 +39,11 @@ const PROPOSAL_MAX_BYTES = 4 * 1024 * 1024; // el server corta en 5 MB; margen
36
39
  const HEAD_BYTES = 8192;
37
40
 
38
41
  // Cuántos ejemplos se muestran por cada motivo de salteo.
42
+ // Techo por campo del bloque `agent` y del prompt (Etapa 4, puntos 1 y 2). El
43
+ // del prompt es el mismo que aplica el servidor: avisar acá ahorra el viaje.
44
+ const AGENT_FIELD_MAX = 200;
45
+ const PROMPT_MAX_BYTES = 64 * 1024;
46
+
39
47
  const SKIP_EXAMPLES = 5;
40
48
  // A partir de cuántos salteos en una misma carpeta de primer nivel sugerimos
41
49
  // ignorarla entera.
@@ -149,6 +157,61 @@ function preflight(dir) {
149
157
  return { uploads, skipped: skips, ignored, gitDirs, findings };
150
158
  }
151
159
 
160
+ /**
161
+ * Aplica los selectores de `--only` sobre los archivos que dejó el preflight.
162
+ *
163
+ * Cada selector se normaliza con `sync.toRepoPath`, que ya blinda contra salir
164
+ * de la carpeta, y matchea por igualdad exacta o como prefijo de carpeta
165
+ * (`Documentacion/` alcanza todo lo de adentro).
166
+ *
167
+ * Un selector que no matchea nada es ERROR, no un push silencioso de todo:
168
+ * confundir "no subí nada de lo que pedí" con "subí el repo entero" es
169
+ * exactamente el fallo que arruinaría la confianza en el flag.
170
+ *
171
+ * Devuelve `{ uploads, leftOut }` o `{ error }`.
172
+ */
173
+ function selectUploads(dir, allUploads, only) {
174
+ const selectors = (Array.isArray(only) ? only : only ? [only] : [])
175
+ .map((s) => String(s).trim())
176
+ .filter(Boolean);
177
+
178
+ if (selectors.length === 0) return { uploads: allUploads, leftOut: [] };
179
+
180
+ const normalized = [];
181
+ for (const raw of selectors) {
182
+ const repoPath = sync.toRepoPath(dir, raw);
183
+ if (!repoPath) return { error: `--only "${raw}" queda fuera de la carpeta del push.` };
184
+ normalized.push({ raw, repoPath });
185
+ }
186
+
187
+ const matched = new Set();
188
+ const uploads = [];
189
+ const leftOut = [];
190
+
191
+ for (const u of allUploads) {
192
+ const hit = normalized.find(
193
+ (s) => u.relPath === s.repoPath || u.relPath.startsWith(`${s.repoPath}/`)
194
+ );
195
+ if (hit) {
196
+ matched.add(hit.repoPath);
197
+ uploads.push(u);
198
+ } else {
199
+ leftOut.push(u);
200
+ }
201
+ }
202
+
203
+ const misses = normalized.filter((s) => !matched.has(s.repoPath));
204
+ if (misses.length > 0) {
205
+ return {
206
+ error:
207
+ `--only no encontró nada para: ${misses.map((s) => `"${s.raw}"`).join(', ')}. ` +
208
+ `Puede estar ignorado, salteado, o escrito distinto; nada se subió.`,
209
+ };
210
+ }
211
+
212
+ return { uploads, leftOut };
213
+ }
214
+
152
215
  /**
153
216
  * Reporte agrupado de salteos. Un repo con un `venv/` adentro generaba miles de
154
217
  * líneas "saltado …" que tapaban todo lo demás; acá se resume por motivo, con
@@ -206,23 +269,98 @@ function suggestIgnore(skips) {
206
269
  }
207
270
  }
208
271
 
209
- /** Muestra los hallazgos de secretos. Nunca imprime el valor detectado. */
210
- function reportSecrets(findings) {
211
- const groups = secrets.groupByFile(findings);
212
- ui.error(`Posibles secretos en ${groups.length} archivo(s):`);
213
- for (const g of groups) {
214
- if (g.name) {
215
- ui.info(` ${ui.c.red('!')} ${g.relPath} ${ui.c.dim(`(coincide con "${g.name}")`)}`);
216
- }
217
- for (const hit of g.content) {
218
- 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
+ }
219
315
  }
220
316
  }
221
317
  ui.info('');
222
- ui.info('No se subió nada. Opciones:');
223
- ui.info(' · agregar esos archivos a .shiloignore (lo más común)');
224
- ui.info(' · sacar el secreto del contenido y volver a intentar');
225
- 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;
226
364
  }
227
365
 
228
366
  /** Todas las carpetas ancestro de un conjunto de rutas de archivo. */
@@ -243,6 +381,7 @@ function neededFolders(relPaths) {
243
381
  /**
244
382
  * shilo push [carpeta] [--project <uuid>] [-m <msg>] [--dry-run] [--yes]
245
383
  * [--allow-secrets] [--concurrency <n>] [--no-index] [--verbose]
384
+ * [--allow-failing-checks]
246
385
  *
247
386
  * Replica una carpeta local a un repo de Shilo: crea archivos nuevos y actualiza
248
387
  * los que cambiaron. Desde la Etapa 3 lo hace como PROPUESTA: el servidor la
@@ -292,8 +431,35 @@ async function push(args) {
292
431
  return 1;
293
432
  }
294
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
+
443
+ // Procedencia (Etapa 4, punto 1). Sin --agent no se manda bloque alguno y el
444
+ // guardado queda como escrito por una persona, que es la verdad.
445
+ const agent = buildAgent(args);
446
+ if (agent && agent.error) {
447
+ ui.error(agent.error);
448
+ return 1;
449
+ }
450
+
451
+ const promptRead = readPrompt(args, agent);
452
+ if (promptRead.error) {
453
+ ui.error(promptRead.error);
454
+ return 1;
455
+ }
456
+ for (const w of promptRead.warnings) ui.warn(w);
457
+ const promptText = promptRead.prompt;
458
+
295
459
  // 3) PREFLIGHT: todo offline. Recién después de esto se toca la red.
296
- const { uploads, skipped, ignored, gitDirs, findings } = preflight(dir);
460
+ // `allUploads` es la carpeta entera; `--only` recorta DESPUÉS (ver 3b),
461
+ // para que los ignores y el escáner de secretos corran igual siempre.
462
+ const { uploads: allUploads, skipped, ignored, gitDirs, findings } = preflight(dir);
297
463
 
298
464
  if (gitDirs.length > 0) {
299
465
  ui.warn(
@@ -308,24 +474,27 @@ async function push(args) {
308
474
 
309
475
  reportSkipped(skipped, { verbose });
310
476
 
311
- if (findings.length > 0 && !args['allow-secrets']) {
312
- ui.info('');
313
- reportSecrets(findings);
314
- return 1;
315
- }
316
- if (findings.length > 0) {
317
- ui.warn(
318
- `${secrets.groupByFile(findings).length} archivo(s) con posibles secretos ` +
319
- `se van a subir igual (--allow-secrets).`
320
- );
321
- }
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.
322
480
 
323
- if (uploads.length === 0) {
481
+ if (allUploads.length === 0) {
324
482
  ui.info('No hay archivos para subir.');
325
483
  ui.info(ui.c.dim(`(${ignored} ignorados, ${skipped.length} salteados)`));
326
484
  return 0;
327
485
  }
328
486
 
487
+ // 3b) `--only`: recorta QUÉ se sube, no qué se mira. El push nunca borra
488
+ // archivos remotos (su plan es added/modified/unchanged/failed), así que
489
+ // subir un subconjunto es seguro por construcción: no hay semántica nueva.
490
+ const selection = selectUploads(dir, allUploads, args.only);
491
+ if (selection.error) {
492
+ ui.error(selection.error);
493
+ return 1;
494
+ }
495
+ const uploads = selection.uploads;
496
+ const leftOut = selection.leftOut;
497
+
329
498
  // 4) Cliente + control de ritmo. `onThrottle` baja la concurrencia sola si el
330
499
  // servidor empieza a responder 429, en vez de insistir hasta que corte.
331
500
  const limit = sync.resolveConcurrency(args);
@@ -513,9 +682,44 @@ async function push(args) {
513
682
 
514
683
  config.persistCredsIfChanged(cfg, mgr.current);
515
684
 
516
- // Con token de acceso personal no sabemos el id del usuario; sólo importa
517
- // para reconocer propuestas propias que este push reemplaza.
518
- 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
+ }
519
723
 
520
724
  // 6) Reporte del plan.
521
725
  ui.info('');
@@ -533,16 +737,33 @@ async function push(args) {
533
737
  for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
534
738
  return 1;
535
739
  }
536
- if (useIndex && !dryRun) saveIndex(dir, index, plan, uploads);
740
+ if (useIndex && !dryRun) saveIndex(dir, index, plan, allUploads);
741
+ await markAlignedIfClean();
537
742
  ui.success('Todo al día. Nada para subir.');
743
+ reportLeftOut(leftOut, remoteByPath);
538
744
  return 0;
539
745
  }
540
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
+
541
761
  if (dryRun) {
542
762
  for (const u of plan.added) ui.info(` ${ui.c.green('+')} ${u.relPath}`);
543
763
  for (const u of plan.modified) ui.info(` ${ui.c.yellow('~')} ${u.relPath}`);
544
764
  for (const f of plan.failed) ui.info(` ${ui.c.red('!')} ${f.relPath} — ${f.reason}`);
545
765
  ui.info('');
766
+ reportLeftOut(leftOut, remoteByPath);
546
767
  ui.info(ui.c.dim('(--dry-run: no se subió nada)'));
547
768
  return plan.failed.length ? 1 : 0;
548
769
  }
@@ -558,6 +779,20 @@ async function push(args) {
558
779
  }
559
780
  }
560
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
+
561
796
  const pushedAt = Date.now();
562
797
 
563
798
  /** Log por archivo: en TTY lo tapa la barra de progreso, así que va sólo con --verbose. */
@@ -594,6 +829,7 @@ async function push(args) {
594
829
  throw e;
595
830
  }
596
831
  config.persistCredsIfChanged(cfg, mgr.current);
832
+ noteListing(listed);
597
833
 
598
834
  const knownBase = savedRemote && savedRemote.base ? savedRemote.base : null;
599
835
  let base = knownBase || listed.main;
@@ -642,6 +878,7 @@ async function push(args) {
642
878
  const failedWrites = [];
643
879
  const uploaded = [];
644
880
  const open = [];
881
+ const lineMerged = [];
645
882
  let applied = 0;
646
883
  let appliedCount = 0;
647
884
  let stale = 0;
@@ -711,10 +948,15 @@ async function push(args) {
711
948
  upProg.update(upDone);
712
949
  if (changes.length === 0) continue;
713
950
 
714
- 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);
715
952
  let result;
716
953
  try {
717
- result = await client.propose(projectId, { base, title, summary: '', changes }, await mgr.valid());
954
+ result = await client.propose(
955
+ projectId,
956
+ { base, title, summary: '', changes, agent, prompt: promptText, card, chequeos: checks },
957
+ await mgr.valid()
958
+ );
959
+ for (const w of result.warnings || []) ui.warn(w);
718
960
  config.persistCredsIfChanged(cfg, mgr.current);
719
961
  } catch (e) {
720
962
  // Una base que el servidor no conoce (proyecto recreado, historial
@@ -740,6 +982,7 @@ async function push(args) {
740
982
  } else if (result.applied) {
741
983
  applied += sent.length;
742
984
  appliedCount += 1;
985
+ lineMerged.push(...(result.lineas || []));
743
986
  for (const u of sent) {
744
987
  logFile(upProg, `${u.remote ? ui.c.yellow('~') : ui.c.green('+')} ${u.relPath}`);
745
988
  }
@@ -751,7 +994,7 @@ async function push(args) {
751
994
  }
752
995
  upProg.clear();
753
996
 
754
- return { failedWrites, committed: applied, commitCount: appliedCount, uploaded, open, stale, main, base };
997
+ return { failedWrites, committed: applied, commitCount: appliedCount, uploaded, open, stale, main, base, lineMerged };
755
998
  }
756
999
 
757
1000
  /**
@@ -1127,11 +1370,11 @@ async function push(args) {
1127
1370
  toUpload.length,
1128
1371
  partNo,
1129
1372
  batches.length,
1130
- card
1373
+ trailers
1131
1374
  );
1132
1375
  try {
1133
1376
  const creds = await mgr.valid();
1134
- await client.createCommit(projectId, msg, batchChanges, creds);
1377
+ await client.createCommit(projectId, msg, batchChanges, creds, { card });
1135
1378
  committed += batchChanges.length;
1136
1379
  commitCount += 1;
1137
1380
  config.persistCredsIfChanged(cfg, mgr.current);
@@ -1163,8 +1406,33 @@ async function push(args) {
1163
1406
  );
1164
1407
  outcome = await pushLegacy();
1165
1408
  }
1166
- 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 || [];
1412
+
1413
+ config.persistCredsIfChanged(cfg, mgr.current);
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
+ }
1167
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 });
1168
1436
  config.persistCredsIfChanged(cfg, mgr.current);
1169
1437
 
1170
1438
  // 10) Índice local: lo que subimos bien y lo que ya estaba igual. El próximo
@@ -1175,21 +1443,27 @@ async function push(args) {
1175
1443
  for (const u of uploaded) {
1176
1444
  pushindex.record(index, u, pushedAt);
1177
1445
  }
1178
- saveIndex(dir, index, plan, uploads, pushedAt);
1446
+ for (const u of broughtIn.written) {
1447
+ pushindex.record(index, u, pushedAt);
1448
+ }
1449
+ saveIndex(dir, index, plan, allUploads, pushedAt);
1179
1450
  }
1180
1451
 
1181
1452
  // 11) Anotar la sincronización y la base nueva. Sólo si TODO quedó en la
1182
1453
  // versión actual: con archivos fallados o propuestas abiertas, la carpeta
1183
1454
  // no está alineada, y mover la base haría que el push siguiente proponga
1184
- // esos mismos archivos como si nadie más los hubiera tocado.
1185
- const clean = failedWrites.length === 0 && plan.failed.length === 0 && open.length === 0;
1186
- 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) {
1187
1460
  remote.touchSync(dir, { projectId, baseURL, base: legacy ? null : main });
1188
1461
  }
1189
1462
 
1190
1463
  // 12) Resumen final.
1191
1464
  ui.info('');
1192
- for (const o of open) reportOpen(o);
1465
+ reportLeftOut(leftOut, remoteByPath);
1466
+ if (!openShown) for (const o of open) reportOpen(o);
1193
1467
  if (stale > 0) {
1194
1468
  ui.warn(
1195
1469
  `${stale} archivo(s) ya no cambian nada: la versión actual avanzó y lo que tenés ` +
@@ -1204,18 +1478,91 @@ async function push(args) {
1204
1478
  } else {
1205
1479
  ui.success(`Push completo: ${committed} archivo(s) ya son parte de la versión actual.`);
1206
1480
  }
1481
+ if (committed > 0 && deploy !== 'off') await reportDeploy(client, projectId, mgr, cfg);
1207
1482
  return 0;
1208
1483
  }
1209
1484
  for (const f of plan.failed) ui.error(`${f.relPath} — ${f.reason}`);
1210
- if (failedWrites.length + plan.failed.length > 0) {
1211
- ui.warn(`Push con problemas: ${failedWrites.length + plan.failed.length} archivo(s) fallaron.`);
1212
- } else {
1213
- ui.warn(
1214
- `${committed} archivo(s) subieron; ${open.reduce((n, o) => n + o.paths.length, 0)} quedaron en ` +
1215
- `${open.length} propuesta(s) abierta(s).`
1216
- );
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;
1217
1556
  }
1218
- 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)}`;
1219
1566
  }
1220
1567
 
1221
1568
  /**
@@ -1240,6 +1587,10 @@ function reportOpen(o) {
1240
1587
  : 'las dos versiones son distintas';
1241
1588
  ui.info(` ${ui.c.red('!')} ${c.path} ${ui.c.dim(`— ${why}`)}`);
1242
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);
1243
1594
  } else if (o.blockedBy.length > 0) {
1244
1595
  ui.warn('Otra propuesta abierta toca los mismos archivos:');
1245
1596
  for (const b of o.blockedBy) {
@@ -1248,15 +1599,104 @@ function reportOpen(o) {
1248
1599
  } else {
1249
1600
  ui.warn(o.message || 'La propuesta quedó abierta.');
1250
1601
  }
1251
- ui.info(
1252
- ` Tus ${o.paths.length} archivo(s) no se perdieron: quedaron como propuesta ${ui.c.bold(id)}, ` +
1253
- 'abierta para que el dueño del proyecto decida.'
1254
- );
1255
- ui.info(
1256
- ui.c.dim(' Para resolverlo vos: "shilo pull" trae la versión actual (no pisa lo tuyo sin --force), ') +
1257
- ui.c.dim('ajustá y volvé a "shilo push".')
1258
- );
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
+
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
+ });
1259
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
+ }
1260
1700
  }
1261
1701
 
1262
1702
  /**
@@ -1291,35 +1731,194 @@ async function loadContent(u) {
1291
1731
  return u.content;
1292
1732
  }
1293
1733
 
1734
+ /**
1735
+ * Lo que `--only` dejó afuera. "Una pieza = una tarjeta" sólo funciona si la
1736
+ * CLI te recuerda lo que quedó atrás.
1737
+ *
1738
+ * "Cambiado" se decide contra el ÁRBOL REMOTO —falta allá, o está con otro
1739
+ * tamaño—, que es el mismo criterio de `shilo status` y no cuesta un request
1740
+ * extra: el árbol ya se recorrió para planificar. El índice local no sirve acá:
1741
+ * `pull` no lo escribe, así que en una carpeta que se bajó de nuevo queda viejo
1742
+ * y daría por cambiado medio repo.
1743
+ *
1744
+ * Misma concesión que `status`: dos archivos del mismo tamaño se dan por
1745
+ * iguales aunque difieran byte a byte.
1746
+ */
1747
+ function reportLeftOut(leftOut, remoteByPath) {
1748
+ if (!leftOut || leftOut.length === 0) return;
1749
+
1750
+ const changed = leftOut.filter((u) => {
1751
+ const r = remoteByPath.get(u.relPath);
1752
+ if (!r) return true; // nunca subido
1753
+ return typeof r.size === 'number' && r.size !== u.size;
1754
+ });
1755
+ if (changed.length === 0) {
1756
+ ui.info(
1757
+ ui.c.dim(
1758
+ `${leftOut.length} archivo(s) quedaron fuera de este push (--only); ninguno tiene cambios sin subir.`
1759
+ )
1760
+ );
1761
+ return;
1762
+ }
1763
+
1764
+ ui.warn(`${changed.length} archivo(s) con cambios locales quedaron fuera de este push (--only):`);
1765
+ for (const u of changed.slice(0, SKIP_EXAMPLES)) ui.info(` ${ui.c.dim(u.relPath)}`);
1766
+ if (changed.length > SKIP_EXAMPLES) {
1767
+ ui.info(ui.c.dim(` … y ${changed.length - SKIP_EXAMPLES} más`));
1768
+ }
1769
+ }
1770
+
1294
1771
  /**
1295
1772
  * Registra en el índice los archivos que ya estaban idénticos (para no volver a
1296
1773
  * compararlos), saca los que ya no existen y lo escribe.
1774
+ *
1775
+ * `presentUploads` es lo que EXISTE en la carpeta, no lo que se subió en esta
1776
+ * corrida: con `--only` son cosas distintas. Podar con la selección borraría del
1777
+ * índice las entradas del resto del repo, y el push completo siguiente tendría
1778
+ * que recompararlo todo — lento, no incorrecto, pero evitable. Las entradas
1779
+ * nuevas se fusionan sobre el índice ya leído de disco, no lo reemplazan.
1297
1780
  */
1298
- function saveIndex(dir, index, plan, uploads, pushedAt = Date.now()) {
1781
+ function saveIndex(dir, index, plan, presentUploads, pushedAt = Date.now()) {
1299
1782
  for (const u of plan.unchanged) {
1300
1783
  if (u.content != null) {
1301
1784
  pushindex.record(index, { ...u, hash: pushindex.hashContent(u.content) }, pushedAt);
1302
1785
  }
1303
1786
  }
1304
- pushindex.prune(index, new Set(uploads.map((u) => u.relPath)));
1787
+ pushindex.prune(index, new Set(presentUploads.map((u) => u.relPath)));
1305
1788
  pushindex.write(dir, index);
1306
1789
  }
1307
1790
 
1308
1791
  /**
1309
- * Arma el mensaje del commit. Si vino `--card`, se agrega como trailer
1310
- * `[card: <id>]` al final: queda visible en el historial y es fácil de grepear
1311
- * para cruzar un cambio con la tarjeta que lo pidió (Metrónomo u otro tablero).
1792
+ * El bloque `agent` que se declara al proponer, o null si nadie lo declaró.
1793
+ *
1794
+ * `--model` y `--session` sin `--agent` son un error y no un descuido que se
1795
+ * pueda completar solo: decir qué modelo escribió algo sin decir qué
1796
+ * herramienta lo corrió deja una procedencia que no se puede rastrear hasta
1797
+ * ningún lado.
1798
+ */
1799
+ function buildAgent(args) {
1800
+ const name = typeof args.agent === 'string' && args.agent.trim() ? args.agent.trim() : null;
1801
+ const model = typeof args.model === 'string' && args.model.trim() ? args.model.trim() : null;
1802
+ const session = typeof args.session === 'string' && args.session.trim() ? args.session.trim() : null;
1803
+
1804
+ if (!name) {
1805
+ if (model || session) {
1806
+ return { error: 'Para declarar procedencia hace falta --agent <nombre>; --model y --session solos no alcanzan.' };
1807
+ }
1808
+ return null;
1809
+ }
1810
+
1811
+ return {
1812
+ name: name.slice(0, AGENT_FIELD_MAX),
1813
+ ...(model ? { model: model.slice(0, AGENT_FIELD_MAX) } : {}),
1814
+ ...(session ? { session_id: session.slice(0, AGENT_FIELD_MAX) } : {}),
1815
+ tool: 'push',
1816
+ };
1817
+ }
1818
+
1819
+ /**
1820
+ * El prompt que se sube, si quien llama lo pidió explícitamente.
1821
+ *
1822
+ * No se infiere del entorno ni se lee de ningún archivo por convención: un
1823
+ * prompt puede llevar adentro cualquier cosa que alguien haya pegado en su
1824
+ * terminal, y subir eso sin que nadie lo haya decidido es lo mismo que
1825
+ * filtrarlo.
1826
+ *
1827
+ * Un prompt más largo que el techo se descarta con aviso, no se trunca: medio
1828
+ * prompt es peor que ninguno porque parece completo.
1829
+ */
1830
+ function readPrompt(args, agent) {
1831
+ const file = typeof args['prompt-file'] === 'string' ? args['prompt-file'].trim() : '';
1832
+ const inline = typeof args.prompt === 'string' ? args.prompt : '';
1833
+
1834
+ if (!file && !inline) return { prompt: null, warnings: [] };
1835
+ if (file && inline) {
1836
+ return { error: 'Elegí uno: --prompt o --prompt-file, no los dos.' };
1837
+ }
1838
+ if (!agent || agent.error) {
1839
+ return { error: 'El prompt se guarda dentro de la procedencia del agente: agregá --agent <nombre>.' };
1840
+ }
1841
+
1842
+ let text = inline;
1843
+ if (file) {
1844
+ const resolved = path.resolve(file);
1845
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
1846
+ return { error: `No encontramos el archivo del prompt: ${resolved}` };
1847
+ }
1848
+ try {
1849
+ text = fs.readFileSync(resolved, 'utf8');
1850
+ } catch (e) {
1851
+ return { error: `No se pudo leer el prompt: ${e.message}` };
1852
+ }
1853
+ }
1854
+
1855
+ if (!text.trim()) return { prompt: null, warnings: [] };
1856
+
1857
+ const bytes = Buffer.byteLength(text, 'utf8');
1858
+ if (bytes > PROMPT_MAX_BYTES) {
1859
+ return {
1860
+ prompt: null,
1861
+ warnings: [
1862
+ `El prompt pesa ${Math.round(bytes / 1024)} KB y el techo es ` +
1863
+ `${PROMPT_MAX_BYTES / 1024} KB: se guarda el cambio sin él.`,
1864
+ ],
1865
+ };
1866
+ }
1867
+
1868
+ return { prompt: text, warnings: [] };
1869
+ }
1870
+
1871
+ /**
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.
1312
1876
  * El backend corta en 500 caracteres, así que recortamos el mensaje del usuario
1313
- * antes que el trailer.
1877
+ * antes que los trailers.
1878
+ *
1879
+ * Desde la Etapa 4 la tarjeta viaja ADEMÁS aparte, en `provenance.card`, que es
1880
+ * lo que se puede consultar (`GET /api/commits/<id>?card=…`). El trailer no se
1881
+ * saca ni se va a sacar: es el formato de todo lo ya guardado, y la procedencia
1882
+ * entra en el oid del commit, así que lo viejo no se puede completar.
1314
1883
  */
1315
- function buildMessage(userMsg, batchCount, total, partNo, nBatches, card) {
1884
+ function buildMessage(userMsg, batchCount, total, partNo, nBatches, trailers = []) {
1316
1885
  let base = userMsg || `shilo push: ${total} archivo(s)`;
1317
1886
  if (nBatches > 1) base = `${base} (${partNo}/${nBatches})`;
1318
- 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
+ }
1319
1893
 
1320
- const trailer = ` [card: ${card}]`;
1321
- const room = 500 - trailer.length;
1322
- 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
+ }
1323
1919
  }
1324
1920
 
1325
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;