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.
@@ -15,7 +15,7 @@ export async function proyectoDelRepo(d, flags) {
15
15
  try {
16
16
  const proyecto = await d.buscarProyecto(d.api, leida.vigente.clave, repo);
17
17
  if (proyecto === null) {
18
- const message = `El repo ${repo} todavía no tiene proyecto. Creá el proyecto: \`supcoflow proyecto crear\`.`;
18
+ const message = `The repo ${repo} doesn't have a project yet. Create the project: \`supcoflow project create\`.`;
19
19
  return { ok: false, resultado: { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "sin_proyecto", message, repo }, texto: message } };
20
20
  }
21
21
  return { ok: true, clave: leida.vigente.clave, proyecto };
@@ -52,7 +52,7 @@ function mismoArchivo(crudo, p) {
52
52
  }
53
53
  function resultadoDelProyecto(p, reusado, raiz, d) {
54
54
  const nuevo = { id: p.id, slug: p.slug, repo: p.repo };
55
- const queda = reusado ? `El proyecto ${p.id} ya existía y lo reusé` : `Creé el proyecto ${p.id}`;
55
+ const queda = reusado ? `Project ${p.id} already existed and I reused it` : `I created project ${p.id}`;
56
56
  let archivo = null;
57
57
  if (raiz !== null) {
58
58
  archivo = path.join(raiz, ".supcoflow", "proyecto.json");
@@ -63,8 +63,8 @@ function resultadoDelProyecto(p, reusado, raiz, d) {
63
63
  // decida. Su contenido NUNCA se copia a la salida (puede traer cualquier cosa, una clave
64
64
  // incluida, y `emitir` no sanea): sólo el id, si tiene forma de id.
65
65
  const idActual = idDelArchivo(actual);
66
- const message = `Ya hay un \`.supcoflow/proyecto.json\` de otro proyecto (${idActual ?? "que no reconozco"}); no lo toqué. `
67
- + `${queda} igual (${p.id}): si es el que querés, borrá el archivo y volvé a correr \`supcoflow proyecto crear\`.`;
66
+ const message = `There is already a \`.supcoflow/proyecto.json\` for another project (${idActual ?? "one I don't recognize"}); I didn't touch it. `
67
+ + `${queda} anyway: if that's the one you want, delete the file and run \`supcoflow project create\` again.`;
68
68
  return fallo(SALIDA.haceFaltaUnaPersona, "archivo_distinto", message, { archivo, id_actual: idActual, nuevo, reusado });
69
69
  }
70
70
  if (actual === null) {
@@ -72,7 +72,7 @@ function resultadoDelProyecto(p, reusado, raiz, d) {
72
72
  d.archivos.escribir(archivo, contenidoDelArchivo(p));
73
73
  }
74
74
  catch {
75
- const message = `${queda} (${p.id}), pero no pude escribir ${archivo}. Volvé a correr \`supcoflow proyecto crear\`.`;
75
+ const message = `${queda}, but I couldn't write ${archivo}. Run \`supcoflow project create\` again.`;
76
76
  return fallo(SALIDA.inesperado, "archivo_no_escrito", message, { archivo, nuevo, reusado });
77
77
  }
78
78
  }
@@ -80,7 +80,7 @@ function resultadoDelProyecto(p, reusado, raiz, d) {
80
80
  return {
81
81
  codigo: SALIDA.ok,
82
82
  json: { ok: true, proyecto: { id: p.id, slug: p.slug, repo: p.repo, rama: p.rama }, reusado, archivo },
83
- texto: `${queda}: ${p.repo}, rama ${p.rama}.${archivo !== null ? ` Lo anoté en ${archivo}.` : ""}`,
83
+ texto: `${queda}: ${p.repo}, branch ${p.rama}.${archivo !== null ? ` Recorded in ${archivo}.` : ""}`,
84
84
  };
85
85
  }
86
86
  export async function proyectoCrear(d, flags) {
@@ -116,18 +116,18 @@ export async function proyectoCrear(d, flags) {
116
116
  if (flags.install !== null || flags.test !== null) {
117
117
  // Sin copiar los valores: son texto del usuario y el aviso sólo dice qué pasó con ellos. Callarlo
118
118
  // haría creer que el proyecto quedó con los comandos pedidos.
119
- d.avisar("El proyecto ya existe: ignoré --install/--test. Para cambiar sus comandos hay que configurarlo.");
119
+ d.avisar("The project already exists: I ignored --install/--test. To change its commands, it has to be configured.");
120
120
  }
121
121
  return resultadoDelProyecto(existente, true, raiz, d);
122
122
  }
123
123
  if (conexion === null || conexion.estado !== "conectado") {
124
- return fallo(SALIDA.haceFaltaUnaPersona, "repo_sin_conectar", "Conectá el repo primero: `supcoflow repo conectar`.", {
124
+ return fallo(SALIDA.haceFaltaUnaPersona, "repo_sin_conectar", "Connect the repo first: `supcoflow repo connect`.", {
125
125
  repo, ...(conexion !== null ? { estado: conexion.estado } : {}),
126
126
  });
127
127
  }
128
128
  if (flags.dryRun) {
129
129
  const haria = `POST /api/conexiones/${conexion.id}/proyecto`;
130
- return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, repo, haria }, texto: `Con --dry-run no hago nada; sin él, crearía el proyecto de ${repo} (${haria}).` };
130
+ return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, repo, haria }, texto: `With --dry-run I do nothing; without it: create the project for ${repo} (${haria}).` };
131
131
  }
132
132
  const cuerpo = {};
133
133
  if (flags.install !== null)
@@ -153,27 +153,27 @@ export async function proyectoCrear(d, flags) {
153
153
  await d.idempotencia.olvidar(OPERACION, repo);
154
154
  const install = flags.install ?? "…";
155
155
  const test = flags.test ?? "…";
156
- const message = `${alta.message || "Faltan comandos del proyecto."} Pasalos a mano: \`supcoflow proyecto crear --install ${comillas(install)} --test ${comillas(test)}\`.`;
156
+ const message = `${alta.message || "The project is missing commands."} Pass them yourself: \`supcoflow project create --install ${comillas(install)} --test ${comillas(test)}\`.`;
157
157
  return fallo(SALIDA.entradaInvalida, "comandos_faltantes", message, { faltantes: alta.faltantes });
158
158
  }
159
159
  case "no_conectada":
160
160
  await d.idempotencia.olvidar(OPERACION, repo);
161
- return fallo(SALIDA.haceFaltaUnaPersona, "repo_sin_conectar", alta.message || "Conectá el repo primero: `supcoflow repo conectar`.", { repo });
161
+ return fallo(SALIDA.haceFaltaUnaPersona, "repo_sin_conectar", alta.message || "Connect the repo first: `supcoflow repo connect`.", { repo });
162
162
  case "en_curso":
163
163
  // Se conserva: el pedido en curso puede ser el nuestro y el reintento tiene que poder retomarlo.
164
- return fallo(SALIDA.inesperado, "en_curso", "Otro pedido con esta clave está en curso; probá en un minuto.");
164
+ return fallo(SALIDA.inesperado, "en_curso", "Another request with this key is already in progress; try again in a minute.");
165
165
  case "clave_reusada":
166
166
  // No debería pasar (la clave es por repo y un reintento manda lo mismo); olvidarla deja reintentar limpio.
167
167
  await d.idempotencia.olvidar(OPERACION, repo);
168
- return fallo(SALIDA.inesperado, "clave_reusada", "El motor rechazó la clave de idempotencia guardada; la descarté, probá de nuevo.");
168
+ return fallo(SALIDA.inesperado, "clave_reusada", "The engine rejected the saved idempotency key; I discarded it, try again.");
169
169
  case "rechazo": {
170
170
  // La clave se conserva: tras un rechazo el motor ya la soltó, y el próximo intento la reusa sin
171
171
  // chocar. Lo que cambia es el código: no todo rechazo es culpa de lo que se escribió.
172
- const message = alta.message || "El motor rechazó el pedido.";
172
+ const message = alta.message || "The engine rejected the request.";
173
173
  if (RECHAZOS_DE_UNA_PERSONA.has(alta.error)) {
174
174
  // Lo arregla alguien con permisos en GitHub (instalar la App, sumarle el repo): reintentar o
175
175
  // cambiar flags no sirve, y un 3 haría que un asistente siguiera probando entradas.
176
- return fallo(SALIDA.haceFaltaUnaPersona, alta.error, `${message} Revisá la instalación de la App en GitHub y volvé a conectar con \`supcoflow repo conectar\`.`);
176
+ return fallo(SALIDA.haceFaltaUnaPersona, alta.error, `${message} Check the App's installation on GitHub and connect again with \`supcoflow repo connect\`.`);
177
177
  }
178
178
  // Dos altas al mismo tiempo, o un proyecto borrado en el medio: el mismo pedido otra vez anda.
179
179
  if (RECHAZOS_TRANSITORIOS.has(alta.error))
@@ -197,14 +197,14 @@ export async function proyectoVer(d, flags) {
197
197
  try {
198
198
  const encontrado = await d.buscarProyecto(d.api, clave, repo);
199
199
  if (encontrado === null) {
200
- return { codigo: SALIDA.ok, json: { ok: true, repo, proyecto: null }, texto: "Este repo todavía no tiene proyecto: `supcoflow proyecto crear`." };
200
+ return { codigo: SALIDA.ok, json: { ok: true, repo, proyecto: null }, texto: "This repo doesn't have a project yet: `supcoflow project create`." };
201
201
  }
202
202
  const { proyecto: p, github } = await d.leerProyecto(d.api, clave, encontrado.id);
203
203
  const proyecto = { id: p.id, slug: p.slug, repo: p.repo, rama: p.rama, install_command: p.installCommand, test_command: p.testCommand };
204
204
  return {
205
205
  codigo: SALIDA.ok,
206
206
  json: { ok: true, proyecto, github: { estado: github.estado, verificado_en: github.verificadoEn } },
207
- texto: `${p.id} (${p.repo}, rama ${p.rama}): instala con \`${p.installCommand}\`, prueba con \`${p.testCommand}\`. GitHub: ${github.estado ?? "sin verificar"}.`,
207
+ texto: `${p.id} (${p.repo}, branch ${p.rama}): installs with \`${p.installCommand}\`, tests with \`${p.testCommand}\`. GitHub: ${github.estado ?? "not verified"}.`,
208
208
  };
209
209
  }
210
210
  catch (e) {
@@ -1,4 +1,5 @@
1
1
  import { FalloDeLaTerminal, resultadoDeFallo, SALIDA } from "../contrato.js";
2
+ import { NOMBRE_DE_LA_FUENTE } from "../guardado/index.js";
2
3
  import { claveVigente } from "./clave-vigente.js";
3
4
  export async function comandoQuienSoy(d) {
4
5
  const origen = d.api.origin;
@@ -7,20 +8,20 @@ export async function comandoQuienSoy(d) {
7
8
  return leida.resultado;
8
9
  const vigente = leida.vigente;
9
10
  if (!vigente.completa) {
10
- const otro = vigente.fuente === "archivo" ? "el llavero del sistema" : "el archivo de credenciales";
11
- d.avisar(`Uso la clave de ${vigente.fuente}, pero no pude leer ${otro}: puede haber guardada otra más nueva.`);
11
+ const otro = vigente.fuente === "archivo" ? NOMBRE_DE_LA_FUENTE.llavero : NOMBRE_DE_LA_FUENTE.archivo;
12
+ d.avisar(`I'm using the key in ${NOMBRE_DE_LA_FUENTE[vigente.fuente]}, but I couldn't read ${otro}: a newer key may be saved there.`);
12
13
  }
13
14
  // Leer no cuesta nada y avisa de algo que la persona no vería de otro modo: claves viejas vivas.
14
15
  try {
15
16
  const { claves, completa } = await d.guardado.leerPorRevocar(origen);
16
17
  if (claves.length > 0) {
17
- d.avisar(`Hay ${claves.length} clave(s) anterior(es) anotadas para revocar; se reintenta en el próximo login o logout.`);
18
+ d.avisar(`${claves.length} previous key(s) are noted down to be revoked; I'll try again on the next login or logout.`);
18
19
  }
19
20
  if (!completa)
20
- d.avisar("No pude leer entera la lista de claves por revocar.");
21
+ d.avisar("I couldn't read the whole list of keys waiting to be revoked.");
21
22
  }
22
23
  catch {
23
- d.avisar("No pude leer entera la lista de claves por revocar.");
24
+ d.avisar("I couldn't read the whole list of keys waiting to be revoked.");
24
25
  }
25
26
  let yo;
26
27
  try {
@@ -30,7 +31,7 @@ export async function comandoQuienSoy(d) {
30
31
  // Con la variable definida, «iniciá sesión de nuevo» no arregla nada: la variable sigue
31
32
  // ganándole a la clave nueva. Hay que decir cuál es la que no vale.
32
33
  if (e instanceof FalloDeLaTerminal && e.codigo === SALIDA.faltaLogin && vigente.fuente === "env") {
33
- return resultadoDeFallo(new FalloDeLaTerminal(e.codigo, e.error, "La clave de SUPCOFLOW_TOKEN no vale: sacala de tu entorno o reemplazala por una vigente."));
34
+ return resultadoDeFallo(new FalloDeLaTerminal(e.codigo, e.error, "The key in SUPCOFLOW_TOKEN is not valid: remove it from your environment or replace it with a valid one."));
34
35
  }
35
36
  return resultadoDeFallo(e);
36
37
  }
@@ -38,6 +39,6 @@ export async function comandoQuienSoy(d) {
38
39
  return {
39
40
  codigo: SALIDA.ok,
40
41
  json: { ok: true, via: yo.via, equipo: yo.equipo, usuario: yo.usuario, proyecto: yo.proyecto, fuente: vigente.fuente },
41
- texto: `${quien} en el equipo ${yo.equipo} (clave en ${vigente.fuente})`,
42
+ texto: `${quien} in team ${yo.equipo} (key in ${NOMBRE_DE_LA_FUENTE[vigente.fuente]})`,
42
43
  };
43
44
  }
@@ -17,22 +17,22 @@ function resultadoDelPedido(repo, p, avisar) {
17
17
  return {
18
18
  codigo: SALIDA.ok,
19
19
  json: { ok: true, estado: "conectado", repo, repo_canonico: p.repoCanonico, pedido },
20
- texto: `${p.repoCanonico ?? repo} está conectado.`,
20
+ texto: `${p.repoCanonico ?? repo} is connected.`,
21
21
  };
22
22
  case "repo_fuera_de_alcance": {
23
- const message = `La App está instalada pero no alcanza a ${repo}: sumalo en GitHub y volvé a tocar Confirmar (${p.url}).`;
23
+ const message = `The App is installed but doesn't have access to ${repo}: add it in GitHub and click Confirm again (${p.url}).`;
24
24
  return fallo(SALIDA.haceFaltaUnaPersona, "repo_fuera_de_alcance", message, { repo, url: p.url, pedido });
25
25
  }
26
26
  case "vencido":
27
- return fallo(SALIDA.haceFaltaUnaPersona, "pedido_vencido", "El pedido venció. Pedí otro con `supcoflow repo conectar --start`.", { repo, pedido });
27
+ return fallo(SALIDA.haceFaltaUnaPersona, "pedido_vencido", "The request has expired. Ask for a new one with `supcoflow repo connect --start`.", { repo, pedido });
28
28
  case "pendiente": {
29
29
  // Un motivo anotado no cierra el pedido: la persona puede reintentar desde la misma página.
30
30
  if (p.fallo !== null)
31
- avisar(`La última confirmación falló: ${p.fallo}.`);
31
+ avisar(`The last confirmation failed: ${p.fallo}.`);
32
32
  return {
33
33
  codigo: SALIDA.esperandoAUnaPersona,
34
34
  json: { ok: false, esperando: "persona", repo, url: p.url, pedido, ...(p.fallo !== null ? { fallo: p.fallo } : {}) },
35
- texto: `Abrí ${p.url}, instalá la App en GitHub si hace falta y tocá Confirmar.`,
35
+ texto: `Open ${p.url}, install the App on GitHub if needed, and click Confirm.`,
36
36
  };
37
37
  }
38
38
  }
@@ -49,7 +49,7 @@ export async function repoConectarStart(d, flags) {
49
49
  return {
50
50
  codigo: SALIDA.ok,
51
51
  json: { ok: true, dry_run: true, repo, haria: "POST /api/conexiones" },
52
- texto: `Con --dry-run no hago nada; sin él, pediría conectar ${repo} (POST /api/conexiones).`,
52
+ texto: `With --dry-run I do nothing; without it: ask to connect ${repo} (POST /api/conexiones).`,
53
53
  };
54
54
  }
55
55
  const leida = await claveVigente(d.guardado, d.api.origin);
@@ -92,7 +92,7 @@ export async function repoConectarWait(d, flags) {
92
92
  return resultadoDeFallo(e);
93
93
  }
94
94
  if (p === null) {
95
- return fallo(SALIDA.entradaInvalida, "sin_conexion_en_curso", `No hay una conexión en curso para ${repo}: empezá con \`supcoflow repo conectar --start\`.`, { repo });
95
+ return fallo(SALIDA.entradaInvalida, "sin_conexion_en_curso", `There is no connection in progress for ${repo}: start with \`supcoflow repo connect --start\`.`, { repo });
96
96
  }
97
97
  ultimo = p;
98
98
  if (p.estado !== "pendiente")
@@ -110,12 +110,12 @@ export async function repoConectarWait(d, flags) {
110
110
  // que nadie va a mirar.
111
111
  export async function repoConectarInteractivo(d, flags) {
112
112
  if (!d.esTty || flags.json) {
113
- return fallo(SALIDA.entradaInvalida, "hace_falta_tty", "Sin terminal interactiva usá `supcoflow repo conectar --start --json` y después `supcoflow repo conectar --wait --json`.");
113
+ return fallo(SALIDA.entradaInvalida, "hace_falta_tty", "Without an interactive terminal, use `supcoflow repo connect --start --json` and then `supcoflow repo connect --wait --json`.");
114
114
  }
115
115
  const inicio = await repoConectarStart(d, flags);
116
116
  if (inicio.codigo !== SALIDA.esperandoAUnaPersona)
117
117
  return inicio;
118
- d.mostrar(`${inicio.texto} Espero acá…`);
118
+ d.mostrar(`${inicio.texto} I'll wait here...`);
119
119
  let r;
120
120
  do {
121
121
  r = await repoConectarWait(d, flags);
@@ -1,20 +1,24 @@
1
1
  import { resultadoDeFallo, SALIDA } from "../contrato.js";
2
2
  import { proyectoDelRepo } from "./proyecto-del-repo.js";
3
3
  function textoDe(w) {
4
+ const anteriores = w.nextjs.archivos_anteriores;
4
5
  return [
5
- "1. Instalá el paquete (con el gestor de la app, la versión exacta):",
6
+ "1. Install the package (with the app's package manager, the exact version):",
6
7
  ` ${w.instalar}`,
7
8
  "",
8
- `2. Creá el archivo ${w.nextjs.archivo_sugerido} con este contenido (Next.js, ${w.nextjs.estado}):`,
9
+ `2. Create the file ${w.nextjs.archivo_sugerido} with this content (Next.js, ${w.nextjs.estado}):`,
9
10
  w.nextjs.contenido.trimEnd(),
11
+ ...(anteriores.length > 0
12
+ ? [` If the chat is already installed in ${anteriores.join(" or ")}, update that file in place instead of creating a second one.`]
13
+ : []),
10
14
  "",
11
- "3. Usalo en el layout que cubre las pantallas donde se traban los usuarios:",
15
+ "3. Use it in the layout that covers the screens where users get stuck:",
12
16
  ` ${w.nextjs.uso}`,
13
17
  "",
14
- "4. Política de contenido (CSP): si la app tiene `connect-src`, agregale este origen conservando lo que ya tiene:",
18
+ "4. Content Security Policy (CSP): if the app has `connect-src`, add this origin and keep what it already has:",
15
19
  ` ${w.csp}`,
16
20
  "",
17
- "Alternativa con <script> (disponible, no probada en una app real):",
21
+ "Alternative with <script> (available, not tested in a real app):",
18
22
  ` ${w.script.linea}`,
19
23
  ].join("\n");
20
24
  }
@@ -0,0 +1,48 @@
1
+ export const TOTAL_DE_INSTALACION = 6;
2
+ // El orden y los nombres con que se dicen: los ids de los puntos del motor son datos suyos y no cambian.
3
+ const DE_LA_INSTALACION = [
4
+ { punto: "sign-in", como: "sign-in" },
5
+ { punto: "repo", como: "repo" },
6
+ { punto: "instalacion", como: "GitHub App" },
7
+ { punto: "sitios", como: "allowed sites" },
8
+ { punto: "chat", como: "chat" },
9
+ { punto: "guia", como: "guide" },
10
+ ];
11
+ const DEL_MOTOR = new Set(["instalacion", "sitios", "chat", "guia"]);
12
+ export const lineaDeLoRevisado = (n) => `Checked ${DE_LA_INSTALACION.map((x) => x.como).slice(0, -1).join(", ")} and ${DE_LA_INSTALACION.at(-1).como}: ${n} of ${TOTAL_DE_INSTALACION}, at no cost.`;
13
+ export const nombreDelPunto = (punto) => DE_LA_INSTALACION.find((x) => x.punto === punto)?.como ?? punto;
14
+ export function puntoDeSesion() {
15
+ return { punto: "sign-in", ok: true, mensaje: "This request was authenticated: you're signed in.", grupo: "instalacion" };
16
+ }
17
+ export function puntoDelRepo(local, repoDelProyecto) {
18
+ const mal = (mensaje) => ({ punto: "repo", ok: false, mensaje, grupo: "instalacion" });
19
+ if (local.repo === null) {
20
+ if (local.motivo === "sin_git")
21
+ return mal("This folder isn't a git repo, so I can't tell which repo it is.");
22
+ if (local.motivo === "sin_origin")
23
+ return mal("This repo has no «origin» remote, so I can't tell which repo it is.");
24
+ return mal("The «origin» of this repo isn't a GitHub repo.");
25
+ }
26
+ if (local.repo.toLowerCase() !== repoDelProyecto.toLowerCase()) {
27
+ return mal(`The origin of this folder is ${local.repo}, not the project's repo ${repoDelProyecto}.`);
28
+ }
29
+ return { punto: "repo", ok: true, mensaje: `The origin of this folder is ${local.repo}, the project's repo.`, grupo: "instalacion" };
30
+ }
31
+ /** Reparte los puntos del motor en los dos grupos y suma los dos locales al de la instalación. */
32
+ export function agruparPuntos(g, locales) {
33
+ const delMotor = g.puntos.map((p) => ({ ...p, grupo: p.grupo ?? (DEL_MOTOR.has(p.punto) ? "instalacion" : "prueba") }));
34
+ const instalacion = [...locales, ...delMotor.filter((p) => p.grupo === "instalacion")];
35
+ // Un punto del grupo que el motor no mandó no se da por bueno: se dice que falta y cuenta como no ok.
36
+ for (const { punto } of DE_LA_INSTALACION) {
37
+ if (!instalacion.some((p) => p.punto === punto)) {
38
+ instalacion.push({ punto, ok: false, mensaje: "The engine didn't report this point.", grupo: "instalacion" });
39
+ }
40
+ }
41
+ const orden = (p) => {
42
+ const i = DE_LA_INSTALACION.findIndex((x) => x.punto === p.punto);
43
+ return i === -1 ? DE_LA_INSTALACION.length : i;
44
+ };
45
+ return { instalacion: instalacion.sort((a, b) => orden(a) - orden(b)), prueba: delMotor.filter((p) => p.grupo === "prueba") };
46
+ }
47
+ /** Cuántos de los seis puntos de la instalación están bien (los extras que un motor nuevo sume no cambian el seis). */
48
+ export const contarBuenos = (instalacion) => DE_LA_INSTALACION.filter(({ punto }) => instalacion.some((p) => p.punto === punto && p.ok)).length;
package/dist/entorno.js CHANGED
@@ -3,17 +3,19 @@
3
3
  import { execFile, spawn } from "node:child_process";
4
4
  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
+ import { NO_LEIDO } from "./lectura.js";
6
7
  import { homedir, hostname } from "node:os";
7
8
  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";
9
+ 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, leerVersionesDeLaGuia, } from "./api.js";
9
10
  import { crearCandados } from "./candado.js";
10
- import { esCarpetaReal, rutaRealOEnNada } from "./comandos/instalacion.js";
11
+ import { esCarpetaReal, esEnlaceReal, rutaRealOEnNada } from "./comandos/instalacion.js";
11
12
  import { DESCRIPCION } from "./comandos/describir.js";
12
13
  import { almacenDeArchivo } from "./guardado/archivo.js";
13
14
  import { crearGuardado } from "./guardado/index.js";
14
15
  import { almacenDeLlavero } from "./guardado/llavero.js";
15
16
  import { crearGuardaDeSecretos } from "./secreto-a-archivo.js";
16
17
  import { crearRegistroDeIdempotencia } from "./idempotencia.js";
18
+ import { crearRegistroDePruebas } from "./pruebas-guardadas.js";
17
19
  import { TRAMO_MAXIMO_MS } from "./sondeo.js";
18
20
  // El `.tmp` se crea en exclusiva (`wx`: falla si quedó algo, enlace incluido, en vez de escribir a
19
21
  // través de él) y el `fchmod` va aparte porque el modo de `open` pasa por la umask del proceso.
@@ -50,7 +52,7 @@ export const fsReal = {
50
52
  return realpathSync(p);
51
53
  }
52
54
  catch {
53
- throw new Error(`No puedo resolver la ruta real de ${p} (¿un enlace roto?); no guardo nada ahí.`);
55
+ throw new Error(`I can't resolve the real path of ${p} (a broken symlink?); I'm not saving anything there.`);
54
56
  }
55
57
  },
56
58
  // `lstat` y no `existsSync`: éste sigue el enlace y ante uno roto dice «no existe», y el control
@@ -251,7 +253,7 @@ export const TOPE_DEL_ARCHIVO_DEL_REPO = 64 * 1024;
251
253
  // Lo que devuelve `leer` cuando hay ALGO ahí que no se lee (un enlace, una carpeta, algo enorme, sin
252
254
  // permiso). No es JSON a propósito: `proyecto crear` lo trata como un archivo distinto (sale 4, «no lo
253
255
  // toqué») sin que nada de lo que hay ahí llegue a la salida.
254
- export const ARCHIVO_QUE_NO_SE_LEE = "\u0000(no se lee)";
256
+ export const ARCHIVO_QUE_NO_SE_LEE = "\u0000(unreadable)";
255
257
  // Un archivo que la persona nombra (el logo) con las mismas defensas que los del repo: sin seguir
256
258
  // enlaces, sin FIFOs que cuelguen, sólo archivos comunes y con tope (se lee tope + 1 por si crece).
257
259
  export function leerArchivoChico(p, tope) {
@@ -261,11 +263,11 @@ export function leerArchivoChico(p, tope) {
261
263
  }
262
264
  catch (e) {
263
265
  const code = e.code;
264
- return { error: code === "ENOENT" ? "no existe" : code === "ELOOP" ? "es un enlace" : "no se pudo abrir" };
266
+ return { error: code === "ENOENT" ? NO_LEIDO.noExiste : code === "ELOOP" ? NO_LEIDO.esUnEnlace : NO_LEIDO.noSePudoAbrir };
265
267
  }
266
268
  try {
267
269
  if (!fstatSync(fd).isFile())
268
- return { error: "no es un archivo común" };
270
+ return { error: NO_LEIDO.noEsComun };
269
271
  const buf = Buffer.alloc(tope + 1);
270
272
  let n = 0;
271
273
  for (;;) {
@@ -274,12 +276,12 @@ export function leerArchivoChico(p, tope) {
274
276
  break;
275
277
  n += leidos;
276
278
  if (n > tope)
277
- return { error: "es demasiado grande" };
279
+ return { error: NO_LEIDO.demasiadoGrande };
278
280
  }
279
281
  return { bytes: new Uint8Array(buf.subarray(0, n)) };
280
282
  }
281
283
  catch {
282
- return { error: "no se pudo leer" };
284
+ return { error: NO_LEIDO.noSePudoLeer };
283
285
  }
284
286
  finally {
285
287
  closeSync(fd);
@@ -298,7 +300,7 @@ export function archivosDelRepo(avisar) {
298
300
  }
299
301
  };
300
302
  const noSeLee = (p, porque) => {
301
- avisar(`No leo ${p}: ${porque}.`);
303
+ avisar(`I can't read ${p}: ${porque}.`);
302
304
  return ARCHIVO_QUE_NO_SE_LEE;
303
305
  };
304
306
  return {
@@ -307,7 +309,7 @@ export function archivosDelRepo(avisar) {
307
309
  if (carpeta === "ausente")
308
310
  return null;
309
311
  if (carpeta === "ajena")
310
- return noSeLee(p, `${basename(dirname(p))} no es una carpeta común (¿un enlace?)`);
312
+ return noSeLee(p, `${basename(dirname(p))} isn't a regular folder (a symlink?)`);
311
313
  let fd;
312
314
  try {
313
315
  // O_NOFOLLOW: un enlace en el último tramo falla (ELOOP) en vez de abrir su destino.
@@ -318,14 +320,14 @@ export function archivosDelRepo(avisar) {
318
320
  const code = e.code;
319
321
  if (code === "ENOENT")
320
322
  return null;
321
- return noSeLee(p, code === "ELOOP" ? "es un enlace" : "no se pudo abrir");
323
+ return noSeLee(p, code === "ELOOP" ? NO_LEIDO.esUnEnlace : NO_LEIDO.noSePudoAbrir);
322
324
  }
323
325
  try {
324
326
  const st = fstatSync(fd);
325
327
  if (!st.isFile())
326
- return noSeLee(p, "no es un archivo común");
328
+ return noSeLee(p, NO_LEIDO.noEsComun);
327
329
  if (st.size > TOPE_DEL_ARCHIVO_DEL_REPO)
328
- return noSeLee(p, "es demasiado grande");
330
+ return noSeLee(p, NO_LEIDO.demasiadoGrande);
329
331
  // Se lee hasta el tope + 1 y no `st.size`: el archivo puede crecer entre el fstat y la lectura.
330
332
  const buf = Buffer.alloc(TOPE_DEL_ARCHIVO_DEL_REPO + 1);
331
333
  let n = 0;
@@ -335,12 +337,12 @@ export function archivosDelRepo(avisar) {
335
337
  break;
336
338
  n += leidos;
337
339
  if (n > TOPE_DEL_ARCHIVO_DEL_REPO)
338
- return noSeLee(p, "es demasiado grande");
340
+ return noSeLee(p, NO_LEIDO.demasiadoGrande);
339
341
  }
340
342
  return buf.subarray(0, n).toString("utf8");
341
343
  }
342
344
  catch {
343
- return noSeLee(p, "no se pudo leer");
345
+ return noSeLee(p, NO_LEIDO.noSePudoLeer);
344
346
  }
345
347
  finally {
346
348
  closeSync(fd);
@@ -354,7 +356,7 @@ export function archivosDelRepo(avisar) {
354
356
  const dir = dirname(p);
355
357
  mkdirSync(dir, { recursive: true });
356
358
  if (carpetaPropia(dir) !== "propia")
357
- throw new Error(`${dir} no es una carpeta común`);
359
+ throw new Error(`${dir} isn't a regular folder`);
358
360
  const tmp = join(dir, `.${basename(p)}.${randomUUID()}.tmp`);
359
361
  try {
360
362
  const fd = openSync(tmp, "wx", 0o644);
@@ -403,6 +405,11 @@ export function entornoReal(env = process.env) {
403
405
  ahora: Date.now,
404
406
  avisar,
405
407
  });
408
+ // El pedido de `probar` (intento + última prueba), con el mismo almacén y la misma regla: fuera de un repo.
409
+ const rutaDePruebas = join(dirname(rutaDelArchivo), "pruebas.json");
410
+ const pruebas = crearRegistroDePruebas({
411
+ origen: api.origin, almacen: almacenDeArchivo(rutaDePruebas, fsReal), rutaDelArchivo: rutaDePruebas, fs: fsReal, ahora: Date.now, avisar,
412
+ });
406
413
  const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
407
414
  const cwd = process.cwd();
408
415
  // Los candados de `claves crear` viven al lado de las credenciales, nunca en un repo.
@@ -460,7 +467,7 @@ export function entornoReal(env = process.env) {
460
467
  guia: {
461
468
  ...configurarEnElMotor,
462
469
  leerArchivoChico, fetch, gitDeclarado: gitRapido,
463
- revisar: revisarGuia, publicar: publicarGuia, ver: leerGuia, elegir: elegirOrigen,
470
+ revisar: revisarGuia, publicar: publicarGuia, ver: leerGuia, elegir: elegirOrigen, versiones: leerVersionesDeLaGuia,
464
471
  },
465
472
  claves: {
466
473
  ...configurarEnElMotor,
@@ -477,9 +484,16 @@ export function entornoReal(env = process.env) {
477
484
  // para lo que la terminal dice de sí misma.
478
485
  instalacion: {
479
486
  ...configurarEnElMotor,
480
- leerArchivoChico, rutaReal: rutaRealOEnNada, esCarpeta: esCarpetaReal,
487
+ leerArchivoChico, rutaReal: rutaRealOEnNada, esCarpeta: esCarpetaReal, esEnlace: esEnlaceReal,
481
488
  escribir: archivosDelRepo(avisar).escribir,
482
489
  terminal: DESCRIPCION.version,
483
490
  },
491
+ prueba: {
492
+ api, guardado, git: gitReal, cwd, avisar, pruebas, generar: randomUUID, ahora: Date.now, dormir, tramoMs,
493
+ buscarProyecto: configurarEnElMotor.buscarProyecto,
494
+ leerDiagnostico: (a, clave, id) => leerDiagnostico(a, clave, id, fetch),
495
+ pedirPrueba: (a, clave, id, intento) => pedirPrueba(a, clave, id, intento, fetch),
496
+ leerPrueba: (a, clave, id, pruebaId, plazoMs) => leerPrueba(a, clave, id, pruebaId, fetch, plazoMs),
497
+ },
484
498
  };
485
499
  }
@@ -1,28 +1,34 @@
1
1
  // Qué comandos aceptan cada flag que no es global. Un flag que no aplica sale 3 en vez de ignorarse:
2
- // un asistente que pasa `--repo` a `quien-soy` creería que preguntó por ese repo. Vive aparte de
3
- // `cli.ts` (que corre al importarse) para poder probarse sin lanzar el binario.
2
+ // un asistente que pasa `--repo` a `whoami` creería que preguntó por ese repo. Vive aparte de
3
+ // `cli.ts` (que corre al importarse) para poder probarse sin lanzar el binario. Los nombres de los
4
+ // campos son los de `Flags`; los textos nombran los flags y comandos canónicos.
4
5
  const DE_CONFIGURAR_CON_REPO = [
5
- "configuracion", "chat prender", "chat apagar", "chat marca", "chat logo", "sitios agregar", "sitios quitar",
6
- "datos fijar", "politica fijar", "claves crear", "claves listar", "claves revocar",
7
- "guia revisar", "guia publicar", "guia ver", "guia usar",
6
+ "config", "chat on", "chat off", "chat brand", "chat logo", "sites add", "sites remove",
7
+ "data set", "policy set", "keys create", "keys list", "keys revoke",
8
+ "guide check", "guide publish", "guide show", "guide use", "guide versions",
8
9
  ];
9
10
  export const FLAGS_POR_COMANDO = [
10
- { flags: ["start", "wait"], comandos: ["login", "repo conectar"], texto: "--start y --wait son de `supcoflow login` y `supcoflow repo conectar`." },
11
+ { flags: ["start"], comandos: ["login", "repo connect"], texto: "--start belongs to `supcoflow login` and `supcoflow repo connect`." },
12
+ { flags: ["wait"], comandos: ["login", "repo connect", "test"], texto: "--wait belongs to `supcoflow login`, `supcoflow repo connect` and `supcoflow test`." },
11
13
  {
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`).",
14
+ flags: ["repo"], comandos: ["repo connect", "project create", "project show", ...DE_CONFIGURAR_CON_REPO, "widget", "install record", "diagnose", "test"],
15
+ texto: "--repo belongs to `supcoflow repo connect`, `project create|show`, `widget`, `install record`, `diagnose`, `test` and the commands that configure a project (not to `catalog`).",
14
16
  },
15
- { flags: ["install", "test"], comandos: ["proyecto crear"], texto: "--install y --test son de `supcoflow proyecto crear`." },
16
- { flags: ["sitio"], comandos: ["sitios agregar", "sitios quitar"], texto: "--sitio es de `supcoflow sitios agregar|quitar`." },
17
- { flags: ["titulo", "saludo", "tema"], comandos: ["chat marca"], texto: "--titulo, --saludo y --tema son de `supcoflow chat marca`." },
18
- { flags: ["archivo"], comandos: ["chat logo"], texto: "--archivo es de `supcoflow chat logo`." },
19
- { flags: ["permitir"], comandos: ["datos fijar"], texto: "--permitir es de `supcoflow datos fijar`." },
20
- { flags: ["modo"], comandos: ["politica fijar"], texto: "--modo es de `supcoflow politica fijar`." },
21
- { flags: ["nombre", "guardarEn"], comandos: ["claves crear"], texto: "--nombre y --guardar-en son de `supcoflow claves crear`." },
22
- { flags: ["origen"], comandos: ["guia usar"], texto: "--origen es de `supcoflow guia usar`." },
23
- { flags: ["desdeGithub"], comandos: ["guia publicar"], texto: "--desde-github es de `supcoflow guia publicar`." },
24
- { flags: ["id"], comandos: ["claves revocar"], texto: "--id es de `supcoflow claves revocar`." },
25
- { flags: ["skill", "app"], comandos: ["instalacion anotar"], texto: "--skill y --app son de `supcoflow instalacion anotar`." },
17
+ { flags: ["install", "test"], comandos: ["project create"], texto: "--install and --test belong to `supcoflow project create`." },
18
+ { flags: ["site"], comandos: ["sites add", "sites remove"], texto: "--site belongs to `supcoflow sites add|remove`." },
19
+ { flags: ["title", "greeting", "theme"], comandos: ["chat brand"], texto: "--title, --greeting and --theme belong to `supcoflow chat brand`." },
20
+ { flags: ["file"], comandos: ["chat logo", "install record"], texto: "--file belongs to `supcoflow chat logo` and `supcoflow install record`." },
21
+ { flags: ["allow"], comandos: ["data set"], texto: "--allow belongs to `supcoflow data set`." },
22
+ { flags: ["mode"], comandos: ["policy set"], texto: "--mode belongs to `supcoflow policy set`." },
23
+ { flags: ["name", "saveTo"], comandos: ["keys create"], texto: "--name and --save-to belong to `supcoflow keys create`." },
24
+ { flags: ["source"], comandos: ["guide use"], texto: "--source belongs to `supcoflow guide use`." },
25
+ { flags: ["fromGithub"], comandos: ["guide publish"], texto: "--from-github belongs to `supcoflow guide publish`." },
26
+ { flags: ["id"], comandos: ["keys revoke"], texto: "--id belongs to `supcoflow keys revoke`." },
27
+ {
28
+ flags: ["authorizeSpend", "ceiling", "expectPolicy", "message", "testId"], comandos: ["test"],
29
+ texto: "--authorize-spend, --ceiling, --expect-policy, --message and --test-id belong to `supcoflow test`.",
30
+ },
31
+ { flags: ["skill", "app"], comandos: ["install record"], texto: "--skill and --app belong to `supcoflow install record`." },
26
32
  ];
27
33
  /** El mensaje del primer flag presente que el comando no acepta, o null si todo aplica. */
28
34
  export function flagQueNoAplica(comando, presentes) {
@@ -21,7 +21,7 @@ export function almacenDeArchivo(ruta, fs) {
21
21
  v = undefined;
22
22
  }
23
23
  if (typeof v !== "object" || v === null || Array.isArray(v)) {
24
- throw new Error(`No entiendo el archivo de credenciales ${ruta}; borralo y volvé a iniciar sesión.`);
24
+ throw new Error(`I don't understand the credentials file ${ruta}; delete it and log in again.`);
25
25
  }
26
26
  return v;
27
27
  }
@@ -44,7 +44,7 @@ export function almacenDeArchivo(ruta, fs) {
44
44
  return null;
45
45
  const r = t[cuenta];
46
46
  if (!esRegistro(r))
47
- throw new Error(`No entiendo el archivo de credenciales ${ruta}; borralo y volvé a iniciar sesión.`);
47
+ throw new Error(`I don't understand the credentials file ${ruta}; delete it and log in again.`);
48
48
  return { valor: r.valor, escritoEn: r.escritoEn };
49
49
  },
50
50
  async escribir(cuenta, r) {