supcoflow 0.6.0 → 0.8.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.
@@ -1,7 +1,8 @@
1
1
  import { FalloDeLaTerminal, resultadoDeFallo, SALIDA, taparSecretos } from "../contrato.js";
2
+ import { NOMBRE_DE_LA_FUENTE } from "../guardado/index.js";
2
3
  import { PLAZO_MINIMO_MS, RESERVA_PARA_CERRAR_MS, sondearUnTramo, TRAMO_MAXIMO_MS } from "../sondeo.js";
3
4
  const TECHO_DE_UN_PASO_MS = 5_000;
4
- const VOLVER_A_EMPEZAR = "Volvé a empezar con `supcoflow login --start`.";
5
+ const VOLVER_A_EMPEZAR = "Start over with `supcoflow login --start`.";
5
6
  const fallo = (codigo, error, message, extra = {}) => ({
6
7
  codigo, json: { ok: false, error, message, ...extra }, texto: message,
7
8
  });
@@ -12,7 +13,7 @@ const motivo = (e) => e instanceof FalloDeLaTerminal ? e.message : taparSecretos
12
13
  export async function reintentarPorRevocar(d, limite) {
13
14
  const origen = d.api.origin;
14
15
  const noSe = () => {
15
- d.avisar("No pude leer entera la lista de claves por revocar (el llavero no respondió); la reintento la próxima vez.");
16
+ d.avisar("I couldn't read the whole list of keys waiting to be revoked (the keychain didn't respond); I'll try again next time.");
16
17
  };
17
18
  let lectura;
18
19
  try {
@@ -54,8 +55,8 @@ export async function reintentarPorRevocar(d, limite) {
54
55
  export async function loginStart(d, flags) {
55
56
  const origen = d.api.origin;
56
57
  if (flags.dryRun) {
57
- const haria = `pedir un código de dispositivo a ${origen}`;
58
- return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `Con --dry-run no hago nada; sin él, iría a ${haria}.` };
58
+ const haria = `ask ${origen} for a device code`;
59
+ return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `With --dry-run I do nothing; without it: ${haria}.` };
59
60
  }
60
61
  await reintentarPorRevocar(d, d.ahora() + 15_000);
61
62
  let c;
@@ -75,7 +76,7 @@ export async function loginStart(d, flags) {
75
76
  await d.guardado.guardarPendiente(origen, pendiente);
76
77
  }
77
78
  catch (e) {
78
- return fallo(SALIDA.inesperado, e instanceof FalloDeLaTerminal ? e.error : "no_se_pudo_guardar", `No pude guardar el inicio de sesión en curso: ${motivo(e)}`);
79
+ return fallo(SALIDA.inesperado, e instanceof FalloDeLaTerminal ? e.error : "no_se_pudo_guardar", `I couldn't save the login in progress: ${motivo(e)}`);
79
80
  }
80
81
  return {
81
82
  codigo: SALIDA.esperandoAUnaPersona,
@@ -83,7 +84,7 @@ export async function loginStart(d, flags) {
83
84
  ok: false, esperando: "persona", verification_uri: c.verificationUri,
84
85
  verification_uri_complete: c.verificationUriComplete, user_code: c.userCode, expires_in: c.expiresIn,
85
86
  },
86
- texto: `Abrí ${c.verificationUriComplete} y confirmá el código ${c.userCode}. Después corré \`supcoflow login --wait\`.`,
87
+ texto: `Open ${c.verificationUriComplete} and confirm the code ${c.userCode}. Then run \`supcoflow login --wait\`.`,
87
88
  };
88
89
  }
89
90
  export async function loginWait(d) {
@@ -96,7 +97,7 @@ export async function loginWait(d) {
96
97
  const techo = () => Math.max(0, Math.min(TECHO_DE_UN_PASO_MS, limite - d.ahora()));
97
98
  const p = await d.guardado.leerPendiente(origen);
98
99
  if (p === null) {
99
- return fallo(SALIDA.entradaInvalida, "sin_login_en_curso", "No hay un inicio de sesión en curso: empezá con `supcoflow login --start`.");
100
+ return fallo(SALIDA.entradaInvalida, "sin_login_en_curso", "There is no login in progress: start with `supcoflow login --start`.");
100
101
  }
101
102
  // La anterior se lee ANTES de sondear, así no consume la reserva del cierre. Se pide por
102
103
  // `leerClaveCompleta` para saber de paso si SUPCOFLOW_TOKEN está definida (sin costo: la variable
@@ -110,9 +111,9 @@ export async function loginWait(d) {
110
111
  // y sin borrar el pendiente, así el mismo `--wait` sirve cuando el llavero vuelva.
111
112
  if (!lectura.completa) {
112
113
  const salida = conVariable
113
- ? "desbloqueá el llavero y volvé a llamar `supcoflow login --wait`."
114
- : "desbloqueá el llavero y volvé a llamar `supcoflow login --wait` (o, sin login, exportá SUPCOFLOW_TOKEN con una clave vigente).";
115
- return fallo(SALIDA.inesperado, "no_pude_leer_la_clave", `No pude leer tu clave guardada (el llavero no respondió), y sin saber cuál es no la puedo reemplazar sin dejarla viva: ${salida}`);
114
+ ? "unlock the keychain and run `supcoflow login --wait` again."
115
+ : "unlock the keychain and run `supcoflow login --wait` again (or, without logging in, export SUPCOFLOW_TOKEN with a valid key).";
116
+ return fallo(SALIDA.inesperado, "no_pude_leer_la_clave", `I couldn't read your saved key (the keychain didn't respond), and without knowing which one it is I can't replace it without leaving it active: ${salida}`);
116
117
  }
117
118
  const anterior = lectura.valor;
118
119
  let fin;
@@ -132,26 +133,26 @@ export async function loginWait(d) {
132
133
  return {
133
134
  codigo: SALIDA.esperandoAUnaPersona,
134
135
  json: { ok: false, esperando: "persona", user_code: fin.pendiente.userCode, verification_uri_complete: fin.pendiente.verificationUriComplete },
135
- texto: `Todavía no se confirmó. Abrí ${fin.pendiente.verificationUriComplete} y confirmá el código ${fin.pendiente.userCode}; después volvé a correr \`supcoflow login --wait\`.`,
136
+ texto: `Not confirmed yet. Open ${fin.pendiente.verificationUriComplete} and confirm the code ${fin.pendiente.userCode}; then run \`supcoflow login --wait\` again.`,
136
137
  };
137
138
  case "token": {
138
139
  const r = await reemplazarClave(d, { clave: fin.clave, equipo: fin.equipo }, anterior?.clave ?? null, limite);
139
140
  // Va acá y no adentro de `reemplazarClave` porque es acá donde se supo, antes de sondear, sin
140
141
  // gastar reserva: preguntarlo al final costaría otra lectura lenta del guardado.
141
142
  if (conVariable && r.json.guardada_en !== undefined) {
142
- d.avisar("SUPCOFLOW_TOKEN está definida y tiene prioridad sobre la clave que acabo de guardar.");
143
+ d.avisar("SUPCOFLOW_TOKEN is set and takes priority over the key I just saved.");
143
144
  }
144
145
  return r;
145
146
  }
146
147
  case "rechazado":
147
148
  await d.guardado.borrarPendiente(origen, techo());
148
- return fallo(SALIDA.haceFaltaUnaPersona, "acceso_rechazado", "Rechazaron el acceso desde la consola; tu sesión anterior, si había, sigue igual.");
149
+ return fallo(SALIDA.haceFaltaUnaPersona, "acceso_rechazado", "Access was denied in the console; your previous session, if there was one, is unchanged.");
149
150
  case "vencido":
150
151
  await d.guardado.borrarPendiente(origen, techo());
151
- return fallo(SALIDA.faltaLogin, "codigo_vencido", `El código venció. ${VOLVER_A_EMPEZAR}`);
152
+ return fallo(SALIDA.faltaLogin, "codigo_vencido", `The code has expired. ${VOLVER_A_EMPEZAR}`);
152
153
  case "invalido":
153
154
  await d.guardado.borrarPendiente(origen, techo());
154
- return fallo(SALIDA.faltaLogin, "codigo_invalido", `El código ya no vale. ${VOLVER_A_EMPEZAR}`);
155
+ return fallo(SALIDA.faltaLogin, "codigo_invalido", `The code is no longer valid. ${VOLVER_A_EMPEZAR}`);
155
156
  }
156
157
  }
157
158
  // El login de una persona frente a la terminal: los mismos dos pasos, encadenados. Sin TTY (o con
@@ -160,14 +161,14 @@ export async function loginWait(d) {
160
161
  const PAUSA_ENTRE_TRAMOS_MS = 1_000;
161
162
  export async function loginInteractivo(d, flags) {
162
163
  if (!d.esTty || flags.json) {
163
- return fallo(SALIDA.entradaInvalida, "hace_falta_tty", "Sin terminal interactiva usá `supcoflow login --start --json` y después `supcoflow login --wait --json`.");
164
+ return fallo(SALIDA.entradaInvalida, "hace_falta_tty", "Without an interactive terminal, use `supcoflow login --start --json` and then `supcoflow login --wait --json`.");
164
165
  }
165
166
  const inicio = await loginStart(d, flags);
166
167
  if (inicio.codigo !== SALIDA.esperandoAUnaPersona)
167
168
  return inicio;
168
169
  // El texto de `--start` le dice a la persona que corra `--wait`; acá eso lo hace el comando solo.
169
170
  const { verification_uri_complete: url, user_code: codigo } = inicio.json;
170
- d.mostrar(`Abrí ${String(url)} y confirmá el código ${String(codigo)}. Espero acá…`);
171
+ d.mostrar(`Open ${String(url)} and confirm the code ${String(codigo)}. I'll wait here...`);
171
172
  // Cada tramo termina por su cuenta (≤ 90 s) y el código vence a los pocos minutos, así que el
172
173
  // bucle tiene fin aunque la persona nunca conteste: termina en `codigo_vencido`.
173
174
  let r;
@@ -206,9 +207,9 @@ export async function reemplazarClave(d, nueva, anterior, limite) {
206
207
  revocada = false;
207
208
  }
208
209
  const despues = revocada
209
- ? "La revoqué para que no quede viva; tu sesión anterior, si había, sigue como estaba."
210
- : "Tampoco pude revocarla: revocala en la consola, en «Accesos desde la terminal».";
211
- return fallo(SALIDA.inesperado, "no_se_pudo_guardar", `No pude guardar la clave nueva (${motivo(e)}). ${despues}`);
210
+ ? "I revoked it so it doesn't stay active; your previous session, if there was one, is as it was."
211
+ : "I couldn't revoke it either: revoke it in the console, under \"Accesos desde la terminal\" (the console is in Spanish).";
212
+ return fallo(SALIDA.inesperado, "no_se_pudo_guardar", `I couldn't save the new key (${motivo(e)}). ${despues}`);
212
213
  }
213
214
  // 2. Revocar la anterior, o anotarla.
214
215
  let anteriorRevocada = null;
@@ -228,10 +229,10 @@ export async function reemplazarClave(d, nueva, anterior, limite) {
228
229
  try {
229
230
  await d.guardado.anotarPorRevocar(origen, anterior, techo());
230
231
  quedaPorRevocar = true;
231
- d.avisar("No pude revocar tu clave anterior; quedó anotada y la reintento en el próximo login o logout.");
232
+ d.avisar("I couldn't revoke your previous key; it's noted down and I'll try again on the next login or logout.");
232
233
  }
233
234
  catch {
234
- const message = "No pude revocar ni anotar tu clave anterior: revocala en la consola, en «Accesos desde la terminal».";
235
+ const message = "I couldn't revoke or note down your previous key: revoke it in the console, under \"Accesos desde la terminal\" (the console is in Spanish).";
235
236
  d.avisar(message);
236
237
  if (queda() >= PLAZO_MINIMO_MS)
237
238
  await d.guardado.borrarPendiente(origen, techo());
@@ -245,10 +246,10 @@ export async function reemplazarClave(d, nueva, anterior, limite) {
245
246
  // 3. Borrar el pendiente; su `false` no cambia nada.
246
247
  if (queda() >= PLAZO_MINIMO_MS)
247
248
  await d.guardado.borrarPendiente(origen, techo());
248
- const donde = guardadaEn === "llavero" ? "el llavero del sistema" : "el archivo de credenciales";
249
+ const donde = guardadaEn === "llavero" ? NOMBRE_DE_LA_FUENTE.llavero : NOMBRE_DE_LA_FUENTE.archivo;
249
250
  return {
250
251
  codigo: SALIDA.ok,
251
252
  json: { ok: true, equipo: nueva.equipo, guardada_en: guardadaEn, anterior_revocada: anteriorRevocada, queda_por_revocar: quedaPorRevocar },
252
- texto: `Sesión iniciada en el equipo ${nueva.equipo}; la clave quedó en ${donde}.`,
253
+ texto: `Logged in to team ${nueva.equipo}; the key is saved in ${donde}.`,
253
254
  };
254
255
  }
@@ -2,6 +2,7 @@
2
2
  // (Review Focus 9 y 14). Borrar sin revocar dejaría la clave sirviendo en el motor y sin nadie que
3
3
  // la recuerde: por eso, si el motor no confirma, no se borra nada y el comando sale 1.
4
4
  import { FalloDeLaTerminal, SALIDA, taparSecretos } from "../contrato.js";
5
+ import { NOMBRE_DE_LA_FUENTE } from "../guardado/index.js";
5
6
  import { reintentarPorRevocar } from "./login.js";
6
7
  const motivo = (e) => e instanceof FalloDeLaTerminal ? e.message : taparSecretos(e instanceof Error ? e.message : String(e));
7
8
  export async function comandoLogout(d, flags) {
@@ -9,7 +10,7 @@ export async function comandoLogout(d, flags) {
9
10
  // Sin nada legible y un almacén que no contestó, no se sabe si hay sesión: decir «no había»
10
11
  // dejaría creer que no queda ninguna clave viva (Review Focus 12).
11
12
  const noPudeLeer = (extra = {}) => {
12
- const message = "No pude leer la clave guardada (el llavero no respondió); no cerré nada. Probá de nuevo.";
13
+ const message = "I couldn't read the saved key (the keychain didn't respond); I didn't log out. Try again.";
13
14
  return { codigo: SALIDA.inesperado, json: { ok: false, error: "no_pude_leer_la_clave", message, ...extra }, texto: message };
14
15
  };
15
16
  if (flags.dryRun) {
@@ -18,20 +19,20 @@ export async function comandoLogout(d, flags) {
18
19
  if (vigente === null && !lectura.completa)
19
20
  return noPudeLeer({ dry_run: true });
20
21
  if (vigente === null) {
21
- return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, habia_sesion: false }, texto: "No hay sesión iniciada: no haría nada." };
22
+ return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, habia_sesion: false }, texto: "You are not logged in: there is nothing to do." };
22
23
  }
23
- const haria = `revocar la clave de ${vigente.fuente} en ${origen}` + (vigente.fuente !== "env" ? " y borrarla" : "");
24
- return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `Con --dry-run no hago nada; sin él, iría a ${haria}.` };
24
+ const haria = `revoke the key in ${NOMBRE_DE_LA_FUENTE[vigente.fuente]} at ${origen}` + (vigente.fuente !== "env" ? " and delete it" : "");
25
+ return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `With --dry-run I do nothing; without it: ${haria}.` };
25
26
  }
26
27
  // Lo pendiente de antes va primero: un logout es justo cuando la persona espera que no quede
27
28
  // ninguna clave suya viva, y es la última oportunidad que tiene la terminal de revocarlas.
28
29
  const { quedan } = await reintentarPorRevocar(d, d.ahora() + 15_000);
29
30
  // `null` es «no lo pude comprobar» y se dice así: un 0 haría creer que no queda nada vivo.
30
31
  if (quedan === null) {
31
- d.avisar("No pude comprobar cuántas claves quedan por revocar; las que haya siguen anotadas y las reintento en el próximo login o logout.");
32
+ d.avisar("I couldn't check how many keys are left to revoke; any that exist are still noted down and I'll try again on the next login or logout.");
32
33
  }
33
34
  else if (quedan > 0) {
34
- d.avisar(`Quedan ${quedan} clave(s) anterior(es) sin revocar; siguen anotadas y las reintento en el próximo login o logout.`);
35
+ d.avisar(`${quedan} previous key(s) are still not revoked; they are noted down and I'll try again on the next login or logout.`);
35
36
  }
36
37
  const lectura = await d.guardado.leerClaveCompleta(origen);
37
38
  const vigente = lectura.valor;
@@ -40,22 +41,22 @@ export async function comandoLogout(d, flags) {
40
41
  const conPendientes = (json) => ({ ...json, quedan_por_revocar: quedan, ...(lectura.completa ? {} : { lectura_completa: false }) });
41
42
  if (vigente === null) {
42
43
  await d.guardado.borrarPendiente(origen);
43
- return { codigo: SALIDA.ok, json: conPendientes({ ok: true, habia_sesion: false }), texto: "No había sesión iniciada." };
44
+ return { codigo: SALIDA.ok, json: conPendientes({ ok: true, habia_sesion: false }), texto: "You were not logged in." };
44
45
  }
45
46
  let revocada;
46
47
  try {
47
48
  revocada = (await d.revocarPropia(d.api, vigente.clave)) === "revocada";
48
49
  }
49
50
  catch (e) {
50
- const cual = vigente.fuente === "env" ? "la clave de SUPCOFLOW_TOKEN sigue viva" : "la clave sigue guardada";
51
- const message = `No pude revocar la clave, así que no cerré la sesión (${motivo(e)}); ${cual}. Probá de nuevo.`;
51
+ const cual = vigente.fuente === "env" ? "the key in SUPCOFLOW_TOKEN is still active" : "the key is still saved";
52
+ const message = `I couldn't revoke the key, so I didn't log out (${motivo(e)}); ${cual}. Try again.`;
52
53
  const error = e instanceof FalloDeLaTerminal ? e.error : "inesperado";
53
54
  return { codigo: SALIDA.inesperado, json: conPendientes({ ok: false, error, message }), texto: message };
54
55
  }
55
56
  // La variable no es nuestra: se revoca la clave que trae, pero lo guardado es OTRA credencial,
56
57
  // que sólo se cierra revocándola a ella. Borrarla acá la dejaría viva y olvidada.
57
58
  if (vigente.fuente === "env") {
58
- d.avisar("Revoqué la clave de SUPCOFLOW_TOKEN; sacala de tu entorno.");
59
+ d.avisar("I revoked the key in SUPCOFLOW_TOKEN; remove it from your environment.");
59
60
  // `undefined` = no se pudo leer: no se afirma que no haya, el json lleva `null` en vez de mentir.
60
61
  let guardada;
61
62
  try {
@@ -66,19 +67,19 @@ export async function comandoLogout(d, flags) {
66
67
  guardada = undefined;
67
68
  }
68
69
  if (guardada) {
69
- d.avisar(`Sigue guardada otra clave en ${guardada.fuente}; para cerrarla, corré \`supcoflow logout\` sin SUPCOFLOW_TOKEN.`);
70
+ d.avisar(`Another key is still saved in ${NOMBRE_DE_LA_FUENTE[guardada.fuente]}; to log it out, run \`supcoflow logout\` without SUPCOFLOW_TOKEN.`);
70
71
  }
71
72
  return {
72
73
  codigo: SALIDA.ok,
73
74
  json: conPendientes({ ok: true, revocada, fuente: "env", queda_clave_guardada: guardada === undefined ? null : guardada !== null }),
74
- texto: "Revoqué la clave de SUPCOFLOW_TOKEN.",
75
+ texto: "I revoked the key in SUPCOFLOW_TOKEN.",
75
76
  };
76
77
  }
77
78
  // Con la lectura incompleta se borra sólo la copia que se leyó y se acaba de revocar: la del
78
79
  // almacén que no contestó puede ser otra clave, viva, y borrarla sin revocarla la olvidaría.
79
80
  if (!lectura.completa) {
80
- const otro = vigente.fuente === "archivo" ? "el llavero del sistema" : "el archivo de credenciales";
81
- d.avisar(`Cierro la clave de ${vigente.fuente}, pero no pude leer ${otro}: puede quedar otra copia guardada ahí; cuando responda, volvé a correr \`supcoflow logout\`.`);
81
+ const otro = vigente.fuente === "archivo" ? NOMBRE_DE_LA_FUENTE.llavero : NOMBRE_DE_LA_FUENTE.archivo;
82
+ d.avisar(`I'm logging out the key in ${NOMBRE_DE_LA_FUENTE[vigente.fuente]}, but I couldn't read ${otro}: another copy may still be saved there; when it responds, run \`supcoflow logout\` again.`);
82
83
  }
83
84
  try {
84
85
  if (lectura.completa)
@@ -88,9 +89,9 @@ export async function comandoLogout(d, flags) {
88
89
  }
89
90
  catch (e) {
90
91
  // La clave ya no sirve en el motor, pero quedó escrita: el comando no puede decir que terminó bien.
91
- const message = `La clave quedó revocada pero no pude borrarla: ${motivo(e)}`;
92
+ const message = `The key was revoked but I couldn't delete it: ${motivo(e)}`;
92
93
  return { codigo: SALIDA.inesperado, json: conPendientes({ ok: false, error: "no_se_pudo_borrar", message }), texto: message };
93
94
  }
94
95
  await d.guardado.borrarPendiente(origen);
95
- return { codigo: SALIDA.ok, json: conPendientes({ ok: true, revocada }), texto: "Sesión cerrada." };
96
+ return { codigo: SALIDA.ok, json: conPendientes({ ok: true, revocada }), texto: "Logged out." };
96
97
  }
@@ -0,0 +1,359 @@
1
+ import { resultadoDeFallo, SALIDA } from "../contrato.js";
2
+ import { agruparPuntos, contarBuenos, lineaDeLoRevisado, nombreDelPunto, puntoDeSesion, puntoDelRepo, TOTAL_DE_INSTALACION } from "../diagnostico.js";
3
+ import { leerRepoLocal } from "../repo-local.js";
4
+ import { PLAZO_MINIMO_DE_SONDEO_MS, TRAMO_MAXIMO_MS } from "../sondeo.js";
5
+ import { fallo, rutaDe } from "./configurar.js";
6
+ import { proyectoDelRepo } from "./proyecto-del-repo.js";
7
+ // Las cotas de `BugReportSchema` del motor (D2): el mensaje es el cuerpo del reporte. Se mira acá para no
8
+ // gastar un pedido (ni guardar un intento que el motor va a rechazar); la regla de verdad es la del motor.
9
+ const MAX_MENSAJE = 20_000;
10
+ const ID_DE_PRUEBA = /^prb_[0-9a-f]{16}$/;
11
+ const TECHO = /^\d+(?:\.\d{1,4})?$/;
12
+ const INTERVALO_DE_SEGUIMIENTO_MS = 5_000;
13
+ const PLAZO_MAXIMO_DE_SONDEO_MS = 15_000;
14
+ // La tabla de la prueba (brief M7, «los estados»): qué código sale en cada estado. Lo que no está acá es
15
+ // un estado que esta terminal no conoce: sale 1, nunca se adivina.
16
+ const ABIERTOS = new Set(["en_curso", "creada"]);
17
+ const CON_DESENLACE_OK = new Set(["issue_creado", "guardado"]);
18
+ const PIDEN_UNA_PERSONA = new Set([
19
+ "issue_con_cierre_anomalo", "fallido", "cierre_sin_evidencia", "reclamado_sin_run", "arranque_incierto", "esperando_cupo", "sin_despacho",
20
+ ]);
21
+ const usd = (n) => `US$ ${n.toFixed(2)}`;
22
+ const usdONull = (n) => (n === null ? "unmeasured" : usd(n));
23
+ // La política como se dice en inglés; a la API siempre viaja la suya (`relevar` / `guardar`).
24
+ const POLITICA_EN_INGLES = { relevar: "investigate", guardar: "save" };
25
+ const enInglesLaPolitica = (p) => (p === null ? "none" : (POLITICA_EN_INGLES[p] ?? p));
26
+ const aLaPoliticaDeLaApi = (p) => p === "investigate" || p === "relevar" ? "relevar" : p === "save" || p === "guardar" ? "guardar" : null;
27
+ // El comando exacto para retomar un pedido guardado: con lo que se autorizó, no con lo que haya ahora. Un pedido SIN
28
+ // política (lo guardó la 0.7.0, o se pidió con el alias viejo) sólo se retoma por el alias: el comando canónico exige
29
+ // `--expect-policy` (3 falta_politica) y, con una política, ya no es el mismo pedido (4 intento_pendiente). No se inventa
30
+ // una política para él: cambiaría su cuerpo y su clave.
31
+ const comandoDeReintento = (i) => i.politicaEsperada === null
32
+ ? `supcoflow probar --autorizo-gasto --ceiling ${i.techoUsd}`
33
+ : `supcoflow test --authorize-spend --ceiling ${i.techoUsd} --expect-policy ${enInglesLaPolitica(i.politicaEsperada)}`;
34
+ function datosDelDiagnostico(g) {
35
+ return {
36
+ listo: g.listo,
37
+ puntos: g.puntos,
38
+ politica: g.politica,
39
+ techo_usd: g.techoUsd,
40
+ saldo_usd: g.saldoUsd,
41
+ tope_usd: g.topeUsd,
42
+ comprometido_usd: g.comprometidoUsd,
43
+ prueba_abierta: g.pruebaAbierta,
44
+ };
45
+ }
46
+ const lineasDePuntos = (puntos, nombre = (p) => p) => puntos.map((p) => ` [${p.ok ? "ok" : "missing"}] ${nombre(p.punto)}: ${p.mensaje}`);
47
+ export async function diagnosticar(d, flags) {
48
+ const p = await proyectoDelRepo(d, flags);
49
+ if (!p.ok)
50
+ return p.resultado;
51
+ let g;
52
+ try {
53
+ g = await d.leerDiagnostico(d.api, p.clave, p.proyecto.id);
54
+ }
55
+ catch (e) {
56
+ return resultadoDeFallo(e);
57
+ }
58
+ // Los dos puntos que sólo la terminal puede ver: la llamada de arriba autenticó (si no, no se llega acá) y el
59
+ // `origin` de esta carpeta es el repo del proyecto. Sin git o sin origin no se adivina: el punto sale no ok.
60
+ let local;
61
+ try {
62
+ local = await leerRepoLocal(d.git, d.cwd);
63
+ }
64
+ catch {
65
+ local = { repo: null, raiz: null, motivo: "sin_git" };
66
+ }
67
+ const { instalacion, prueba } = agruparPuntos(g, [puntoDeSesion(), puntoDelRepo(local, p.proyecto.repo)]);
68
+ const buenos = contarBuenos(instalacion);
69
+ const listo = g.listo && instalacion.every((x) => x.ok || !["sign-in", "repo"].includes(x.punto));
70
+ const datos = {
71
+ proyecto: { id: p.proyecto.id, repo: p.proyecto.repo },
72
+ ...datosDelDiagnostico(g),
73
+ listo,
74
+ puntos: [...instalacion, ...prueba],
75
+ instalacion: { ok: buenos, total: TOTAL_DE_INSTALACION, puntos: instalacion },
76
+ prueba: { listo: g.listo, puntos: prueba },
77
+ };
78
+ const resumen = `Monthly balance ${usdONull(g.saldoUsd)} of ${usdONull(g.topeUsd)}; a test authorizes up to ${usd(g.techoUsd)}. Policy: ${enInglesLaPolitica(g.politica)}.`;
79
+ const motivos = [
80
+ ...prueba.filter((x) => !x.ok).map((x) => x.mensaje),
81
+ ...(buenos < TOTAL_DE_INSTALACION ? [`the installation isn't complete (${buenos} of ${TOTAL_DE_INSTALACION})`] : []),
82
+ ];
83
+ const listaParaProbar = listo ? "Ready for a test: yes." : `Ready for a test: no, because ${motivos.join("; ") || "the engine says it isn't ready"}`;
84
+ const cuerpo = [
85
+ lineaDeLoRevisado(buenos),
86
+ ...lineasDePuntos(instalacion, nombreDelPunto),
87
+ listaParaProbar,
88
+ ...lineasDePuntos(prueba),
89
+ resumen,
90
+ ];
91
+ if (listo)
92
+ return { codigo: SALIDA.ok, json: { ok: true, ...datos }, texto: cuerpo.join("\n") };
93
+ const message = `Something needs fixing before testing ${p.proyecto.repo}.`;
94
+ return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "no_listo", message, ...datos }, texto: [message, ...cuerpo].join("\n") };
95
+ }
96
+ export async function probar(d, flags) {
97
+ // El reloj del tramo arranca con el comando: los 90 s son del comando entero, no sólo de los sondeos.
98
+ const inicio = d.ahora();
99
+ // Todo lo que se puede rechazar sin mirar nada, antes de tocar el motor o lo guardado.
100
+ if (flags.wait && (flags.authorizeSpend || flags.message !== null || flags.ceiling !== null || flags.expectPolicy !== null)) {
101
+ return fallo(SALIDA.entradaInvalida, "flags_incompatibles", "`test --wait` only follows a test: no --authorize-spend, --ceiling, --expect-policy or --message. First `supcoflow test --authorize-spend --ceiling <usd> --expect-policy <policy>`, then `supcoflow test --wait`.");
102
+ }
103
+ if (!flags.wait && flags.testId !== null) {
104
+ return fallo(SALIDA.entradaInvalida, "flags_incompatibles", "--test-id goes with --wait: `supcoflow test --wait --test-id prb_…`.");
105
+ }
106
+ if (!flags.authorizeSpend && !flags.wait && (flags.ceiling !== null || flags.expectPolicy !== null)) {
107
+ return fallo(SALIDA.entradaInvalida, "flags_incompatibles", "--ceiling and --expect-policy go with --authorize-spend: they say what you authorize.");
108
+ }
109
+ if (flags.testId !== null && !ID_DE_PRUEBA.test(flags.testId)) {
110
+ return fallo(SALIDA.entradaInvalida, "prueba_invalida", "--test-id is the id `supcoflow test` printed (prb_ and 16 characters).");
111
+ }
112
+ let mensaje = null;
113
+ if (flags.message !== null) {
114
+ mensaje = flags.message.trim();
115
+ if (mensaje.length === 0 || mensaje.length > MAX_MENSAJE) {
116
+ return fallo(SALIDA.entradaInvalida, "mensaje_invalido", `--message goes with text, up to ${MAX_MENSAJE} characters.`);
117
+ }
118
+ }
119
+ // Lo que la persona autorizó: el techo y la política que vio. Sin eso no hay pedido que valga (R5).
120
+ let autorizado = null;
121
+ if (flags.authorizeSpend) {
122
+ if (flags.ceiling === null) {
123
+ const comoSeEscribe = "`supcoflow test --authorize-spend --ceiling <usd> --expect-policy investigate|save`";
124
+ return fallo(SALIDA.entradaInvalida, "falta_techo", flags.viejo === true
125
+ ? `\`probar --autorizo-gasto\` now needs --ceiling <usd>, the number you were shown: an alias can't widen what you authorized. Use ${comoSeEscribe}. (The old behaviour is in supcoflow 0.7.0.)`
126
+ : `--authorize-spend needs --ceiling <usd>, the number you were shown: ${comoSeEscribe}.`);
127
+ }
128
+ if (!TECHO.test(flags.ceiling) || !(Number(flags.ceiling) > 0)) {
129
+ return fallo(SALIDA.entradaInvalida, "techo_invalido", "--ceiling is a positive number of US dollars, for example 2 or 2.50.");
130
+ }
131
+ let politicaEsperada = null;
132
+ if (flags.expectPolicy !== null) {
133
+ politicaEsperada = aLaPoliticaDeLaApi(flags.expectPolicy);
134
+ if (politicaEsperada === null)
135
+ return fallo(SALIDA.entradaInvalida, "politica_invalida", "--expect-policy is `investigate` or `save`.");
136
+ }
137
+ else if (flags.viejo !== true) {
138
+ return fallo(SALIDA.entradaInvalida, "falta_politica", "--authorize-spend needs --expect-policy investigate|save: the policy you were offered, so a yes to a test that won't investigate can't turn into a paid one.");
139
+ }
140
+ autorizado = { techoUsd: Number(flags.ceiling), politicaEsperada };
141
+ }
142
+ const p = await proyectoDelRepo(d, flags);
143
+ if (!p.ok)
144
+ return p.resultado;
145
+ const proyecto = p.proyecto;
146
+ const clave = p.clave;
147
+ if (flags.wait)
148
+ return seguir(d, clave, proyecto.id, flags.testId, inicio);
149
+ // ── Sin el flag, o con --dry-run: sólo el diagnóstico (gratis). Nada que escriba, ni acá ni en el motor.
150
+ if (autorizado === null || flags.dryRun) {
151
+ let g;
152
+ try {
153
+ g = await d.leerDiagnostico(d.api, clave, proyecto.id);
154
+ }
155
+ catch (e) {
156
+ return resultadoDeFallo(e);
157
+ }
158
+ const datos = { proyecto: { id: proyecto.id, repo: proyecto.repo }, ...datosDelDiagnostico(g) };
159
+ const gasto = g.politica === "guardar"
160
+ ? `With the \`save\` policy the test goes as far as the saved case and spends nothing; the ceiling it authorizes is still ${usd(g.techoUsd)}.`
161
+ : `A test runs an investigation that spends up to ${usd(g.techoUsd)} (monthly balance ${usdONull(g.saldoUsd)} of ${usdONull(g.topeUsd)}).`;
162
+ if (flags.dryRun) {
163
+ // Lo guardado se mira sólo para decir qué haría; si no se puede leer, el dry-run igual contesta.
164
+ let pendiente = null;
165
+ try {
166
+ pendiente = (await d.pruebas.leer(proyecto.id)).intento !== null;
167
+ }
168
+ catch {
169
+ pendiente = null;
170
+ }
171
+ const ruta = rutaDe(proyecto, "/pruebas");
172
+ const cuerpo = autorizado === null ? `techo_usd: ${g.techoUsd}` : `techo_usd: ${autorizado.techoUsd}${autorizado.politicaEsperada !== null ? `, politica_esperada: ${autorizado.politicaEsperada}` : ""}`;
173
+ const haria = pendiente
174
+ ? `would resend the saved request that was never confirmed (POST ${ruta}, the same Idempotency-Key)`
175
+ : `would send POST ${ruta} {${cuerpo}${mensaje !== null ? ", mensaje" : ""}} with a new Idempotency-Key`;
176
+ return {
177
+ codigo: SALIDA.ok,
178
+ json: { ok: true, dry_run: true, autorizado: flags.authorizeSpend, intento_pendiente: pendiente, haria, ...datos },
179
+ texto: `With --dry-run I do nothing. ${flags.authorizeSpend ? "Without --dry-run, I" : "With --authorize-spend and without --dry-run, I"}${haria.replace(/^would/, "")} (project ${proyecto.repo}, policy ${enInglesLaPolitica(g.politica)}). ${gasto}`,
180
+ };
181
+ }
182
+ const lanzar = `supcoflow test --authorize-spend --ceiling ${g.techoUsd} --expect-policy ${enInglesLaPolitica(g.politica)}`;
183
+ const message = `${gasto} To launch it: \`${lanzar}\`. \`supcoflow diagnose\` first says whether everything is ready.`;
184
+ return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "falta_autorizacion", message, ...datos }, texto: message };
185
+ }
186
+ // ── Con --authorize-spend: el ciclo de la clave.
187
+ let intento;
188
+ try {
189
+ const guardado = await d.pruebas.leer(proyecto.id);
190
+ if (guardado.intento !== null) {
191
+ const previo = guardado.intento;
192
+ const igual = Math.abs(previo.techoUsd - autorizado.techoUsd) < 1e-9 && previo.politicaEsperada === autorizado.politicaEsperada;
193
+ if (mensaje !== null && mensaje !== previo.mensaje) {
194
+ return fallo(SALIDA.haceFaltaUnaPersona, "intento_pendiente", `There's a test request waiting for confirmation; repeat \`${comandoDeReintento(previo)}\` without --message to recover it.`);
195
+ }
196
+ if (!igual) {
197
+ return fallo(SALIDA.haceFaltaUnaPersona, "intento_pendiente", `There's a test request waiting for confirmation, authorized with a ceiling of ${usd(previo.techoUsd)}; repeat it with exactly that to recover it: \`${comandoDeReintento(previo)}\`.`);
198
+ }
199
+ intento = previo;
200
+ }
201
+ else {
202
+ // Lo que se manda es lo que la persona vio (R5): no se relee el diagnóstico para elegirlo. El motor compara
203
+ // el techo y la política con los de ahora y rechaza si cambiaron.
204
+ intento = { clave: d.generar(), mensaje, techoUsd: autorizado.techoUsd, politicaEsperada: autorizado.politicaEsperada };
205
+ await d.pruebas.abrir(proyecto.id, intento);
206
+ }
207
+ }
208
+ catch (e) {
209
+ return resultadoDeFallo(e);
210
+ }
211
+ const r = await d.pedirPrueba(d.api, clave, proyecto.id, intento);
212
+ if (r.tipo === "aceptada") {
213
+ try {
214
+ await d.pruebas.confirmar(proyecto.id, r.pruebaId);
215
+ }
216
+ catch {
217
+ const message = `The engine accepted test ${r.pruebaId}, but I couldn't save it on this machine. Repeat \`${comandoDeReintento(intento)}\` (it recovers the same one) or follow it with \`supcoflow test --wait --test-id ${r.pruebaId}\`.`;
218
+ return { codigo: SALIDA.inesperado, json: { ok: false, error: "prueba_no_guardada", message, prueba_id: r.pruebaId, intento_pendiente: true }, texto: message };
219
+ }
220
+ return {
221
+ codigo: SALIDA.esperandoAUnaPersona,
222
+ json: { ok: false, esperando: "relevamiento", prueba_id: r.pruebaId, reporte_id: r.reporteId, estado: r.estado },
223
+ texto: `Test ${r.pruebaId} requested (${r.estado}). Follow it with \`supcoflow test --wait\`.`,
224
+ };
225
+ }
226
+ if (r.tipo === "ambigua") {
227
+ const message = `${r.message} The request was saved: repeat \`${comandoDeReintento(intento)}\` and I'll pick up the same test, without asking for another.`;
228
+ return { codigo: SALIDA.inesperado, json: { ok: false, error: r.error, message, intento_pendiente: true }, texto: message };
229
+ }
230
+ // Un rechazo definitivo: el motor no creó nada con esta clave, así que el intento se cierra.
231
+ try {
232
+ await d.pruebas.descartar(proyecto.id);
233
+ }
234
+ catch {
235
+ d.avisar("I couldn't close the saved request; the next `test --authorize-spend` will resend it.");
236
+ }
237
+ return rechazo(r, intento, d.avisar);
238
+ }
239
+ function enCurso(pruebaId) {
240
+ const message = `This project already has a test with no outcome (${pruebaId}). Follow it with \`supcoflow test --wait --test-id ${pruebaId}\`.`;
241
+ return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "prueba_en_curso", message, prueba_id: pruebaId }, texto: message };
242
+ }
243
+ function rechazo(r, intento, avisar) {
244
+ const c = r.cuerpo;
245
+ switch (r.error) {
246
+ case "prueba_en_curso":
247
+ return typeof c.prueba_id === "string" ? enCurso(c.prueba_id) : fallo(SALIDA.haceFaltaUnaPersona, r.error, r.message);
248
+ case "techo_distinto": {
249
+ // Sin reintentar: lo que se autorizó ya no es lo que hay, y decidir de nuevo es de la persona (con el número nuevo).
250
+ const nuevo = typeof c.techo_usd === "number" ? c.techo_usd : null;
251
+ const message = `The ceiling for a test changed${nuevo !== null ? `: it's now ${usd(nuevo)}` : ""}, and you authorized ${usd(intento.techoUsd)}. Nothing was started. Ask again with the new number.`;
252
+ return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: r.error, message, techo_usd: nuevo, autorizado_usd: intento.techoUsd }, texto: message };
253
+ }
254
+ case "politica_distinta": {
255
+ const ahora = typeof c.politica === "string" ? c.politica : typeof c.politica_actual === "string" ? c.politica_actual : null;
256
+ const message = `The project's policy changed${ahora !== null ? `: it's now \`${enInglesLaPolitica(ahora)}\`` : ""}, and you authorized a test under \`${enInglesLaPolitica(intento.politicaEsperada)}\`. Nothing was started. Ask again with the current policy.`;
257
+ return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: r.error, message, politica: ahora, politica_esperada: intento.politicaEsperada }, texto: message };
258
+ }
259
+ case "tope_mensual":
260
+ return {
261
+ codigo: SALIDA.topeAlcanzado,
262
+ json: { ok: false, error: r.error, message: r.message, comprometido_usd: c.comprometido_usd ?? null, tope_usd: c.tope_usd ?? null },
263
+ texto: r.message,
264
+ };
265
+ case "requiere_api_key": {
266
+ const message = `The test is requested with your personal API key (the one \`supcoflow login\` leaves), not with a read-only key or the console session. ${r.message}`;
267
+ return fallo(SALIDA.haceFaltaUnaPersona, r.error, message);
268
+ }
269
+ case "idempotency_key_reusada":
270
+ // No debería pasar: siempre se reenvía lo guardado con su clave. Si pasa, la clave ya no sirve.
271
+ avisar("The engine says this request's key was already used with other content; I discarded it.");
272
+ return fallo(SALIDA.inesperado, r.error, `${r.message} Run \`${comandoDeReintento(intento)}\` again to ask for a new test.`);
273
+ }
274
+ if (r.status === 401)
275
+ return fallo(SALIDA.faltaLogin, "clave_invalida", r.message);
276
+ if (r.status === 403 || r.error === "chat_deshabilitado")
277
+ return fallo(SALIDA.haceFaltaUnaPersona, r.error, r.message);
278
+ return fallo(SALIDA.entradaInvalida, r.error, r.message);
279
+ }
280
+ function textoDelCobro(c) {
281
+ // Pendiente y perdido nunca se muestran como cero: el monto todavía no se sabe, la cota es el techo.
282
+ if (c.estado === "leido" && c.usd !== null)
283
+ return `Cost: ${usd(c.usd)}.`;
284
+ if (c.estado === "pendiente")
285
+ return `Charge pending: it's read a few minutes after the run closes (up to ${usd(c.techo_usd)}).`;
286
+ if (c.estado === "perdido")
287
+ return `Charge lost: the spend couldn't be read (up to ${usd(c.techo_usd)}).`;
288
+ if (c.estado === null)
289
+ return null;
290
+ return `Charge: ${c.estado} (up to ${usd(c.techo_usd)}).`;
291
+ }
292
+ function resultadoDelSeguimiento(s) {
293
+ const datos = {
294
+ prueba_id: s.pruebaId, estado: s.estado, abierta: s.abierta, mensaje: s.mensaje,
295
+ reporte: s.reporte, run: s.run, issue: s.issue, cobro: s.cobro,
296
+ };
297
+ const lineas = [
298
+ `Test ${s.pruebaId}: ${s.estado}.${s.mensaje ? ` ${s.mensaje}` : ""}`,
299
+ s.issue ? `Issue #${s.issue.numero}: ${s.issue.url}` : null,
300
+ s.run?.motivo ? `Run reason: ${s.run.motivo}` : null,
301
+ textoDelCobro(s.cobro),
302
+ ];
303
+ if (ABIERTOS.has(s.estado)) {
304
+ lineas.push("Run `supcoflow test --wait` again.");
305
+ return { codigo: SALIDA.esperandoAUnaPersona, json: { ok: false, esperando: "relevamiento", ...datos }, texto: lineas.filter(Boolean).join("\n") };
306
+ }
307
+ if (CON_DESENLACE_OK.has(s.estado))
308
+ return { codigo: SALIDA.ok, json: { ok: true, ...datos }, texto: lineas.filter(Boolean).join("\n") };
309
+ if (PIDEN_UNA_PERSONA.has(s.estado)) {
310
+ if (s.reporte)
311
+ lineas.push(`The case in the console: ${s.reporte.url}`);
312
+ const texto = lineas.filter(Boolean).join("\n");
313
+ return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: s.estado, message: s.mensaje ?? texto, ...datos }, texto };
314
+ }
315
+ const message = `The engine says the test is in «${s.estado}», a state this version of the terminal doesn't know. Update supcoflow or look at it in the console.`;
316
+ return { codigo: SALIDA.inesperado, json: { ok: false, error: "estado_desconocido", message, ...datos }, texto: message };
317
+ }
318
+ async function seguir(d, clave, proyectoId, elegida, inicio) {
319
+ let pruebaId = elegida;
320
+ if (pruebaId === null) {
321
+ try {
322
+ const g = await d.pruebas.leer(proyectoId);
323
+ // Un pedido sin confirmar gana sobre la última: seguir la anterior y salir 0 con SU issue se leería
324
+ // como el desenlace de la prueba recién pedida. Con `--test-id` explícito no se llega acá.
325
+ pruebaId = g.ultima;
326
+ if (g.intento !== null) {
327
+ return fallo(SALIDA.haceFaltaUnaPersona, "intento_pendiente", `There's a test request waiting for confirmation; repeat \`${comandoDeReintento(g.intento)}\` without --message to recover it, and then \`--wait\`.`);
328
+ }
329
+ }
330
+ catch (e) {
331
+ return resultadoDeFallo(e);
332
+ }
333
+ if (pruebaId === null) {
334
+ return fallo(SALIDA.entradaInvalida, "sin_prueba", "There's no saved test on this machine for this project: request one with `supcoflow test --authorize-spend --ceiling <usd> --expect-policy <policy>`, or pass `--test-id prb_…`.");
335
+ }
336
+ }
337
+ // Se aparta lo que puede tardar un sondeo forzado (el primero siempre va, aunque quede poco): así el
338
+ // comando entero no pasa de 90 s aunque resolver el proyecto haya tardado.
339
+ const limite = inicio + Math.min(d.tramoMs ?? TRAMO_MAXIMO_MS, TRAMO_MAXIMO_MS) - PLAZO_MINIMO_DE_SONDEO_MS;
340
+ let ultimo = null;
341
+ for (;;) {
342
+ const plazoMs = Math.min(PLAZO_MAXIMO_DE_SONDEO_MS, limite - d.ahora());
343
+ if (plazoMs < PLAZO_MINIMO_DE_SONDEO_MS && ultimo !== null)
344
+ break;
345
+ try {
346
+ // Un fallo del motor a mitad del tramo sale 1 y no se reintenta en silencio (P13): quien llama decide.
347
+ ultimo = await d.leerPrueba(d.api, clave, proyectoId, pruebaId, Math.max(plazoMs, PLAZO_MINIMO_DE_SONDEO_MS));
348
+ }
349
+ catch (e) {
350
+ return resultadoDeFallo(e);
351
+ }
352
+ if (!ABIERTOS.has(ultimo.estado))
353
+ break;
354
+ if (limite - (d.ahora() + INTERVALO_DE_SEGUIMIENTO_MS) < PLAZO_MINIMO_DE_SONDEO_MS)
355
+ break;
356
+ await d.dormir(INTERVALO_DE_SEGUIMIENTO_MS);
357
+ }
358
+ return resultadoDelSeguimiento(ultimo);
359
+ }