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.
- package/README.md +19 -0
- package/bin/shilo.js +15 -1
- package/package.json +1 -1
- package/src/ShiloClient.js +45 -6
- package/src/checks.js +57 -0
- package/src/commands/deploys.js +105 -0
- package/src/commands/diff.js +1 -1
- package/src/commands/init.js +8 -0
- package/src/commands/push.js +436 -62
- package/src/commands/status.js +112 -14
- package/src/projectconfig.js +76 -0
- package/src/safelist.js +101 -0
- package/src/secrets.js +75 -9
- package/src/sync.js +52 -4
package/src/commands/push.js
CHANGED
|
@@ -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
|
-
/**
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
//
|
|
606
|
-
//
|
|
607
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1282
|
-
|
|
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 (
|
|
1308
|
-
ui.warn(`Push con problemas: ${
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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.
|
|
1530
|
-
* `[
|
|
1531
|
-
* para cruzar un cambio con la tarjeta que lo
|
|
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
|
|
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,
|
|
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 (!
|
|
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
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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;
|