supcoflow 0.0.0-stage → 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.
package/dist/cli.js ADDED
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ // El binario `supcoflow`: lee los argumentos, arma el entorno real y despacha. Todo va dentro de un
3
+ // solo try/catch: cualquier cosa que se escape (un archivo de credenciales ilegible, un llavero roto)
4
+ // sale como un 1 limpio, en una línea si se pidió --json, sin traza y con los secretos tapados.
5
+ import { revocarPropia } from "./api.js";
6
+ import { leerArgumentos } from "./argumentos.js";
7
+ import { clavesCrear, clavesListar, clavesRevocar } from "./comandos/claves.js";
8
+ import { chatLogo, chatMarca, chatPrenderApagar, comandoConfiguracion, sitios } from "./comandos/configurar.js";
9
+ import { comandoCatalogo, datosFijar, politicaFijar } from "./comandos/datos-y-politica.js";
10
+ import { ayuda, comandoDescribir, DESCRIPCION } from "./comandos/describir.js";
11
+ import { loginInteractivo, loginStart, loginWait } from "./comandos/login.js";
12
+ import { comandoLogout } from "./comandos/logout.js";
13
+ import { proyectoCrear, proyectoVer } from "./comandos/proyecto.js";
14
+ import { comandoQuienSoy } from "./comandos/quien-soy.js";
15
+ import { repoConectarInteractivo, repoConectarStart, repoConectarWait } from "./comandos/repo-conectar.js";
16
+ import { emitir, FalloDeLaTerminal, resultadoDeFallo, SALIDA } from "./contrato.js";
17
+ import { entornoReal } from "./entorno.js";
18
+ import { flagQueNoAplica } from "./flags-por-comando.js";
19
+ const COMANDOS = new Set(DESCRIPCION.comandos.map((c) => c.nombre));
20
+ const invalida = (error, message) => new FalloDeLaTerminal(SALIDA.entradaInvalida, error, message);
21
+ async function correr(argv) {
22
+ // Si los argumentos no se pueden leer no se sabe qué flags hay: se mira `--json` a mano para que
23
+ // el error salga en el formato que pidió quien llama.
24
+ let json = argv.includes("--json");
25
+ try {
26
+ const { comando, flags } = leerArgumentos(argv);
27
+ json = flags.json;
28
+ if (comando !== null && !COMANDOS.has(comando)) {
29
+ throw invalida("comando_desconocido", `No conozco el comando «${comando}». Probá \`supcoflow --help\`.`);
30
+ }
31
+ if (flags.help || comando === null) {
32
+ const texto = ayuda(comando);
33
+ return { r: { codigo: SALIDA.ok, json: { ok: true, ayuda: texto }, texto }, json };
34
+ }
35
+ const presentes = {
36
+ start: flags.start, wait: flags.wait, repo: flags.repo !== null, install: flags.install !== null, test: flags.test !== null,
37
+ sitio: flags.sitio !== null, titulo: flags.titulo !== null, saludo: flags.saludo !== null, tema: flags.tema.length > 0,
38
+ archivo: flags.archivo !== null, permitir: flags.permitir !== null, modo: flags.modo !== null,
39
+ nombre: flags.nombre !== null, guardarEn: flags.guardarEn !== null, id: flags.id !== null,
40
+ };
41
+ const noAplica = flagQueNoAplica(comando, Object.keys(presentes).filter((k) => presentes[k]));
42
+ if (noAplica !== null)
43
+ throw invalida("flag_no_aplica", noAplica);
44
+ if (flags.start && flags.wait) {
45
+ throw invalida("flags_incompatibles", "--start y --wait van por separado: primero uno y después el otro.");
46
+ }
47
+ if (comando === "describir")
48
+ return { r: comandoDescribir(), json };
49
+ if (comando === "login") {
50
+ // ANTES de armar el entorno: no hay cómo simular un canje sin consumirlo, y este error no
51
+ // puede depender de leer nada guardado ni de llamar al motor.
52
+ if (flags.wait && flags.dryRun) {
53
+ throw invalida("dry_run_no_soportado", "`login --wait` no tiene --dry-run: sondear consume el código. Probá `supcoflow login --start --dry-run`.");
54
+ }
55
+ }
56
+ const e = entornoReal();
57
+ switch (comando) {
58
+ case "login":
59
+ if (flags.start)
60
+ return { r: await loginStart(e.login, flags), json };
61
+ if (flags.wait)
62
+ return { r: await loginWait(e.login), json };
63
+ // Sin TTY, `login --dry-run` igual tiene algo que decir: lo que haría el primer paso.
64
+ if (flags.dryRun)
65
+ return { r: await loginStart(e.login, { dryRun: true }), json };
66
+ return { r: await loginInteractivo({ ...e.login, esTty: e.esTty, mostrar: e.mostrar }, flags), json };
67
+ case "logout":
68
+ return {
69
+ r: await comandoLogout({
70
+ api: e.api, guardado: e.guardado, ahora: e.login.ahora, avisar: e.avisar,
71
+ revocarPropia: (a, clave, plazoMs) => revocarPropia(a, clave, fetch, plazoMs),
72
+ }, flags),
73
+ json,
74
+ };
75
+ case "quien-soy":
76
+ return { r: await comandoQuienSoy({ api: e.api, guardado: e.guardado, quienSoy: e.quienSoy, avisar: e.avisar }), json };
77
+ case "repo conectar":
78
+ if (flags.start)
79
+ return { r: await repoConectarStart(e.conexion, flags), json };
80
+ // `--wait` sólo lee (un GET por tramo): con --dry-run haría lo mismo, así que no se rechaza.
81
+ if (flags.wait)
82
+ return { r: await repoConectarWait(e.conexion, flags), json };
83
+ // Como `login`: sin TTY, --dry-run igual dice lo que haría el primer paso, sin tocar el motor.
84
+ if (flags.dryRun)
85
+ return { r: await repoConectarStart(e.conexion, { repo: flags.repo, dryRun: true }), json };
86
+ return { r: await repoConectarInteractivo({ ...e.conexion, esTty: e.esTty, mostrar: e.mostrar }, flags), json };
87
+ case "proyecto crear":
88
+ return { r: await proyectoCrear(e.proyecto, flags), json };
89
+ case "proyecto ver":
90
+ return { r: await proyectoVer(e.proyecto, flags), json };
91
+ case "catalogo":
92
+ return { r: await comandoCatalogo(e.configurar), json };
93
+ case "configuracion":
94
+ return { r: await comandoConfiguracion(e.configurar, flags), json };
95
+ case "chat prender":
96
+ return { r: await chatPrenderApagar(e.configurar, flags, true), json };
97
+ case "chat apagar":
98
+ return { r: await chatPrenderApagar(e.configurar, flags, false), json };
99
+ case "chat marca":
100
+ return { r: await chatMarca(e.configurar, flags), json };
101
+ case "chat logo":
102
+ return { r: await chatLogo(e.configurar, flags), json };
103
+ case "sitios agregar":
104
+ return { r: await sitios(e.configurar, flags, "agregar"), json };
105
+ case "sitios quitar":
106
+ return { r: await sitios(e.configurar, flags, "quitar"), json };
107
+ case "datos fijar":
108
+ return { r: await datosFijar(e.configurar, flags), json };
109
+ case "politica fijar":
110
+ return { r: await politicaFijar(e.configurar, flags), json };
111
+ case "claves crear":
112
+ return { r: await clavesCrear(e.claves, flags), json };
113
+ case "claves listar":
114
+ return { r: await clavesListar(e.claves, flags), json };
115
+ case "claves revocar":
116
+ return { r: await clavesRevocar(e.claves, flags), json };
117
+ }
118
+ throw new Error(`comando sin despacho: ${comando}`);
119
+ }
120
+ catch (e) {
121
+ return { r: resultadoDeFallo(e), json };
122
+ }
123
+ }
124
+ // `process.exitCode` y nunca `process.exit()`: éste corta el proceso con stdout a medio escribir
125
+ // cuando la salida va a un pipe, y quien llama leería una línea de JSON partida.
126
+ const { r, json } = await correr(process.argv.slice(2));
127
+ emitir(r, json, (s) => void process.stdout.write(s));
128
+ process.exitCode = r.codigo;
@@ -0,0 +1,18 @@
1
+ // La clave con la que la terminal le habla al motor, leída igual para todo comando que la necesite
2
+ // (`quien-soy`, `repo conectar`, …): un solo lugar decide qué es «sin sesión» y qué es «no pude mirar».
3
+ import { SALIDA } from "../contrato.js";
4
+ export async function claveVigente(guardado, origen) {
5
+ const lectura = await guardado.leerClaveCompleta(origen);
6
+ const vigente = lectura.valor;
7
+ // «Iniciá sesión» con la clave guardada en un llavero que no contestó invita a un login que la
8
+ // pisaría: no es «sin sesión», es «no pude mirar» (Review Focus 12).
9
+ if (vigente === null && !lectura.completa) {
10
+ const message = "No pude leer la clave guardada (el llavero no respondió). Desbloquealo y probá de nuevo.";
11
+ return { ok: false, resultado: { codigo: SALIDA.inesperado, json: { ok: false, error: "llavero_no_responde", message }, texto: message } };
12
+ }
13
+ if (vigente === null) {
14
+ const message = "Iniciá sesión con `supcoflow login`.";
15
+ return { ok: false, resultado: { codigo: SALIDA.faltaLogin, json: { ok: false, error: "sin_sesion", message }, texto: message } };
16
+ }
17
+ return { ok: true, vigente: { clave: vigente.clave, fuente: vigente.fuente, completa: lectura.completa } };
18
+ }
@@ -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
+ }