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.
@@ -5,11 +5,9 @@ import path from "node:path";
5
5
  import { resultadoDeFallo, SALIDA } from "../contrato.js";
6
6
  import { repoPedido } from "../repo-local.js";
7
7
  import { claveVigente } from "./clave-vigente.js";
8
+ import { armarElContexto, coherenciaAlCrear, coherenciaDelClon, idDelArchivo, leerElArchivoDelClon, lineaDelContexto } from "./contexto.js";
8
9
  const PLAZO_DE_LECTURA_MS = 15_000;
9
10
  const OPERACION = "proyecto_crear";
10
- // Lo único que se lee de un `.supcoflow/proyecto.json` ajeno es su id, y sólo si tiene esta forma:
11
- // así lo que salga a la terminal no puede ser otra cosa que un id (Review Focus 11).
12
- const FORMA_DEL_ID = /^proj_[a-z0-9-]{1,80}$/;
13
11
  // Los 422/409 del alta que el plan no listaba (alta-desde-conexion.ts del motor).
14
12
  const RECHAZOS_DE_UNA_PERSONA = new Set(["instalacion_no_verificada", "repo_fuera_de_la_instalacion"]);
15
13
  const RECHAZOS_TRANSITORIOS = new Set(["slug_ocupado", "repo_ya_conectado"]);
@@ -23,18 +21,6 @@ const comillas = (s) => `"${s.replace(/["\\]/g, "\\$&")}"`;
23
21
  function contenidoDelArchivo(p) {
24
22
  return JSON.stringify({ id: p.id, slug: p.slug, repo: p.repo }, null, 2) + "\n";
25
23
  }
26
- function idDelArchivo(crudo) {
27
- try {
28
- const v = JSON.parse(crudo);
29
- if (typeof v !== "object" || v === null)
30
- return null;
31
- const id = v.id;
32
- return typeof id === "string" && FORMA_DEL_ID.test(id) ? id : null;
33
- }
34
- catch {
35
- return null;
36
- }
37
- }
38
24
  // Mismo contenido aunque el formato cambie (otra indentación, otro orden): no es «otro proyecto».
39
25
  function mismoArchivo(crudo, p) {
40
26
  if (crudo === contenidoDelArchivo(p))
@@ -52,7 +38,7 @@ function mismoArchivo(crudo, p) {
52
38
  }
53
39
  function resultadoDelProyecto(p, reusado, raiz, d) {
54
40
  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}`;
41
+ const queda = reusado ? `Project ${p.id} already existed and I reused it` : `I created project ${p.id}`;
56
42
  let archivo = null;
57
43
  if (raiz !== null) {
58
44
  archivo = path.join(raiz, ".supcoflow", "proyecto.json");
@@ -63,8 +49,8 @@ function resultadoDelProyecto(p, reusado, raiz, d) {
63
49
  // decida. Su contenido NUNCA se copia a la salida (puede traer cualquier cosa, una clave
64
50
  // incluida, y `emitir` no sanea): sólo el id, si tiene forma de id.
65
51
  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\`.`;
52
+ const message = `There is already a \`.supcoflow/proyecto.json\` for another project (${idActual ?? "one I don't recognize"}); I didn't touch it. `
53
+ + `${queda} anyway: if that's the one you want, delete the file and run \`supcoflow project create\` again.`;
68
54
  return fallo(SALIDA.haceFaltaUnaPersona, "archivo_distinto", message, { archivo, id_actual: idActual, nuevo, reusado });
69
55
  }
70
56
  if (actual === null) {
@@ -72,7 +58,7 @@ function resultadoDelProyecto(p, reusado, raiz, d) {
72
58
  d.archivos.escribir(archivo, contenidoDelArchivo(p));
73
59
  }
74
60
  catch {
75
- const message = `${queda} (${p.id}), pero no pude escribir ${archivo}. Volvé a correr \`supcoflow proyecto crear\`.`;
61
+ const message = `${queda}, but I couldn't write ${archivo}. Run \`supcoflow project create\` again.`;
76
62
  return fallo(SALIDA.inesperado, "archivo_no_escrito", message, { archivo, nuevo, reusado });
77
63
  }
78
64
  }
@@ -80,7 +66,7 @@ function resultadoDelProyecto(p, reusado, raiz, d) {
80
66
  return {
81
67
  codigo: SALIDA.ok,
82
68
  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}.` : ""}`,
69
+ texto: `${queda}: ${p.repo}, branch ${p.rama}.${archivo !== null ? ` Recorded in ${archivo}.` : ""}`,
84
70
  };
85
71
  }
86
72
  export async function proyectoCrear(d, flags) {
@@ -96,12 +82,24 @@ export async function proyectoCrear(d, flags) {
96
82
  if (!leida.ok)
97
83
  return leida.resultado;
98
84
  const clave = leida.vigente.clave;
85
+ // Antes de conectar o crear nada (fricción 6): el archivo del clon contra lo que la clave ve, y, si va a haber un alta, el
86
+ // dueño del repo contra la cuenta. Sale 4 `contexto_incoherente` sin tocar nada.
87
+ const archivo = leerElArchivoDelClon(d.archivos, raiz);
88
+ if (archivo.estado === "ilegible")
89
+ return coherenciaDelClon(archivo, null, repo);
99
90
  let existente;
100
91
  let conexion = null;
101
92
  try {
102
93
  existente = await d.buscarProyecto(d.api, clave, repo);
103
- if (existente === null)
94
+ const incoherente = coherenciaDelClon(archivo, existente, repo);
95
+ if (incoherente !== null)
96
+ return incoherente;
97
+ if (existente === null) {
98
+ const alCrear = coherenciaAlCrear({ yo: await d.quienSoy(d.api, clave), fuente: leida.vigente.fuente, repo });
99
+ if (alCrear !== null)
100
+ return alCrear;
104
101
  conexion = await d.ultimaConexion(d.api, clave, repo, PLAZO_DE_LECTURA_MS);
102
+ }
105
103
  }
106
104
  catch (e) {
107
105
  return resultadoDeFallo(e);
@@ -116,18 +114,18 @@ export async function proyectoCrear(d, flags) {
116
114
  if (flags.install !== null || flags.test !== null) {
117
115
  // Sin copiar los valores: son texto del usuario y el aviso sólo dice qué pasó con ellos. Callarlo
118
116
  // 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.");
117
+ d.avisar("The project already exists: I ignored --install/--test. To change its commands, it has to be configured.");
120
118
  }
121
119
  return resultadoDelProyecto(existente, true, raiz, d);
122
120
  }
123
121
  if (conexion === null || conexion.estado !== "conectado") {
124
- return fallo(SALIDA.haceFaltaUnaPersona, "repo_sin_conectar", "Conectá el repo primero: `supcoflow repo conectar`.", {
122
+ return fallo(SALIDA.haceFaltaUnaPersona, "repo_sin_conectar", "Connect the repo first: `supcoflow repo connect`.", {
125
123
  repo, ...(conexion !== null ? { estado: conexion.estado } : {}),
126
124
  });
127
125
  }
128
126
  if (flags.dryRun) {
129
127
  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}).` };
128
+ 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
129
  }
132
130
  const cuerpo = {};
133
131
  if (flags.install !== null)
@@ -153,27 +151,27 @@ export async function proyectoCrear(d, flags) {
153
151
  await d.idempotencia.olvidar(OPERACION, repo);
154
152
  const install = flags.install ?? "…";
155
153
  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)}\`.`;
154
+ const message = `${alta.message || "The project is missing commands."} Pass them yourself: \`supcoflow project create --install ${comillas(install)} --test ${comillas(test)}\`.`;
157
155
  return fallo(SALIDA.entradaInvalida, "comandos_faltantes", message, { faltantes: alta.faltantes });
158
156
  }
159
157
  case "no_conectada":
160
158
  await d.idempotencia.olvidar(OPERACION, repo);
161
- return fallo(SALIDA.haceFaltaUnaPersona, "repo_sin_conectar", alta.message || "Conectá el repo primero: `supcoflow repo conectar`.", { repo });
159
+ return fallo(SALIDA.haceFaltaUnaPersona, "repo_sin_conectar", alta.message || "Connect the repo first: `supcoflow repo connect`.", { repo });
162
160
  case "en_curso":
163
161
  // 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.");
162
+ return fallo(SALIDA.inesperado, "en_curso", "Another request with this key is already in progress; try again in a minute.");
165
163
  case "clave_reusada":
166
164
  // No debería pasar (la clave es por repo y un reintento manda lo mismo); olvidarla deja reintentar limpio.
167
165
  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.");
166
+ return fallo(SALIDA.inesperado, "clave_reusada", "The engine rejected the saved idempotency key; I discarded it, try again.");
169
167
  case "rechazo": {
170
168
  // La clave se conserva: tras un rechazo el motor ya la soltó, y el próximo intento la reusa sin
171
169
  // 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.";
170
+ const message = alta.message || "The engine rejected the request.";
173
171
  if (RECHAZOS_DE_UNA_PERSONA.has(alta.error)) {
174
172
  // Lo arregla alguien con permisos en GitHub (instalar la App, sumarle el repo): reintentar o
175
173
  // 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\`.`);
174
+ return fallo(SALIDA.haceFaltaUnaPersona, alta.error, `${message} Check the App's installation on GitHub and connect again with \`supcoflow repo connect\`.`);
177
175
  }
178
176
  // Dos altas al mismo tiempo, o un proyecto borrado en el medio: el mismo pedido otra vez anda.
179
177
  if (RECHAZOS_TRANSITORIOS.has(alta.error))
@@ -184,8 +182,9 @@ export async function proyectoCrear(d, flags) {
184
182
  }
185
183
  export async function proyectoVer(d, flags) {
186
184
  let repo;
185
+ let raiz;
187
186
  try {
188
- repo = (await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd })).repo;
187
+ ({ repo, raiz } = await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd }));
189
188
  }
190
189
  catch (e) {
191
190
  return resultadoDeFallo(e);
@@ -194,17 +193,32 @@ export async function proyectoVer(d, flags) {
194
193
  if (!leida.ok)
195
194
  return leida.resultado;
196
195
  const clave = leida.vigente.clave;
196
+ const archivo = leerElArchivoDelClon(d.archivos, raiz);
197
+ if (archivo.estado === "ilegible")
198
+ return coherenciaDelClon(archivo, null, repo);
197
199
  try {
198
200
  const encontrado = await d.buscarProyecto(d.api, clave, repo);
201
+ const incoherente = coherenciaDelClon(archivo, encontrado, repo);
202
+ if (incoherente !== null)
203
+ return incoherente;
204
+ // Quién soy, en qué equipo, qué repo y qué proyecto: la línea que la persona lee al empezar, también cuando todo cierra.
205
+ const yo = await d.quienSoy(d.api, clave);
206
+ const contexto = armarElContexto({ yo, fuente: leida.vigente.fuente, repo, proyecto: encontrado, archivo });
207
+ const linea = lineaDelContexto(contexto, yo.proyecto);
199
208
  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`." };
209
+ // Sin proyecto, el recorrido sigue con `repo connect`: el freno por cuenta (dueño distinto, sin GitHub) tiene que llegar ANTES,
210
+ // no al crear. Con proyecto existente no se compara nada, y con una clave de consulta tampoco.
211
+ const alCrear = coherenciaAlCrear({ yo, fuente: leida.vigente.fuente, repo });
212
+ if (alCrear !== null)
213
+ return alCrear;
214
+ return { codigo: SALIDA.ok, json: { ok: true, repo, proyecto: null, contexto }, texto: `${linea} This repo doesn't have a project yet: \`supcoflow project create\`.` };
201
215
  }
202
216
  const { proyecto: p, github } = await d.leerProyecto(d.api, clave, encontrado.id);
203
217
  const proyecto = { id: p.id, slug: p.slug, repo: p.repo, rama: p.rama, install_command: p.installCommand, test_command: p.testCommand };
204
218
  return {
205
219
  codigo: SALIDA.ok,
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"}.`,
220
+ json: { ok: true, proyecto, github: { estado: github.estado, verificado_en: github.verificadoEn }, contexto },
221
+ texto: `${linea} ${p.id} (${p.repo}, branch ${p.rama}): installs with \`${p.installCommand}\`, tests with \`${p.testCommand}\`. GitHub: ${github.estado ?? "not verified"}.`,
208
222
  };
209
223
  }
210
224
  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,51 @@
1
+ import { frasePorQueNoAtaClon } from "./repo-local.js";
2
+ export const TOTAL_DE_INSTALACION = 6;
3
+ // El orden y los nombres con que se dicen: los ids de los puntos del motor son datos suyos y no cambian.
4
+ const DE_LA_INSTALACION = [
5
+ { punto: "sign-in", como: "sign-in" },
6
+ { punto: "repo", como: "repo" },
7
+ { punto: "instalacion", como: "GitHub App" },
8
+ { punto: "sitios", como: "allowed sites" },
9
+ { punto: "chat", como: "chat" },
10
+ { punto: "guia", como: "guide" },
11
+ ];
12
+ const DEL_MOTOR = new Set(["instalacion", "sitios", "chat", "guia"]);
13
+ 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.`;
14
+ export const nombreDelPunto = (punto) => DE_LA_INSTALACION.find((x) => x.punto === punto)?.como ?? punto;
15
+ export function puntoDeSesion() {
16
+ return { punto: "sign-in", ok: true, mensaje: "This request was authenticated: you're signed in.", grupo: "instalacion" };
17
+ }
18
+ export function puntoDelRepo(local, repoDelProyecto) {
19
+ const mal = (mensaje) => ({ punto: "repo", ok: false, mensaje, grupo: "instalacion" });
20
+ if (local.repo === null) {
21
+ if (local.motivo === "sin_git")
22
+ return mal("This folder isn't a git repo, so I can't tell which repo it is.");
23
+ if (local.motivo === "sin_origin")
24
+ return mal("This repo has no «origin» remote, so I can't tell which repo it is.");
25
+ if (local.motivo === "origin_alias_ssh")
26
+ return mal(`The «origin» of this repo isn't a GitHub repo. ${frasePorQueNoAtaClon(local, null)}`);
27
+ return mal("The «origin» of this repo isn't a GitHub repo.");
28
+ }
29
+ if (local.repo.toLowerCase() !== repoDelProyecto.toLowerCase()) {
30
+ return mal(`The origin of this folder is ${local.repo}, not the project's repo ${repoDelProyecto}.`);
31
+ }
32
+ return { punto: "repo", ok: true, mensaje: `The origin of this folder is ${local.repo}, the project's repo.`, grupo: "instalacion" };
33
+ }
34
+ /** Reparte los puntos del motor en los dos grupos y suma los dos locales al de la instalación. */
35
+ export function agruparPuntos(g, locales) {
36
+ const delMotor = g.puntos.map((p) => ({ ...p, grupo: p.grupo ?? (DEL_MOTOR.has(p.punto) ? "instalacion" : "prueba") }));
37
+ const instalacion = [...locales, ...delMotor.filter((p) => p.grupo === "instalacion")];
38
+ // Un punto del grupo que el motor no mandó no se da por bueno: se dice que falta y cuenta como no ok.
39
+ for (const { punto } of DE_LA_INSTALACION) {
40
+ if (!instalacion.some((p) => p.punto === punto)) {
41
+ instalacion.push({ punto, ok: false, mensaje: "The engine didn't report this point.", grupo: "instalacion" });
42
+ }
43
+ }
44
+ const orden = (p) => {
45
+ const i = DE_LA_INSTALACION.findIndex((x) => x.punto === p.punto);
46
+ return i === -1 ? DE_LA_INSTALACION.length : i;
47
+ };
48
+ return { instalacion: instalacion.sort((a, b) => orden(a) - orden(b)), prueba: delMotor.filter((p) => p.grupo === "prueba") };
49
+ }
50
+ /** Cuántos de los seis puntos de la instalación están bien (los extras que un motor nuevo sume no cambian el seis). */
51
+ export const contarBuenos = (instalacion) => DE_LA_INSTALACION.filter(({ punto }) => instalacion.some((p) => p.punto === punto && p.ok)).length;
package/dist/entorno.js CHANGED
@@ -2,15 +2,18 @@
2
2
  // demás recibe estas piezas inyectadas y se prueba con dobles; acá sólo se enchufan.
3
3
  import { execFile, spawn } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
- import { chmodSync, closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeSync, } from "node:fs";
5
+ import { accessSync, chmodSync, closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, statSync, 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, leerDiagnostico, leerPrueba, leerWidget, pedirPrueba, crearConexion, crearProyecto, elegirOrigen, esClaveDeConsultaDelProyecto, leerCatalogo, leerConfiguracion, leerGuia, leerProyecto, listarClaves, pedirCodigo, quienSoy, revocarPropia, subirLogo, ultimaConexion, urlDeLaApi, publicarGuia, revisarGuia, } from "./api.js";
9
+ 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";
15
+ import { almacenProtegido } from "./guardado/protegido.js";
16
+ import { revisarElLugar } from "./guardado/lugar.js";
14
17
  import { almacenDeLlavero } from "./guardado/llavero.js";
15
18
  import { crearGuardaDeSecretos } from "./secreto-a-archivo.js";
16
19
  import { crearRegistroDeIdempotencia } from "./idempotencia.js";
@@ -51,7 +54,7 @@ export const fsReal = {
51
54
  return realpathSync(p);
52
55
  }
53
56
  catch {
54
- throw new Error(`No puedo resolver la ruta real de ${p} (¿un enlace roto?); no guardo nada ahí.`);
57
+ throw new Error(`I can't resolve the real path of ${p} (a broken symlink?); I'm not saving anything there.`);
55
58
  }
56
59
  },
57
60
  // `lstat` y no `existsSync`: éste sigue el enlace y ante uno roto dice «no existe», y el control
@@ -67,6 +70,29 @@ export const fsReal = {
67
70
  }
68
71
  },
69
72
  };
73
+ // Lo que el prechequeo de la máquina (src/guardado/lugar.ts) ve del disco, sin escribir. `estado` sigue los enlaces (`stat`): un
74
+ // enlace roto es «no existe» para él, y `existe` (que es `lstat`) lo distingue.
75
+ export const fsDelLugarReal = {
76
+ ...fsReal,
77
+ estado(p) {
78
+ try {
79
+ const st = statSync(p);
80
+ return { existe: true, tipo: st.isDirectory() ? "carpeta" : st.isFile() ? "archivo" : "otro", propio: typeof process.getuid !== "function" || st.uid === process.getuid() };
81
+ }
82
+ catch {
83
+ return { existe: false };
84
+ }
85
+ },
86
+ puede(p, que) {
87
+ try {
88
+ accessSync(p, que === "leer" ? constants.R_OK : constants.W_OK | constants.X_OK);
89
+ return true;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ },
95
+ };
70
96
  // El candado de `claves crear` (src/candado.ts). `wx` es la exclusión: SÓLO `EEXIST` es «ya hay uno»;
71
97
  // cualquier otro error (permisos, disco) se relanza y sale `no_disponible`, nunca «ocupado». La lectura no
72
98
  // sigue enlaces: un `.lock` que alguien cambió por un enlace no se lee como si fuera el nuestro.
@@ -252,7 +278,7 @@ export const TOPE_DEL_ARCHIVO_DEL_REPO = 64 * 1024;
252
278
  // Lo que devuelve `leer` cuando hay ALGO ahí que no se lee (un enlace, una carpeta, algo enorme, sin
253
279
  // permiso). No es JSON a propósito: `proyecto crear` lo trata como un archivo distinto (sale 4, «no lo
254
280
  // toqué») sin que nada de lo que hay ahí llegue a la salida.
255
- export const ARCHIVO_QUE_NO_SE_LEE = "\u0000(no se lee)";
281
+ export const ARCHIVO_QUE_NO_SE_LEE = "\u0000(unreadable)";
256
282
  // Un archivo que la persona nombra (el logo) con las mismas defensas que los del repo: sin seguir
257
283
  // enlaces, sin FIFOs que cuelguen, sólo archivos comunes y con tope (se lee tope + 1 por si crece).
258
284
  export function leerArchivoChico(p, tope) {
@@ -262,11 +288,11 @@ export function leerArchivoChico(p, tope) {
262
288
  }
263
289
  catch (e) {
264
290
  const code = e.code;
265
- return { error: code === "ENOENT" ? "no existe" : code === "ELOOP" ? "es un enlace" : "no se pudo abrir" };
291
+ return { error: code === "ENOENT" ? NO_LEIDO.noExiste : code === "ELOOP" ? NO_LEIDO.esUnEnlace : NO_LEIDO.noSePudoAbrir };
266
292
  }
267
293
  try {
268
294
  if (!fstatSync(fd).isFile())
269
- return { error: "no es un archivo común" };
295
+ return { error: NO_LEIDO.noEsComun };
270
296
  const buf = Buffer.alloc(tope + 1);
271
297
  let n = 0;
272
298
  for (;;) {
@@ -275,12 +301,12 @@ export function leerArchivoChico(p, tope) {
275
301
  break;
276
302
  n += leidos;
277
303
  if (n > tope)
278
- return { error: "es demasiado grande" };
304
+ return { error: NO_LEIDO.demasiadoGrande };
279
305
  }
280
306
  return { bytes: new Uint8Array(buf.subarray(0, n)) };
281
307
  }
282
308
  catch {
283
- return { error: "no se pudo leer" };
309
+ return { error: NO_LEIDO.noSePudoLeer };
284
310
  }
285
311
  finally {
286
312
  closeSync(fd);
@@ -299,7 +325,7 @@ export function archivosDelRepo(avisar) {
299
325
  }
300
326
  };
301
327
  const noSeLee = (p, porque) => {
302
- avisar(`No leo ${p}: ${porque}.`);
328
+ avisar(`I can't read ${p}: ${porque}.`);
303
329
  return ARCHIVO_QUE_NO_SE_LEE;
304
330
  };
305
331
  return {
@@ -308,7 +334,7 @@ export function archivosDelRepo(avisar) {
308
334
  if (carpeta === "ausente")
309
335
  return null;
310
336
  if (carpeta === "ajena")
311
- return noSeLee(p, `${basename(dirname(p))} no es una carpeta común (¿un enlace?)`);
337
+ return noSeLee(p, `${basename(dirname(p))} isn't a regular folder (a symlink?)`);
312
338
  let fd;
313
339
  try {
314
340
  // O_NOFOLLOW: un enlace en el último tramo falla (ELOOP) en vez de abrir su destino.
@@ -319,14 +345,14 @@ export function archivosDelRepo(avisar) {
319
345
  const code = e.code;
320
346
  if (code === "ENOENT")
321
347
  return null;
322
- return noSeLee(p, code === "ELOOP" ? "es un enlace" : "no se pudo abrir");
348
+ return noSeLee(p, code === "ELOOP" ? NO_LEIDO.esUnEnlace : NO_LEIDO.noSePudoAbrir);
323
349
  }
324
350
  try {
325
351
  const st = fstatSync(fd);
326
352
  if (!st.isFile())
327
- return noSeLee(p, "no es un archivo común");
353
+ return noSeLee(p, NO_LEIDO.noEsComun);
328
354
  if (st.size > TOPE_DEL_ARCHIVO_DEL_REPO)
329
- return noSeLee(p, "es demasiado grande");
355
+ return noSeLee(p, NO_LEIDO.demasiadoGrande);
330
356
  // Se lee hasta el tope + 1 y no `st.size`: el archivo puede crecer entre el fstat y la lectura.
331
357
  const buf = Buffer.alloc(TOPE_DEL_ARCHIVO_DEL_REPO + 1);
332
358
  let n = 0;
@@ -336,12 +362,12 @@ export function archivosDelRepo(avisar) {
336
362
  break;
337
363
  n += leidos;
338
364
  if (n > TOPE_DEL_ARCHIVO_DEL_REPO)
339
- return noSeLee(p, "es demasiado grande");
365
+ return noSeLee(p, NO_LEIDO.demasiadoGrande);
340
366
  }
341
367
  return buf.subarray(0, n).toString("utf8");
342
368
  }
343
369
  catch {
344
- return noSeLee(p, "no se pudo leer");
370
+ return noSeLee(p, NO_LEIDO.noSePudoLeer);
345
371
  }
346
372
  finally {
347
373
  closeSync(fd);
@@ -355,7 +381,7 @@ export function archivosDelRepo(avisar) {
355
381
  const dir = dirname(p);
356
382
  mkdirSync(dir, { recursive: true });
357
383
  if (carpetaPropia(dir) !== "propia")
358
- throw new Error(`${dir} no es una carpeta común`);
384
+ throw new Error(`${dir} isn't a regular folder`);
359
385
  const tmp = join(dir, `.${basename(p)}.${randomUUID()}.tmp`);
360
386
  try {
361
387
  const fd = openSync(tmp, "wx", 0o644);
@@ -395,27 +421,31 @@ export function entornoReal(env = process.env) {
395
421
  // Al lado de las credenciales y con el mismo almacén (carpeta 0700, archivo 0600, `.tmp` en
396
422
  // exclusiva): nunca dentro del repo donde se corre el comando, que la publicaría con un `git add -A`.
397
423
  const rutaDeIdempotencia = join(dirname(rutaDelArchivo), "idempotencia.json");
424
+ // Estos dos registros (y sólo estos) pueden vivir bajo una carpeta personal que es un repo de git: el almacén
425
+ // protegido comprueba con git, antes de cada escritura y cada borrado, que los deja afuera del repo.
426
+ const protegido = (ruta) => almacenProtegido(almacenDeArchivo(ruta, fsReal), { ruta, fs: fsReal, git: gitReal });
398
427
  const idempotencia = crearRegistroDeIdempotencia({
399
428
  origen: api.origin,
400
- almacen: almacenDeArchivo(rutaDeIdempotencia, fsReal),
429
+ almacen: protegido(rutaDeIdempotencia),
401
430
  rutaDelArchivo: rutaDeIdempotencia,
402
- fs: fsReal,
403
431
  generar: randomUUID,
404
432
  ahora: Date.now,
405
433
  avisar,
406
434
  });
407
- // El pedido de `probar` (intento + última prueba), con el mismo almacén y la misma regla: fuera de un repo.
435
+ // El pedido de `probar` (intento + última prueba), con el mismo almacén protegido.
408
436
  const rutaDePruebas = join(dirname(rutaDelArchivo), "pruebas.json");
409
437
  const pruebas = crearRegistroDePruebas({
410
- origen: api.origin, almacen: almacenDeArchivo(rutaDePruebas, fsReal), rutaDelArchivo: rutaDePruebas, fs: fsReal, ahora: Date.now, avisar,
438
+ origen: api.origin, almacen: protegido(rutaDePruebas), rutaDelArchivo: rutaDePruebas, ahora: Date.now, avisar,
439
+ revisarElLugar: () => revisarElLugar({ ruta: rutaDePruebas, fs: fsDelLugarReal, git: gitReal }),
411
440
  });
412
441
  const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
413
442
  const cwd = process.cwd();
414
443
  // Los candados de `claves crear` viven al lado de las credenciales, nunca en un repo.
415
444
  const candados = crearCandados(fsDelCandadoReal, join(dirname(rutaDelArchivo), "candados"), process.pid, procesoVivo);
416
445
  const secretos = crearGuardaDeSecretos(fsDeSecretosReal);
446
+ // `archivos`: `proyectoDelRepo` lee `.supcoflow/proyecto.json` del clon con él (fricción 6).
417
447
  const configurarEnElMotor = {
418
- api, guardado, git: gitReal, cwd, avisar,
448
+ api, guardado, git: gitReal, cwd, avisar, archivos: archivosDelRepo(avisar),
419
449
  buscarProyecto: (a, clave, repo) => buscarProyecto(a, clave, repo, fetch),
420
450
  leerConfiguracion: (a, clave, id) => leerConfiguracion(a, clave, id, fetch),
421
451
  configurar: (a, clave, ruta, cuerpo, extra) => configurar(a, clave, ruta, cuerpo, fetch, extra),
@@ -453,6 +483,7 @@ export function entornoReal(env = process.env) {
453
483
  proyecto: {
454
484
  api, guardado, git: gitReal, cwd, idempotencia, archivos: archivosDelRepo(avisar), avisar,
455
485
  buscarProyecto: (a, clave, repo) => buscarProyecto(a, clave, repo, fetch),
486
+ quienSoy: (a, clave) => quienSoy(a, clave, fetch),
456
487
  ultimaConexion: (a, clave, repo, plazoMs) => ultimaConexion(a, clave, repo, fetch, plazoMs),
457
488
  crearProyecto: (a, clave, conexionId, cuerpo, key) => crearProyecto(a, clave, conexionId, cuerpo, key, fetch),
458
489
  leerProyecto: (a, clave, id) => leerProyecto(a, clave, id, fetch),
@@ -466,7 +497,7 @@ export function entornoReal(env = process.env) {
466
497
  guia: {
467
498
  ...configurarEnElMotor,
468
499
  leerArchivoChico, fetch, gitDeclarado: gitRapido,
469
- revisar: revisarGuia, publicar: publicarGuia, ver: leerGuia, elegir: elegirOrigen,
500
+ revisar: revisarGuia, publicar: publicarGuia, ver: leerGuia, elegir: elegirOrigen, versiones: leerVersionesDeLaGuia,
470
501
  },
471
502
  claves: {
472
503
  ...configurarEnElMotor,
@@ -475,7 +506,7 @@ export function entornoReal(env = process.env) {
475
506
  idempotencia, candados, secretos,
476
507
  },
477
508
  widget: {
478
- api, guardado, git: gitReal, cwd,
509
+ api, guardado, git: gitReal, cwd, archivos: archivosDelRepo(avisar),
479
510
  buscarProyecto: configurarEnElMotor.buscarProyecto,
480
511
  leerWidget: (a, clave, id) => leerWidget(a, clave, id, fetch),
481
512
  },
@@ -483,12 +514,12 @@ export function entornoReal(env = process.env) {
483
514
  // para lo que la terminal dice de sí misma.
484
515
  instalacion: {
485
516
  ...configurarEnElMotor,
486
- leerArchivoChico, rutaReal: rutaRealOEnNada, esCarpeta: esCarpetaReal,
517
+ leerArchivoChico, rutaReal: rutaRealOEnNada, esCarpeta: esCarpetaReal, esEnlace: esEnlaceReal,
487
518
  escribir: archivosDelRepo(avisar).escribir,
488
519
  terminal: DESCRIPCION.version,
489
520
  },
490
521
  prueba: {
491
- api, guardado, git: gitReal, cwd, avisar, pruebas, generar: randomUUID, ahora: Date.now, dormir, tramoMs,
522
+ api, guardado, git: gitReal, cwd, avisar, archivos: archivosDelRepo(avisar), pruebas, generar: randomUUID, ahora: Date.now, dormir, tramoMs,
492
523
  buscarProyecto: configurarEnElMotor.buscarProyecto,
493
524
  leerDiagnostico: (a, clave, id) => leerDiagnostico(a, clave, id, fetch),
494
525
  pedirPrueba: (a, clave, id, intento) => pedirPrueba(a, clave, id, intento, fetch),