shilo-cli 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +338 -255
- package/bin/shilo.js +211 -165
- package/package.json +1 -1
- package/src/ShiloClient.js +1066 -837
- package/src/args.js +32 -8
- package/src/checks.js +57 -0
- package/src/commands/deploys.js +105 -0
- package/src/commands/diff.js +19 -3
- package/src/commands/export.js +226 -0
- package/src/commands/init.js +8 -0
- package/src/commands/log.js +4 -0
- package/src/commands/pull.js +86 -2
- package/src/commands/push.js +668 -69
- package/src/commands/review.js +101 -0
- package/src/commands/status.js +148 -14
- package/src/exportgit.js +417 -0
- package/src/gitobjects.js +532 -0
- package/src/projectconfig.js +76 -0
- package/src/safelist.js +101 -0
- package/src/secrets.js +75 -9
- package/src/sync.js +56 -5
- package/src/walk.js +30 -0
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,
|
|
@@ -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
|
-
/**
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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 (
|
|
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
|
-
//
|
|
517
|
-
//
|
|
518
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
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 (
|
|
1211
|
-
ui.warn(`Push con problemas: ${
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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,
|
|
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(
|
|
1787
|
+
pushindex.prune(index, new Set(presentUploads.map((u) => u.relPath)));
|
|
1305
1788
|
pushindex.write(dir, index);
|
|
1306
1789
|
}
|
|
1307
1790
|
|
|
1308
1791
|
/**
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
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
|
|
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,
|
|
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 (!
|
|
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
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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;
|