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.
package/README.md CHANGED
@@ -3,7 +3,8 @@
3
3
  La terminal de [Supcoflow](https://app.supcoflow.dev): iniciar sesión y manejar el chat de soporte
4
4
  desde la consola, o desde un asistente de código que corre comandos por vos.
5
5
 
6
- Por ahora trae lo básico: iniciar y cerrar sesión, y ver con qué cuenta estás.
6
+ Trae iniciar y cerrar sesión, ver con qué cuenta estás, conectar el repo con la App de GitHub,
7
+ crear su proyecto y configurar el chat.
7
8
 
8
9
  ## Instalar
9
10
 
@@ -22,9 +23,20 @@ npx supcoflow login
22
23
  | `supcoflow login --wait` | El segundo paso: espera la aprobación hasta 90 s. Si todavía no llegó, sale 5: volvé a correrlo. |
23
24
  | `supcoflow logout` | Revoca la clave en uso y la borra de esta máquina. |
24
25
  | `supcoflow quien-soy` | Con qué clave anda la terminal, a nombre de quién y en qué equipo. |
26
+ | `supcoflow repo conectar` | Conecta el repo de esta carpeta con la App de GitHub: te da un enlace, lo confirmás en la consola. Sin terminal, `--start` y después `--wait`, como `login`. |
27
+ | `supcoflow proyecto crear` | Crea (o reusa) el proyecto del repo ya conectado y lo anota en `.supcoflow/proyecto.json`. Si el motor no deduce los comandos, `--install "…" --test "…"`. |
28
+ | `supcoflow proyecto ver` | El proyecto del repo: rama, comandos y el estado de la App de GitHub. |
25
29
  | `supcoflow describir --json` | Todo el contrato (comandos, flags, facultades de la clave, códigos) en una línea de JSON. |
26
30
 
27
- `supcoflow --help` (o `supcoflow <comando> --help`) muestra la ayuda con ejemplos.
31
+ `supcoflow --help` (o `supcoflow <comando> --help`) muestra la ayuda con ejemplos. Los comandos de
32
+ repo y proyecto toman el repo del `origin` de la carpeta; `--repo owner/repo` lo elige a mano.
33
+
34
+ ### De cero a un proyecto
35
+
36
+ 1. `supcoflow login`: aprobás el código en la consola.
37
+ 2. `supcoflow repo conectar`: instalás la App en GitHub si hace falta y tocás Confirmar.
38
+ 3. `supcoflow proyecto crear`: queda el proyecto y `.supcoflow/proyecto.json` para commitear.
39
+ 4. `supcoflow proyecto ver`: para revisar cómo quedó.
28
40
 
29
41
  ### Ejemplos
30
42
 
@@ -36,10 +48,35 @@ supcoflow login
36
48
  supcoflow login --start --json # sale 5 con verification_uri_complete y user_code
37
49
  supcoflow login --wait --json # sale 5 mientras la persona no apruebe; 0 cuando aprueba
38
50
 
51
+ supcoflow repo conectar --start --json # sale 5 con la url para confirmar
52
+ supcoflow repo conectar --wait --json # sale 5 mientras nadie confirme; 0 conectado
53
+ supcoflow proyecto crear --json # 4 si hay un .supcoflow/proyecto.json distinto (no lo pisa)
54
+ supcoflow proyecto crear --install "pnpm i" --test "pnpm test"
55
+ supcoflow proyecto ver --json
56
+
39
57
  supcoflow quien-soy --json
40
58
  supcoflow logout --dry-run # qué revocaría, sin revocar nada
41
59
  ```
42
60
 
61
+ ## Configurar
62
+
63
+ Con el repo ya conectado y su proyecto creado, se configura el chat sin entrar a la consola (la clave
64
+ tiene que ser de sesión, no de consulta). Todos toman el repo del `origin`, o `--repo owner/repo`, y
65
+ los que escriben aceptan `--dry-run`.
66
+
67
+ | Comando | Qué hace |
68
+ |---|---|
69
+ | `supcoflow catalogo` | Qué se puede configurar y con qué valores. |
70
+ | `supcoflow configuracion` | Chat, sitios, marca y acuerdo de datos del proyecto. |
71
+ | `supcoflow chat prender` / `chat apagar` | Prende o apaga el chat (sin skill aprobada, prender sale 4). |
72
+ | `supcoflow chat marca --titulo "Ayuda" --tema acento=#0a7` | Título, saludo y colores; lo no nombrado queda igual. |
73
+ | `supcoflow chat logo --archivo logo.png` | Sube el logo (.png, .jpg o .webp, hasta 200 KB). |
74
+ | `supcoflow sitios agregar\|quitar --sitio https://app.ejemplo.com` | Sitios desde los que carga el chat. |
75
+ | `supcoflow datos fijar --permitir usuario_id,atributo:plan` | Acuerdo de datos: frena lo que se guarda y lo que lee la IA que releva; no cubre lo que ve la IA del chat mientras conversa. |
76
+ | `supcoflow politica fijar --modo guardar` | Qué hace el motor con un problema que llega por el chat: `relevar` (gasta) o `guardar`. Guardar frena sólo lo automático del chat; una persona puede pedir el relevamiento desde la consola, y cuesta. |
77
+ | `supcoflow claves crear --nombre ci --guardar-en ~/ci.key` | Clave de consulta para CI: el secreto queda sólo en ese archivo (0600, fuera de un repo), nunca en la salida. |
78
+ | `supcoflow claves listar` / `claves revocar --id cla_…` | Ver y revocar claves de consulta. |
79
+
43
80
  ## El contrato
44
81
 
45
82
  Pensado para que lo lea un programa, no sólo una persona:
@@ -48,7 +85,8 @@ Pensado para que lo lea un programa, no sólo una persona:
48
85
  Los errores también: `{"ok":false,"error":"…","message":"…"}`, con su código de salida.
49
86
  - **Sin preguntas sin terminal interactiva** (o con `--json`): si falta un dato, sale 3 diciendo qué
50
87
  flag usar. `supcoflow login` sin terminal sale 3 y te indica el camino en dos pasos.
51
- - **`--dry-run`** donde se puede simular (`login`, `login --start`, `logout`). `login --wait
88
+ - **`--dry-run`** donde se puede simular (`login`, `login --start`, `logout`, `repo conectar --start`,
89
+ `proyecto crear`). `login --wait
52
90
  --dry-run` sale 3: sondear consume el código y no hay cómo simularlo.
53
91
 
54
92
  | Código | Quiere decir |
@@ -70,6 +108,8 @@ Pensado para que lo lea un programa, no sólo una persona:
70
108
  de dotfiles, por ejemplo), no guarda nada y te pide usar el llavero o `SUPCOFLOW_TOKEN`.
71
109
  - **`SUPCOFLOW_TOKEN`** tiene prioridad sobre lo guardado: sirve para CI y para la nube.
72
110
  - La clave nunca sale en un log ni en una salida `--json`.
111
+ - `proyecto crear` guarda su clave de idempotencia (no es un secreto: sólo evita crear dos veces si se
112
+ pierde la respuesta) en `idempotencia.json`, al lado de las credenciales y con los mismos permisos.
73
113
 
74
114
  Cuando iniciás sesión de nuevo, la clave anterior se revoca recién cuando la nueva quedó guardada; si
75
115
  no se pudo revocar, queda anotada y se reintenta en el próximo `login` o `logout`.
package/dist/api.js CHANGED
@@ -1,5 +1,5 @@
1
- // Los cuatro pedidos de la terminal al motor. Sin dependencias: `fetch` viene de Node y se inyecta.
2
- import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
1
+ // Los pedidos de la terminal al motor. Sin dependencias: `fetch` viene de Node y se inyecta.
2
+ import { FalloDeLaTerminal, SALIDA, taparSecretos } from "./contrato.js";
3
3
  // La URL viene FIJA (§5.2): si un archivo del repo pudiera cambiarla, clonar un repo ajeno y correr
4
4
  // `supcoflow` mandaría el token a un servidor ajeno. Sólo el entorno del proceso la mueve, y `http`
5
5
  // únicamente contra la propia máquina: contra un host remoto la clave viajaría en claro.
@@ -21,10 +21,14 @@ export function urlDeLaApi(env) {
21
21
  return new URL(u.origin);
22
22
  throw invalida();
23
23
  }
24
- // A mano a propósito: tiene que coincidir con package.json (0.1.0); sólo identifica a la terminal
24
+ // A mano a propósito: tiene que coincidir con package.json (0.3.0); sólo identifica a la terminal
25
25
  // en los logs del motor, no decide nada.
26
- const VERSION = "0.1.0";
26
+ const VERSION = "0.3.0";
27
27
  const PLAZO_POR_DEFECTO_MS = 15_000;
28
+ // Los dos POST que crean algo: detrás, el motor acuña con Vercel Connect y le pregunta a GitHub varias
29
+ // veces (instalación, repos, comandos, rama). Con 15 s una respuesta lenta pero buena se cortaba y
30
+ // sólo quedaba reintentar; el sondeo no los usa, así que su tope por tramo no cambia.
31
+ const PLAZO_DE_ALTA_MS = 30_000;
28
32
  // Todo lo que vuelve de afuera se trata como no confiable: nunca se pega el cuerpo crudo en un
29
33
  // mensaje (una página HTML no sirve en una línea y podría traer cualquier cosa), y el mensaje del
30
34
  // error de red no se reenvía (podría llevar la URL o la clave).
@@ -37,8 +41,9 @@ async function pedir(api, ruta, init, f, plazoMs) {
37
41
  headers: { ...init.headers, "user-agent": `supcoflow-cli/${VERSION}` },
38
42
  signal: AbortSignal.timeout(plazoMs ?? PLAZO_POR_DEFECTO_MS),
39
43
  // Un 307/308 reenvía el cuerpo del formulario (con el device_code) adonde diga `Location`, aunque
40
- // sea otro origen o http. Los cuatro endpoints del motor nunca redirigen: una redirección es
41
- // anómala y sale como `motor_inalcanzable`, sin Location ni cuerpo en el mensaje.
44
+ // sea otro origen o http; con el Bearer pasaría lo mismo con la clave. Ninguna ruta del motor que
45
+ // llama la terminal redirige: una redirección es anómala y sale como `motor_inalcanzable`, sin
46
+ // Location ni cuerpo en el mensaje.
42
47
  redirect: "error",
43
48
  });
44
49
  texto = await res.text();
@@ -131,3 +136,160 @@ export async function revocarPropia(api, clave, f, plazoMs) {
131
136
  throw sinServicio(status);
132
137
  throw inesperada(status);
133
138
  }
139
+ // ---- Conexión del repo y proyecto (bloque 3) ----
140
+ // Una sola puerta para los pedidos con clave: suma el Bearer y traduce lo que significa lo mismo en
141
+ // todos (clave rota, clave sin alcance, repo mal escrito, cuenta sin GitHub). Lo que es propio de
142
+ // cada pedido (404, 409, 422 de comandos) lo decide quien llama. Los `message` del motor son texto de
143
+ // afuera: pasan por taparSecretos antes de salir en una línea de la terminal.
144
+ async function conClave(api, clave, ruta, init, f, plazoMs) {
145
+ const r = await pedir(api, ruta, { ...init, headers: { ...init.headers, ...bearer(clave) } }, f, plazoMs);
146
+ const error = texto(r.cuerpo.error) ?? "respuesta_inesperada";
147
+ const message = (porDefecto) => taparSecretos(texto(r.cuerpo.message) ?? porDefecto);
148
+ if (r.status === 401) {
149
+ throw new FalloDeLaTerminal(SALIDA.faltaLogin, "clave_invalida", message("La clave ya no vale: iniciá sesión de nuevo."));
150
+ }
151
+ if (r.status === 403 && (error === "requiere_persona" || error === "clave_de_consulta")) {
152
+ throw new FalloDeLaTerminal(SALIDA.haceFaltaUnaPersona, error, message("Esto lo tiene que hacer una persona con sesión."));
153
+ }
154
+ if (r.status === 422 && error === "cuenta_sin_github") {
155
+ throw new FalloDeLaTerminal(SALIDA.haceFaltaUnaPersona, error, message("Tu cuenta no tiene GitHub asociado."));
156
+ }
157
+ if (r.status === 400 && error === "repo_invalido") {
158
+ throw new FalloDeLaTerminal(SALIDA.entradaInvalida, error, message("El repo tiene que ser owner/repo."));
159
+ }
160
+ if (r.status >= 500)
161
+ throw sinServicio(r.status);
162
+ return r;
163
+ }
164
+ const ESTADOS_DE_PEDIDO = new Set(["pendiente", "conectado", "repo_fuera_de_alcance", "vencido"]);
165
+ function aPedido(v, status) {
166
+ const c = (typeof v === "object" && v !== null ? v : {});
167
+ const [id, repo, estado, url, venceEn] = [c.id, c.repo, c.estado, c.url, c.vence_en].map(texto);
168
+ if (!id || !repo || !estado || !ESTADOS_DE_PEDIDO.has(estado) || !url || !venceEn)
169
+ throw inesperada(status);
170
+ return { id, repo, estado: estado, url, venceEn, repoCanonico: texto(c.repo_canonico), fallo: texto(c.fallo) };
171
+ }
172
+ function aProyecto(v, status) {
173
+ const c = (typeof v === "object" && v !== null ? v : {});
174
+ const [id, slug, repo, rama, installCommand, testCommand] = [c.id, c.slug, c.repo, c.rama, c.install_command, c.test_command].map(texto);
175
+ if (!id || !slug || !repo || !rama || installCommand === null || testCommand === null)
176
+ throw inesperada(status);
177
+ return { id, slug, repo, rama, installCommand, testCommand };
178
+ }
179
+ // Un fallo propio del pedido que el mapeo común no cubrió (404, 409…): sale como entrada inválida
180
+ // con el motivo del motor, nunca con el cuerpo crudo.
181
+ function rechazada(r) {
182
+ const error = texto(r.cuerpo.error);
183
+ if (r.status >= 400 && r.status < 500 && error) {
184
+ return new FalloDeLaTerminal(SALIDA.entradaInvalida, error, taparSecretos(texto(r.cuerpo.message) ?? `El motor rechazó el pedido (${r.status}).`));
185
+ }
186
+ return inesperada(r.status);
187
+ }
188
+ const JSON_BODY = { "content-type": "application/json" };
189
+ export async function crearConexion(api, clave, repo, f) {
190
+ const r = await conClave(api, clave, "/api/conexiones", { method: "POST", headers: JSON_BODY, body: JSON.stringify({ repo }) }, f, PLAZO_DE_ALTA_MS);
191
+ if (r.status !== 200 && r.status !== 201)
192
+ throw rechazada(r);
193
+ return aPedido(r.cuerpo.pedido, r.status);
194
+ }
195
+ export async function ultimaConexion(api, clave, repo, f, plazoMs) {
196
+ const r = await conClave(api, clave, `/api/conexiones?${new URLSearchParams({ repo })}`, { method: "GET" }, f, plazoMs);
197
+ if (r.status !== 200)
198
+ throw rechazada(r);
199
+ return r.cuerpo.pedido === null ? null : aPedido(r.cuerpo.pedido, r.status);
200
+ }
201
+ export async function crearProyecto(api, clave, conexionId, cuerpo, idempotencyKey, f) {
202
+ const r = await conClave(api, clave, `/api/conexiones/${encodeURIComponent(conexionId)}/proyecto`, { method: "POST", headers: { ...JSON_BODY, "idempotency-key": idempotencyKey }, body: JSON.stringify(cuerpo) }, f, PLAZO_DE_ALTA_MS);
203
+ const error = texto(r.cuerpo.error);
204
+ const message = taparSecretos(texto(r.cuerpo.message) ?? "");
205
+ if (r.status === 200 || r.status === 201) {
206
+ return { tipo: "ok", proyecto: aProyecto(r.cuerpo.proyecto, r.status), reusado: r.status === 200 };
207
+ }
208
+ if (r.status === 422 && error === "comandos_faltantes") {
209
+ const faltantes = Array.isArray(r.cuerpo.faltantes) ? r.cuerpo.faltantes.filter((x) => typeof x === "string") : [];
210
+ return { tipo: "comandos_faltantes", faltantes, message };
211
+ }
212
+ if (r.status === 409 && error === "idempotency_en_curso")
213
+ return { tipo: "en_curso" };
214
+ if (r.status === 409 && error === "idempotency_key_reusada")
215
+ return { tipo: "clave_reusada" };
216
+ if (r.status === 409 && error === "conexion_no_conectada")
217
+ return { tipo: "no_conectada", message };
218
+ if (r.status >= 400 && r.status < 500 && error)
219
+ return { tipo: "rechazo", error, message };
220
+ throw inesperada(r.status);
221
+ }
222
+ export async function buscarProyecto(api, clave, repo, f) {
223
+ const r = await conClave(api, clave, `/api/proyectos?${new URLSearchParams({ repo })}`, { method: "GET" }, f);
224
+ if (r.status !== 200)
225
+ throw rechazada(r);
226
+ return r.cuerpo.proyecto === null ? null : aProyecto(r.cuerpo.proyecto, r.status);
227
+ }
228
+ export async function leerProyecto(api, clave, id, f) {
229
+ const r = await conClave(api, clave, `/api/proyectos/${encodeURIComponent(id)}`, { method: "GET" }, f);
230
+ if (r.status !== 200)
231
+ throw rechazada(r);
232
+ const g = (typeof r.cuerpo.github === "object" && r.cuerpo.github !== null ? r.cuerpo.github : {});
233
+ return { proyecto: aProyecto(r.cuerpo.proyecto, r.status), github: { estado: texto(g.estado), verificadoEn: texto(g.verificado_en) } };
234
+ }
235
+ // ---- Configurar (bloque 4) ----
236
+ const objeto = (v, status) => {
237
+ if (typeof v !== "object" || v === null || Array.isArray(v))
238
+ throw inesperada(status);
239
+ return v;
240
+ };
241
+ export async function leerCatalogo(api, clave, f) {
242
+ const r = await conClave(api, clave, "/api/catalogo", { method: "GET" }, f);
243
+ if (r.status !== 200 || !Array.isArray(r.cuerpo.supcoflow))
244
+ throw rechazada(r);
245
+ return r.cuerpo;
246
+ }
247
+ export async function leerConfiguracion(api, clave, proyectoId, f) {
248
+ const r = await conClave(api, clave, `/api/proyectos/${encodeURIComponent(proyectoId)}/configuracion`, { method: "GET" }, f);
249
+ if (r.status !== 200)
250
+ throw rechazada(r);
251
+ objeto(r.cuerpo.chat, r.status);
252
+ if (!Array.isArray(r.cuerpo.sitios))
253
+ throw inesperada(r.status);
254
+ return r.cuerpo;
255
+ }
256
+ // Las escrituras devuelven la respuesta entera: qué significa un 409 lo decide cada comando (prender sin
257
+ // guía es «hace falta una persona»; la lista que cambió es «probá de nuevo»).
258
+ export async function configurar(api, clave, ruta, cuerpo, f, extra = {}) {
259
+ const headers = { ...JSON_BODY };
260
+ if (extra.idempotencyKey !== undefined)
261
+ headers["idempotency-key"] = extra.idempotencyKey;
262
+ return conClave(api, clave, ruta, { method: "POST", headers, body: JSON.stringify(cuerpo) }, f);
263
+ }
264
+ export async function subirLogo(api, clave, proyectoId, archivo, f) {
265
+ const form = new FormData();
266
+ form.set("logo", new Blob([archivo.bytes], { type: archivo.mime }), archivo.nombre);
267
+ // Sin content-type a mano: `fetch` pone el multipart con su boundary.
268
+ return conClave(api, clave, `/api/proyectos/${encodeURIComponent(proyectoId)}/chat/logo`, { method: "POST", body: form }, f);
269
+ }
270
+ export async function listarClaves(api, clave, proyectoId, f) {
271
+ const r = await conClave(api, clave, `/api/proyectos/${encodeURIComponent(proyectoId)}/claves`, { method: "GET" }, f);
272
+ if (r.status !== 200 || !Array.isArray(r.cuerpo.claves))
273
+ throw rechazada(r);
274
+ return r.cuerpo.claves.map((x) => {
275
+ const o = objeto(x, r.status);
276
+ const [id, nombre, creadaEn] = [o.id, o.nombre, o.creada_en].map(texto);
277
+ if (!id || !nombre || !creadaEn)
278
+ throw inesperada(r.status);
279
+ return { id, nombre, creadaEn };
280
+ });
281
+ }
282
+ // ¿Esta clave es una clave de CONSULTA de este proyecto y autentica? La prueba de posesión de
283
+ // `claves crear`: el motor es el único que sabe cuál generación es la vigente, y de qué alcance. Que lea el
284
+ // proyecto no alcanza: una clave personal también lo lee y puede mucho más (tercera revisión, hallazgo 2).
285
+ // Sin `conClave`: un 401 acá es la respuesta («no»), no «iniciá sesión».
286
+ export async function esClaveDeConsultaDelProyecto(api, clave, proyectoId, f) {
287
+ const r = await pedir(api, "/api/yo", { method: "GET", headers: bearer(clave) }, f);
288
+ if (r.status === 401)
289
+ return false;
290
+ if (r.status >= 500)
291
+ throw sinServicio(r.status);
292
+ if (r.status !== 200)
293
+ throw inesperada(r.status);
294
+ return r.cuerpo.via === "clave_de_consulta" && r.cuerpo.proyecto === proyectoId;
295
+ }
@@ -1,5 +1,15 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
3
+ export const COMANDOS_COMPUESTOS = [
4
+ "repo conectar", "proyecto crear", "proyecto ver",
5
+ "chat prender", "chat apagar", "chat marca", "chat logo", "sitios agregar", "sitios quitar",
6
+ "datos fijar", "politica fijar", "claves crear", "claves listar", "claves revocar",
7
+ ];
8
+ // Sólo estas primeras palabras admiten una segunda: cualquier otro par sigue siendo «de más».
9
+ const PRIMERAS_PALABRAS = new Set(COMANDOS_COMPUESTOS.map((c) => c.split(" ")[0]));
10
+ const FLAGS_CON_VALOR = [
11
+ "repo", "install", "test", "sitio", "titulo", "saludo", "archivo", "permitir", "modo", "nombre", "guardar-en", "id",
12
+ ];
3
13
  // strict: un flag mal escrito (`--estart`) no se ignora en silencio: un asistente creería que
4
14
  // pidió algo que no pidió.
5
15
  export function leerArgumentos(argv) {
@@ -15,7 +25,21 @@ export function leerArgumentos(argv) {
15
25
  help: { type: "boolean", short: "h" },
16
26
  start: { type: "boolean" },
17
27
  wait: { type: "boolean" },
28
+ repo: { type: "string" },
29
+ install: { type: "string" },
30
+ test: { type: "string" },
31
+ sitio: { type: "string" },
32
+ titulo: { type: "string" },
33
+ saludo: { type: "string" },
34
+ archivo: { type: "string" },
35
+ permitir: { type: "string" },
36
+ modo: { type: "string" },
37
+ nombre: { type: "string" },
38
+ "guardar-en": { type: "string" },
39
+ id: { type: "string" },
40
+ tema: { type: "string", multiple: true },
18
41
  },
42
+ tokens: true,
19
43
  });
20
44
  }
21
45
  catch (e) {
@@ -26,18 +50,49 @@ export function leerArgumentos(argv) {
26
50
  }
27
51
  throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "argumentos_invalidos", `${e.message} Probá \`supcoflow --help\`.`);
28
52
  }
29
- if (parsed.positionals.length > 1) {
30
- throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "argumentos_de_mas", "Pasaste más de un comando. Probá `supcoflow --help`.");
53
+ const pos = parsed.positionals;
54
+ const deMas = () => new FalloDeLaTerminal(SALIDA.entradaInvalida, "argumentos_de_mas", "Pasaste más de un comando. Probá `supcoflow --help`.");
55
+ if (pos.length > 2 || (pos.length === 2 && !PRIMERAS_PALABRAS.has(pos[0])))
56
+ throw deMas();
57
+ // `parseArgs` se queda con el último valor de un flag repetido: un asistente que repite `--repo`
58
+ // creería que se usó el primero. Se mira en los tokens y se rechaza.
59
+ for (const nombre of FLAGS_CON_VALOR) {
60
+ const toks = (parsed.tokens ?? []).filter((t) => t.kind === "option" && t.name === nombre);
61
+ if (toks.length > 1 || toks.some((t) => t.kind === "option" && t.value === "")) {
62
+ throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "argumentos_invalidos", `--${nombre} va una sola vez y con un valor. Probá \`supcoflow --help\`.`);
63
+ }
64
+ }
65
+ // `--tema` sí se repite (uno por clave), pero ninguno vacío: un `--tema ""` es un error de quien arma
66
+ // el comando, no «sin tema».
67
+ if ((parsed.values.tema ?? []).some((t) => t === "")) {
68
+ throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "argumentos_invalidos", "--tema va con un valor clave=valor. Probá `supcoflow --help`.");
31
69
  }
70
+ // Un par desconocido (`repo borrar`) queda como la primera palabra sola: el despacho la rechaza
71
+ // como comando desconocido, con el mismo mensaje que cualquier otra.
72
+ const compuesto = pos.length === 2 ? `${pos[0]} ${pos[1]}` : null;
73
+ const comando = compuesto !== null && COMANDOS_COMPUESTOS.includes(compuesto) ? compuesto : (pos[0] ?? null);
32
74
  const v = parsed.values;
33
75
  return {
34
- comando: parsed.positionals[0] ?? null,
76
+ comando,
35
77
  flags: {
36
78
  json: v.json === true,
37
79
  dryRun: v["dry-run"] === true,
38
80
  help: v.help === true,
39
81
  start: v.start === true,
40
82
  wait: v.wait === true,
83
+ repo: v.repo ?? null,
84
+ install: v.install ?? null,
85
+ test: v.test ?? null,
86
+ sitio: v.sitio ?? null,
87
+ titulo: v.titulo ?? null,
88
+ saludo: v.saludo ?? null,
89
+ archivo: v.archivo ?? null,
90
+ permitir: v.permitir ?? null,
91
+ modo: v.modo ?? null,
92
+ nombre: v.nombre ?? null,
93
+ guardarEn: v["guardar-en"] ?? null,
94
+ id: v.id ?? null,
95
+ tema: v.tema ?? [],
41
96
  },
42
97
  };
43
98
  }
@@ -0,0 +1,57 @@
1
+ // Un candado por operación en ESTA máquina (segunda revisión externa del 2026-10-16, hallazgo 1): comprobar
2
+ // que un archivo autentica y después escribirlo no es atómico, y dos `claves crear` del mismo proyecto y
3
+ // nombre pisaban uno el secreto del otro. Un archivo creado en exclusiva es el candado más simple que
4
+ // funciona entre procesos sin dependencias.
5
+ //
6
+ // ─── No se recupera solo (tercera revisión, hallazgo 1) ─────────────────────────
7
+ //
8
+ // La primera versión borraba un candado «viejo» y tomaba uno nuevo. Leer la fecha y después borrar no es
9
+ // atómico: dos procesos que encontraban el mismo candado viejo entraban los dos, y el primero en soltar
10
+ // borraba el del otro. Ahora un candado de un proceso que ya no existe se informa con su ruta y lo borra una
11
+ // persona, que sabe que no hay nadie corriendo. Y `soltar` sólo borra si el archivo sigue siendo el suyo.
12
+ import { createHash, randomUUID } from "node:crypto";
13
+ import path from "node:path";
14
+ export function crearCandados(fs, carpeta, pid, procesoVivo) {
15
+ return {
16
+ tomar(nombre) {
17
+ // El nombre lleva el id del proyecto y el nombre de la clave que eligió la persona: el hash lo vuelve
18
+ // un nombre de archivo sin barras ni puntos, sea lo que sea.
19
+ const ruta = path.join(carpeta, `${createHash("sha256").update(nombre).digest("hex")}.lock`);
20
+ const token = randomUUID();
21
+ let creado;
22
+ try {
23
+ fs.crearDir(carpeta, 0o700);
24
+ creado = fs.crearExclusivo(ruta, JSON.stringify({ pid, token }), 0o600);
25
+ }
26
+ catch {
27
+ return { tipo: "no_disponible", ruta };
28
+ }
29
+ if (!creado) {
30
+ let duenio = null;
31
+ try {
32
+ const v = JSON.parse(fs.leer(ruta) ?? "null");
33
+ duenio = typeof v?.pid === "number" ? v.pid : null;
34
+ }
35
+ catch {
36
+ duenio = null;
37
+ }
38
+ // Un candado que no se entiende tampoco se toca: «abandonado» le pide a una persona que mire.
39
+ return duenio !== null && procesoVivo(duenio) ? { tipo: "ocupado" } : { tipo: "abandonado", ruta };
40
+ }
41
+ return {
42
+ tipo: "tomado",
43
+ soltar() {
44
+ // Sólo si sigue siendo MÍO: si una persona lo borró y otro tomó uno nuevo, no se toca.
45
+ try {
46
+ const v = JSON.parse(fs.leer(ruta) ?? "null");
47
+ if (v?.token === token)
48
+ fs.borrar(ruta);
49
+ }
50
+ catch {
51
+ /* best-effort: si no se pudo, queda abandonado y se informa con su ruta */
52
+ }
53
+ },
54
+ };
55
+ },
56
+ };
57
+ }
package/dist/cli.js CHANGED
@@ -4,12 +4,18 @@
4
4
  // sale como un 1 limpio, en una línea si se pidió --json, sin traza y con los secretos tapados.
5
5
  import { revocarPropia } from "./api.js";
6
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";
7
10
  import { ayuda, comandoDescribir, DESCRIPCION } from "./comandos/describir.js";
8
11
  import { loginInteractivo, loginStart, loginWait } from "./comandos/login.js";
9
12
  import { comandoLogout } from "./comandos/logout.js";
13
+ import { proyectoCrear, proyectoVer } from "./comandos/proyecto.js";
10
14
  import { comandoQuienSoy } from "./comandos/quien-soy.js";
15
+ import { repoConectarInteractivo, repoConectarStart, repoConectarWait } from "./comandos/repo-conectar.js";
11
16
  import { emitir, FalloDeLaTerminal, resultadoDeFallo, SALIDA } from "./contrato.js";
12
17
  import { entornoReal } from "./entorno.js";
18
+ import { flagQueNoAplica } from "./flags-por-comando.js";
13
19
  const COMANDOS = new Set(DESCRIPCION.comandos.map((c) => c.nombre));
14
20
  const invalida = (error, message) => new FalloDeLaTerminal(SALIDA.entradaInvalida, error, message);
15
21
  async function correr(argv) {
@@ -26,15 +32,21 @@ async function correr(argv) {
26
32
  const texto = ayuda(comando);
27
33
  return { r: { codigo: SALIDA.ok, json: { ok: true, ayuda: texto }, texto }, json };
28
34
  }
29
- if ((flags.start || flags.wait) && comando !== "login") {
30
- throw invalida("flag_no_aplica", "--start y --wait son sólo de `supcoflow login`.");
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.");
31
46
  }
32
47
  if (comando === "describir")
33
48
  return { r: comandoDescribir(), json };
34
49
  if (comando === "login") {
35
- if (flags.start && flags.wait) {
36
- throw invalida("flags_incompatibles", "--start y --wait van por separado: primero uno y después el otro.");
37
- }
38
50
  // ANTES de armar el entorno: no hay cómo simular un canje sin consumirlo, y este error no
39
51
  // puede depender de leer nada guardado ni de llamar al motor.
40
52
  if (flags.wait && flags.dryRun) {
@@ -62,6 +74,46 @@ async function correr(argv) {
62
74
  };
63
75
  case "quien-soy":
64
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 };
65
117
  }
66
118
  throw new Error(`comando sin despacho: ${comando}`);
67
119
  }
@@ -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
+ }