supcoflow 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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
+ };
@@ -21,20 +21,21 @@ function esIntento(v) {
21
21
  if (typeof v !== "object" || v === null)
22
22
  return false;
23
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";
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;
25
26
  }
26
27
  export function crearRegistroDePruebas(d) {
27
28
  // Por origen, como la clave y el registro de idempotencia: un motor local (o el proxy de P4) y
28
29
  // producción no comparten intentos. Cambiar de origen entre dos comandos es otra clave.
29
30
  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
+ 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.`);
31
32
  async function leer(proyectoId) {
32
33
  let crudo;
33
34
  try {
34
35
  crudo = (await d.almacen.leer(cuenta(proyectoId)))?.valor ?? null;
35
36
  }
36
37
  catch {
37
- throw noGuardada("leer");
38
+ throw noGuardada("read");
38
39
  }
39
40
  if (crudo === null)
40
41
  return { intento: null, ultima: null };
@@ -43,15 +44,18 @@ export function crearRegistroDePruebas(d) {
43
44
  v = JSON.parse(crudo);
44
45
  }
45
46
  catch {
46
- throw noGuardada("entender");
47
+ throw noGuardada("understand");
47
48
  }
48
49
  const o = (typeof v === "object" && v !== null ? v : {});
49
50
  const intentoOk = o.intento === null || esIntento(o.intento);
50
51
  const ultimaOk = o.ultima === null || typeof o.ultima === "string";
51
52
  if (!intentoOk || !ultimaOk)
52
- throw noGuardada("entender");
53
+ throw noGuardada("understand");
53
54
  const i = o.intento;
54
- return { intento: i === null ? null : { clave: i.clave, mensaje: i.mensaje, techoUsd: i.techo_usd }, ultima: o.ultima };
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
+ };
55
59
  }
56
60
  async function escribir(proyectoId, g) {
57
61
  // Misma regla que las credenciales: un destino dentro de un repo se publicaría con un `git add -A`.
@@ -61,20 +65,22 @@ export function crearRegistroDePruebas(d) {
61
65
  raiz = dentroDeUnWorkingTree(path.dirname(d.rutaDelArchivo), d.fs);
62
66
  }
63
67
  catch {
64
- throw noGuardada("resolver la ruta de");
68
+ throw noGuardada("resolve the path of");
65
69
  }
66
70
  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.`);
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.`);
68
72
  }
69
73
  const valor = JSON.stringify({
70
- intento: g.intento === null ? null : { clave: g.intento.clave, mensaje: g.intento.mensaje, techo_usd: g.intento.techoUsd },
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 },
71
77
  ultima: g.ultima,
72
78
  });
73
79
  try {
74
80
  await d.almacen.escribir(cuenta(proyectoId), { valor, escritoEn: d.ahora() });
75
81
  }
76
82
  catch {
77
- throw noGuardada("guardar en");
83
+ throw noGuardada("save to");
78
84
  }
79
85
  }
80
86
  return {
@@ -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.7.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" },