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.
@@ -4,6 +4,10 @@ import path from "node:path";
4
4
  import { FalloDeLaTerminal, SALIDA } from "../contrato.js";
5
5
  import { LlaveroAusente, TECHO_DEL_LLAVERO_MS } from "./llavero.js";
6
6
  import { dentroDeUnWorkingTree } from "./repo.js";
7
+ // Cómo se nombra cada fuente en un texto para la persona; el valor `fuente` del JSON sigue siendo el de arriba.
8
+ export const NOMBRE_DE_LA_FUENTE = {
9
+ llavero: "the system keychain", archivo: "the credentials file", env: "SUPCOFLOW_TOKEN",
10
+ };
7
11
  // La cuenta ES el origen de la API que emitió lo guardado (Review Focus 3): una clave acuñada por
8
12
  // un motor en `localhost` queda en otra cuenta que la de producción, y la terminal apuntada a
9
13
  // producción simplemente no la encuentra (sale 2) en vez de mandarla a un servidor que no la emitió.
@@ -71,8 +75,8 @@ export function crearGuardado(d) {
71
75
  }
72
76
  const leerCuenta = async (cuenta, queda) => (await leerCuentaCompleta(cuenta, queda)).mejor;
73
77
  const errorDeBorrado = (fallidos) => {
74
- const donde = fallidos.map((n) => (n === "llavero" ? "del llavero del sistema" : `de ${d.rutaDelArchivo}`)).join(" ni ");
75
- return new FalloDeLaTerminal(SALIDA.inesperado, "no_se_pudo_borrar", `No pude borrar la clave guardada ${donde}.`);
78
+ const donde = fallidos.map((n) => (n === "llavero" ? NOMBRE_DE_LA_FUENTE.llavero : d.rutaDelArchivo)).join(" or from ");
79
+ return new FalloDeLaTerminal(SALIDA.inesperado, "no_se_pudo_borrar", `I couldn't delete the saved key from ${donde}.`);
76
80
  };
77
81
  // El chequeo del repo va ANTES de escribir (Review Focus 1): una vez escrita, la clave ya está a
78
82
  // un `git add -A` de publicarse, y borrarla después no deshace un commit. Mira la ruta REAL además
@@ -81,10 +85,10 @@ export function crearGuardado(d) {
81
85
  async function escribirEnArchivo(cuenta, r, que) {
82
86
  const raiz = dentroDeUnWorkingTree(path.dirname(d.rutaDelArchivo), d.fs);
83
87
  if (raiz !== null) {
84
- throw new FalloDeLaTerminal(SALIDA.inesperado, "destino_dentro_de_un_repo", `No guardo ${que} dentro de un repo de git (${raiz}). Usá el llavero del sistema o exportá SUPCOFLOW_TOKEN.`);
88
+ throw new FalloDeLaTerminal(SALIDA.inesperado, "destino_dentro_de_un_repo", `I won't save ${que} inside a git repo (${raiz}). Use the system keychain or export SUPCOFLOW_TOKEN.`);
85
89
  }
86
90
  await d.archivo.escribir(cuenta, r);
87
- d.avisar(`No hay llavero del sistema: guardé ${que} en ${d.rutaDelArchivo} con permisos 0600.`);
91
+ d.avisar(`There is no system keychain: I saved ${que} in ${d.rutaDelArchivo} with 0600 permissions.`);
88
92
  }
89
93
  async function escribirCuenta(cuenta, valor, que, techoMs) {
90
94
  const queda = plazo(techoMs);
@@ -145,7 +149,7 @@ export function crearGuardado(d) {
145
149
  return { valor: mejor === null ? null : { clave: mejor.r.valor, fuente: mejor.fuente }, completa };
146
150
  },
147
151
  async guardarClave(origen, clave, techoMs) {
148
- return { donde: await escribirCuenta(cuentaDeClave(origen), clave, "la clave", techoMs) };
152
+ return { donde: await escribirCuenta(cuentaDeClave(origen), clave, "the key", techoMs) };
149
153
  },
150
154
  async borrarClave(origen) {
151
155
  // Un borrado a medias de una clave no se informa como hecho: la que quedó sigue sirviendo.
@@ -174,7 +178,7 @@ export function crearGuardado(d) {
174
178
  }
175
179
  },
176
180
  async guardarPendiente(origen, p, techoMs) {
177
- await escribirCuenta(cuentaDePendiente(origen), JSON.stringify(p), "el inicio de sesión en curso", techoMs);
181
+ await escribirCuenta(cuentaDePendiente(origen), JSON.stringify(p), "the login in progress", techoMs);
178
182
  },
179
183
  async borrarPendiente(origen, techoMs) {
180
184
  // Nunca lanza: un pendiente viejo es inofensivo (su device_code ya se consumió o vence en
@@ -217,7 +221,7 @@ export function crearGuardado(d) {
217
221
  const r = { valor: JSON.stringify([...l.valor, clave]), escritoEn: d.ahora() };
218
222
  if (a.nombre === "archivo") {
219
223
  try {
220
- await escribirEnArchivo(cuenta, r, "las claves por revocar");
224
+ await escribirEnArchivo(cuenta, r, "the keys to revoke");
221
225
  return;
222
226
  }
223
227
  catch (e) {
@@ -229,7 +233,7 @@ export function crearGuardado(d) {
229
233
  if ((await intentar(a, queda, (x, t) => x.escribir(cuenta, r, t))).estado === "ok")
230
234
  return;
231
235
  }
232
- throw new FalloDeLaTerminal(SALIDA.inesperado, "no_se_pudo_anotar", "No pude anotar la clave anterior como «por revocar» ni en el llavero ni en el archivo.");
236
+ throw new FalloDeLaTerminal(SALIDA.inesperado, "no_se_pudo_anotar", "I couldn't note down the previous key as \"to revoke\" in either the keychain or the file.");
233
237
  },
234
238
  // Quitar sólo achica la lista, así que no pasa por el control del repo: no agrega ningún secreto.
235
239
  // Un almacén presente que falla deja `completo = false`: la clave puede seguir anotada ahí, el
@@ -9,7 +9,7 @@ export const TECHO_DEL_LLAVERO_MS = 5_000;
9
9
  // nada que borrar en él; uno que está y falla puede tener datos adentro.
10
10
  export class LlaveroAusente extends Error {
11
11
  constructor(bin) {
12
- super(`No hay llavero del sistema (falta ${bin}).`);
12
+ super(`There is no system keychain (${bin} is missing).`);
13
13
  this.name = "LlaveroAusente";
14
14
  }
15
15
  }
@@ -26,14 +26,14 @@ function decodificar(s) {
26
26
  v = undefined;
27
27
  }
28
28
  if (!esRegistro(v))
29
- throw new Error("El llavero devolvió algo que no entiendo.");
29
+ throw new Error("The keychain returned something I don't understand.");
30
30
  return { valor: v.valor, escritoEn: v.escritoEn };
31
31
  }
32
32
  // La cuenta viaja en una línea de `security -i`, que también parte por espacios y entiende comillas:
33
33
  // una cuenta con algo de eso se leería distinta de lo que se escribió. Un origen nunca lo tiene.
34
34
  function cuentaSegura(cuenta) {
35
35
  if (!/^[A-Za-z0-9:/.\[\]#_-]+$/.test(cuenta))
36
- throw new Error("Cuenta del llavero inválida.");
36
+ throw new Error("Invalid keychain account.");
37
37
  return cuenta;
38
38
  }
39
39
  // La ruta ABSOLUTA: un `security` del PATH podría ser otro programa (un `node_modules/.bin`, un
@@ -79,10 +79,10 @@ export function almacenDeLlavero(plataforma, correr, existe = existsSync) {
79
79
  if (r.ausente)
80
80
  throw new LlaveroAusente(bin);
81
81
  if (r.vencido)
82
- throw new Error(`El llavero no contestó en ${Math.round(techoMs / 1000)} s.`);
82
+ throw new Error(`The keychain did not respond within ${Math.round(techoMs / 1000)} s.`);
83
83
  return r;
84
84
  }
85
- const fallo = (codigo) => new Error(`El llavero falló (código ${codigo}).`);
85
+ const fallo = (codigo) => new Error(`The keychain failed (code ${codigo}).`);
86
86
  return {
87
87
  nombre: "llavero",
88
88
  async leer(cuenta, techoMs = TECHO_DEL_LLAVERO_MS) {
package/dist/guia.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // La guía del producto que vive en el repo del cliente. Sin dependencias: sólo `node:crypto`.
2
2
  import { createHash } from "node:crypto";
3
- export const RUTA_DE_LA_GUIA = ".supcoflow/guia.md";
3
+ // El nombre canónico desde la 0.8.0; `guia.md` es el viejo y se sigue leyendo SÓLO si `guide.md` no existe.
4
+ export const RUTA_DE_LA_GUIA = ".supcoflow/guide.md";
5
+ export const RUTA_VIEJA_DE_LA_GUIA = ".supcoflow/guia.md";
4
6
  export const MAX_BYTES_DE_LA_GUIA = 64 * 1024;
5
7
  // La misma regla que el motor (`src/guia/formato.ts`), copiada a propósito y clavada con los vectores
6
8
  // de `test/vectores/huella.json` (idénticos en los dos repos). Se normaliza antes de hashear porque un
@@ -15,7 +15,7 @@ export function crearRegistroDeIdempotencia(d) {
15
15
  // repo) y el motor lo reusa. Una clave de consulta no tiene clave natural: sin la de idempotencia
16
16
  // guardada, el reintento tras una respuesta perdida crea OTRA fila. Mejor no mandar nada y decir qué
17
17
  // arreglar. Por eso, en estricto, cada «aviso y sigo» de abajo lanza.
18
- const noGuardada = () => new FalloDeLaTerminal(SALIDA.inesperado, "idempotencia_no_guardada", `No pude guardar la clave de reintento en ${d.rutaDelArchivo}; sin ella, un reintento crearía otra clave. Revisá ese archivo (no puede estar dentro de un repo) y probá de nuevo.`);
18
+ const noGuardada = () => new FalloDeLaTerminal(SALIDA.inesperado, "idempotencia_no_guardada", `I couldn't save the retry key in ${d.rutaDelArchivo}; without it, a retry would create another key. Check that file (it can't be inside a repo) and try again.`);
19
19
  return {
20
20
  async claveDe(operacion, alcance, opciones = {}) {
21
21
  const c = cuenta(operacion, alcance);
@@ -29,7 +29,7 @@ export function crearRegistroDeIdempotencia(d) {
29
29
  throw noGuardada();
30
30
  // Un archivo que no se entiende no se reescribe (perdería otras claves); se sigue sin guardar.
31
31
  const nueva = d.generar();
32
- d.avisar(`No pude leer ${d.rutaDelArchivo}: sigo con una clave de idempotencia sin guardar.`);
32
+ d.avisar(`I couldn't read ${d.rutaDelArchivo}: I'm going on with an idempotency key that isn't saved.`);
33
33
  return nueva;
34
34
  }
35
35
  const nueva = d.generar();
@@ -45,13 +45,13 @@ export function crearRegistroDeIdempotencia(d) {
45
45
  catch {
46
46
  if (opciones.estricta)
47
47
  throw noGuardada();
48
- d.avisar(`No pude resolver la ruta de ${d.rutaDelArchivo}: sigo con una clave de idempotencia sin guardar.`);
48
+ d.avisar(`I couldn't resolve the path of ${d.rutaDelArchivo}: I'm going on with an idempotency key that isn't saved.`);
49
49
  return nueva;
50
50
  }
51
51
  if (raiz !== null) {
52
52
  if (opciones.estricta)
53
53
  throw noGuardada();
54
- d.avisar(`No guardo la clave de idempotencia dentro de un repo de git (${raiz}): un reintento mandará otra.`);
54
+ d.avisar(`I'm not saving the idempotency key inside a git repo (${raiz}): a retry will send a different one.`);
55
55
  return nueva;
56
56
  }
57
57
  try {
@@ -60,7 +60,7 @@ export function crearRegistroDeIdempotencia(d) {
60
60
  catch {
61
61
  if (opciones.estricta)
62
62
  throw noGuardada();
63
- d.avisar(`No pude guardar la clave de idempotencia en ${d.rutaDelArchivo}: un reintento mandará otra.`);
63
+ d.avisar(`I couldn't save the idempotency key in ${d.rutaDelArchivo}: a retry will send a different one.`);
64
64
  }
65
65
  return nueva;
66
66
  },
@@ -81,7 +81,7 @@ export function crearRegistroDeIdempotencia(d) {
81
81
  await d.almacen.borrar(cuenta(operacion, alcance));
82
82
  }
83
83
  catch {
84
- d.avisar(`No pude borrar la clave de idempotencia de ${d.rutaDelArchivo}.`);
84
+ d.avisar(`I couldn't delete the idempotency key from ${d.rutaDelArchivo}.`);
85
85
  }
86
86
  },
87
87
  };
@@ -1,4 +1,4 @@
1
- // `.supcoflow/instalacion.json` (bloque 6, decisión 3): con qué versiones se hizo la instalación, anotado en el
1
+ // `.supcoflow/instalacion.json` (bloque 6, decisión 3; v2 en la 0.8.0): con qué versiones se hizo la instalación, anotado en el
2
2
  // repo de quien instala. Supcoflow no lo guarda ni lo ve. Puro: recibe lo ya leído y arma el archivo.
3
3
  //
4
4
  // El archivo queda en el repo y se commitea: un secreto acá se publica con el primer `push`. Por eso se arma con
@@ -13,7 +13,7 @@ export const esSemver = (s) => s.length <= 64 && SEMVER.test(s);
13
13
  const objeto = (v) => (typeof v === "object" && v !== null && !Array.isArray(v) ? v : null);
14
14
  const entero = (v) => typeof v === "number" && Number.isInteger(v) && v >= 1;
15
15
  const ilegible = (message) => new FalloDeLaTerminal(SALIDA.inesperado, "paquete_ilegible", message);
16
- const inesperada = (que) => new FalloDeLaTerminal(SALIDA.inesperado, "configuracion_inesperada", `El motor contestó la configuración con ${que} que no entiendo; no anoto nada a medias. Probá de nuevo en un rato.`);
16
+ const inesperada = (que) => new FalloDeLaTerminal(SALIDA.inesperado, "configuracion_inesperada", `The engine answered the configuration with ${que} I don't understand; I'm not recording anything halfway. Try again in a while.`);
17
17
  // Lo declarado en el `package.json` de la app lo escribió quien escribió el repo: puede ser un rango, un alias o
18
18
  // cualquier cosa. Se copia tal cual sólo si es texto imprimible y corto; si no, no se adivina (`paquete_ilegible`).
19
19
  // `taparSecretos` igual, por si alguien pegó una clave ahí: el archivo nuevo no la repite.
@@ -25,7 +25,7 @@ function declaradoEn(app) {
25
25
  if (v === undefined || v === null)
26
26
  return null;
27
27
  if (typeof v !== "string" || v.length === 0 || v.length > 200 || !/^[\x20-\x7e]+$/.test(v)) {
28
- throw ilegible(`La versión de ${PAQUETE_DEL_CHAT} en el package.json de la app no se entiende; no la anoto.`);
28
+ throw ilegible(`The version of ${PAQUETE_DEL_CHAT} in the app's package.json isn't understandable; I'm not recording it.`);
29
29
  }
30
30
  return taparSecretos(v);
31
31
  }
@@ -35,7 +35,7 @@ function instaladoEn(paquete) {
35
35
  return null;
36
36
  const v = objeto(paquete)?.version;
37
37
  if (typeof v !== "string" || !esSemver(v))
38
- throw ilegible(`El package.json de ${PAQUETE_DEL_CHAT} instalado no trae una versión que entienda.`);
38
+ throw ilegible(`The installed package.json of ${PAQUETE_DEL_CHAT} has no version I understand.`);
39
39
  return v;
40
40
  }
41
41
  function acuerdoDe(c) {
@@ -44,16 +44,16 @@ function acuerdoDe(c) {
44
44
  const a = objeto(c.acuerdo);
45
45
  // Ilegible es un estado conocido del motor (aplica cerrado), no «sin acuerdo»: anotarlo como null mentiría.
46
46
  if (a?.ilegible === true) {
47
- throw new FalloDeLaTerminal(SALIDA.haceFaltaUnaPersona, "acuerdo_ilegible", "El acuerdo de datos está fijado pero no se lee: revisalo en la consola y volvé a correr.");
47
+ throw new FalloDeLaTerminal(SALIDA.haceFaltaUnaPersona, "acuerdo_ilegible", "The data agreement is set but can't be read: check it in the console and run again.");
48
48
  }
49
49
  if (a === null || !entero(a.version))
50
- throw inesperada("un acuerdo");
50
+ throw inesperada("an agreement");
51
51
  return a.version;
52
52
  }
53
53
  function politicaDe(c) {
54
54
  const modo = objeto(c.politica)?.modo;
55
55
  if (modo !== "relevar" && modo !== "guardar")
56
- throw inesperada("una política");
56
+ throw inesperada("a policy");
57
57
  return modo;
58
58
  }
59
59
  function guiaDe(c) {
@@ -62,24 +62,63 @@ function guiaDe(c) {
62
62
  const g = objeto(c.guia);
63
63
  if (g?.origen === "repo") {
64
64
  if (g.procedencia !== "terminal" && g.procedencia !== "github")
65
- throw inesperada("una guía");
65
+ throw inesperada("a guide");
66
66
  if (g.commit !== null && (typeof g.commit !== "string" || !/^[0-9a-f]{40}$/.test(g.commit)))
67
- throw inesperada("una guía");
67
+ throw inesperada("a guide");
68
68
  return { origen: "repo", procedencia: g.procedencia, commit: g.commit };
69
69
  }
70
70
  if (g?.origen === "agente" && entero(g.version))
71
71
  return { origen: "agente", version: g.version };
72
- throw inesperada("una guía");
72
+ throw inesperada("a guide");
73
73
  }
74
74
  export function armarInstalacion(i) {
75
75
  return {
76
- version: 1,
76
+ version: 2,
77
77
  skill: i.skill,
78
78
  terminal: i.terminal,
79
79
  chat: { declarado: declaradoEn(i.paqueteDeLaApp), instalado: instaladoEn(i.paqueteInstalado) },
80
80
  configuracion: { acuerdo_version: acuerdoDe(i.configuracion), politica: politicaDe(i.configuracion), guia: guiaDe(i.configuracion) },
81
+ archivo: i.archivo ?? null,
81
82
  };
82
83
  }
84
+ // ─── El archivo del chat (`install record --file`) ───
85
+ const MAX_LARGO_DEL_ARCHIVO = 300;
86
+ // Pura: una ruta RELATIVA a la raíz del repo, sin `..`, sin ruta absoluta. Se devuelve normalizada (separador `/`, sin
87
+ // `./` ni barras de más), que es como queda anotada. Que no sea un enlace simbólico lo mira el comando, con el disco.
88
+ export function validarElArchivoDelChat(crudo) {
89
+ if (crudo.length === 0 || crudo.length > MAX_LARGO_DEL_ARCHIVO || /[\u0000-\u001f\u007f]/.test(crudo)) {
90
+ return { ok: false, motivo: `--file goes with a path of up to ${MAX_LARGO_DEL_ARCHIVO} printable characters.` };
91
+ }
92
+ if (crudo.startsWith("/") || crudo.startsWith("\\") || /^[A-Za-z]:/.test(crudo)) {
93
+ return { ok: false, motivo: "--file is a path relative to the repo root, not an absolute one." };
94
+ }
95
+ const partes = crudo.split(/[\\/]/).filter((x) => x !== "" && x !== ".");
96
+ if (partes.includes(".."))
97
+ return { ok: false, motivo: "--file can't go out of the repo: no `..` in the path." };
98
+ if (partes.length === 0)
99
+ return { ok: false, motivo: "--file is the chat file, not a folder: say its path." };
100
+ return { ok: true, ruta: partes.join("/") };
101
+ }
102
+ /** El `archivo` anotado en un `instalacion.json` ya existente: v1 y cualquier cosa ilegible o inválida dan null (nunca un error). */
103
+ export function archivoAnotado(texto) {
104
+ if (texto === null)
105
+ return null;
106
+ let v;
107
+ try {
108
+ v = JSON.parse(texto);
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ // AD9: un v1 se reconoce por `version: 1` (lo escribe la terminal 0.7.0) y no tiene archivo, aunque el campo aparezca.
114
+ if (objeto(v)?.version === 1)
115
+ return null;
116
+ const a = objeto(v)?.archivo;
117
+ if (typeof a !== "string")
118
+ return null;
119
+ const r = validarElArchivoDelChat(a);
120
+ return r.ok && r.ruta === a ? a : null;
121
+ }
83
122
  // El orden de las claves es el de `armarInstalacion`: el mismo estado da los mismos bytes, y eso es `sin_cambios`.
84
123
  export function serializar(x) {
85
124
  return JSON.stringify(x, null, 2) + "\n";
@@ -0,0 +1,10 @@
1
+ // Por qué `leerArchivoChico` no pudo leer un archivo. Son frases sueltas que van después de «I can't read X:», y
2
+ // los comandos comparan contra estas constantes (nunca contra el texto a mano).
3
+ export const NO_LEIDO = {
4
+ noExiste: "it doesn't exist",
5
+ esUnEnlace: "it's a symlink",
6
+ noSePudoAbrir: "it couldn't be opened",
7
+ noEsComun: "it isn't a regular file",
8
+ demasiadoGrande: "it's too large",
9
+ noSePudoLeer: "it couldn't be read",
10
+ };
@@ -0,0 +1,101 @@
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
+ const politica = i.politica_esperada === undefined || i.politica_esperada === null || i.politica_esperada === "relevar" || i.politica_esperada === "guardar";
25
+ return typeof i.clave === "string" && i.clave.length > 0 && (i.mensaje === null || typeof i.mensaje === "string") && typeof i.techo_usd === "number" && politica;
26
+ }
27
+ export function crearRegistroDePruebas(d) {
28
+ // Por origen, como la clave y el registro de idempotencia: un motor local (o el proxy de P4) y
29
+ // producción no comparten intentos. Cambiar de origen entre dos comandos es otra clave.
30
+ const cuenta = (proyectoId) => `${d.origen}#prueba#${proyectoId}`;
31
+ const noGuardada = (que) => new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `I couldn't ${que} ${d.rutaDelArchivo}; without that, a retry could ask for another test (and spend twice). Check that file (it can't be inside a repo) and try again.`);
32
+ async function leer(proyectoId) {
33
+ let crudo;
34
+ try {
35
+ crudo = (await d.almacen.leer(cuenta(proyectoId)))?.valor ?? null;
36
+ }
37
+ catch {
38
+ throw noGuardada("read");
39
+ }
40
+ if (crudo === null)
41
+ return { intento: null, ultima: null };
42
+ let v;
43
+ try {
44
+ v = JSON.parse(crudo);
45
+ }
46
+ catch {
47
+ throw noGuardada("understand");
48
+ }
49
+ const o = (typeof v === "object" && v !== null ? v : {});
50
+ const intentoOk = o.intento === null || esIntento(o.intento);
51
+ const ultimaOk = o.ultima === null || typeof o.ultima === "string";
52
+ if (!intentoOk || !ultimaOk)
53
+ throw noGuardada("understand");
54
+ const i = o.intento;
55
+ return {
56
+ intento: i === null ? null : { clave: i.clave, mensaje: i.mensaje, techoUsd: i.techo_usd, politicaEsperada: i.politica_esperada ?? null },
57
+ ultima: o.ultima,
58
+ };
59
+ }
60
+ async function escribir(proyectoId, g) {
61
+ // Misma regla que las credenciales: un destino dentro de un repo se publicaría con un `git add -A`.
62
+ // No saber dónde cae (un enlace roto) es otro «no se puede guardar».
63
+ let raiz;
64
+ try {
65
+ raiz = dentroDeUnWorkingTree(path.dirname(d.rutaDelArchivo), d.fs);
66
+ }
67
+ catch {
68
+ throw noGuardada("resolve the path of");
69
+ }
70
+ if (raiz !== null) {
71
+ throw new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `I'm not saving the test request inside a git repo (${raiz}); without saving it I won't send it. Point XDG_CONFIG_HOME outside a repo.`);
72
+ }
73
+ const valor = JSON.stringify({
74
+ intento: g.intento === null
75
+ ? null
76
+ : { clave: g.intento.clave, mensaje: g.intento.mensaje, techo_usd: g.intento.techoUsd, politica_esperada: g.intento.politicaEsperada },
77
+ ultima: g.ultima,
78
+ });
79
+ try {
80
+ await d.almacen.escribir(cuenta(proyectoId), { valor, escritoEn: d.ahora() });
81
+ }
82
+ catch {
83
+ throw noGuardada("save to");
84
+ }
85
+ }
86
+ return {
87
+ leer,
88
+ async abrir(proyectoId, intento) {
89
+ const antes = await leer(proyectoId);
90
+ await escribir(proyectoId, { intento, ultima: antes.ultima });
91
+ },
92
+ async confirmar(proyectoId, pruebaId) {
93
+ await leer(proyectoId);
94
+ await escribir(proyectoId, { intento: null, ultima: pruebaId });
95
+ },
96
+ async descartar(proyectoId) {
97
+ const antes = await leer(proyectoId);
98
+ await escribir(proyectoId, { intento: null, ultima: antes.ultima });
99
+ },
100
+ };
101
+ }
@@ -45,15 +45,15 @@ export async function leerRepoLocal(git, cwd) {
45
45
  return { repo, raiz };
46
46
  }
47
47
  const MOTIVOS = {
48
- sin_git: "esta carpeta no es un repo de git",
49
- sin_origin: "el repo no tiene un remoto «origin»",
50
- origin_no_es_github: "el «origin» no es un repo de GitHub",
48
+ sin_git: "this folder isn't a git repo",
49
+ sin_origin: "the repo has no remote called \"origin\"",
50
+ origin_no_es_github: "the \"origin\" remote isn't a GitHub repo",
51
51
  };
52
52
  export async function repoPedido(d) {
53
53
  const local = await leerRepoLocal(d.git, d.cwd);
54
54
  if (d.flagRepo !== null) {
55
55
  if (!esRepoValido(d.flagRepo)) {
56
- throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "repo_invalido", "El valor de --repo tiene que ser owner/repo (por ejemplo duenio/mi-app), sin la URL.");
56
+ throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "repo_invalido", "The value of --repo must be owner/repo (for example owner/my-app), without the URL.");
57
57
  }
58
58
  // La raíz sólo se da si el origin local es ESE repo: con otro, lo que se escriba en esta
59
59
  // carpeta (el archivo del proyecto) no le pertenece.
@@ -61,8 +61,8 @@ export async function repoPedido(d) {
61
61
  return { repo: d.flagRepo, raiz: mismo ? local.raiz : null };
62
62
  }
63
63
  if (local.repo === null) {
64
- const porque = local.motivo ? MOTIVOS[local.motivo] : "no se pudo leer";
65
- throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "sin_repo", `No encontré un repo de GitHub acá (${porque}). Pasá --repo owner/repo.`);
64
+ const porque = local.motivo ? MOTIVOS[local.motivo] : "the folder couldn't be read";
65
+ throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "sin_repo", `I couldn't find a GitHub repo here (${porque}). Pass --repo owner/repo.`);
66
66
  }
67
67
  return { repo: local.repo, raiz: local.raiz };
68
68
  }
@@ -22,7 +22,7 @@ import { dentroDeUnWorkingTree } from "./guardado/repo.js";
22
22
  // Una clave ocupa 85 caracteres: 4 KB es de sobra, y algo más grande ya no es una clave.
23
23
  const TOPE = 4 * 1024;
24
24
  const CLAVE = /^scf_([0-9a-f]{16})_[0-9a-f]{64}$/;
25
- const OCUPADO = { error: "el archivo ya tiene otra cosa; borralo o elegí otro destino", codigo: 4 };
25
+ const OCUPADO = { error: "the file already has something else; delete it or pick another destination", codigo: 4 };
26
26
  export function crearGuardaDeSecretos(fs) {
27
27
  function borrarTemporal(tmp) {
28
28
  try {
@@ -50,10 +50,10 @@ export function crearGuardaDeSecretos(fs) {
50
50
  raiz = dentroDeUnWorkingTree(path.dirname(destino), fs);
51
51
  }
52
52
  catch {
53
- return { error: "no pude resolver la ruta real de la carpeta (¿un enlace roto?)", codigo: 3 };
53
+ return { error: "I couldn't resolve the folder's real path (a broken link?)", codigo: 3 };
54
54
  }
55
55
  if (raiz !== null)
56
- return { error: `cae dentro del repo de git ${raiz}, y una clave en un repo termina commiteada; elegí un destino fuera`, codigo: 3 };
56
+ return { error: `it falls inside the git repo ${raiz}, and a key in a repo ends up committed; pick a destination outside it`, codigo: 3 };
57
57
  return null;
58
58
  }
59
59
  return {
@@ -67,7 +67,7 @@ export function crearGuardaDeSecretos(fs) {
67
67
  return invalido;
68
68
  const hay = fs.leerSinSeguir(destino, TOPE);
69
69
  if (hay.tipo === "enlace")
70
- return { error: "el destino es un enlace; elegí un archivo común", codigo: 3 };
70
+ return { error: "the destination is a symlink; pick a regular file", codigo: 3 };
71
71
  if (hay.tipo !== "ausente") {
72
72
  const actual = leer(destino);
73
73
  if (actual.estado !== "clave" || actual.claveId !== claveId)
@@ -90,7 +90,7 @@ export function crearGuardaDeSecretos(fs) {
90
90
  catch {
91
91
  // El mensaje del sistema no se reenvía: podría traer la ruta del temporal o algo peor.
92
92
  borrarTemporal(tmp);
93
- return { error: "no se pudo escribir ahí (¿la carpeta existe y tenés permiso?)", codigo: 3 };
93
+ return { error: "it couldn't be written there (does the folder exist, and do you have permission?)", codigo: 3 };
94
94
  }
95
95
  // Después de publicar, el temporal es un nombre de más (con `link`) o ya no existe (con `rename`): no
96
96
  // borrarlo no cambia que la clave quedó en el destino, y convertir eso en un fallo dejaría una clave
@@ -101,7 +101,7 @@ export function crearGuardaDeSecretos(fs) {
101
101
  return OCUPADO;
102
102
  // Sin caer a `rename`: eso reabre la carrera que `link` cierra. Otro destino, en otro disco, sirve.
103
103
  if (publicado === "sin_enlaces") {
104
- return { error: "el disco de esa carpeta no admite enlaces duros (¿FAT, exFAT o una carpeta de red?), y sin ellos no puedo escribir sin riesgo de pisar otra clave; elegí un destino en otro disco", codigo: 3 };
104
+ return { error: "that folder's disk doesn't support hard links (FAT, exFAT or a network folder?), and without them I can't write without risking overwriting another key; pick a destination on another disk", codigo: 3 };
105
105
  }
106
106
  return "guardado";
107
107
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supcoflow",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "La terminal de Supcoflow: iniciar sesión y manejar el chat de soporte desde la consola o un asistente.",
5
5
  "type": "module",
6
6
  "bin": { "supcoflow": "dist/cli.js" },