supcoflow 0.6.0 → 0.7.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 +20 -1
- package/dist/api.js +86 -3
- package/dist/argumentos.js +7 -0
- package/dist/cli.js +6 -0
- package/dist/comandos/describir.js +23 -1
- package/dist/comandos/probar.js +280 -0
- package/dist/entorno.js +14 -1
- package/dist/flags-por-comando.js +5 -3
- package/dist/pruebas-guardadas.js +95 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ La terminal de [Supcoflow](https://app.supcoflow.dev): iniciar sesión y manejar
|
|
|
4
4
|
desde la consola, o desde un asistente de código que corre comandos por vos.
|
|
5
5
|
|
|
6
6
|
Trae iniciar y cerrar sesión, ver con qué cuenta estás, conectar el repo con la App de GitHub,
|
|
7
|
-
crear su proyecto, configurar el chat
|
|
7
|
+
crear su proyecto, configurar el chat, ponerlo en la app y probar que un problema llega hasta un issue.
|
|
8
8
|
|
|
9
9
|
## Instalar
|
|
10
10
|
|
|
@@ -142,6 +142,25 @@ Después, `supcoflow instalacion anotar --skill <versión> [--app <carpeta>]` de
|
|
|
142
142
|
declarado e instalado, y la configuración del proyecto. Nunca lleva un secreto ni una fecha; repetirlo
|
|
143
143
|
sin cambios no lo toca, y si el archivo tiene cambios sin commit sale 4 y no lo pisa.
|
|
144
144
|
|
|
145
|
+
## Probar la instalación
|
|
146
|
+
|
|
147
|
+
Con el chat puesto, dos comandos dicen si un problema que entra por el chat llega hasta un issue. Los dos
|
|
148
|
+
toman el repo del `origin`, o `--repo owner/repo`.
|
|
149
|
+
|
|
150
|
+
| Comando | Qué hace |
|
|
151
|
+
|---|---|
|
|
152
|
+
| `supcoflow diagnosticar` | Gratis y sin escribir nada: revisa instalación de GitHub, sitios, chat, guía, política, saldo del mes y pruebas abiertas. Sale 0 si está todo listo, 4 si algo hay que arreglar (con cada punto). |
|
|
153
|
+
| `supcoflow probar` | Sin `--autorizo-gasto` no manda nada: sale 4 con el techo de la prueba, el saldo, el tope y lo comprometido del mes. |
|
|
154
|
+
| `supcoflow probar --autorizo-gasto` | Pide la prueba: el problema entra por la misma puerta que el chat y corre un relevamiento que **gasta** hasta el techo que mostró el motor (con la política `guardar` llega hasta el caso guardado y no gasta). Sale 5 con el id. `--mensaje "…"` usa un texto propio. |
|
|
155
|
+
| `supcoflow probar --wait` | Sigue la última prueba hasta 90 s: 5 mientras corre (volvé a llamarlo), 0 con el issue, 4 si hay que mirarla en la consola (trae el enlace). El cobro se muestra como esté: `pendiente` justo al cerrar, nunca 0. `--prueba prb_…` sigue otra. |
|
|
156
|
+
| `supcoflow probar --dry-run --autorizo-gasto` | Lo que haría (proyecto, techo, saldo, política), sin pedir nada. |
|
|
157
|
+
|
|
158
|
+
Si la respuesta del motor no llega, el pedido queda guardado en esta máquina (fuera del repo, junto a las
|
|
159
|
+
credenciales): repetir `supcoflow probar --autorizo-gasto` retoma **la misma** prueba en vez de pedir otra.
|
|
160
|
+
Mientras haya un pedido sin confirmar, otro `--mensaje` sale 4 `intento_pendiente`. La prueba va con la clave
|
|
161
|
+
personal de `supcoflow login`; una clave de consulta no puede pedirla. Una sola prueba abierta por proyecto: otra
|
|
162
|
+
sale 4 `prueba_en_curso` con el id de la abierta. El issue de una prueba queda abierto y rotulado como prueba.
|
|
163
|
+
|
|
145
164
|
## Con un asistente
|
|
146
165
|
|
|
147
166
|
Si usás un asistente de código como Claude Code, la skill de Supcoflow hace todo el camino
|
package/dist/api.js
CHANGED
|
@@ -21,9 +21,9 @@ export function urlDeLaApi(env) {
|
|
|
21
21
|
return new URL(u.origin);
|
|
22
22
|
throw invalida();
|
|
23
23
|
}
|
|
24
|
-
// A mano a propósito: tiene que coincidir con package.json (
|
|
25
|
-
// en los logs del motor, no decide nada.
|
|
26
|
-
const VERSION = "0.
|
|
24
|
+
// A mano a propósito: tiene que coincidir con package.json (lo pinza test/describir.test.ts); sólo
|
|
25
|
+
// identifica a la terminal en los logs del motor, no decide nada.
|
|
26
|
+
const VERSION = "0.7.0";
|
|
27
27
|
const PLAZO_POR_DEFECTO_MS = 15_000;
|
|
28
28
|
// Los dos POST que crean algo: detrás, el motor acuña con Vercel Connect y le pregunta a GitHub varias
|
|
29
29
|
// veces (instalación, repos, comandos, rama). Con 15 s una respuesta lenta pero buena se cortaba y
|
|
@@ -337,3 +337,86 @@ export async function leerWidget(api, clave, proyectoId, f) {
|
|
|
337
337
|
throw rechazada(r);
|
|
338
338
|
return aWidget(r.cuerpo, r.status);
|
|
339
339
|
}
|
|
340
|
+
// ---- La prueba (bloque 7) ----
|
|
341
|
+
const rutaDelProyecto = (proyectoId) => `/api/proyectos/${encodeURIComponent(proyectoId)}`;
|
|
342
|
+
const numeroONull = (v) => (v === null ? null : typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
343
|
+
export async function leerDiagnostico(api, clave, proyectoId, f) {
|
|
344
|
+
const r = await conClave(api, clave, `${rutaDelProyecto(proyectoId)}/diagnostico`, { method: "GET" }, f);
|
|
345
|
+
if (r.status !== 200)
|
|
346
|
+
throw rechazada(r);
|
|
347
|
+
const c = r.cuerpo;
|
|
348
|
+
const puntos = Array.isArray(c.puntos) ? c.puntos.map((x) => objeto(x, r.status)) : null;
|
|
349
|
+
const [saldo, tope, comprometido] = [c.saldo_usd, c.tope_usd, c.comprometido_usd].map(numeroONull);
|
|
350
|
+
if (typeof c.listo !== "boolean" || puntos === null || !texto(c.politica) || typeof c.techo_usd !== "number"
|
|
351
|
+
|| saldo === undefined || tope === undefined || comprometido === undefined
|
|
352
|
+
|| (c.prueba_abierta !== null && !texto(c.prueba_abierta))
|
|
353
|
+
|| puntos.some((p) => !texto(p.punto) || typeof p.ok !== "boolean" || typeof p.mensaje !== "string"))
|
|
354
|
+
throw inesperada(r.status);
|
|
355
|
+
return {
|
|
356
|
+
listo: c.listo,
|
|
357
|
+
puntos: puntos.map((p) => ({ punto: p.punto, ok: p.ok, mensaje: taparSecretos(p.mensaje) })),
|
|
358
|
+
politica: c.politica,
|
|
359
|
+
techoUsd: c.techo_usd,
|
|
360
|
+
saldoUsd: saldo, topeUsd: tope, comprometidoUsd: comprometido,
|
|
361
|
+
pruebaAbierta: texto(c.prueba_abierta),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
// Sin `conClave`: su 401/403/5xx lanzan, y acá cada uno tiene que decidir qué pasa con el intento.
|
|
365
|
+
// `idempotency_en_curso` es ambigua: otro intento con la misma clave sigue corriendo y puede crearla.
|
|
366
|
+
// Un 503 de la pausa de despachos (D10) también: el motor no guardó nada, pero conservar el intento no
|
|
367
|
+
// cuesta (el reintento con la misma clave crea la prueba una sola vez) y un 503 genérico no se distingue.
|
|
368
|
+
export async function pedirPrueba(api, clave, proyectoId, intento, f) {
|
|
369
|
+
const cuerpo = { techo_usd: intento.techoUsd };
|
|
370
|
+
if (intento.mensaje !== null)
|
|
371
|
+
cuerpo.mensaje = intento.mensaje;
|
|
372
|
+
let r;
|
|
373
|
+
try {
|
|
374
|
+
r = await pedir(api, `${rutaDelProyecto(proyectoId)}/pruebas`, {
|
|
375
|
+
method: "POST", headers: { ...JSON_BODY, ...bearer(clave), "idempotency-key": intento.clave }, body: JSON.stringify(cuerpo),
|
|
376
|
+
}, f, PLAZO_DE_ALTA_MS);
|
|
377
|
+
}
|
|
378
|
+
catch (e) {
|
|
379
|
+
const fallo = e instanceof FalloDeLaTerminal ? e : new FalloDeLaTerminal(SALIDA.inesperado, "motor_inalcanzable", "No pude comunicarme con el motor.");
|
|
380
|
+
return { tipo: "ambigua", error: fallo.error, message: fallo.message };
|
|
381
|
+
}
|
|
382
|
+
const error = texto(r.cuerpo.error);
|
|
383
|
+
const message = taparSecretos(texto(r.cuerpo.message) ?? `El motor respondió ${r.status}.`);
|
|
384
|
+
if (r.status === 202 || r.status === 200 || r.status === 201) {
|
|
385
|
+
const [pruebaId, reporteId, estado] = [r.cuerpo.prueba_id, r.cuerpo.reporte_id, r.cuerpo.estado].map(texto);
|
|
386
|
+
if (pruebaId && /^prb_[0-9a-f]{16}$/.test(pruebaId) && reporteId && estado)
|
|
387
|
+
return { tipo: "aceptada", pruebaId, reporteId, estado };
|
|
388
|
+
return { tipo: "ambigua", error: "respuesta_inesperada", message: `El motor respondió ${r.status} sin la prueba.` };
|
|
389
|
+
}
|
|
390
|
+
if (r.status >= 500 || (r.status === 409 && error === "idempotency_en_curso")) {
|
|
391
|
+
return { tipo: "ambigua", error: error ?? "motor_inalcanzable", message };
|
|
392
|
+
}
|
|
393
|
+
if (r.status >= 400)
|
|
394
|
+
return { tipo: "rechazo", status: r.status, error: error ?? "rechazado", message, cuerpo: r.cuerpo };
|
|
395
|
+
return { tipo: "ambigua", error: "respuesta_inesperada", message: `El motor respondió ${r.status} con algo que no esperaba.` };
|
|
396
|
+
}
|
|
397
|
+
export async function leerPrueba(api, clave, proyectoId, pruebaId, f, plazoMs) {
|
|
398
|
+
const r = await conClave(api, clave, `${rutaDelProyecto(proyectoId)}/pruebas/${encodeURIComponent(pruebaId)}`, { method: "GET" }, f, plazoMs);
|
|
399
|
+
if (r.status !== 200)
|
|
400
|
+
throw rechazada(r);
|
|
401
|
+
const c = r.cuerpo;
|
|
402
|
+
const cobro = objeto(c.cobro, r.status);
|
|
403
|
+
const usd = numeroONull(cobro.usd);
|
|
404
|
+
if (!texto(c.prueba_id) || !texto(c.estado) || typeof cobro.techo_usd !== "number" || usd === undefined
|
|
405
|
+
|| (cobro.estado !== null && !texto(cobro.estado)))
|
|
406
|
+
throw inesperada(r.status);
|
|
407
|
+
const rep = c.reporte === null || c.reporte === undefined ? null : objeto(c.reporte, r.status);
|
|
408
|
+
const run = c.run === null || c.run === undefined ? null : objeto(c.run, r.status);
|
|
409
|
+
const issue = c.issue === null || c.issue === undefined ? null : objeto(c.issue, r.status);
|
|
410
|
+
if (issue !== null && (typeof issue.numero !== "number" || !texto(issue.url)))
|
|
411
|
+
throw inesperada(r.status);
|
|
412
|
+
return {
|
|
413
|
+
pruebaId: c.prueba_id,
|
|
414
|
+
estado: c.estado,
|
|
415
|
+
abierta: typeof c.abierta === "boolean" ? c.abierta : null,
|
|
416
|
+
mensaje: texto(c.mensaje) === null ? null : taparSecretos(c.mensaje),
|
|
417
|
+
reporte: rep === null || !texto(rep.id) || !texto(rep.url) ? null : { id: rep.id, url: rep.url },
|
|
418
|
+
run: run === null ? null : { id: String(run.id), status: String(run.status), motivo: texto(run.motivo) },
|
|
419
|
+
issue: issue === null ? null : { numero: issue.numero, url: issue.url },
|
|
420
|
+
cobro: { estado: texto(cobro.estado), usd, techo_usd: cobro.techo_usd },
|
|
421
|
+
};
|
|
422
|
+
}
|
package/dist/argumentos.js
CHANGED
|
@@ -11,6 +11,7 @@ export const COMANDOS_COMPUESTOS = [
|
|
|
11
11
|
const PRIMERAS_PALABRAS = new Set(COMANDOS_COMPUESTOS.map((c) => c.split(" ")[0]));
|
|
12
12
|
const FLAGS_CON_VALOR = [
|
|
13
13
|
"repo", "install", "test", "sitio", "titulo", "saludo", "archivo", "permitir", "modo", "nombre", "guardar-en", "id", "origen", "skill", "app",
|
|
14
|
+
"mensaje", "prueba",
|
|
14
15
|
];
|
|
15
16
|
// strict: un flag mal escrito (`--estart`) no se ignora en silencio: un asistente creería que
|
|
16
17
|
// pidió algo que no pidió.
|
|
@@ -43,6 +44,9 @@ export function leerArgumentos(argv) {
|
|
|
43
44
|
"desde-github": { type: "boolean" },
|
|
44
45
|
skill: { type: "string" },
|
|
45
46
|
app: { type: "string" },
|
|
47
|
+
"autorizo-gasto": { type: "boolean" },
|
|
48
|
+
mensaje: { type: "string" },
|
|
49
|
+
prueba: { type: "string" },
|
|
46
50
|
tema: { type: "string", multiple: true },
|
|
47
51
|
},
|
|
48
52
|
tokens: true,
|
|
@@ -102,6 +106,9 @@ export function leerArgumentos(argv) {
|
|
|
102
106
|
desdeGithub: v["desde-github"] === true,
|
|
103
107
|
skill: v.skill ?? null,
|
|
104
108
|
app: v.app ?? null,
|
|
109
|
+
autorizoGasto: v["autorizo-gasto"] === true,
|
|
110
|
+
mensaje: v.mensaje ?? null,
|
|
111
|
+
prueba: v.prueba ?? null,
|
|
105
112
|
tema: v.tema ?? [],
|
|
106
113
|
},
|
|
107
114
|
};
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { comandoCatalogo, datosFijar, politicaFijar } from "./comandos/datos-y-p
|
|
|
12
12
|
import { ayuda, comandoDescribir, DESCRIPCION } from "./comandos/describir.js";
|
|
13
13
|
import { loginInteractivo, loginStart, loginWait } from "./comandos/login.js";
|
|
14
14
|
import { comandoLogout } from "./comandos/logout.js";
|
|
15
|
+
import { diagnosticar, probar } from "./comandos/probar.js";
|
|
15
16
|
import { proyectoCrear, proyectoVer } from "./comandos/proyecto.js";
|
|
16
17
|
import { comandoQuienSoy } from "./comandos/quien-soy.js";
|
|
17
18
|
import { repoConectarInteractivo, repoConectarStart, repoConectarWait } from "./comandos/repo-conectar.js";
|
|
@@ -41,6 +42,7 @@ async function correr(argv) {
|
|
|
41
42
|
archivo: flags.archivo !== null, permitir: flags.permitir !== null, modo: flags.modo !== null,
|
|
42
43
|
nombre: flags.nombre !== null, guardarEn: flags.guardarEn !== null, id: flags.id !== null, origen: flags.origen !== null, desdeGithub: flags.desdeGithub,
|
|
43
44
|
skill: flags.skill !== null, app: flags.app !== null,
|
|
45
|
+
autorizoGasto: flags.autorizoGasto, mensaje: flags.mensaje !== null, prueba: flags.prueba !== null,
|
|
44
46
|
};
|
|
45
47
|
const noAplica = flagQueNoAplica(comando, Object.keys(presentes).filter((k) => presentes[k]));
|
|
46
48
|
if (noAplica !== null)
|
|
@@ -130,6 +132,10 @@ async function correr(argv) {
|
|
|
130
132
|
return { r: await widget(e.widget, flags), json };
|
|
131
133
|
case "instalacion anotar":
|
|
132
134
|
return { r: await instalacionAnotar(e.instalacion, flags), json };
|
|
135
|
+
case "diagnosticar":
|
|
136
|
+
return { r: await diagnosticar(e.prueba, flags), json };
|
|
137
|
+
case "probar":
|
|
138
|
+
return { r: await probar(e.prueba, flags), json };
|
|
133
139
|
}
|
|
134
140
|
throw new Error(`comando sin despacho: ${comando}`);
|
|
135
141
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ayuda pensada para personas. La ayuda se arma desde acá: una sola fuente, así no se desfasan.
|
|
4
4
|
import { SALIDA } from "../contrato.js";
|
|
5
5
|
// A mano a propósito, como en api.ts: tiene que coincidir con package.json (lo pinza un test).
|
|
6
|
-
const VERSION = "0.
|
|
6
|
+
const VERSION = "0.7.0";
|
|
7
7
|
export const DESCRIPCION = {
|
|
8
8
|
version: VERSION,
|
|
9
9
|
comandos: [
|
|
@@ -230,6 +230,28 @@ export const DESCRIPCION = {
|
|
|
230
230
|
ejemplos: ["supcoflow instalacion anotar --skill 0.1.0 --app web --json"],
|
|
231
231
|
codigos: [0, 1, 2, 3, 4],
|
|
232
232
|
},
|
|
233
|
+
{
|
|
234
|
+
nombre: "diagnosticar",
|
|
235
|
+
resumen: "Dice, gratis y sin escribir nada, si una prueba del proyecto entraría y hasta dónde llegaría: instalación de GitHub, sitios, chat, guía, política, saldo del mes contra el techo de la prueba, pruebas abiertas y lo que el motor sume. Sale 0 si está todo listo y 4 si algo hay que arreglar (no_listo), con cada punto.",
|
|
236
|
+
flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--json"],
|
|
237
|
+
ejemplos: ["supcoflow diagnosticar --json"],
|
|
238
|
+
codigos: [0, 1, 2, 3, 4],
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
nombre: "probar",
|
|
242
|
+
resumen: "Prueba la instalación de punta a punta: un problema entra por la misma puerta que el chat y termina en un issue. Gasta un relevamiento (hasta el techo que dice el motor; con la política guardar llega hasta el caso guardado y no gasta). Sin --autorizo-gasto no manda nada: sale 4 falta_autorizacion con techo_usd, saldo_usd, tope_usd y comprometido_usd. Con --autorizo-gasto pide la prueba y sale 5 con prueba_id; después --wait la sigue. Otros: 4 prueba_en_curso (con su id), techo_distinto, chat_deshabilitado, requiere_api_key, intento_pendiente; 6 tope_mensual; 1 con intento_pendiente: true si la respuesta no llegó (repetir retoma la misma prueba).",
|
|
243
|
+
flags: [
|
|
244
|
+
"--autorizo-gasto: autoriza el gasto de UNA prueba, hasta el techo que mostró el motor; sin él no se manda nada que escriba",
|
|
245
|
+
"--mensaje \"…\": el texto del problema de la prueba (por defecto, uno fijo del motor); con un pedido sin confirmar, otro --mensaje sale 4 intento_pendiente",
|
|
246
|
+
"--wait: sigue la última prueba hasta 90 s; sale 5 mientras corre, 0 con el issue (o guardado), 4 si hace falta mirarla (con el enlace a la bandeja); el cobro sale como esté (pendiente nunca es 0)",
|
|
247
|
+
"--prueba prb_…: con --wait, sigue esa prueba en vez de la última guardada",
|
|
248
|
+
"--repo owner/repo: el repo, si no es el «origin» de esta carpeta",
|
|
249
|
+
"--dry-run: dice qué haría (proyecto, techo, saldo, política) sin pedir nada que escriba",
|
|
250
|
+
"--json",
|
|
251
|
+
],
|
|
252
|
+
ejemplos: ["supcoflow probar --json", "supcoflow probar --autorizo-gasto --json", "supcoflow probar --wait --json", "supcoflow probar --dry-run --autorizo-gasto --json"],
|
|
253
|
+
codigos: [0, 1, 2, 3, 4, 5, 6],
|
|
254
|
+
},
|
|
233
255
|
],
|
|
234
256
|
flags_globales: ["--json: el resultado en una línea de JSON por stdout; los avisos van por stderr", "--help, -h: la ayuda con ejemplos"],
|
|
235
257
|
facultades: [
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { resultadoDeFallo, SALIDA } from "../contrato.js";
|
|
2
|
+
import { PLAZO_MINIMO_DE_SONDEO_MS, TRAMO_MAXIMO_MS } from "../sondeo.js";
|
|
3
|
+
import { fallo, rutaDe } from "./configurar.js";
|
|
4
|
+
import { proyectoDelRepo } from "./proyecto-del-repo.js";
|
|
5
|
+
// Las cotas de `BugReportSchema` del motor (D2): el mensaje es el cuerpo del reporte. Se mira acá para no
|
|
6
|
+
// gastar un pedido (ni guardar un intento que el motor va a rechazar); la regla de verdad es la del motor.
|
|
7
|
+
const MAX_MENSAJE = 20_000;
|
|
8
|
+
const ID_DE_PRUEBA = /^prb_[0-9a-f]{16}$/;
|
|
9
|
+
const INTERVALO_DE_SEGUIMIENTO_MS = 5_000;
|
|
10
|
+
const PLAZO_MAXIMO_DE_SONDEO_MS = 15_000;
|
|
11
|
+
// La tabla de la prueba (brief M7, «los estados»): qué código sale en cada estado. Lo que no está acá es
|
|
12
|
+
// un estado que esta terminal no conoce: sale 1, nunca se adivina.
|
|
13
|
+
const ABIERTOS = new Set(["en_curso", "creada"]);
|
|
14
|
+
const CON_DESENLACE_OK = new Set(["issue_creado", "guardado"]);
|
|
15
|
+
const PIDEN_UNA_PERSONA = new Set([
|
|
16
|
+
"issue_con_cierre_anomalo", "fallido", "cierre_sin_evidencia", "reclamado_sin_run", "arranque_incierto", "esperando_cupo", "sin_despacho",
|
|
17
|
+
]);
|
|
18
|
+
const usd = (n) => `US$ ${n.toFixed(2)}`;
|
|
19
|
+
const usdONull = (n) => (n === null ? "sin medir" : usd(n));
|
|
20
|
+
function datosDelDiagnostico(g) {
|
|
21
|
+
return {
|
|
22
|
+
listo: g.listo,
|
|
23
|
+
puntos: g.puntos,
|
|
24
|
+
politica: g.politica,
|
|
25
|
+
techo_usd: g.techoUsd,
|
|
26
|
+
saldo_usd: g.saldoUsd,
|
|
27
|
+
tope_usd: g.topeUsd,
|
|
28
|
+
comprometido_usd: g.comprometidoUsd,
|
|
29
|
+
prueba_abierta: g.pruebaAbierta,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const lineasDePuntos = (g) => g.puntos.map((p) => ` [${p.ok ? "ok" : "falta"}] ${p.punto}: ${p.mensaje}`);
|
|
33
|
+
export async function diagnosticar(d, flags) {
|
|
34
|
+
const p = await proyectoDelRepo(d, flags);
|
|
35
|
+
if (!p.ok)
|
|
36
|
+
return p.resultado;
|
|
37
|
+
let g;
|
|
38
|
+
try {
|
|
39
|
+
g = await d.leerDiagnostico(d.api, p.clave, p.proyecto.id);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
return resultadoDeFallo(e);
|
|
43
|
+
}
|
|
44
|
+
const datos = { proyecto: { id: p.proyecto.id, repo: p.proyecto.repo }, ...datosDelDiagnostico(g) };
|
|
45
|
+
const resumen = `Saldo del mes ${usdONull(g.saldoUsd)} de ${usdONull(g.topeUsd)}; una prueba autoriza hasta ${usd(g.techoUsd)}. Política: ${g.politica}.`;
|
|
46
|
+
if (g.listo) {
|
|
47
|
+
return { codigo: SALIDA.ok, json: { ok: true, ...datos }, texto: [`Todo listo para probar ${p.proyecto.repo}.`, ...lineasDePuntos(g), resumen].join("\n") };
|
|
48
|
+
}
|
|
49
|
+
const message = `Hay algo para arreglar antes de probar ${p.proyecto.repo}.`;
|
|
50
|
+
return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "no_listo", message, ...datos }, texto: [message, ...lineasDePuntos(g), resumen].join("\n") };
|
|
51
|
+
}
|
|
52
|
+
export async function probar(d, flags) {
|
|
53
|
+
// El reloj del tramo arranca con el comando: los 90 s son del comando entero, no sólo de los sondeos.
|
|
54
|
+
const inicio = d.ahora();
|
|
55
|
+
// Todo lo que se puede rechazar sin mirar nada, antes de tocar el motor o lo guardado.
|
|
56
|
+
if (flags.wait && (flags.autorizoGasto || flags.mensaje !== null)) {
|
|
57
|
+
return fallo(SALIDA.entradaInvalida, "flags_incompatibles", "`probar --wait` sólo sigue una prueba: sin --autorizo-gasto ni --mensaje. Primero `supcoflow probar --autorizo-gasto` y después `supcoflow probar --wait`.");
|
|
58
|
+
}
|
|
59
|
+
if (!flags.wait && flags.prueba !== null) {
|
|
60
|
+
return fallo(SALIDA.entradaInvalida, "flags_incompatibles", "--prueba va con --wait: `supcoflow probar --wait --prueba prb_…`.");
|
|
61
|
+
}
|
|
62
|
+
if (flags.prueba !== null && !ID_DE_PRUEBA.test(flags.prueba)) {
|
|
63
|
+
return fallo(SALIDA.entradaInvalida, "prueba_invalida", "--prueba es el id que dio `supcoflow probar` (prb_ y 16 caracteres).");
|
|
64
|
+
}
|
|
65
|
+
let mensaje = null;
|
|
66
|
+
if (flags.mensaje !== null) {
|
|
67
|
+
mensaje = flags.mensaje.trim();
|
|
68
|
+
if (mensaje.length === 0 || mensaje.length > MAX_MENSAJE) {
|
|
69
|
+
return fallo(SALIDA.entradaInvalida, "mensaje_invalido", `--mensaje va con texto, hasta ${MAX_MENSAJE} caracteres.`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const p = await proyectoDelRepo(d, flags);
|
|
73
|
+
if (!p.ok)
|
|
74
|
+
return p.resultado;
|
|
75
|
+
const proyecto = p.proyecto;
|
|
76
|
+
const clave = p.clave;
|
|
77
|
+
if (flags.wait)
|
|
78
|
+
return seguir(d, clave, proyecto.id, flags.prueba, inicio);
|
|
79
|
+
// ── Sin el flag, o con --dry-run: sólo el diagnóstico (gratis). Nada que escriba, ni acá ni en el motor.
|
|
80
|
+
if (!flags.autorizoGasto || flags.dryRun) {
|
|
81
|
+
let g;
|
|
82
|
+
try {
|
|
83
|
+
g = await d.leerDiagnostico(d.api, clave, proyecto.id);
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
return resultadoDeFallo(e);
|
|
87
|
+
}
|
|
88
|
+
const datos = { proyecto: { id: proyecto.id, repo: proyecto.repo }, ...datosDelDiagnostico(g) };
|
|
89
|
+
const gasto = g.politica === "guardar"
|
|
90
|
+
? `Con la política \`guardar\` la prueba llega hasta el caso guardado y no gasta; el techo que se autoriza igual es ${usd(g.techoUsd)}.`
|
|
91
|
+
: `Una prueba corre un relevamiento que gasta hasta ${usd(g.techoUsd)} (saldo del mes ${usdONull(g.saldoUsd)} de ${usdONull(g.topeUsd)}).`;
|
|
92
|
+
if (flags.dryRun) {
|
|
93
|
+
// Lo guardado se mira sólo para decir qué haría; si no se puede leer, el dry-run igual contesta.
|
|
94
|
+
let pendiente = null;
|
|
95
|
+
try {
|
|
96
|
+
pendiente = (await d.pruebas.leer(proyecto.id)).intento !== null;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
pendiente = null;
|
|
100
|
+
}
|
|
101
|
+
const ruta = rutaDe(proyecto, "/pruebas");
|
|
102
|
+
const haria = pendiente
|
|
103
|
+
? `reenviaría el pedido guardado sin confirmar (POST ${ruta}, la misma Idempotency-Key)`
|
|
104
|
+
: `POST ${ruta} {techo_usd: ${g.techoUsd}${mensaje !== null ? ", mensaje" : ""}} con una Idempotency-Key nueva`;
|
|
105
|
+
return {
|
|
106
|
+
codigo: SALIDA.ok,
|
|
107
|
+
json: { ok: true, dry_run: true, autorizado: flags.autorizoGasto, intento_pendiente: pendiente, haria, ...datos },
|
|
108
|
+
texto: `Con --dry-run no hago nada. ${flags.autorizoGasto ? "Sin --dry-run" : "Con --autorizo-gasto y sin --dry-run"}: ${haria} (proyecto ${proyecto.repo}, política ${g.politica}). ${gasto}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const message = `${gasto} Para lanzarla: \`supcoflow probar --autorizo-gasto\`. Antes, \`supcoflow diagnosticar\` dice si está todo listo.`;
|
|
112
|
+
return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "falta_autorizacion", message, ...datos }, texto: message };
|
|
113
|
+
}
|
|
114
|
+
// ── Con --autorizo-gasto: el ciclo de la clave.
|
|
115
|
+
let intento;
|
|
116
|
+
try {
|
|
117
|
+
const guardado = await d.pruebas.leer(proyecto.id);
|
|
118
|
+
if (guardado.intento !== null) {
|
|
119
|
+
if (mensaje !== null && mensaje !== guardado.intento.mensaje) {
|
|
120
|
+
return fallo(SALIDA.haceFaltaUnaPersona, "intento_pendiente", "Hay una prueba pedida sin confirmar; repetí `supcoflow probar --autorizo-gasto` sin `--mensaje` para recuperarla.");
|
|
121
|
+
}
|
|
122
|
+
intento = guardado.intento;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// El techo que se autoriza es el que dice el motor ahora (D6), no uno escrito en la terminal.
|
|
126
|
+
const g = await d.leerDiagnostico(d.api, clave, proyecto.id);
|
|
127
|
+
if (g.pruebaAbierta !== null)
|
|
128
|
+
return enCurso(g.pruebaAbierta);
|
|
129
|
+
// No frena (el motor decide lo que acepta), pero quien autorizó tiene que ver lo que falta.
|
|
130
|
+
for (const punto of g.puntos.filter((x) => !x.ok))
|
|
131
|
+
d.avisar(`Aviso (${punto.punto}): ${punto.mensaje}`);
|
|
132
|
+
intento = { clave: d.generar(), mensaje, techoUsd: g.techoUsd };
|
|
133
|
+
await d.pruebas.abrir(proyecto.id, intento);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
return resultadoDeFallo(e);
|
|
138
|
+
}
|
|
139
|
+
const r = await d.pedirPrueba(d.api, clave, proyecto.id, intento);
|
|
140
|
+
if (r.tipo === "aceptada") {
|
|
141
|
+
try {
|
|
142
|
+
await d.pruebas.confirmar(proyecto.id, r.pruebaId);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
const message = `El motor aceptó la prueba ${r.pruebaId}, pero no pude guardarla en esta máquina. Repetí \`supcoflow probar --autorizo-gasto\` (recupera la misma) o seguila con \`supcoflow probar --wait --prueba ${r.pruebaId}\`.`;
|
|
146
|
+
return { codigo: SALIDA.inesperado, json: { ok: false, error: "prueba_no_guardada", message, prueba_id: r.pruebaId, intento_pendiente: true }, texto: message };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
codigo: SALIDA.esperandoAUnaPersona,
|
|
150
|
+
json: { ok: false, esperando: "relevamiento", prueba_id: r.pruebaId, reporte_id: r.reporteId, estado: r.estado },
|
|
151
|
+
texto: `Prueba ${r.pruebaId} pedida (${r.estado}). Seguila con \`supcoflow probar --wait\`.`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (r.tipo === "ambigua") {
|
|
155
|
+
const message = `${r.message} El pedido quedó guardado: repetí \`supcoflow probar --autorizo-gasto\` y retomo la misma prueba, sin pedir otra.`;
|
|
156
|
+
return { codigo: SALIDA.inesperado, json: { ok: false, error: r.error, message, intento_pendiente: true }, texto: message };
|
|
157
|
+
}
|
|
158
|
+
// Un rechazo definitivo: el motor no creó nada con esta clave, así que el intento se cierra.
|
|
159
|
+
try {
|
|
160
|
+
await d.pruebas.descartar(proyecto.id);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
d.avisar("No pude cerrar el pedido guardado; el próximo `probar --autorizo-gasto` lo va a reenviar.");
|
|
164
|
+
}
|
|
165
|
+
return rechazo(r, d.avisar);
|
|
166
|
+
}
|
|
167
|
+
function enCurso(pruebaId) {
|
|
168
|
+
const message = `Ya hay una prueba de este proyecto sin desenlace (${pruebaId}). Seguila con \`supcoflow probar --wait --prueba ${pruebaId}\`.`;
|
|
169
|
+
return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "prueba_en_curso", message, prueba_id: pruebaId }, texto: message };
|
|
170
|
+
}
|
|
171
|
+
function rechazo(r, avisar) {
|
|
172
|
+
const c = r.cuerpo;
|
|
173
|
+
switch (r.error) {
|
|
174
|
+
case "prueba_en_curso":
|
|
175
|
+
return typeof c.prueba_id === "string" ? enCurso(c.prueba_id) : fallo(SALIDA.haceFaltaUnaPersona, r.error, r.message);
|
|
176
|
+
case "techo_distinto": {
|
|
177
|
+
const message = `${r.message} Volvé a correr \`supcoflow probar\` para ver el techo vigente y autorizalo.`;
|
|
178
|
+
return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: r.error, message, techo_usd: c.techo_usd ?? null }, texto: message };
|
|
179
|
+
}
|
|
180
|
+
case "tope_mensual":
|
|
181
|
+
return {
|
|
182
|
+
codigo: SALIDA.topeAlcanzado,
|
|
183
|
+
json: { ok: false, error: r.error, message: r.message, comprometido_usd: c.comprometido_usd ?? null, tope_usd: c.tope_usd ?? null },
|
|
184
|
+
texto: r.message,
|
|
185
|
+
};
|
|
186
|
+
case "requiere_api_key": {
|
|
187
|
+
const message = `La prueba se pide con tu API key personal (la que deja \`supcoflow login\`), no con una clave de consulta ni con la sesión de la consola. ${r.message}`;
|
|
188
|
+
return fallo(SALIDA.haceFaltaUnaPersona, r.error, message);
|
|
189
|
+
}
|
|
190
|
+
case "idempotency_key_reusada":
|
|
191
|
+
// No debería pasar: siempre se reenvía lo guardado con su clave. Si pasa, la clave ya no sirve.
|
|
192
|
+
avisar("El motor dice que la clave del pedido ya se usó con otro contenido; la descarté.");
|
|
193
|
+
return fallo(SALIDA.inesperado, r.error, `${r.message} Volvé a correr \`supcoflow probar --autorizo-gasto\` para pedir una prueba nueva.`);
|
|
194
|
+
}
|
|
195
|
+
if (r.status === 401)
|
|
196
|
+
return fallo(SALIDA.faltaLogin, "clave_invalida", r.message);
|
|
197
|
+
if (r.status === 403 || r.error === "chat_deshabilitado")
|
|
198
|
+
return fallo(SALIDA.haceFaltaUnaPersona, r.error, r.message);
|
|
199
|
+
return fallo(SALIDA.entradaInvalida, r.error, r.message);
|
|
200
|
+
}
|
|
201
|
+
function textoDelCobro(c) {
|
|
202
|
+
// Pendiente y perdido nunca se muestran como cero: el monto todavía no se sabe, la cota es el techo.
|
|
203
|
+
if (c.estado === "leido" && c.usd !== null)
|
|
204
|
+
return `Costo: ${usd(c.usd)}.`;
|
|
205
|
+
if (c.estado === "pendiente")
|
|
206
|
+
return `Cobro pendiente: se lee unos minutos después del cierre (hasta ${usd(c.techo_usd)}).`;
|
|
207
|
+
if (c.estado === "perdido")
|
|
208
|
+
return `Cobro perdido: no se pudo leer el gasto (hasta ${usd(c.techo_usd)}).`;
|
|
209
|
+
if (c.estado === null)
|
|
210
|
+
return null;
|
|
211
|
+
return `Cobro: ${c.estado} (hasta ${usd(c.techo_usd)}).`;
|
|
212
|
+
}
|
|
213
|
+
function resultadoDelSeguimiento(s) {
|
|
214
|
+
const datos = {
|
|
215
|
+
prueba_id: s.pruebaId, estado: s.estado, abierta: s.abierta, mensaje: s.mensaje,
|
|
216
|
+
reporte: s.reporte, run: s.run, issue: s.issue, cobro: s.cobro,
|
|
217
|
+
};
|
|
218
|
+
const lineas = [
|
|
219
|
+
`Prueba ${s.pruebaId}: ${s.estado}.${s.mensaje ? ` ${s.mensaje}` : ""}`,
|
|
220
|
+
s.issue ? `Issue #${s.issue.numero}: ${s.issue.url}` : null,
|
|
221
|
+
s.run?.motivo ? `Motivo del run: ${s.run.motivo}` : null,
|
|
222
|
+
textoDelCobro(s.cobro),
|
|
223
|
+
];
|
|
224
|
+
if (ABIERTOS.has(s.estado)) {
|
|
225
|
+
lineas.push("Volvé a correr `supcoflow probar --wait`.");
|
|
226
|
+
return { codigo: SALIDA.esperandoAUnaPersona, json: { ok: false, esperando: "relevamiento", ...datos }, texto: lineas.filter(Boolean).join("\n") };
|
|
227
|
+
}
|
|
228
|
+
if (CON_DESENLACE_OK.has(s.estado))
|
|
229
|
+
return { codigo: SALIDA.ok, json: { ok: true, ...datos }, texto: lineas.filter(Boolean).join("\n") };
|
|
230
|
+
if (PIDEN_UNA_PERSONA.has(s.estado)) {
|
|
231
|
+
if (s.reporte)
|
|
232
|
+
lineas.push(`El caso en la consola: ${s.reporte.url}`);
|
|
233
|
+
const texto = lineas.filter(Boolean).join("\n");
|
|
234
|
+
return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: s.estado, message: s.mensaje ?? texto, ...datos }, texto };
|
|
235
|
+
}
|
|
236
|
+
const message = `El motor dice que la prueba está en «${s.estado}», un estado que esta versión de la terminal no conoce. Actualizá supcoflow o mirala en la consola.`;
|
|
237
|
+
return { codigo: SALIDA.inesperado, json: { ok: false, error: "estado_desconocido", message, ...datos }, texto: message };
|
|
238
|
+
}
|
|
239
|
+
async function seguir(d, clave, proyectoId, elegida, inicio) {
|
|
240
|
+
let pruebaId = elegida;
|
|
241
|
+
if (pruebaId === null) {
|
|
242
|
+
try {
|
|
243
|
+
const g = await d.pruebas.leer(proyectoId);
|
|
244
|
+
// Un pedido sin confirmar gana sobre la última: seguir la anterior y salir 0 con SU issue se leería
|
|
245
|
+
// como el desenlace de la prueba recién pedida. Con `--prueba` explícito no se llega acá.
|
|
246
|
+
pruebaId = g.ultima;
|
|
247
|
+
if (g.intento !== null) {
|
|
248
|
+
return fallo(SALIDA.haceFaltaUnaPersona, "intento_pendiente", "Hay una prueba pedida sin confirmar; repetí `supcoflow probar --autorizo-gasto` sin `--mensaje` para recuperarla, y después `--wait`.");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
return resultadoDeFallo(e);
|
|
253
|
+
}
|
|
254
|
+
if (pruebaId === null) {
|
|
255
|
+
return fallo(SALIDA.entradaInvalida, "sin_prueba", "No hay una prueba guardada en esta máquina para este proyecto: pedila con `supcoflow probar --autorizo-gasto`, o pasá `--prueba prb_…`.");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Se aparta lo que puede tardar un sondeo forzado (el primero siempre va, aunque quede poco): así el
|
|
259
|
+
// comando entero no pasa de 90 s aunque resolver el proyecto haya tardado.
|
|
260
|
+
const limite = inicio + Math.min(d.tramoMs ?? TRAMO_MAXIMO_MS, TRAMO_MAXIMO_MS) - PLAZO_MINIMO_DE_SONDEO_MS;
|
|
261
|
+
let ultimo = null;
|
|
262
|
+
for (;;) {
|
|
263
|
+
const plazoMs = Math.min(PLAZO_MAXIMO_DE_SONDEO_MS, limite - d.ahora());
|
|
264
|
+
if (plazoMs < PLAZO_MINIMO_DE_SONDEO_MS && ultimo !== null)
|
|
265
|
+
break;
|
|
266
|
+
try {
|
|
267
|
+
// Un fallo del motor a mitad del tramo sale 1 y no se reintenta en silencio (P13): quien llama decide.
|
|
268
|
+
ultimo = await d.leerPrueba(d.api, clave, proyectoId, pruebaId, Math.max(plazoMs, PLAZO_MINIMO_DE_SONDEO_MS));
|
|
269
|
+
}
|
|
270
|
+
catch (e) {
|
|
271
|
+
return resultadoDeFallo(e);
|
|
272
|
+
}
|
|
273
|
+
if (!ABIERTOS.has(ultimo.estado))
|
|
274
|
+
break;
|
|
275
|
+
if (limite - (d.ahora() + INTERVALO_DE_SEGUIMIENTO_MS) < PLAZO_MINIMO_DE_SONDEO_MS)
|
|
276
|
+
break;
|
|
277
|
+
await d.dormir(INTERVALO_DE_SEGUIMIENTO_MS);
|
|
278
|
+
}
|
|
279
|
+
return resultadoDelSeguimiento(ultimo);
|
|
280
|
+
}
|
package/dist/entorno.js
CHANGED
|
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
import { chmodSync, closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeSync, } from "node:fs";
|
|
6
6
|
import { homedir, hostname } from "node:os";
|
|
7
7
|
import { basename, dirname, join } from "node:path";
|
|
8
|
-
import { buscarProyecto, canjear, configurar, leerWidget, crearConexion, crearProyecto, elegirOrigen, esClaveDeConsultaDelProyecto, leerCatalogo, leerConfiguracion, leerGuia, leerProyecto, listarClaves, pedirCodigo, quienSoy, revocarPropia, subirLogo, ultimaConexion, urlDeLaApi, publicarGuia, revisarGuia, } from "./api.js";
|
|
8
|
+
import { buscarProyecto, canjear, configurar, leerDiagnostico, leerPrueba, leerWidget, pedirPrueba, crearConexion, crearProyecto, elegirOrigen, esClaveDeConsultaDelProyecto, leerCatalogo, leerConfiguracion, leerGuia, leerProyecto, listarClaves, pedirCodigo, quienSoy, revocarPropia, subirLogo, ultimaConexion, urlDeLaApi, publicarGuia, revisarGuia, } from "./api.js";
|
|
9
9
|
import { crearCandados } from "./candado.js";
|
|
10
10
|
import { esCarpetaReal, rutaRealOEnNada } from "./comandos/instalacion.js";
|
|
11
11
|
import { DESCRIPCION } from "./comandos/describir.js";
|
|
@@ -14,6 +14,7 @@ import { crearGuardado } from "./guardado/index.js";
|
|
|
14
14
|
import { almacenDeLlavero } from "./guardado/llavero.js";
|
|
15
15
|
import { crearGuardaDeSecretos } from "./secreto-a-archivo.js";
|
|
16
16
|
import { crearRegistroDeIdempotencia } from "./idempotencia.js";
|
|
17
|
+
import { crearRegistroDePruebas } from "./pruebas-guardadas.js";
|
|
17
18
|
import { TRAMO_MAXIMO_MS } from "./sondeo.js";
|
|
18
19
|
// El `.tmp` se crea en exclusiva (`wx`: falla si quedó algo, enlace incluido, en vez de escribir a
|
|
19
20
|
// través de él) y el `fchmod` va aparte porque el modo de `open` pasa por la umask del proceso.
|
|
@@ -403,6 +404,11 @@ export function entornoReal(env = process.env) {
|
|
|
403
404
|
ahora: Date.now,
|
|
404
405
|
avisar,
|
|
405
406
|
});
|
|
407
|
+
// El pedido de `probar` (intento + última prueba), con el mismo almacén y la misma regla: fuera de un repo.
|
|
408
|
+
const rutaDePruebas = join(dirname(rutaDelArchivo), "pruebas.json");
|
|
409
|
+
const pruebas = crearRegistroDePruebas({
|
|
410
|
+
origen: api.origin, almacen: almacenDeArchivo(rutaDePruebas, fsReal), rutaDelArchivo: rutaDePruebas, fs: fsReal, ahora: Date.now, avisar,
|
|
411
|
+
});
|
|
406
412
|
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
407
413
|
const cwd = process.cwd();
|
|
408
414
|
// Los candados de `claves crear` viven al lado de las credenciales, nunca en un repo.
|
|
@@ -481,5 +487,12 @@ export function entornoReal(env = process.env) {
|
|
|
481
487
|
escribir: archivosDelRepo(avisar).escribir,
|
|
482
488
|
terminal: DESCRIPCION.version,
|
|
483
489
|
},
|
|
490
|
+
prueba: {
|
|
491
|
+
api, guardado, git: gitReal, cwd, avisar, pruebas, generar: randomUUID, ahora: Date.now, dormir, tramoMs,
|
|
492
|
+
buscarProyecto: configurarEnElMotor.buscarProyecto,
|
|
493
|
+
leerDiagnostico: (a, clave, id) => leerDiagnostico(a, clave, id, fetch),
|
|
494
|
+
pedirPrueba: (a, clave, id, intento) => pedirPrueba(a, clave, id, intento, fetch),
|
|
495
|
+
leerPrueba: (a, clave, id, pruebaId, plazoMs) => leerPrueba(a, clave, id, pruebaId, fetch, plazoMs),
|
|
496
|
+
},
|
|
484
497
|
};
|
|
485
498
|
}
|
|
@@ -7,10 +7,11 @@ const DE_CONFIGURAR_CON_REPO = [
|
|
|
7
7
|
"guia revisar", "guia publicar", "guia ver", "guia usar",
|
|
8
8
|
];
|
|
9
9
|
export const FLAGS_POR_COMANDO = [
|
|
10
|
-
{ flags: ["start"
|
|
10
|
+
{ flags: ["start"], comandos: ["login", "repo conectar"], texto: "--start es de `supcoflow login` y `supcoflow repo conectar`." },
|
|
11
|
+
{ flags: ["wait"], comandos: ["login", "repo conectar", "probar"], texto: "--wait es de `supcoflow login`, `supcoflow repo conectar` y `supcoflow probar`." },
|
|
11
12
|
{
|
|
12
|
-
flags: ["repo"], comandos: ["repo conectar", "proyecto crear", "proyecto ver", ...DE_CONFIGURAR_CON_REPO, "widget", "instalacion anotar"],
|
|
13
|
-
texto: "--repo es de `supcoflow repo conectar`, `proyecto crear|ver`, `widget`, `instalacion anotar` y los comandos que configuran un proyecto (no de `catalogo`).",
|
|
13
|
+
flags: ["repo"], comandos: ["repo conectar", "proyecto crear", "proyecto ver", ...DE_CONFIGURAR_CON_REPO, "widget", "instalacion anotar", "diagnosticar", "probar"],
|
|
14
|
+
texto: "--repo es de `supcoflow repo conectar`, `proyecto crear|ver`, `widget`, `instalacion anotar`, `diagnosticar`, `probar` y los comandos que configuran un proyecto (no de `catalogo`).",
|
|
14
15
|
},
|
|
15
16
|
{ flags: ["install", "test"], comandos: ["proyecto crear"], texto: "--install y --test son de `supcoflow proyecto crear`." },
|
|
16
17
|
{ flags: ["sitio"], comandos: ["sitios agregar", "sitios quitar"], texto: "--sitio es de `supcoflow sitios agregar|quitar`." },
|
|
@@ -22,6 +23,7 @@ export const FLAGS_POR_COMANDO = [
|
|
|
22
23
|
{ flags: ["origen"], comandos: ["guia usar"], texto: "--origen es de `supcoflow guia usar`." },
|
|
23
24
|
{ flags: ["desdeGithub"], comandos: ["guia publicar"], texto: "--desde-github es de `supcoflow guia publicar`." },
|
|
24
25
|
{ flags: ["id"], comandos: ["claves revocar"], texto: "--id es de `supcoflow claves revocar`." },
|
|
26
|
+
{ flags: ["autorizoGasto", "mensaje", "prueba"], comandos: ["probar"], texto: "--autorizo-gasto, --mensaje y --prueba son de `supcoflow probar`." },
|
|
25
27
|
{ flags: ["skill", "app"], comandos: ["instalacion anotar"], texto: "--skill y --app son de `supcoflow instalacion anotar`." },
|
|
26
28
|
];
|
|
27
29
|
/** El mensaje del primer flag presente que el comando no acepta, o null si todo aplica. */
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Lo que `probar` guarda en esta máquina, por origen y por proyecto (bloque 7, hallazgo 5): el INTENTO
|
|
2
|
+
// (`{clave, mensaje, techo_usd}`) y la última prueba confirmada. Fuera del repo, al lado de las credenciales.
|
|
3
|
+
//
|
|
4
|
+
// Por qué no alcanza el registro de idempotencia (src/idempotencia.ts): ése guarda sólo la clave. El motor
|
|
5
|
+
// compara la huella del pedido (proyecto, techo, mensaje) y una misma clave con otro mensaje es
|
|
6
|
+
// `idempotency_key_reusada`; para que el reintento tras una respuesta perdida recupere la MISMA prueba hay
|
|
7
|
+
// que reenviar la clave con el mensaje y el techo que llevó. Por eso se guarda el intento entero.
|
|
8
|
+
//
|
|
9
|
+
// Por qué una sola cuenta con las dos cosas: «guardar el id» y «cerrar el intento» son UNA escritura
|
|
10
|
+
// atómica (el `rename` del almacén). Si falla, el archivo queda como estaba: el intento sigue abierto, el
|
|
11
|
+
// próximo comando reenvía la misma clave, recibe la misma prueba y vuelve a intentar (R14d). Con dos
|
|
12
|
+
// escrituras, un corte entre medio dejaba la última guardada y el intento abierto, y la próxima «prueba
|
|
13
|
+
// nueva» sería en realidad un reintento de la vieja.
|
|
14
|
+
//
|
|
15
|
+
// Estricto en todo, como `claves crear` en el registro de idempotencia: una prueba no tiene clave natural
|
|
16
|
+
// del lado de la terminal, así que mandar sin el intento guardado arriesga una segunda prueba (que gasta).
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
|
|
19
|
+
import { dentroDeUnWorkingTree } from "./guardado/repo.js";
|
|
20
|
+
function esIntento(v) {
|
|
21
|
+
if (typeof v !== "object" || v === null)
|
|
22
|
+
return false;
|
|
23
|
+
const i = v;
|
|
24
|
+
return typeof i.clave === "string" && i.clave.length > 0 && (i.mensaje === null || typeof i.mensaje === "string") && typeof i.techo_usd === "number";
|
|
25
|
+
}
|
|
26
|
+
export function crearRegistroDePruebas(d) {
|
|
27
|
+
// Por origen, como la clave y el registro de idempotencia: un motor local (o el proxy de P4) y
|
|
28
|
+
// producción no comparten intentos. Cambiar de origen entre dos comandos es otra clave.
|
|
29
|
+
const cuenta = (proyectoId) => `${d.origen}#prueba#${proyectoId}`;
|
|
30
|
+
const noGuardada = (que) => new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `No pude ${que} ${d.rutaDelArchivo}; sin eso, un reintento podría pedir otra prueba (y gastar dos veces). Revisá ese archivo (no puede estar dentro de un repo) y probá de nuevo.`);
|
|
31
|
+
async function leer(proyectoId) {
|
|
32
|
+
let crudo;
|
|
33
|
+
try {
|
|
34
|
+
crudo = (await d.almacen.leer(cuenta(proyectoId)))?.valor ?? null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw noGuardada("leer");
|
|
38
|
+
}
|
|
39
|
+
if (crudo === null)
|
|
40
|
+
return { intento: null, ultima: null };
|
|
41
|
+
let v;
|
|
42
|
+
try {
|
|
43
|
+
v = JSON.parse(crudo);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw noGuardada("entender");
|
|
47
|
+
}
|
|
48
|
+
const o = (typeof v === "object" && v !== null ? v : {});
|
|
49
|
+
const intentoOk = o.intento === null || esIntento(o.intento);
|
|
50
|
+
const ultimaOk = o.ultima === null || typeof o.ultima === "string";
|
|
51
|
+
if (!intentoOk || !ultimaOk)
|
|
52
|
+
throw noGuardada("entender");
|
|
53
|
+
const i = o.intento;
|
|
54
|
+
return { intento: i === null ? null : { clave: i.clave, mensaje: i.mensaje, techoUsd: i.techo_usd }, ultima: o.ultima };
|
|
55
|
+
}
|
|
56
|
+
async function escribir(proyectoId, g) {
|
|
57
|
+
// Misma regla que las credenciales: un destino dentro de un repo se publicaría con un `git add -A`.
|
|
58
|
+
// No saber dónde cae (un enlace roto) es otro «no se puede guardar».
|
|
59
|
+
let raiz;
|
|
60
|
+
try {
|
|
61
|
+
raiz = dentroDeUnWorkingTree(path.dirname(d.rutaDelArchivo), d.fs);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw noGuardada("resolver la ruta de");
|
|
65
|
+
}
|
|
66
|
+
if (raiz !== null) {
|
|
67
|
+
throw new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `No guardo el pedido de la prueba dentro de un repo de git (${raiz}); sin guardarlo no lo mando. Apuntá XDG_CONFIG_HOME fuera de un repo.`);
|
|
68
|
+
}
|
|
69
|
+
const valor = JSON.stringify({
|
|
70
|
+
intento: g.intento === null ? null : { clave: g.intento.clave, mensaje: g.intento.mensaje, techo_usd: g.intento.techoUsd },
|
|
71
|
+
ultima: g.ultima,
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
await d.almacen.escribir(cuenta(proyectoId), { valor, escritoEn: d.ahora() });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw noGuardada("guardar en");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
leer,
|
|
82
|
+
async abrir(proyectoId, intento) {
|
|
83
|
+
const antes = await leer(proyectoId);
|
|
84
|
+
await escribir(proyectoId, { intento, ultima: antes.ultima });
|
|
85
|
+
},
|
|
86
|
+
async confirmar(proyectoId, pruebaId) {
|
|
87
|
+
await leer(proyectoId);
|
|
88
|
+
await escribir(proyectoId, { intento: null, ultima: pruebaId });
|
|
89
|
+
},
|
|
90
|
+
async descartar(proyectoId) {
|
|
91
|
+
const antes = await leer(proyectoId);
|
|
92
|
+
await escribir(proyectoId, { intento: null, ultima: antes.ultima });
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
package/package.json
CHANGED