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.
@@ -13,12 +13,12 @@ const OPERACION = "clave_crear";
13
13
  const SUFIJO = /^scf_([0-9a-f]{16})_[0-9a-f]{64}$/;
14
14
  const ID_DE_CLAVE = /^cla_[0-9a-f]{16}$/;
15
15
  export async function clavesCrear(d, flags) {
16
- if (flags.guardarEn === null)
17
- return fallo(SALIDA.entradaInvalida, "falta_guardar_en", "Decí dónde guardar la clave: `supcoflow claves crear --nombre ci --guardar-en ~/ci.key` (fuera de un repo).");
18
- if (flags.nombre === null)
19
- return fallo(SALIDA.entradaInvalida, "falta_nombre", "Poné un nombre que diga para qué es: `--nombre ci`.");
16
+ if (flags.saveTo === null)
17
+ return fallo(SALIDA.entradaInvalida, "falta_guardar_en", "Say where to save the key: `supcoflow keys create --name ci --save-to ~/ci.key` (outside a repo).");
18
+ if (flags.name === null)
19
+ return fallo(SALIDA.entradaInvalida, "falta_nombre", "Give it a name that says what it's for: `--name ci`.");
20
20
  // Absoluta desde ya: la salida dice dónde quedó sin depender de dónde estaba parado el comando.
21
- const destino = path.resolve(d.cwd, flags.guardarEn);
21
+ const destino = path.resolve(d.cwd, flags.saveTo);
22
22
  const p = await proyectoDelRepo(d, flags);
23
23
  if (!p.ok)
24
24
  return p.resultado;
@@ -28,8 +28,8 @@ export async function clavesCrear(d, flags) {
28
28
  if (invalido !== null)
29
29
  return fallo(invalido.codigo, "destino_invalido", `${destino}: ${invalido.error}.`);
30
30
  if (flags.dryRun)
31
- return dryRun(`POST ${ruta} y después …/confirmar`);
32
- const alcance = `${p.proyecto.id}#${flags.nombre}`;
31
+ return dryRun(`POST ${ruta} and then …/confirmar`);
32
+ const alcance = `${p.proyecto.id}#${flags.name}`;
33
33
  // El registro de idempotencia pone el alcance en minúsculas (`CI` y `ci` comparten fila): el candado
34
34
  // tiene que coincidir, o dos procesos con la misma clave de reintento corrían a la vez.
35
35
  // 1. El candado: el ciclo entero, de a uno por operación en esta máquina. Comprobar que el archivo
@@ -38,18 +38,18 @@ export async function clavesCrear(d, flags) {
38
38
  // máquinas no comparten la clave de idempotencia, así que crean filas distintas y no se pisan.
39
39
  const candado = d.candados.tomar(`${OPERACION}#${alcance.toLowerCase()}`);
40
40
  if (candado.tipo === "ocupado")
41
- return fallo(SALIDA.inesperado, "en_curso", "Otro `supcoflow claves crear` igual está corriendo en esta máquina; esperá a que termine.");
41
+ return fallo(SALIDA.inesperado, "en_curso", "Another identical `supcoflow keys create` is running on this machine; wait for it to finish.");
42
42
  if (candado.tipo === "abandonado") {
43
- return fallo(SALIDA.inesperado, "candado_abandonado", `Quedó el candado de un \`claves crear\` que no terminó: ${candado.ruta}. Si no hay otro corriendo, borralo y volvé a probar.`);
43
+ return fallo(SALIDA.inesperado, "candado_abandonado", `A lock from a \`keys create\` that didn't finish was left behind: ${candado.ruta}. If nothing else is running, delete it and try again.`);
44
44
  }
45
45
  if (candado.tipo === "no_disponible") {
46
- return fallo(SALIDA.inesperado, "candado_no_disponible", `No pude crear el candado en ${candado.ruta} (¿permisos?). Revisá esa carpeta y volvé a probar.`);
46
+ return fallo(SALIDA.inesperado, "candado_no_disponible", `I couldn't create the lock at ${candado.ruta} (permissions?). Check that folder and try again.`);
47
47
  }
48
48
  try {
49
49
  const autentica = async (secreto) => d.esClaveDeConsultaDelProyecto(d.api, secreto, p.proyecto.id);
50
50
  const yaCreada = async (claveId) => {
51
51
  await d.idempotencia.olvidar(OPERACION, alcance);
52
- return { codigo: SALIDA.ok, json: { ok: true, clave_id: claveId, archivo: destino, ya_confirmada: true }, texto: `La clave ${claveId} ya estaba creada; está en ${destino}.` };
52
+ return { codigo: SALIDA.ok, json: { ok: true, clave_id: claveId, archivo: destino, ya_confirmada: true }, texto: `Key ${claveId} was already created; it's in ${destino}.` };
53
53
  };
54
54
  // 2. Mirar el destino antes de pedir nada. Una clave de consulta de ESTE proyecto que autentica es un
55
55
  // éxito anterior: repetir el comando no crea otra fila (el archivo es la referencia durable; la clave
@@ -60,12 +60,12 @@ export async function clavesCrear(d, flags) {
60
60
  if (hay.estado === "clave" && (await autentica(hay.secreto)))
61
61
  return await yaCreada(hay.claveId);
62
62
  if (hay.estado !== "ausente" && !(hay.estado === "clave" && (await d.idempotencia.tiene(OPERACION, alcance)))) {
63
- return fallo(SALIDA.haceFaltaUnaPersona, "destino_ocupado", `${destino} ya tiene algo que no es una clave vigente de este proyecto; no lo toco. Borrá el archivo o elegí otro destino con --guardar-en.`);
63
+ return fallo(SALIDA.haceFaltaUnaPersona, "destino_ocupado", `${destino} already has something that isn't a current key for this project; I'm not touching it. Delete the file or pick another destination with --save-to.`);
64
64
  }
65
65
  // 3. El ciclo. Estricta: sin la clave de reintento guardada no se manda nada (Review Focus 15).
66
66
  const key = await d.idempotencia.claveDe(OPERACION, alcance, { estricta: true });
67
67
  for (let vuelta = 0; vuelta < 2; vuelta++) {
68
- const r = await d.configurar(d.api, p.clave, ruta, { nombre: flags.nombre }, { idempotencyKey: key });
68
+ const r = await d.configurar(d.api, p.clave, ruta, { nombre: flags.name }, { idempotencyKey: key });
69
69
  if (r.status === 200 && r.cuerpo.estado === "activa") {
70
70
  // El motor no rota una activa (no devuelve secreto): vale sólo si lo que tenemos es su secreto vigente.
71
71
  const claveId = String(r.cuerpo.clave_id);
@@ -73,7 +73,7 @@ export async function clavesCrear(d, flags) {
73
73
  if (ahora.estado === "clave" && ahora.claveId === claveId && (await autentica(ahora.secreto)))
74
74
  return await yaCreada(claveId);
75
75
  await d.idempotencia.olvidar(OPERACION, alcance);
76
- return fallo(SALIDA.haceFaltaUnaPersona, "archivo_desactualizado", `La clave ${claveId} quedó activa, pero ${destino} no tiene su secreto vigente. Revocala con \`supcoflow claves revocar --id ${claveId}\` y creá otra.`);
76
+ return fallo(SALIDA.haceFaltaUnaPersona, "archivo_desactualizado", `Key ${claveId} is active, but ${destino} doesn't hold its current secret. Revoke it with \`supcoflow keys revoke --id ${claveId}\` and create another.`);
77
77
  }
78
78
  if (r.status !== 201) {
79
79
  if (r.cuerpo.error === "clave_vencida") {
@@ -81,7 +81,7 @@ export async function clavesCrear(d, flags) {
81
81
  await d.idempotencia.olvidar(OPERACION, alcance);
82
82
  // El archivo conserva el secreto pendiente vencido y un reintento daría 4 destino_ocupado. Borrarlo
83
83
  // desde acá sería tocar un archivo que quizá no escribió este run; se lo pedimos a la persona.
84
- return fallo(SALIDA.inesperado, "clave_vencida", `La clave pendiente venció sin confirmarse. Borrá ${destino} (guarda el secreto vencido, ya no sirve) y volvé a correr el comando para crear otra.`);
84
+ return fallo(SALIDA.inesperado, "clave_vencida", `The pending key expired without being confirmed. Delete ${destino} (it holds the expired secret, which is no longer any use) and run the command again to create another.`);
85
85
  }
86
86
  // Misma clave de reintento con otro cuerpo: no se arregla reintentando, y guardada trabaría para siempre.
87
87
  if (r.cuerpo.error === "idempotency_key_reusada")
@@ -91,25 +91,25 @@ export async function clavesCrear(d, flags) {
91
91
  const claveId = String(r.cuerpo.clave_id);
92
92
  const secreto = typeof r.cuerpo.clave === "string" && SUFIJO.test(r.cuerpo.clave) ? r.cuerpo.clave : null;
93
93
  if (secreto === null)
94
- return fallo(SALIDA.inesperado, "respuesta_inesperada", "El motor no devolvió una clave con la forma esperada.");
94
+ return fallo(SALIDA.inesperado, "respuesta_inesperada", "The engine didn't return a key in the expected form.");
95
95
  // El id y el secreto tienen que ser de la misma fila: si no, lo que se guarda no es lo que se confirma.
96
96
  if (claveId !== `cla_${SUFIJO.exec(secreto)[1]}`)
97
- return fallo(SALIDA.inesperado, "respuesta_inesperada", "El motor devolvió una clave que no corresponde a su id; no la guardé.");
97
+ return fallo(SALIDA.inesperado, "respuesta_inesperada", "The engine returned a key that doesn't match its id; I didn't save it.");
98
98
  const g = d.secretos.guardar(destino, claveId, secreto);
99
99
  // Sin guardarla NO se confirma: una pendiente sin confirmar no autentica y vence sola a los 10 minutos.
100
100
  if (g !== "guardado")
101
- return fallo(g.codigo, "no_se_guardo", `No guardé la clave en ${destino}: ${g.error}. No quedó activa.`);
101
+ return fallo(g.codigo, "no_se_guardo", `I didn't save the key in ${destino}: ${g.error}. It was not activated.`);
102
102
  const c = await d.configurar(d.api, p.clave, `${ruta}/${encodeURIComponent(claveId)}/confirmar`, { clave: secreto });
103
103
  if (c.status === 200) {
104
104
  await d.idempotencia.olvidar(OPERACION, alcance);
105
- return { codigo: SALIDA.ok, json: { ok: true, clave_id: claveId, archivo: destino }, texto: `Creé la clave de consulta ${claveId} y la guardé en ${destino}.` };
105
+ return { codigo: SALIDA.ok, json: { ok: true, clave_id: claveId, archivo: destino }, texto: `Created read-only key ${claveId} and saved it in ${destino}.` };
106
106
  }
107
107
  // Obsoleta: otro intento con la misma clave de idempotencia le acuñó otro secreto a la misma fila.
108
108
  // Una vuelta más trae el vigente y lo guarda encima (mismo id).
109
109
  if (c.cuerpo.error !== "confirmacion_obsoleta")
110
110
  return desenlace(c, () => { throw new Error("inalcanzable"); });
111
111
  }
112
- return fallo(SALIDA.inesperado, "confirmacion_obsoleta", "Otro intento cambió la clave mientras la confirmaba; volvé a correr el comando.");
112
+ return fallo(SALIDA.inesperado, "confirmacion_obsoleta", "Another attempt changed the key while I was confirming it; run the command again.");
113
113
  }
114
114
  catch (e) {
115
115
  // Red caída a mitad, o el registro estricto que no pudo guardar: la clave de idempotencia (si llegó a
@@ -128,8 +128,8 @@ export async function clavesListar(d, flags) {
128
128
  // El motor ya manda sólo id, nombre y fecha (nunca el hash); acá se arma la forma pública igual.
129
129
  const claves = (await d.listarClaves(d.api, p.clave, p.proyecto.id)).map((c) => ({ id: c.id, nombre: c.nombre, creada_en: c.creadaEn }));
130
130
  const texto = claves.length === 0
131
- ? "No hay claves de consulta activas."
132
- : `Claves de consulta activas: ${claves.map((c) => `${c.id} (${c.nombre})`).join(", ")}.`;
131
+ ? "There are no active read-only keys."
132
+ : `Active read-only keys: ${claves.map((c) => `${c.id} (${c.nombre})`).join(", ")}.`;
133
133
  return { codigo: SALIDA.ok, json: { ok: true, claves }, texto };
134
134
  }
135
135
  catch (e) {
@@ -138,7 +138,7 @@ export async function clavesListar(d, flags) {
138
138
  }
139
139
  export async function clavesRevocar(d, flags) {
140
140
  if (flags.id === null || !ID_DE_CLAVE.test(flags.id)) {
141
- return fallo(SALIDA.entradaInvalida, "id_invalido", "Decí qué clave revocar con su id: `supcoflow claves revocar --id cla_…` (lo da `supcoflow claves listar`).");
141
+ return fallo(SALIDA.entradaInvalida, "id_invalido", "Say which key to revoke by its id: `supcoflow keys revoke --id cla_…` (`supcoflow keys list` gives it).");
142
142
  }
143
143
  const claveId = flags.id;
144
144
  const p = await proyectoDelRepo(d, flags);
@@ -151,9 +151,9 @@ export async function clavesRevocar(d, flags) {
151
151
  const r = await d.configurar(d.api, p.clave, ruta, { clave_id: claveId });
152
152
  // Revocar dos veces deja lo mismo que una: para quien lo pide, ya está hecho.
153
153
  if (r.status === 409 && r.cuerpo.error === "ya_revocada") {
154
- return { codigo: SALIDA.ok, json: { ok: true, clave_id: claveId, ya_revocada: true }, texto: `La clave ${claveId} ya estaba revocada.` };
154
+ return { codigo: SALIDA.ok, json: { ok: true, clave_id: claveId, ya_revocada: true }, texto: `Key ${claveId} was already revoked.` };
155
155
  }
156
- return desenlace(r, () => ({ codigo: SALIDA.ok, json: { ok: true, clave_id: claveId }, texto: `Revoqué la clave ${claveId}; deja de autenticar.` }));
156
+ return desenlace(r, () => ({ codigo: SALIDA.ok, json: { ok: true, clave_id: claveId }, texto: `Revoked key ${claveId}; it no longer authenticates.` }));
157
157
  }
158
158
  catch (e) {
159
159
  return resultadoDeFallo(e);
@@ -14,12 +14,12 @@ export function desenlace(r, ok) {
14
14
  // Propio de esta función (hoy `conClave` ya lo filtra antes): sin esto, un 401 o un 5xx caerían en el 3 de
15
15
  // «entrada inválida» y la persona (o el asistente) corregiría una entrada que estaba bien.
16
16
  if (r.status === 401)
17
- return fallo(SALIDA.faltaLogin, "clave_invalida", "La clave ya no vale: iniciá sesión de nuevo.");
17
+ return fallo(SALIDA.faltaLogin, "clave_invalida", "The key is no longer valid: log in again.");
18
18
  if (r.status >= 500 || typeof r.cuerpo !== "object" || r.cuerpo === null) {
19
- return fallo(SALIDA.inesperado, "respuesta_inesperada", `El motor contestó algo que no entiendo (${r.status}); probá de nuevo en un rato.`);
19
+ return fallo(SALIDA.inesperado, "respuesta_inesperada", `The engine answered something I don't understand (${r.status}); try again in a while.`);
20
20
  }
21
21
  const error = typeof r.cuerpo.error === "string" ? r.cuerpo.error : "rechazado";
22
- const message = taparSecretos(typeof r.cuerpo.message === "string" ? r.cuerpo.message : `El motor rechazó el pedido (${r.status}).`);
22
+ const message = taparSecretos(typeof r.cuerpo.message === "string" ? r.cuerpo.message : `The engine rejected the request (${r.status}).`);
23
23
  if (PIDEN_UNA_PERSONA.has(error))
24
24
  return fallo(SALIDA.haceFaltaUnaPersona, error, message);
25
25
  if (TRANSITORIOS.has(error))
@@ -27,7 +27,7 @@ export function desenlace(r, ok) {
27
27
  return fallo(SALIDA.entradaInvalida, error, message);
28
28
  }
29
29
  export const rutaDe = (p, resto) => `/api/proyectos/${encodeURIComponent(p.id)}${resto}`;
30
- export const dryRun = (haria) => ({ codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `Con --dry-run no hago nada; sin él: ${haria}.` });
30
+ export const dryRun = (haria) => ({ codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `With --dry-run I do nothing; without it: ${haria}.` });
31
31
  export async function comandoConfiguracion(d, flags) {
32
32
  const p = await proyectoDelRepo(d, flags);
33
33
  if (!p.ok)
@@ -41,18 +41,18 @@ export async function comandoConfiguracion(d, flags) {
41
41
  // promete «ninguna IA» ni «toda IA». `acuerdo` puede ser null, un acuerdo o {ilegible: true}.
42
42
  const ac = c.acuerdo;
43
43
  const acuerdo = ac === null || ac === undefined
44
- ? "sin fijar (entra todo, como hoy)"
44
+ ? "not set (everything gets through, as today)"
45
45
  : ac.ilegible === true
46
- ? "fijado pero ilegible (revisalo en la consola)"
47
- : "fijado";
48
- const alcance = ac === null || ac === undefined ? "" : " Frena lo que se guarda y lo que lee la IA que releva; no cubre lo que ve la IA del chat mientras conversa.";
46
+ ? "set but unreadable (check it in the console)"
47
+ : "set";
48
+ const alcance = ac === null || ac === undefined ? "" : " It limits what is saved and what the AI that investigates reads; it does not cover what the chat's AI sees while it talks.";
49
49
  return {
50
50
  codigo: SALIDA.ok,
51
51
  json: { ok: true, proyecto: p.proyecto.id, ...c },
52
52
  texto: [
53
- `${p.proyecto.id}: chat ${chat.habilitado ? "prendido" : "apagado"}, ${sitiosLista.length} sitio(s), acuerdo de datos ${acuerdo}.${alcance}`,
54
- `Política de problemas: ${textoDePolitica(c.politica)}`,
55
- `Marca: ${textoDeMarca(c.marca)}`,
53
+ `${p.proyecto.id}: chat ${chat.habilitado ? "on" : "off"}, ${sitiosLista.length} site(s), data agreement ${acuerdo}.${alcance}`,
54
+ `Problem policy: ${textoDePolitica(c.politica)}`,
55
+ `Brand: ${textoDeMarca(c.marca)}`,
56
56
  ].join("\n"),
57
57
  };
58
58
  }
@@ -66,10 +66,10 @@ export async function comandoConfiguracion(d, flags) {
66
66
  function textoDePolitica(v) {
67
67
  const modo = v?.modo;
68
68
  if (modo === "relevar")
69
- return "relevar: cada problema se releva y nace el issue (gasta).";
69
+ return "investigate: every problem is investigated and an issue is created (this costs money).";
70
70
  if (modo === "guardar")
71
- return "guardar: los problemas del chat se guardan sin relevar; desde la consola una persona puede pedir el relevamiento (gasta).";
72
- return "el motor no la informó.";
71
+ return "save: the chat's problems are saved without investigating; from the console a person can still ask for the investigation (this costs money).";
72
+ return "the engine didn't report it.";
73
73
  }
74
74
  // Título, saludo y tema los escribe quien configura el chat y llegan tal cual: un ESC en el título movería el
75
75
  // cursor o cambiaría colores en la terminal de quien corre `configuracion`. En texto se quitan los caracteres
@@ -79,21 +79,21 @@ const limpio = (t) => t.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
79
79
  // muestran las claves tal cual vinieron: el motor decide cuáles hay, la terminal no las reinterpreta.
80
80
  function textoDeMarca(v) {
81
81
  if (v === undefined)
82
- return "el motor no la informó.";
82
+ return "the engine didn't report it.";
83
83
  if (v === null)
84
- return "sin fijar (el chat usa la de fábrica).";
84
+ return "not set (the chat uses the default one).";
85
85
  const m = v;
86
86
  const partes = [];
87
87
  if (typeof m.titulo === "string" && m.titulo !== "")
88
- partes.push(`título «${limpio(m.titulo)}»`);
88
+ partes.push(`title «${limpio(m.titulo)}»`);
89
89
  if (typeof m.saludo === "string" && m.saludo !== "")
90
- partes.push(`saludo «${limpio(m.saludo)}»`);
90
+ partes.push(`greeting «${limpio(m.saludo)}»`);
91
91
  if (typeof m.tema === "object" && m.tema !== null) {
92
92
  const colores = Object.entries(m.tema).filter(([, x]) => typeof x === "string").map(([k, x]) => `${limpio(k)} ${limpio(x)}`);
93
93
  if (colores.length > 0)
94
- partes.push(`tema ${colores.join(", ")}`);
94
+ partes.push(`theme ${colores.join(", ")}`);
95
95
  }
96
- partes.push(typeof m.logo_url === "string" && m.logo_url !== "" ? "con logo" : "sin logo");
96
+ partes.push(typeof m.logo_url === "string" && m.logo_url !== "" ? "with logo" : "no logo");
97
97
  return partes.join(", ") + ".";
98
98
  }
99
99
  export async function chatPrenderApagar(d, flags, habilitar) {
@@ -104,35 +104,35 @@ export async function chatPrenderApagar(d, flags, habilitar) {
104
104
  if (flags.dryRun)
105
105
  return dryRun(`POST ${ruta} {habilitado: ${habilitar}}`);
106
106
  return desenlace(await d.configurar(d.api, p.clave, ruta, { habilitado: habilitar }), () => ({
107
- codigo: SALIDA.ok, json: { ok: true, habilitado: habilitar }, texto: habilitar ? "Chat prendido." : "Chat apagado.",
107
+ codigo: SALIDA.ok, json: { ok: true, habilitado: habilitar }, texto: habilitar ? "Chat turned on." : "Chat turned off.",
108
108
  }));
109
109
  }
110
110
  export async function sitios(d, flags, accion) {
111
- if (flags.sitio === null)
112
- return fallo(SALIDA.entradaInvalida, "falta_sitio", `Falta el sitio: \`supcoflow sitios ${accion} --sitio https://tu-app.com\`.`);
111
+ if (flags.site === null)
112
+ return fallo(SALIDA.entradaInvalida, "falta_sitio", `The site is missing: \`supcoflow sites ${accion === "agregar" ? "add" : "remove"} --site https://your-app.com\`.`);
113
113
  const p = await proyectoDelRepo(d, flags);
114
114
  if (!p.ok)
115
115
  return p.resultado;
116
116
  const ruta = rutaDe(p.proyecto, "/origenes");
117
117
  if (flags.dryRun)
118
118
  return dryRun(`POST ${ruta} {accion: ${accion}}`);
119
- return desenlace(await d.configurar(d.api, p.clave, ruta, { accion, origen: flags.sitio }), (c) => ({
120
- codigo: SALIDA.ok, json: { ok: true, sitios: c.origenes }, texto: `Sitios: ${c.origenes.join(", ") || "ninguno"}.`,
119
+ return desenlace(await d.configurar(d.api, p.clave, ruta, { accion, origen: flags.site }), (c) => ({
120
+ codigo: SALIDA.ok, json: { ok: true, sitios: c.origenes }, texto: `Sites: ${c.origenes.join(", ") || "none"}.`,
121
121
  }));
122
122
  }
123
123
  export async function chatMarca(d, flags) {
124
124
  const tema = {};
125
- for (const t of flags.tema) {
125
+ for (const t of flags.theme) {
126
126
  const i = t.indexOf("=");
127
127
  if (i <= 0 || i === t.length - 1)
128
- return fallo(SALIDA.entradaInvalida, "tema_invalido", `--tema va como clave=valor (por ejemplo acento=#0a7); recibí «${t}».`);
128
+ return fallo(SALIDA.entradaInvalida, "tema_invalido", `--theme goes as key=value (for example acento=#0a7); I got «${t}».`);
129
129
  const clave = t.slice(0, i);
130
130
  if (clave in tema)
131
- return fallo(SALIDA.entradaInvalida, "tema_invalido", `--tema ${clave} va una sola vez.`);
131
+ return fallo(SALIDA.entradaInvalida, "tema_invalido", `--theme ${clave} can only be given once.`);
132
132
  tema[clave] = t.slice(i + 1);
133
133
  }
134
- if (flags.titulo === null && flags.saludo === null && Object.keys(tema).length === 0) {
135
- return fallo(SALIDA.entradaInvalida, "marca_vacia", "Pasá al menos --titulo, --saludo o --tema.");
134
+ if (flags.title === null && flags.greeting === null && Object.keys(tema).length === 0) {
135
+ return fallo(SALIDA.entradaInvalida, "marca_vacia", "Give at least one of --title, --greeting or --theme.");
136
136
  }
137
137
  const p = await proyectoDelRepo(d, flags);
138
138
  if (!p.ok)
@@ -151,8 +151,8 @@ export async function chatMarca(d, flags) {
151
151
  return resultadoDeFallo(e);
152
152
  }
153
153
  const cuerpo = {};
154
- const titulo = flags.titulo ?? (typeof actual?.titulo === "string" ? actual.titulo : null);
155
- const saludo = flags.saludo ?? (typeof actual?.saludo === "string" ? actual.saludo : null);
154
+ const titulo = flags.title ?? (typeof actual?.titulo === "string" ? actual.titulo : null);
155
+ const saludo = flags.greeting ?? (typeof actual?.saludo === "string" ? actual.saludo : null);
156
156
  if (titulo !== null)
157
157
  cuerpo.titulo = titulo;
158
158
  if (saludo !== null)
@@ -164,24 +164,24 @@ export async function chatMarca(d, flags) {
164
164
  const ruta = rutaDe(p.proyecto, "/chat/marca");
165
165
  if (flags.dryRun)
166
166
  return dryRun(`POST ${ruta}`);
167
- return desenlace(await d.configurar(d.api, p.clave, ruta, cuerpo), (c) => ({ codigo: SALIDA.ok, json: { ok: true, marca: c.marca }, texto: "Marca guardada." }));
167
+ return desenlace(await d.configurar(d.api, p.clave, ruta, cuerpo), (c) => ({ codigo: SALIDA.ok, json: { ok: true, marca: c.marca }, texto: "Brand saved." }));
168
168
  }
169
169
  const MIMES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
170
170
  // El tope del motor es 200 KB (`MAX_LOGO_BYTES`); acá se corta antes para no mandar de más. El motor decide.
171
171
  const TOPE_DEL_LOGO = 200_000;
172
172
  export async function chatLogo(d, flags) {
173
- if (flags.archivo === null)
174
- return fallo(SALIDA.entradaInvalida, "falta_archivo", "Falta el logo: `supcoflow chat logo --archivo logo.png`.");
175
- const mime = MIMES[path.extname(flags.archivo).toLowerCase()];
173
+ if (flags.file === null)
174
+ return fallo(SALIDA.entradaInvalida, "falta_archivo", "The logo is missing: `supcoflow chat logo --file logo.png`.");
175
+ const mime = MIMES[path.extname(flags.file).toLowerCase()];
176
176
  if (mime === undefined)
177
- return fallo(SALIDA.entradaInvalida, "logo_invalido", "El logo tiene que ser .png, .jpg o .webp.");
178
- const leido = d.leerArchivoChico(flags.archivo, TOPE_DEL_LOGO);
177
+ return fallo(SALIDA.entradaInvalida, "logo_invalido", "The logo must be .png, .jpg or .webp.");
178
+ const leido = d.leerArchivoChico(flags.file, TOPE_DEL_LOGO);
179
179
  if ("error" in leido)
180
- return fallo(SALIDA.entradaInvalida, "logo_ilegible", `No leo ${flags.archivo}: ${leido.error}.`);
180
+ return fallo(SALIDA.entradaInvalida, "logo_ilegible", `I can't read ${flags.file}: ${leido.error}.`);
181
181
  const p = await proyectoDelRepo(d, flags);
182
182
  if (!p.ok)
183
183
  return p.resultado;
184
184
  if (flags.dryRun)
185
185
  return dryRun(`POST ${rutaDe(p.proyecto, "/chat/logo")} (${leido.bytes.length} bytes)`);
186
- return desenlace(await d.subirLogo(d.api, p.clave, p.proyecto.id, { bytes: leido.bytes, mime, nombre: path.basename(flags.archivo) }), (c) => ({ codigo: SALIDA.ok, json: { ok: true, logo_url: c.logo_url }, texto: "Logo subido." }));
186
+ return desenlace(await d.subirLogo(d.api, p.clave, p.proyecto.id, { bytes: leido.bytes, mime, nombre: path.basename(flags.file) }), (c) => ({ codigo: SALIDA.ok, json: { ok: true, logo_url: c.logo_url }, texto: "Logo uploaded." }));
187
187
  }
@@ -0,0 +1,102 @@
1
+ // ¿Este repo va con la cuenta en la que estoy? (1A.1, fricción 6). La terminal junta lo que ya sabe y frena, ANTES de
2
+ // conectar, crear o publicar nada, ante lo que no cierra y se puede VER:
3
+ // - el `.supcoflow/proyecto.json` del clon contra el proyecto que la clave ve para ese repo;
4
+ // - ese archivo cuando no se entiende;
5
+ // - al crear un proyecto: el dueño del repo contra el login de GitHub de la clave, y la falta de GitHub.
6
+ // Qué NO cubre: un proyecto del mismo repo en OTRO equipo cuando el clon no tiene su archivo (el motor tendría que mirar
7
+ // en todos los equipos y eso filtra información entre clientes), ni prueba una identidad: el motor compara el número de
8
+ // GitHub y el login es una etiqueta que se puede cambiar. Por eso `dueno_distinto_del_login` es una posible incoherencia.
9
+ import path from "node:path";
10
+ import { SALIDA } from "../contrato.js";
11
+ // Lo único que se lee de un `.supcoflow/proyecto.json` ajeno es su id, y sólo si tiene esta forma:
12
+ // así lo que salga a la terminal no puede ser otra cosa que un id (Review Focus 11).
13
+ const FORMA_DEL_ID = /^proj_[a-z0-9-]{1,80}$/;
14
+ export const RUTA_DEL_ARCHIVO_DEL_CLON = path.join(".supcoflow", "proyecto.json");
15
+ export function idDelArchivo(crudo) {
16
+ try {
17
+ const v = JSON.parse(crudo);
18
+ if (typeof v !== "object" || v === null)
19
+ return null;
20
+ const id = v.id;
21
+ return typeof id === "string" && FORMA_DEL_ID.test(id) ? id : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ /** Los tres estados del archivo del clon. Nunca se imprime su contenido; sólo el id, si tiene forma de id. */
28
+ export function leerElArchivoDelClon(archivos, raiz) {
29
+ // Sin clon de este repo (un `--repo` ajeno a la carpeta) no hay archivo que le pertenezca.
30
+ if (archivos === undefined || raiz === null)
31
+ return { estado: "ausente" };
32
+ let crudo;
33
+ try {
34
+ crudo = archivos.leer(path.join(raiz, RUTA_DEL_ARCHIVO_DEL_CLON));
35
+ }
36
+ catch {
37
+ return { estado: "ilegible" };
38
+ }
39
+ if (crudo === null)
40
+ return { estado: "ausente" };
41
+ const id = idDelArchivo(crudo);
42
+ return id === null ? { estado: "ilegible" } : { estado: "valido", id };
43
+ }
44
+ export function contextoIncoherente(motivo, message, extra = {}) {
45
+ return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "contexto_incoherente", motivo, message, ...extra }, texto: message };
46
+ }
47
+ /**
48
+ * El clon contra lo que la clave ve (lo que comparten TODOS los comandos que actúan sobre el proyecto del repo).
49
+ * `null` = coherente. `proyecto` es el que la clave ve para este repo, o null si no ve ninguno.
50
+ */
51
+ export function coherenciaDelClon(archivo, proyecto, repo) {
52
+ if (archivo.estado === "ilegible") {
53
+ return contextoIncoherente("archivo_del_clon_ilegible", "I can't read `.supcoflow/proyecto.json` in this clone; I won't act on this repo until it's readable. Look at it before going on.", { repo, en_el_clon: { estado: "ilegible", id: null } });
54
+ }
55
+ if (archivo.estado === "valido" && archivo.id !== proyecto?.id) {
56
+ return contextoIncoherente("proyecto_del_clon_distinto", `The project noted in this clone (${archivo.id}) isn't the one your account sees for this repo (${proyecto?.id ?? "none"}). `
57
+ + "Check which account you signed in with (`supcoflow whoami`), or, if that file is old, review it.", { repo, en_el_clon: { estado: "valido", id: archivo.id }, proyecto: proyecto?.id ?? null });
58
+ }
59
+ return null;
60
+ }
61
+ export function armarElContexto(d) {
62
+ const { yo } = d;
63
+ const soloLectura = yo.via === "clave_de_consulta";
64
+ const login = yo.usuario?.login ?? null;
65
+ const tipo = soloLectura ? "clave_de_consulta" : login !== null ? "github" : "email";
66
+ return {
67
+ equipo: yo.equipo,
68
+ identidad: tipo === "github" ? login : tipo,
69
+ identidad_tipo: tipo,
70
+ fuente: d.fuente,
71
+ repo: d.repo,
72
+ proyecto: d.proyecto?.id ?? null,
73
+ en_el_clon: { estado: d.archivo.estado, id: d.archivo.estado === "valido" ? d.archivo.id : null },
74
+ };
75
+ }
76
+ /** La línea que la persona lee: quién es, en qué equipo, qué repo y qué proyecto. */
77
+ export function lineaDelContexto(c, readOnlyDe = null) {
78
+ const quien = c.identidad_tipo === "github" ? `@${c.identidad}` : c.identidad_tipo === "email" ? "signed in with email (no GitHub)" : `using a read-only key${readOnlyDe ? ` for ${readOnlyDe}` : ""}`;
79
+ const cuenta = `You're ${quien} in team ${c.equipo}`;
80
+ const proyecto = c.proyecto !== null ? `project ${c.proyecto} (existing)` : `no project yet (it will be created in team ${c.equipo})`;
81
+ return `${cuenta}; repo ${c.repo}; ${proyecto}.`;
82
+ }
83
+ /**
84
+ * Sólo cuando la clave NO ve proyecto para el repo y se va a crear uno: lo que se ve de la cuenta contra el repo.
85
+ * Una clave de consulta no crea nada, así que no se compara su login con nadie.
86
+ */
87
+ export function coherenciaAlCrear(d) {
88
+ if (d.yo.via === "clave_de_consulta")
89
+ return null;
90
+ const login = d.yo.usuario?.login ?? null;
91
+ if (login === null) {
92
+ return contextoIncoherente("sin_github", "Connecting a repo needs a Supcoflow account signed in with GitHub.", { repo: d.repo });
93
+ }
94
+ const dueno = d.repo.split("/")[0];
95
+ if (dueno.toLowerCase() === login.toLowerCase())
96
+ return null;
97
+ const deLaVariable = d.fuente === "env"
98
+ ? " Your key comes from SUPCOFLOW_TOKEN, which wins over a new login: unset it or replace it first; I don't touch it."
99
+ : "";
100
+ return contextoIncoherente("dueno_distinto_del_login", `This repo's owner on GitHub (@${dueno}) isn't the GitHub account your key shows (@${login}). `
101
+ + `If you renamed your GitHub account, sign in to the Supcoflow console with GitHub again (that updates it) and run this again; otherwise sign in as @${dueno}.${deLaVariable}`, { repo: d.repo, dueno, login, ...(d.fuente === "env" ? { fuente: "env" } : {}) });
102
+ }
@@ -1,52 +1,63 @@
1
+ import { PERMITIDOS_VIEJOS, PREFIJO_VIEJO_DE_ATRIBUTO } from "../alias.js";
1
2
  import { resultadoDeFallo, SALIDA } from "../contrato.js";
2
3
  import { claveVigente } from "./clave-vigente.js";
3
4
  import { desenlace } from "./configurar.js";
4
5
  import { proyectoDelRepo } from "./proyecto-del-repo.js";
5
- const BANDERAS = ["usuario_id", "usuario_rol", "pagina_y_navegador", "capturas", "elementos", "email"];
6
+ // Los nombres de `--allow` son ingleses; a la API viaja el suyo (`Permitido`). Los castellanos de la 0.7.0 valen igual
7
+ // (`src/alias.ts`).
8
+ const DE_LA_API = { user_id: "usuario_id", user_role: "usuario_rol", page_and_browser: "pagina_y_navegador", screenshots: "capturas", elements: "elementos", email: "email" };
9
+ const BANDERAS = Object.keys(DE_LA_API);
10
+ const PREFIJO_DEL_ATRIBUTO = "attribute:";
6
11
  // Los límites del contrato (`Permitido.atributos`: ≤ 8, 1–40 caracteres). Se cortan acá para que un
7
- // --permitir imposible salga 3 sin pedido; el motor sigue siendo quien decide.
12
+ // --allow imposible salga 3 sin pedido; el motor sigue siendo quien decide.
8
13
  const MAX_ATRIBUTOS = 8;
9
14
  const MAX_LARGO_DE_ATRIBUTO = 40;
10
15
  const fallo = (codigo, error, message) => ({ codigo, json: { ok: false, error, message }, texto: message });
11
16
  const rutaDe = (p, resto) => `/api/proyectos/${encodeURIComponent(p.id)}${resto}`;
12
- const dryRun = (haria) => ({ codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `Con --dry-run no hago nada; sin él: ${haria}.` });
17
+ const dryRun = (haria) => ({ codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `With --dry-run I do nothing; without it: ${haria}.` });
18
+ // Lleva un valor de `--allow` (nuevo o viejo) a su forma inglesa canónica.
19
+ function aInglesElPermitido(x) {
20
+ if (x.startsWith(PREFIJO_VIEJO_DE_ATRIBUTO))
21
+ return PREFIJO_DEL_ATRIBUTO + x.slice(PREFIJO_VIEJO_DE_ATRIBUTO.length);
22
+ return PERMITIDOS_VIEJOS[x] ?? x;
23
+ }
13
24
  export function leerPermitir(crudo) {
14
- const partes = crudo.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
25
+ const partes = crudo.split(",").map((s) => s.trim()).filter((s) => s.length > 0).map(aInglesElPermitido);
15
26
  const p = { usuario_id: false, usuario_rol: false, atributos: [], pagina_y_navegador: false, capturas: false, elementos: false, email: false };
16
- if (partes.length === 1 && partes[0] === "nada")
27
+ if (partes.length === 1 && partes[0] === "nothing")
17
28
  return p;
18
- if (partes.length === 0 || partes.includes("nada"))
19
- return { error: "`nada` va solo; si no, nombrá lo que sí pasa." };
29
+ if (partes.length === 0 || partes.includes("nothing"))
30
+ return { error: "`nothing` goes alone; otherwise, name what does get through." };
20
31
  const vistos = new Set();
21
32
  for (const x of partes) {
22
33
  if (vistos.has(x))
23
- return { error: `«${x}» está repetido.` };
34
+ return { error: `«${x}» is repeated.` };
24
35
  vistos.add(x);
25
- if (x.startsWith("atributo:")) {
36
+ if (x.startsWith(PREFIJO_DEL_ATRIBUTO)) {
26
37
  // Recortado: « plan» y «plan» son el mismo atributo para quien lo escribe, y el motor lo compara exacto.
27
- const nombre = x.slice("atributo:".length).trim();
38
+ const nombre = x.slice(PREFIJO_DEL_ATRIBUTO.length).trim();
28
39
  if (nombre.length === 0)
29
- return { error: "`atributo:` va con el nombre exacto del atributo, por ejemplo atributo:plan." };
40
+ return { error: "`attribute:` goes with the attribute's exact name, for example attribute:plan." };
30
41
  if (nombre.length > MAX_LARGO_DE_ATRIBUTO)
31
- return { error: `El nombre de un atributo tiene hasta ${MAX_LARGO_DE_ATRIBUTO} caracteres.` };
42
+ return { error: `An attribute's name has up to ${MAX_LARGO_DE_ATRIBUTO} characters.` };
32
43
  if (p.atributos.length >= MAX_ATRIBUTOS)
33
- return { error: `Hasta ${MAX_ATRIBUTOS} atributos.` };
44
+ return { error: `Up to ${MAX_ATRIBUTOS} attributes.` };
34
45
  p.atributos.push(nombre);
35
46
  }
36
47
  else if (BANDERAS.includes(x)) {
37
- p[x] = true;
48
+ p[DE_LA_API[x]] = true;
38
49
  }
39
50
  else {
40
- return { error: `No conozco «${x}». Valen: ${BANDERAS.join(", ")}, atributo:<nombre>, o nada.` };
51
+ return { error: `I don't know «${x}». Valid: ${BANDERAS.join(", ")}, attribute:<name>, or nothing.` };
41
52
  }
42
53
  }
43
54
  return p;
44
55
  }
45
56
  export async function datosFijar(d, flags) {
46
- if (flags.permitir === null) {
47
- return fallo(SALIDA.entradaInvalida, "falta_permitir", "Falta qué datos pasan: `supcoflow datos fijar --permitir usuario_id,capturas` (o `--permitir nada`).");
57
+ if (flags.allow === null) {
58
+ return fallo(SALIDA.entradaInvalida, "falta_permitir", "Say which data gets through: `supcoflow data set --allow user_id,screenshots` (or `--allow nothing`).");
48
59
  }
49
- const permitido = leerPermitir(flags.permitir);
60
+ const permitido = leerPermitir(flags.allow);
50
61
  if ("error" in permitido)
51
62
  return fallo(SALIDA.entradaInvalida, "permitir_invalido", permitido.error);
52
63
  const p = await proyectoDelRepo(d, flags);
@@ -69,15 +80,16 @@ export async function datosFijar(d, flags) {
69
80
  return {
70
81
  codigo: SALIDA.ok,
71
82
  json: { ok: true, acuerdo: c.acuerdo, alcance: "guardado_y_relevamiento" },
72
- texto: `Acuerdo de datos fijado (versión ${version}). Frena lo que se guarda y lo que lee la IA que releva; no lo que ve la IA del chat mientras conversa.`,
83
+ texto: `Data agreement set (version ${version}). It limits what is saved and what the AI that investigates reads; not what the chat's AI sees while it talks.`,
73
84
  };
74
85
  });
75
86
  }
76
87
  export async function politicaFijar(d, flags) {
77
- if (flags.modo !== "relevar" && flags.modo !== "guardar") {
78
- return fallo(SALIDA.entradaInvalida, "modo_invalido", "--modo vale `relevar` o `guardar`: `supcoflow politica fijar --modo guardar`.");
88
+ // A la API viaja `relevar` / `guardar`; `investigate` / `save` son sus nombres ingleses.
89
+ const modo = flags.mode === "investigate" || flags.mode === "relevar" ? "relevar" : flags.mode === "save" || flags.mode === "guardar" ? "guardar" : null;
90
+ if (modo === null) {
91
+ return fallo(SALIDA.entradaInvalida, "modo_invalido", "--mode is `investigate` or `save`: `supcoflow policy set --mode save`.");
79
92
  }
80
- const modo = flags.modo;
81
93
  const p = await proyectoDelRepo(d, flags);
82
94
  if (!p.ok)
83
95
  return p.resultado;
@@ -94,7 +106,7 @@ export async function politicaFijar(d, flags) {
94
106
  return desenlace(r, (c) => ({
95
107
  codigo: SALIDA.ok,
96
108
  json: { ok: true, politica: c.politica },
97
- texto: `Política de problemas fijada: ${modo}.`,
109
+ texto: `Problem policy set: ${modo === "relevar" ? "investigate" : "save"}.`,
98
110
  }));
99
111
  }
100
112
  export async function comandoCatalogo(d) {