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.
- package/README.md +210 -162
- package/dist/alias.js +60 -0
- package/dist/api.js +121 -16
- package/dist/argumentos.js +92 -78
- package/dist/cli.js +42 -31
- package/dist/comandos/clave-vigente.js +2 -2
- package/dist/comandos/claves.js +25 -25
- package/dist/comandos/configurar.js +40 -40
- package/dist/comandos/datos-y-politica.js +35 -23
- package/dist/comandos/describir.js +170 -138
- package/dist/comandos/guia.js +148 -61
- package/dist/comandos/instalacion.js +71 -40
- package/dist/comandos/login.js +25 -24
- package/dist/comandos/logout.js +17 -16
- package/dist/comandos/probar.js +359 -0
- package/dist/comandos/proyecto-del-repo.js +1 -1
- package/dist/comandos/proyecto.js +16 -16
- package/dist/comandos/quien-soy.js +8 -7
- package/dist/comandos/repo-conectar.js +9 -9
- package/dist/comandos/widget.js +9 -5
- package/dist/diagnostico.js +48 -0
- package/dist/entorno.js +32 -18
- package/dist/flags-por-comando.js +25 -19
- package/dist/guardado/archivo.js +2 -2
- package/dist/guardado/index.js +12 -8
- package/dist/guardado/llavero.js +5 -5
- package/dist/guia.js +3 -1
- package/dist/idempotencia.js +6 -6
- package/dist/instalacion.js +50 -11
- package/dist/lectura.js +10 -0
- package/dist/pruebas-guardadas.js +101 -0
- package/dist/repo-local.js +6 -6
- package/dist/secreto-a-archivo.js +6 -6
- package/package.json +1 -1
package/dist/comandos/login.js
CHANGED
|
@@ -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 = "
|
|
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("
|
|
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 = `
|
|
58
|
-
return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `
|
|
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", `
|
|
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: `
|
|
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", "
|
|
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
|
-
? "
|
|
114
|
-
: "
|
|
115
|
-
return fallo(SALIDA.inesperado, "no_pude_leer_la_clave", `
|
|
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: `
|
|
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
|
|
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", "
|
|
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", `
|
|
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", `
|
|
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", "
|
|
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(`
|
|
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
|
-
? "
|
|
210
|
-
: "
|
|
211
|
-
return fallo(SALIDA.inesperado, "no_se_pudo_guardar", `
|
|
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("
|
|
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 = "
|
|
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" ?
|
|
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: `
|
|
253
|
+
texto: `Logged in to team ${nueva.equipo}; the key is saved in ${donde}.`,
|
|
253
254
|
};
|
|
254
255
|
}
|
package/dist/comandos/logout.js
CHANGED
|
@@ -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 = "
|
|
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: "
|
|
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 = `
|
|
24
|
-
return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `
|
|
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("
|
|
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(
|
|
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: "
|
|
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" ? "
|
|
51
|
-
const message = `
|
|
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("
|
|
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(`
|
|
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: "
|
|
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" ?
|
|
81
|
-
d.avisar(`
|
|
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 = `
|
|
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: "
|
|
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
|
+
}
|