supcoflow 0.7.0 → 0.8.1

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.
@@ -14,27 +14,27 @@
14
14
  //
15
15
  // Estricto en todo, como `claves crear` en el registro de idempotencia: una prueba no tiene clave natural
16
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
17
  import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
19
- import { dentroDeUnWorkingTree } from "./guardado/repo.js";
18
+ import { SinProteccionDeGit } from "./guardado/protegido.js";
20
19
  function esIntento(v) {
21
20
  if (typeof v !== "object" || v === null)
22
21
  return false;
23
22
  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";
23
+ const politica = i.politica_esperada === undefined || i.politica_esperada === null || i.politica_esperada === "relevar" || i.politica_esperada === "guardar";
24
+ return typeof i.clave === "string" && i.clave.length > 0 && (i.mensaje === null || typeof i.mensaje === "string") && typeof i.techo_usd === "number" && politica;
25
25
  }
26
26
  export function crearRegistroDePruebas(d) {
27
27
  // Por origen, como la clave y el registro de idempotencia: un motor local (o el proxy de P4) y
28
28
  // producción no comparten intentos. Cambiar de origen entre dos comandos es otra clave.
29
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.`);
30
+ 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 and try again.`);
31
31
  async function leer(proyectoId) {
32
32
  let crudo;
33
33
  try {
34
34
  crudo = (await d.almacen.leer(cuenta(proyectoId)))?.valor ?? null;
35
35
  }
36
36
  catch {
37
- throw noGuardada("leer");
37
+ throw noGuardada("read");
38
38
  }
39
39
  if (crudo === null)
40
40
  return { intento: null, ultima: null };
@@ -43,42 +43,60 @@ export function crearRegistroDePruebas(d) {
43
43
  v = JSON.parse(crudo);
44
44
  }
45
45
  catch {
46
- throw noGuardada("entender");
46
+ throw noGuardada("understand");
47
47
  }
48
48
  const o = (typeof v === "object" && v !== null ? v : {});
49
49
  const intentoOk = o.intento === null || esIntento(o.intento);
50
50
  const ultimaOk = o.ultima === null || typeof o.ultima === "string";
51
51
  if (!intentoOk || !ultimaOk)
52
- throw noGuardada("entender");
52
+ throw noGuardada("understand");
53
53
  const i = o.intento;
54
- return { intento: i === null ? null : { clave: i.clave, mensaje: i.mensaje, techoUsd: i.techo_usd }, ultima: o.ultima };
54
+ return {
55
+ intento: i === null ? null : { clave: i.clave, mensaje: i.mensaje, techoUsd: i.techo_usd, politicaEsperada: i.politica_esperada ?? null },
56
+ ultima: o.ultima,
57
+ };
55
58
  }
56
59
  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
60
  const valor = JSON.stringify({
70
- intento: g.intento === null ? null : { clave: g.intento.clave, mensaje: g.intento.mensaje, techo_usd: g.intento.techoUsd },
61
+ intento: g.intento === null
62
+ ? null
63
+ : { clave: g.intento.clave, mensaje: g.intento.mensaje, techo_usd: g.intento.techoUsd, politica_esperada: g.intento.politicaEsperada },
71
64
  ultima: g.ultima,
72
65
  });
73
66
  try {
74
67
  await d.almacen.escribir(cuenta(proyectoId), { valor, escritoEn: d.ahora() });
75
68
  }
69
+ catch (e) {
70
+ // El almacén protegido no escribe dentro de un repo si git no garantiza que lo deja afuera (fricción 2c):
71
+ // el mensaje dice qué archivo, qué repo y qué hacer.
72
+ if (e instanceof SinProteccionDeGit) {
73
+ throw new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `I'm not saving the test request in ${d.rutaDelArchivo}; without saving it I won't send it. ${e.message}`);
74
+ }
75
+ throw noGuardada("save to");
76
+ }
77
+ }
78
+ async function revisar(proyectoId) {
79
+ const impedimentos = [];
80
+ try {
81
+ impedimentos.push(...(await d.revisarElLugar?.() ?? []));
82
+ }
83
+ catch {
84
+ impedimentos.push({ codigo: "no_se_pudo_revisar", detalle: `I couldn't check whether ${d.rutaDelArchivo} can be saved.` });
85
+ }
86
+ let intentoPendiente = null;
87
+ try {
88
+ const { intento } = await leer(proyectoId);
89
+ if (intento !== null)
90
+ intentoPendiente = { techoUsd: intento.techoUsd, politica: intento.politicaEsperada };
91
+ }
76
92
  catch {
77
- throw noGuardada("guardar en");
93
+ impedimentos.push({ codigo: "archivo_ilegible", detalle: `I can't read or understand ${d.rutaDelArchivo}.` });
78
94
  }
95
+ return { maquina: { ok: impedimentos.length === 0, impedimentos }, intentoPendiente };
79
96
  }
80
97
  return {
81
98
  leer,
99
+ revisar,
82
100
  async abrir(proyectoId, intento) {
83
101
  const antes = await leer(proyectoId);
84
102
  await escribir(proyectoId, { intento, ultima: antes.ultima });
@@ -30,6 +30,52 @@ export function repoDelOrigin(url) {
30
30
  }
31
31
  return null;
32
32
  }
33
+ // El `origin` con forma de alias de SSH (`git@github-gf0:dueño/repo`, `ssh://git@github-gf0/dueño/repo`): el host no
34
+ // es github.com, pero el dueño/repo sí tiene la forma de uno de GitHub. NO se resuelve (no se corre `ssh` ni se lee su
35
+ // configuración: `Match exec` ejecutaría programas de la persona): sólo se le dice qué asociar a mano, una vez.
36
+ const FORMAS_DE_ALIAS = [
37
+ /^[A-Za-z0-9._-]+@([A-Za-z0-9._-]+):([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
38
+ /^ssh:\/\/[A-Za-z0-9._-]+@([A-Za-z0-9._-]+)(?::\d+)?\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
39
+ ];
40
+ export function aliasDelOrigin(url) {
41
+ const limpia = url.trim();
42
+ for (const forma of FORMAS_DE_ALIAS) {
43
+ const m = forma.exec(limpia);
44
+ if (!m)
45
+ continue;
46
+ const repo = `${m[2]}/${m[3]}`;
47
+ if (m[1].toLowerCase() === "github.com" || !esRepoValido(repo))
48
+ return null;
49
+ return { host: m[1], repo };
50
+ }
51
+ return null;
52
+ }
53
+ // Lo que se muestra de un `origin`: nunca el usuario:clave@ de una URL (podría ser un token). Las credenciales se quitan de la
54
+ // URL ENTERA y recién después se limita el tamaño (recortar antes dejaría pasar un `@` lejano con su clave a medias). Lo que no se
55
+ // puede interpretar con seguridad no se muestra crudo: sale un marcador neutro.
56
+ const TOPE_DEL_ORIGEN_MOSTRADO = 200;
57
+ export function origenParaMostrar(url) {
58
+ const limpia = url.trim();
59
+ const url_ = /^([A-Za-z][A-Za-z0-9+.-]*:\/\/)([^/\s]*)(.*)$/s.exec(limpia);
60
+ let mostrable = null;
61
+ if (url_) {
62
+ // La autoridad llega hasta la primera `/`; el usuario:clave es todo lo anterior al ÚLTIMO `@` de la autoridad.
63
+ const arroba = url_[2].lastIndexOf("@");
64
+ const usuario = arroba === -1 ? "" : url_[2].slice(0, arroba).split(":")[0];
65
+ const host = arroba === -1 ? url_[2] : url_[2].slice(arroba + 1);
66
+ mostrable = `${url_[1]}${usuario === "git" ? "git@" : ""}${host}${url_[3]}`;
67
+ }
68
+ else if (/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:/.test(limpia)) {
69
+ mostrable = limpia; // `git@host:o/r`: el usuario de un scp no lleva clave
70
+ }
71
+ if (mostrable === null || /\s/.test(mostrable))
72
+ return "(an address I don't recognize)";
73
+ return mostrable.length > TOPE_DEL_ORIGEN_MOSTRADO ? mostrable.slice(0, TOPE_DEL_ORIGEN_MOSTRADO) + "…" : mostrable;
74
+ }
75
+ // El remoto de asociación explícita: la persona le dice una sola vez a qué repo de GitHub corresponde este clon cuando su
76
+ // `origin` no es una dirección de GitHub (alias de SSH, otro servidor). Con las mismas formas estrictas de `origin`;
77
+ // nunca se usa para traer nada.
78
+ export const REMOTO_DE_ASOCIACION = "supcoflow";
33
79
  export async function leerRepoLocal(git, cwd) {
34
80
  const top = await git(["rev-parse", "--show-toplevel"], cwd);
35
81
  const raiz = top.codigo === 0 ? top.stdout.trim() : "";
@@ -37,32 +83,68 @@ export async function leerRepoLocal(git, cwd) {
37
83
  return { repo: null, raiz: null, motivo: "sin_git" };
38
84
  const origin = await git(["remote", "get-url", "origin"], cwd);
39
85
  const url = origin.codigo === 0 ? origin.stdout.trim() : "";
86
+ const repoDelOrigen = url ? repoDelOrigin(url) : null;
87
+ // Si `origin` es de GitHub, gana `origin`, como siempre.
88
+ if (repoDelOrigen)
89
+ return { repo: repoDelOrigen, raiz };
90
+ const asociado = await git(["remote", "get-url", REMOTO_DE_ASOCIACION], cwd);
91
+ const urlAsociada = asociado.codigo === 0 ? asociado.stdout.trim() : "";
92
+ const repoAsociado = urlAsociada ? repoDelOrigin(urlAsociada) : null;
93
+ if (repoAsociado)
94
+ return { repo: repoAsociado, raiz };
95
+ const alias = url ? aliasDelOrigin(url) : null;
96
+ const comun = { repo: null, raiz, ...(url ? { origen: origenParaMostrar(url) } : {}), ...(urlAsociada ? { hayRemotoSupcoflow: true } : {}) };
40
97
  if (!url)
41
- return { repo: null, raiz, motivo: "sin_origin" };
42
- const repo = repoDelOrigin(url);
43
- if (!repo)
44
- return { repo: null, raiz, motivo: "origin_no_es_github" };
45
- return { repo, raiz };
98
+ return { ...comun, motivo: "sin_origin" };
99
+ if (alias)
100
+ return { ...comun, motivo: "origin_alias_ssh", alias };
101
+ return { ...comun, motivo: "origin_no_es_github" };
102
+ }
103
+ // El comando exacto que asocia este clon con un repo de GitHub. `add` si todavía no hay remoto `supcoflow`; `set-url` si
104
+ // hay uno que no es una dirección de GitHub.
105
+ export function comandoDeAsociacion(repo, hayRemoto) {
106
+ return `git remote ${hayRemoto ? "set-url" : "add"} ${REMOTO_DE_ASOCIACION} https://github.com/${repo}.git`;
46
107
  }
47
108
  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",
109
+ sin_git: "this folder isn't a git repo",
110
+ sin_origin: "the repo has no remote called \"origin\"",
111
+ origin_no_es_github: "the \"origin\" remote isn't a GitHub repo",
112
+ origin_alias_ssh: "the \"origin\" remote isn't a GitHub repo",
51
113
  };
114
+ // La frase que dice la verdad sobre un clon que no se puede atar a `repo` (owner/repo de GitHub) y cómo atarlo.
115
+ export function frasePorQueNoAtaClon(local, repo) {
116
+ if (local.motivo === "sin_git")
117
+ return "This folder isn't a git clone.";
118
+ const sugerido = repo ?? local.alias?.repo ?? null;
119
+ const como = sugerido === null ? ""
120
+ : ` If this is ${sugerido} on GitHub, tell me once: \`${comandoDeAsociacion(sugerido, local.hayRemotoSupcoflow === true)}\` (it's never fetched).`;
121
+ if (local.motivo === "origin_alias_ssh" && repo === null && local.alias) {
122
+ return `Your origin uses the SSH alias \`${local.alias.host}\`, which I don't resolve.${como}`;
123
+ }
124
+ const clon = local.origen ? `This clone is \`${local.origen}\`, which I can't tie to github.com${repo ? `/${repo}` : ""}.` : "This clone has no \"origin\" remote I can tie to GitHub.";
125
+ return `${clon}${como}`;
126
+ }
52
127
  export async function repoPedido(d) {
53
128
  const local = await leerRepoLocal(d.git, d.cwd);
54
129
  if (d.flagRepo !== null) {
55
130
  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.");
131
+ throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "repo_invalido", "The value of --repo must be owner/repo (for example owner/my-app), without the URL.");
57
132
  }
58
- // La raíz sólo se da si el origin local es ESE repo: con otro, lo que se escriba en esta
133
+ // La raíz sólo se da si el repo local reconocido es ESE repo: con otro, lo que se escriba en esta
59
134
  // carpeta (el archivo del proyecto) no le pertenece.
60
135
  const mismo = local.repo !== null && local.repo.toLowerCase() === d.flagRepo.toLowerCase();
61
- return { repo: d.flagRepo, raiz: mismo ? local.raiz : null };
136
+ if (mismo)
137
+ return { repo: d.flagRepo, raiz: local.raiz };
138
+ // El mensaje de «no es tu clon» dice por qué, y deja el comando exacto para atarlo si la persona SABE que lo es.
139
+ const sinClon = local.repo !== null
140
+ ? `This clone is github.com/${local.repo}, not ${d.flagRepo}.`
141
+ : frasePorQueNoAtaClon(local, d.flagRepo);
142
+ return { repo: d.flagRepo, raiz: null, sinClon };
62
143
  }
63
144
  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.`);
145
+ const porque = local.motivo ? MOTIVOS[local.motivo] : "the folder couldn't be read";
146
+ const detalle = local.motivo === "origin_alias_ssh" ? ` ${frasePorQueNoAtaClon(local, null)}` : "";
147
+ throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "sin_repo", `I couldn't find a GitHub repo here (${porque}).${detalle} Pass --repo owner/repo.`);
66
148
  }
67
149
  return { repo: local.repo, raiz: local.raiz };
68
150
  }
@@ -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.1",
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" },