supcoflow 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ // `supcoflow claves crear`: una clave de consulta (sólo lee su proyecto) para CI. Dos fases (§6.5): el
2
+ // motor la crea pendiente y devuelve el secreto UNA vez; la terminal lo guarda en el archivo que dijo la
3
+ // persona y recién ahí confirma mandándolo de vuelta (demuestra que lo tiene). El secreto nunca sale por
4
+ // stdout ni stderr: quien llama suele ser un asistente, y su salida queda en su contexto.
5
+ //
6
+ // La semántica entera (candado, mirar el destino antes, el ciclo) está en la tarea C4 del plan del bloque 4
7
+ // y en los comentarios de abajo; cada rama de este archivo es una de esas reglas.
8
+ import path from "node:path";
9
+ import { resultadoDeFallo, SALIDA } from "../contrato.js";
10
+ import { desenlace, dryRun, fallo, rutaDe } from "./configurar.js";
11
+ import { proyectoDelRepo } from "./proyecto-del-repo.js";
12
+ const OPERACION = "clave_crear";
13
+ const SUFIJO = /^scf_([0-9a-f]{16})_[0-9a-f]{64}$/;
14
+ const ID_DE_CLAVE = /^cla_[0-9a-f]{16}$/;
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`.");
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);
22
+ const p = await proyectoDelRepo(d, flags);
23
+ if (!p.ok)
24
+ return p.resultado;
25
+ const ruta = rutaDe(p.proyecto, "/claves");
26
+ // Antes de pedir nada: un destino que no sirve (dentro de un repo) dejaba una fila pendiente de más.
27
+ const invalido = d.secretos.validarDestino(destino);
28
+ if (invalido !== null)
29
+ return fallo(invalido.codigo, "destino_invalido", `${destino}: ${invalido.error}.`);
30
+ if (flags.dryRun)
31
+ return dryRun(`POST ${ruta} y después …/confirmar`);
32
+ const alcance = `${p.proyecto.id}#${flags.nombre}`;
33
+ // El registro de idempotencia pone el alcance en minúsculas (`CI` y `ci` comparten fila): el candado
34
+ // tiene que coincidir, o dos procesos con la misma clave de reintento corrían a la vez.
35
+ // 1. El candado: el ciclo entero, de a uno por operación en esta máquina. Comprobar que el archivo
36
+ // autentica y después escribirlo no es atómico: sin candado, un segundo `claves crear` atrasado pisaba
37
+ // el secreto vigente con uno que después no confirmaba (segunda revisión externa, hallazgo 1). Dos
38
+ // máquinas no comparten la clave de idempotencia, así que crean filas distintas y no se pisan.
39
+ const candado = d.candados.tomar(`${OPERACION}#${alcance.toLowerCase()}`);
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.");
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.`);
44
+ }
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.`);
47
+ }
48
+ try {
49
+ const autentica = async (secreto) => d.esClaveDeConsultaDelProyecto(d.api, secreto, p.proyecto.id);
50
+ const yaCreada = async (claveId) => {
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}.` };
53
+ };
54
+ // 2. Mirar el destino antes de pedir nada. Una clave de consulta de ESTE proyecto que autentica es un
55
+ // éxito anterior: repetir el comando no crea otra fila (el archivo es la referencia durable; la clave
56
+ // de idempotencia ya se olvidó). Una que no autentica sólo se retoma si hay un intento a medias
57
+ // nuestro. Cualquier otra cosa (una clave personal aunque autentique, una de otro proyecto, una
58
+ // revocada) no se pisa: podría ser una clave vigente de alguien (tercera revisión, hallazgo 2).
59
+ const hay = d.secretos.leer(destino);
60
+ if (hay.estado === "clave" && (await autentica(hay.secreto)))
61
+ return await yaCreada(hay.claveId);
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.`);
64
+ }
65
+ // 3. El ciclo. Estricta: sin la clave de reintento guardada no se manda nada (Review Focus 15).
66
+ const key = await d.idempotencia.claveDe(OPERACION, alcance, { estricta: true });
67
+ for (let vuelta = 0; vuelta < 2; vuelta++) {
68
+ const r = await d.configurar(d.api, p.clave, ruta, { nombre: flags.nombre }, { idempotencyKey: key });
69
+ if (r.status === 200 && r.cuerpo.estado === "activa") {
70
+ // El motor no rota una activa (no devuelve secreto): vale sólo si lo que tenemos es su secreto vigente.
71
+ const claveId = String(r.cuerpo.clave_id);
72
+ const ahora = d.secretos.leer(destino);
73
+ if (ahora.estado === "clave" && ahora.claveId === claveId && (await autentica(ahora.secreto)))
74
+ return await yaCreada(claveId);
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.`);
77
+ }
78
+ if (r.status !== 201) {
79
+ if (r.cuerpo.error === "clave_vencida") {
80
+ // La fila vencida no vuelve: el próximo intento tiene que ser otra clave de idempotencia.
81
+ await d.idempotencia.olvidar(OPERACION, alcance);
82
+ // El archivo conserva el secreto pendiente vencido y un reintento daría 4 destino_ocupado. Borrarlo
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.`);
85
+ }
86
+ // Misma clave de reintento con otro cuerpo: no se arregla reintentando, y guardada trabaría para siempre.
87
+ if (r.cuerpo.error === "idempotency_key_reusada")
88
+ await d.idempotencia.olvidar(OPERACION, alcance);
89
+ return desenlace(r, () => { throw new Error("inalcanzable"); });
90
+ }
91
+ const claveId = String(r.cuerpo.clave_id);
92
+ const secreto = typeof r.cuerpo.clave === "string" && SUFIJO.test(r.cuerpo.clave) ? r.cuerpo.clave : null;
93
+ if (secreto === null)
94
+ return fallo(SALIDA.inesperado, "respuesta_inesperada", "El motor no devolvió una clave con la forma esperada.");
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
+ 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é.");
98
+ const g = d.secretos.guardar(destino, claveId, secreto);
99
+ // Sin guardarla NO se confirma: una pendiente sin confirmar no autentica y vence sola a los 10 minutos.
100
+ if (g !== "guardado")
101
+ return fallo(g.codigo, "no_se_guardo", `No guardé la clave en ${destino}: ${g.error}. No quedó activa.`);
102
+ const c = await d.configurar(d.api, p.clave, `${ruta}/${encodeURIComponent(claveId)}/confirmar`, { clave: secreto });
103
+ if (c.status === 200) {
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}.` };
106
+ }
107
+ // Obsoleta: otro intento con la misma clave de idempotencia le acuñó otro secreto a la misma fila.
108
+ // Una vuelta más trae el vigente y lo guarda encima (mismo id).
109
+ if (c.cuerpo.error !== "confirmacion_obsoleta")
110
+ return desenlace(c, () => { throw new Error("inalcanzable"); });
111
+ }
112
+ return fallo(SALIDA.inesperado, "confirmacion_obsoleta", "Otro intento cambió la clave mientras la confirmaba; volvé a correr el comando.");
113
+ }
114
+ catch (e) {
115
+ // Red caída a mitad, o el registro estricto que no pudo guardar: la clave de idempotencia (si llegó a
116
+ // guardarse) se conserva y el reintento retoma la misma fila.
117
+ return resultadoDeFallo(e);
118
+ }
119
+ finally {
120
+ candado.soltar();
121
+ }
122
+ }
123
+ export async function clavesListar(d, flags) {
124
+ const p = await proyectoDelRepo(d, flags);
125
+ if (!p.ok)
126
+ return p.resultado;
127
+ try {
128
+ // El motor ya manda sólo id, nombre y fecha (nunca el hash); acá se arma la forma pública igual.
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
+ 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(", ")}.`;
133
+ return { codigo: SALIDA.ok, json: { ok: true, claves }, texto };
134
+ }
135
+ catch (e) {
136
+ return resultadoDeFallo(e);
137
+ }
138
+ }
139
+ export async function clavesRevocar(d, flags) {
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`).");
142
+ }
143
+ const claveId = flags.id;
144
+ const p = await proyectoDelRepo(d, flags);
145
+ if (!p.ok)
146
+ return p.resultado;
147
+ const ruta = rutaDe(p.proyecto, "/claves/revocar");
148
+ if (flags.dryRun)
149
+ return dryRun(`POST ${ruta} {clave_id: ${claveId}}`);
150
+ try {
151
+ const r = await d.configurar(d.api, p.clave, ruta, { clave_id: claveId });
152
+ // Revocar dos veces deja lo mismo que una: para quien lo pide, ya está hecho.
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.` };
155
+ }
156
+ return desenlace(r, () => ({ codigo: SALIDA.ok, json: { ok: true, clave_id: claveId }, texto: `Revoqué la clave ${claveId}; deja de autenticar.` }));
157
+ }
158
+ catch (e) {
159
+ return resultadoDeFallo(e);
160
+ }
161
+ }
@@ -0,0 +1,187 @@
1
+ // src/comandos/configurar.ts
2
+ // Los comandos que configuran el proyecto del repo (spec de supcoflow go §5.4, §9). Cada uno resuelve el
3
+ // proyecto, manda UN pedido y traduce la respuesta a los códigos de §13. Lo que se valida acá (un --tema
4
+ // sin `=`, un --sitio que falta) se valida para no gastar un pedido; la regla de verdad es la del motor.
5
+ import path from "node:path";
6
+ import { resultadoDeFallo, SALIDA, taparSecretos } from "../contrato.js";
7
+ import { proyectoDelRepo } from "./proyecto-del-repo.js";
8
+ export const fallo = (codigo, error, message) => ({ codigo, json: { ok: false, error, message }, texto: message });
9
+ const PIDEN_UNA_PERSONA = new Set(["skill_no_aprobada"]);
10
+ const TRANSITORIOS = new Set(["sitios_cambiaron", "idempotency_en_curso"]);
11
+ export function desenlace(r, ok) {
12
+ if (r.status >= 200 && r.status < 300)
13
+ return ok(r.cuerpo);
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
+ // «entrada inválida» y la persona (o el asistente) corregiría una entrada que estaba bien.
16
+ if (r.status === 401)
17
+ return fallo(SALIDA.faltaLogin, "clave_invalida", "La clave ya no vale: iniciá sesión de nuevo.");
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.`);
20
+ }
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}).`);
23
+ if (PIDEN_UNA_PERSONA.has(error))
24
+ return fallo(SALIDA.haceFaltaUnaPersona, error, message);
25
+ if (TRANSITORIOS.has(error))
26
+ return fallo(SALIDA.inesperado, error, message);
27
+ return fallo(SALIDA.entradaInvalida, error, message);
28
+ }
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}.` });
31
+ export async function comandoConfiguracion(d, flags) {
32
+ const p = await proyectoDelRepo(d, flags);
33
+ if (!p.ok)
34
+ return p.resultado;
35
+ try {
36
+ const c = await d.leerConfiguracion(d.api, p.clave, p.proyecto.id);
37
+ const chat = c.chat;
38
+ const sitiosLista = c.sitios;
39
+ // El acuerdo de datos frena lo que se guarda y lo que lee la IA que releva el repo; NO alcanza a lo
40
+ // que ve la IA del propio chat mientras conversa (decisión 1b). Por eso el texto lo dice, y nunca
41
+ // promete «ninguna IA» ni «toda IA». `acuerdo` puede ser null, un acuerdo o {ilegible: true}.
42
+ const ac = c.acuerdo;
43
+ const acuerdo = ac === null || ac === undefined
44
+ ? "sin fijar (entra todo, como hoy)"
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.";
49
+ return {
50
+ codigo: SALIDA.ok,
51
+ json: { ok: true, proyecto: p.proyecto.id, ...c },
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)}`,
56
+ ].join("\n"),
57
+ };
58
+ }
59
+ catch (e) {
60
+ return resultadoDeFallo(e);
61
+ }
62
+ }
63
+ // La política dicha en palabras llanas (decisión del dueño, 2026-10-16): «guardar» frena sólo lo automático
64
+ // del chat; una persona todavía puede pedir el relevamiento desde la consola, y eso gasta. Un motor viejo que
65
+ // no la manda no se adivina: se dice que no vino.
66
+ function textoDePolitica(v) {
67
+ const modo = v?.modo;
68
+ if (modo === "relevar")
69
+ return "relevar: cada problema se releva y nace el issue (gasta).";
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ó.";
73
+ }
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
+ // cursor o cambiaría colores en la terminal de quien corre `configuracion`. En texto se quitan los caracteres
76
+ // de control (C0, DEL y C1); el JSON los conserva, porque ahí van escapados.
77
+ const limpio = (t) => t.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
78
+ // `marca` es null (nunca se fijó: el chat usa la de fábrica) o {titulo, saludo, tema, logo_url}. Del tema se
79
+ // muestran las claves tal cual vinieron: el motor decide cuáles hay, la terminal no las reinterpreta.
80
+ function textoDeMarca(v) {
81
+ if (v === undefined)
82
+ return "el motor no la informó.";
83
+ if (v === null)
84
+ return "sin fijar (el chat usa la de fábrica).";
85
+ const m = v;
86
+ const partes = [];
87
+ if (typeof m.titulo === "string" && m.titulo !== "")
88
+ partes.push(`título «${limpio(m.titulo)}»`);
89
+ if (typeof m.saludo === "string" && m.saludo !== "")
90
+ partes.push(`saludo «${limpio(m.saludo)}»`);
91
+ if (typeof m.tema === "object" && m.tema !== null) {
92
+ const colores = Object.entries(m.tema).filter(([, x]) => typeof x === "string").map(([k, x]) => `${limpio(k)} ${limpio(x)}`);
93
+ if (colores.length > 0)
94
+ partes.push(`tema ${colores.join(", ")}`);
95
+ }
96
+ partes.push(typeof m.logo_url === "string" && m.logo_url !== "" ? "con logo" : "sin logo");
97
+ return partes.join(", ") + ".";
98
+ }
99
+ export async function chatPrenderApagar(d, flags, habilitar) {
100
+ const p = await proyectoDelRepo(d, flags);
101
+ if (!p.ok)
102
+ return p.resultado;
103
+ const ruta = rutaDe(p.proyecto, "/chat");
104
+ if (flags.dryRun)
105
+ return dryRun(`POST ${ruta} {habilitado: ${habilitar}}`);
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.",
108
+ }));
109
+ }
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\`.`);
113
+ const p = await proyectoDelRepo(d, flags);
114
+ if (!p.ok)
115
+ return p.resultado;
116
+ const ruta = rutaDe(p.proyecto, "/origenes");
117
+ if (flags.dryRun)
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"}.`,
121
+ }));
122
+ }
123
+ export async function chatMarca(d, flags) {
124
+ const tema = {};
125
+ for (const t of flags.tema) {
126
+ const i = t.indexOf("=");
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}».`);
129
+ const clave = t.slice(0, i);
130
+ if (clave in tema)
131
+ return fallo(SALIDA.entradaInvalida, "tema_invalido", `--tema ${clave} va una sola vez.`);
132
+ tema[clave] = t.slice(i + 1);
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.");
136
+ }
137
+ const p = await proyectoDelRepo(d, flags);
138
+ if (!p.ok)
139
+ return p.resultado;
140
+ // ─── Se manda la marca ENTERA, mezclada con la guardada (revisión externa del 2026-10-16, hallazgo 5) ───
141
+ // La route REEMPLAZA la marca (`escribirMarcaDelChat`: lo omitido queda NULL y el tema `{}`), porque así
142
+ // la usa el formulario de la consola, que manda todo junto y vacía un campo dejándolo en blanco. Mandar
143
+ // sólo `--titulo` borraba el saludo y los colores. La terminal lee lo guardado y cambia sólo lo nombrado.
144
+ // Costo aceptado: dos ediciones a la vez (terminal y consola) pueden pisarse; es una persona configurando.
145
+ // Borrar un campo desde la terminal no existe en este bloque: se hace en la consola.
146
+ let actual;
147
+ try {
148
+ actual = (await d.leerConfiguracion(d.api, p.clave, p.proyecto.id)).marca;
149
+ }
150
+ catch (e) {
151
+ return resultadoDeFallo(e);
152
+ }
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);
156
+ if (titulo !== null)
157
+ cuerpo.titulo = titulo;
158
+ if (saludo !== null)
159
+ cuerpo.saludo = saludo;
160
+ const temaActual = typeof actual?.tema === "object" && actual.tema !== null ? actual.tema : {};
161
+ const temaFinal = { ...temaActual, ...tema };
162
+ if (Object.keys(temaFinal).length > 0)
163
+ cuerpo.tema = temaFinal;
164
+ const ruta = rutaDe(p.proyecto, "/chat/marca");
165
+ if (flags.dryRun)
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." }));
168
+ }
169
+ const MIMES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
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
+ const TOPE_DEL_LOGO = 200_000;
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()];
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);
179
+ if ("error" in leido)
180
+ return fallo(SALIDA.entradaInvalida, "logo_ilegible", `No leo ${flags.archivo}: ${leido.error}.`);
181
+ const p = await proyectoDelRepo(d, flags);
182
+ if (!p.ok)
183
+ return p.resultado;
184
+ if (flags.dryRun)
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." }));
187
+ }
@@ -0,0 +1,112 @@
1
+ import { resultadoDeFallo, SALIDA } from "../contrato.js";
2
+ import { claveVigente } from "./clave-vigente.js";
3
+ import { desenlace } from "./configurar.js";
4
+ import { proyectoDelRepo } from "./proyecto-del-repo.js";
5
+ const BANDERAS = ["usuario_id", "usuario_rol", "pagina_y_navegador", "capturas", "elementos", "email"];
6
+ // 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.
8
+ const MAX_ATRIBUTOS = 8;
9
+ const MAX_LARGO_DE_ATRIBUTO = 40;
10
+ const fallo = (codigo, error, message) => ({ codigo, json: { ok: false, error, message }, texto: message });
11
+ 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}.` });
13
+ export function leerPermitir(crudo) {
14
+ const partes = crudo.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
15
+ 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")
17
+ return p;
18
+ if (partes.length === 0 || partes.includes("nada"))
19
+ return { error: "`nada` va solo; si no, nombrá lo que sí pasa." };
20
+ const vistos = new Set();
21
+ for (const x of partes) {
22
+ if (vistos.has(x))
23
+ return { error: `«${x}» está repetido.` };
24
+ vistos.add(x);
25
+ if (x.startsWith("atributo:")) {
26
+ // 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();
28
+ if (nombre.length === 0)
29
+ return { error: "`atributo:` va con el nombre exacto del atributo, por ejemplo atributo:plan." };
30
+ if (nombre.length > MAX_LARGO_DE_ATRIBUTO)
31
+ return { error: `El nombre de un atributo tiene hasta ${MAX_LARGO_DE_ATRIBUTO} caracteres.` };
32
+ if (p.atributos.length >= MAX_ATRIBUTOS)
33
+ return { error: `Hasta ${MAX_ATRIBUTOS} atributos.` };
34
+ p.atributos.push(nombre);
35
+ }
36
+ else if (BANDERAS.includes(x)) {
37
+ p[x] = true;
38
+ }
39
+ else {
40
+ return { error: `No conozco «${x}». Valen: ${BANDERAS.join(", ")}, atributo:<nombre>, o nada.` };
41
+ }
42
+ }
43
+ return p;
44
+ }
45
+ 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`).");
48
+ }
49
+ const permitido = leerPermitir(flags.permitir);
50
+ if ("error" in permitido)
51
+ return fallo(SALIDA.entradaInvalida, "permitir_invalido", permitido.error);
52
+ const p = await proyectoDelRepo(d, flags);
53
+ if (!p.ok)
54
+ return p.resultado;
55
+ const ruta = rutaDe(p.proyecto, "/acuerdo-de-datos");
56
+ if (flags.dryRun)
57
+ return dryRun(`POST ${ruta} {permitido}`);
58
+ let r;
59
+ try {
60
+ r = await d.configurar(d.api, p.clave, ruta, { permitido });
61
+ }
62
+ catch (e) {
63
+ return resultadoDeFallo(e);
64
+ }
65
+ // El acuerdo frena lo que se guarda y lo que lee la IA que releva; NO alcanza a lo que ve la IA del
66
+ // propio chat mientras conversa (decisión 1b): el texto lo dice y no promete «toda» ni «ninguna» IA.
67
+ return desenlace(r, (c) => {
68
+ const version = c.acuerdo?.version;
69
+ return {
70
+ codigo: SALIDA.ok,
71
+ 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.`,
73
+ };
74
+ });
75
+ }
76
+ 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`.");
79
+ }
80
+ const modo = flags.modo;
81
+ const p = await proyectoDelRepo(d, flags);
82
+ if (!p.ok)
83
+ return p.resultado;
84
+ const ruta = rutaDe(p.proyecto, "/politica");
85
+ if (flags.dryRun)
86
+ return dryRun(`POST ${ruta} {modo: ${modo}}`);
87
+ let r;
88
+ try {
89
+ r = await d.configurar(d.api, p.clave, ruta, { modo });
90
+ }
91
+ catch (e) {
92
+ return resultadoDeFallo(e);
93
+ }
94
+ return desenlace(r, (c) => ({
95
+ codigo: SALIDA.ok,
96
+ json: { ok: true, politica: c.politica },
97
+ texto: `Política de problemas fijada: ${modo}.`,
98
+ }));
99
+ }
100
+ export async function comandoCatalogo(d) {
101
+ const leida = await claveVigente(d.guardado, d.api.origin);
102
+ if (!leida.ok)
103
+ return leida.resultado;
104
+ try {
105
+ const c = await d.leerCatalogo(d.api, leida.vigente.clave);
106
+ const items = c.supcoflow.map((i) => `${i.clave}: ${i.valores}`);
107
+ return { codigo: SALIDA.ok, json: { ok: true, ...c }, texto: items.join("\n") };
108
+ }
109
+ catch (e) {
110
+ return resultadoDeFallo(e);
111
+ }
112
+ }
@@ -3,7 +3,7 @@
3
3
  // ayuda pensada para personas. La ayuda se arma desde acá: una sola fuente, así no se desfasan.
4
4
  import { SALIDA } from "../contrato.js";
5
5
  // A mano a propósito, como en api.ts: tiene que coincidir con package.json (lo pinza un test).
6
- const VERSION = "0.1.0";
6
+ const VERSION = "0.3.0";
7
7
  export const DESCRIPCION = {
8
8
  version: VERSION,
9
9
  comandos: [
@@ -40,6 +40,148 @@ export const DESCRIPCION = {
40
40
  ejemplos: ["supcoflow describir --json"],
41
41
  codigos: [0],
42
42
  },
43
+ {
44
+ nombre: "repo conectar",
45
+ resumen: "Conecta el repo de esta carpeta (o el de --repo) con la App de GitHub: una persona lo confirma en la consola. Sin terminal interactiva, en dos pasos: --start y después --wait.",
46
+ flags: [
47
+ "--start: pide la conexión y sale 5 con la URL para confirmarla",
48
+ "--wait: espera la confirmación hasta 90 s; sale 5 si todavía no llegó (volvé a llamarlo)",
49
+ "--repo owner/repo: el repo, si no es el «origin» de esta carpeta",
50
+ "--dry-run: dice qué pediría sin pedir nada (--wait sólo lee)",
51
+ "--json",
52
+ ],
53
+ ejemplos: ["supcoflow repo conectar", "supcoflow repo conectar --start --json", "supcoflow repo conectar --wait --json"],
54
+ codigos: [0, 1, 2, 3, 4, 5],
55
+ },
56
+ {
57
+ nombre: "proyecto crear",
58
+ resumen: "Crea (o reusa) el proyecto del repo ya conectado y lo anota en .supcoflow/proyecto.json; un archivo distinto nunca se pisa (sale 4).",
59
+ flags: [
60
+ "--install \"…\": el comando de instalación, cuando el motor no lo puede deducir",
61
+ "--test \"…\": el comando de pruebas, ídem",
62
+ "--repo owner/repo: el repo, si no es el «origin» de esta carpeta (sin escribir el archivo)",
63
+ "--dry-run: dice qué crearía sin crear nada",
64
+ "--json",
65
+ ],
66
+ ejemplos: ["supcoflow proyecto crear --json", "supcoflow proyecto crear --install \"pnpm i\" --test \"pnpm test\""],
67
+ codigos: [0, 1, 2, 3, 4],
68
+ },
69
+ {
70
+ nombre: "proyecto ver",
71
+ resumen: "Muestra el proyecto del repo: rama, comandos y el estado de la App de GitHub; null si todavía no tiene.",
72
+ flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--json"],
73
+ ejemplos: ["supcoflow proyecto ver --json"],
74
+ codigos: [0, 1, 2, 3],
75
+ },
76
+ {
77
+ nombre: "catalogo",
78
+ resumen: "Lista lo que se puede configurar en el chat (claves y valores admitidos) y el estado del contrato de la App.",
79
+ flags: ["--json"],
80
+ ejemplos: ["supcoflow catalogo --json"],
81
+ codigos: [0, 1, 2, 3],
82
+ },
83
+ {
84
+ nombre: "configuracion",
85
+ resumen: "Muestra la configuración del proyecto del repo: chat, sitios, marca, política de problemas y acuerdo de datos. El acuerdo frena lo que se guarda y lo que lee la IA que releva; no cubre lo que ve la IA del chat mientras conversa.",
86
+ flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--json"],
87
+ ejemplos: ["supcoflow configuracion --json"],
88
+ codigos: [0, 1, 2, 3, 4],
89
+ },
90
+ {
91
+ nombre: "chat prender",
92
+ resumen: "Prende el chat de soporte del proyecto. Sin skill aprobada hace falta una persona (sale 4).",
93
+ flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
94
+ ejemplos: ["supcoflow chat prender --json"],
95
+ codigos: [0, 1, 2, 3, 4],
96
+ },
97
+ {
98
+ nombre: "chat apagar",
99
+ resumen: "Apaga el chat de soporte del proyecto.",
100
+ flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
101
+ ejemplos: ["supcoflow chat apagar --json"],
102
+ codigos: [0, 1, 2, 3, 4],
103
+ },
104
+ {
105
+ nombre: "chat marca",
106
+ resumen: "Cambia título, saludo y colores del chat; lo que no nombrás queda como estaba.",
107
+ flags: [
108
+ "--titulo \"…\": el título del chat",
109
+ "--saludo \"…\": el saludo inicial",
110
+ "--tema clave=valor: un color o ajuste del tema (se repite, uno por clave)",
111
+ "--repo owner/repo: el repo, si no es el «origin» de esta carpeta",
112
+ "--dry-run: dice qué haría sin hacerlo",
113
+ "--json",
114
+ ],
115
+ ejemplos: ["supcoflow chat marca --titulo \"Ayuda\" --tema acento=#0a7"],
116
+ codigos: [0, 1, 2, 3, 4],
117
+ },
118
+ {
119
+ nombre: "chat logo",
120
+ resumen: "Sube el logo del chat (.png, .jpg o .webp, hasta 200 KB).",
121
+ flags: ["--archivo ruta: la imagen", "--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
122
+ ejemplos: ["supcoflow chat logo --archivo logo.png --json"],
123
+ codigos: [0, 1, 2, 3, 4],
124
+ },
125
+ {
126
+ nombre: "sitios agregar",
127
+ resumen: "Agrega un sitio desde el que el chat puede cargarse.",
128
+ flags: ["--sitio https://…: el sitio", "--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
129
+ ejemplos: ["supcoflow sitios agregar --sitio https://app.ejemplo.com"],
130
+ codigos: [0, 1, 2, 3, 4],
131
+ },
132
+ {
133
+ nombre: "sitios quitar",
134
+ resumen: "Quita un sitio de los que pueden cargar el chat.",
135
+ flags: ["--sitio https://…: el sitio", "--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
136
+ ejemplos: ["supcoflow sitios quitar --sitio https://app.ejemplo.com"],
137
+ codigos: [0, 1, 2, 3, 4],
138
+ },
139
+ {
140
+ nombre: "datos fijar",
141
+ resumen: "Fija el acuerdo de datos: qué datos pasan. Frena lo que se guarda y lo que lee la IA que releva el repo; no cubre lo que ve la IA del chat mientras conversa.",
142
+ flags: [
143
+ "--permitir lista: datos que pasan, separados por coma (usuario_id, usuario_rol, pagina_y_navegador, capturas, elementos, email, atributo:nombre) o `nada`",
144
+ "--repo owner/repo: el repo, si no es el «origin» de esta carpeta",
145
+ "--dry-run: dice qué haría sin hacerlo",
146
+ "--json",
147
+ ],
148
+ ejemplos: ["supcoflow datos fijar --permitir usuario_id,atributo:plan"],
149
+ codigos: [0, 1, 2, 3, 4],
150
+ },
151
+ {
152
+ nombre: "politica fijar",
153
+ resumen: "Fija qué hace el motor con un problema que llega por el chat: relevarlo en el acto (gasta) o guardarlo. Guardar frena sólo lo automático del chat: desde la consola una persona puede pedir el relevamiento, y cuesta.",
154
+ flags: ["--modo relevar|guardar", "--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
155
+ ejemplos: ["supcoflow politica fijar --modo guardar"],
156
+ codigos: [0, 1, 2, 3, 4],
157
+ },
158
+ {
159
+ nombre: "claves crear",
160
+ resumen: "Crea una clave de consulta (sólo lee su proyecto) y la guarda en un archivo 0600 fuera de todo repo; el secreto nunca sale por la salida.",
161
+ flags: [
162
+ "--nombre ci: para qué es la clave",
163
+ "--guardar-en ruta: el archivo donde queda (fuera de un repo; si ya tiene otra cosa sale 4 y no lo toca)",
164
+ "--repo owner/repo: el repo, si no es el «origin» de esta carpeta",
165
+ "--dry-run: dice qué haría sin hacerlo",
166
+ "--json",
167
+ ],
168
+ ejemplos: ["supcoflow claves crear --nombre ci --guardar-en ~/ci.key --json"],
169
+ codigos: [0, 1, 2, 3, 4],
170
+ },
171
+ {
172
+ nombre: "claves listar",
173
+ resumen: "Lista las claves de consulta activas del proyecto (id, nombre, fecha; nunca el secreto).",
174
+ flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--json"],
175
+ ejemplos: ["supcoflow claves listar --json"],
176
+ codigos: [0, 1, 2, 3, 4],
177
+ },
178
+ {
179
+ nombre: "claves revocar",
180
+ resumen: "Revoca una clave de consulta por su id; revocar dos veces deja lo mismo.",
181
+ flags: ["--id cla_…: la clave (la da `claves listar`)", "--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
182
+ ejemplos: ["supcoflow claves revocar --id cla_0123456789abcdef --json"],
183
+ codigos: [0, 1, 2, 3, 4],
184
+ },
43
185
  ],
44
186
  flags_globales: ["--json: el resultado en una línea de JSON por stdout; los avisos van por stderr", "--help, -h: la ayuda con ejemplos"],
45
187
  facultades: [
@@ -0,0 +1,26 @@
1
+ import { resultadoDeFallo, SALIDA } from "../contrato.js";
2
+ import { repoPedido } from "../repo-local.js";
3
+ import { claveVigente } from "./clave-vigente.js";
4
+ export async function proyectoDelRepo(d, flags) {
5
+ let repo;
6
+ try {
7
+ repo = (await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd })).repo;
8
+ }
9
+ catch (e) {
10
+ return { ok: false, resultado: resultadoDeFallo(e) };
11
+ }
12
+ const leida = await claveVigente(d.guardado, d.api.origin);
13
+ if (!leida.ok)
14
+ return { ok: false, resultado: leida.resultado };
15
+ try {
16
+ const proyecto = await d.buscarProyecto(d.api, leida.vigente.clave, repo);
17
+ if (proyecto === null) {
18
+ const message = `El repo ${repo} todavía no tiene proyecto. Creá el proyecto: \`supcoflow proyecto crear\`.`;
19
+ return { ok: false, resultado: { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "sin_proyecto", message, repo }, texto: message } };
20
+ }
21
+ return { ok: true, clave: leida.vigente.clave, proyecto };
22
+ }
23
+ catch (e) {
24
+ return { ok: false, resultado: resultadoDeFallo(e) };
25
+ }
26
+ }