supcoflow 0.3.0 → 0.6.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
@@ -4,7 +4,7 @@ La terminal de [Supcoflow](https://app.supcoflow.dev): iniciar sesión y manejar
4
4
  desde la consola, o desde un asistente de código que corre comandos por vos.
5
5
 
6
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
+ crear su proyecto, configurar el chat y ponerlo en la app.
8
8
 
9
9
  ## Instalar
10
10
 
@@ -26,6 +26,12 @@ npx supcoflow login
26
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
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
28
  | `supcoflow proyecto ver` | El proyecto del repo: rama, comandos y el estado de la App de GitHub. |
29
+ | `supcoflow guia revisar` | Revisa `.supcoflow/guia.md` contra el motor sin publicar nada. |
30
+ | `supcoflow guia publicar` | Sube `.supcoflow/guia.md` directo (con el commit y la rama que declara tu clon, no verificados en GitHub). `--desde-github` verifica contra la rama principal en vez de subir el archivo. |
31
+ | `supcoflow guia ver` | Muestra la guía activa, de dónde viene y si la rama sigue al día. |
32
+ | `supcoflow guia usar --origen repo\|agente` | Elige cuál guía queda activa. |
33
+ | `supcoflow widget` | Lo que hay que pegar en la app para tener el chat (ver «Poner el chat en la app»). |
34
+ | `supcoflow instalacion anotar --skill 0.1.0` | Anota en `.supcoflow/instalacion.json` con qué versiones se instaló el chat. |
29
35
  | `supcoflow describir --json` | Todo el contrato (comandos, flags, facultades de la clave, códigos) en una línea de JSON. |
30
36
 
31
37
  `supcoflow --help` (o `supcoflow <comando> --help`) muestra la ayuda con ejemplos. Los comandos de
@@ -68,7 +74,7 @@ los que escriben aceptan `--dry-run`.
68
74
  |---|---|
69
75
  | `supcoflow catalogo` | Qué se puede configurar y con qué valores. |
70
76
  | `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). |
77
+ | `supcoflow chat prender` / `chat apagar` | Prende o apaga el chat (hace falta una guía: publicá la del repo con `supcoflow guia publicar` o generá y aprobá la de nuestro agente en Brain). |
72
78
  | `supcoflow chat marca --titulo "Ayuda" --tema acento=#0a7` | Título, saludo y colores; lo no nombrado queda igual. |
73
79
  | `supcoflow chat logo --archivo logo.png` | Sube el logo (.png, .jpg o .webp, hasta 200 KB). |
74
80
  | `supcoflow sitios agregar\|quitar --sitio https://app.ejemplo.com` | Sitios desde los que carga el chat. |
@@ -77,6 +83,77 @@ los que escriben aceptan `--dry-run`.
77
83
  | `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
84
  | `supcoflow claves listar` / `claves revocar --id cla_…` | Ver y revocar claves de consulta. |
79
85
 
86
+ ## La guía
87
+
88
+ La guía le cuenta al chat qué hace cada pantalla de tu producto. Vive en el repo, en
89
+ `.supcoflow/guia.md` (en la raíz). El flujo, en este orden:
90
+
91
+ 1. `supcoflow guia revisar`: la revisa contra el motor sin publicar nada (si está mal armada, sale 3 con la línea de cada error).
92
+ 2. `supcoflow guia publicar`: sube el archivo directo y queda como guía activa. Manda también el commit y la rama de tu
93
+ clon (`git rev-parse HEAD`, si hay git y commits; si no, va sin commit): son declarados por la terminal, no
94
+ verificados en GitHub, y así se muestran. Repetir con el mismo archivo no duplica.
95
+ 3. Opcional, `supcoflow guia publicar --desde-github`: no sube el archivo; manda su huella y el motor la compara con la
96
+ de la rama principal de GitHub (commit verificado). Si todavía no está subida (o la rama tiene otra), sale 5:
97
+ subí la guía a la rama principal y volvé a correr.
98
+
99
+ `supcoflow guia ver` muestra la guía activa y si la rama sigue al día (`al_dia` en el JSON);
100
+ `supcoflow guia usar --origen repo|agente` elige cuál queda activa cuando hay una del repo y otra de nuestro agente.
101
+ Todos toman el repo del `origin`, o `--repo owner/repo` (con `revisar` y `publicar`, sin clon local de ese repo sale 3 `falta_guia`: la guía se lee del archivo); `revisar`, `publicar` y `usar` aceptan `--dry-run`; `--desde-github` es sólo de `publicar`.
102
+
103
+ El formato de `.supcoflow/guia.md`:
104
+
105
+ ```markdown
106
+ # Guía de Mi App ← opcional; se ignora
107
+
108
+ ## Facturas ← una sección por pantalla o función (1 a 60). Nombre: 1–120 caracteres
109
+ Para qué sirve: Ver y pagar las facturas del mes. ← obligatorio, 1–600
110
+ Qué puede hacer el usuario: Descargar el PDF, pagar con ← obligatorio, 1–600; puede seguir
111
+ tarjeta y ver el historial. en las líneas de abajo
112
+ Problemas comunes: ← opcional; hasta 10, cada uno 1–300
113
+ - El botón Pagar no responde con tarjetas de débito.
114
+ - El PDF sale en blanco en Safari.
115
+ ```
116
+
117
+ - Las etiquetas se reconocen al principio de la línea, sin distinguir mayúsculas y con o sin tildes
118
+ («Para que sirve:» vale). Una línea que no es etiqueta continúa el campo anterior (se une con un espacio).
119
+ - Texto antes del primer `## ` que no sea el `# ` del título → error. Una etiqueta repetida en una sección →
120
+ error. Una línea con `<` o `>` → error. Más de 64 KB → `guia_muy_grande`.
121
+
122
+ ## Poner el chat en la app
123
+
124
+ `supcoflow widget` muestra lo que hay que pegar, armado por el motor con los ids de tu proyecto (no los
125
+ copies a mano: un id mal copiado deja el chat invisible sin ningún error). No escribe archivos de la app:
126
+
127
+ 1. El paquete con su versión exacta (`npm i @supcoflow/chat@… --save-exact`, o lo mismo con tu gestor).
128
+ 2. **Next.js (App Router):** un archivo envoltorio en la ruta que sugiere el motor
129
+ (`app/_supcoflow/chat-de-soporte.tsx`; si la app usa `src/app`, dentro de `src/app`), con el contenido
130
+ tal cual. Hace falta porque el componente del chat usa hooks de React y no trae `"use client"`, y un
131
+ `layout.tsx` es de servidor por defecto: importarlo directo ahí no anda. El envoltorio es esa directiva
132
+ más el componente con los ids.
133
+ 3. Usar el envoltorio en el layout que cubre las pantallas donde se traban tus usuarios.
134
+ 4. Si la app tiene una política de contenido (CSP) con `connect-src`, agregarle el origen del chat
135
+ conservando lo que ya tiene (una segunda `connect-src` se ignora).
136
+
137
+ La línea de `<script>` que también muestra está disponible pero no probada en una app real, y no es
138
+ para Next.js. `supcoflow widget --json` devuelve el objeto del motor tal cual.
139
+
140
+ Después, `supcoflow instalacion anotar --skill <versión> [--app <carpeta>]` deja en
141
+ `.supcoflow/instalacion.json` (para commitear) con qué versiones quedó: la skill, la terminal, el chat
142
+ declarado e instalado, y la configuración del proyecto. Nunca lleva un secreto ni una fecha; repetirlo
143
+ sin cambios no lo toca, y si el archivo tiene cambios sin commit sale 4 y no lo pisa.
144
+
145
+ ## Con un asistente
146
+
147
+ Si usás un asistente de código como Claude Code, la skill de Supcoflow hace todo el camino
148
+ por vos: sesión, proyecto, configuración, guía, el chat en la app y el resumen.
149
+
150
+ ```bash
151
+ npx skills add supcoflow/skills
152
+ ```
153
+
154
+ Y en el asistente: `/supcoflow go`. Las decisiones sobre los datos de tus usuarios y qué pasa con cada
155
+ problema te las pregunta siempre; no sube nada a GitHub.
156
+
80
157
  ## El contrato
81
158
 
82
159
  Pensado para que lo lea un programa, no sólo una persona:
@@ -86,7 +163,7 @@ Pensado para que lo lea un programa, no sólo una persona:
86
163
  - **Sin preguntas sin terminal interactiva** (o con `--json`): si falta un dato, sale 3 diciendo qué
87
164
  flag usar. `supcoflow login` sin terminal sale 3 y te indica el camino en dos pasos.
88
165
  - **`--dry-run`** donde se puede simular (`login`, `login --start`, `logout`, `repo conectar --start`,
89
- `proyecto crear`). `login --wait
166
+ `proyecto crear`, `guia revisar`, `guia publicar`, `guia usar`, `instalacion anotar`). `login --wait
90
167
  --dry-run` sale 3: sondear consume el código y no hay cómo simularlo.
91
168
 
92
169
  | Código | Quiere decir |
package/dist/api.js CHANGED
@@ -21,9 +21,9 @@ 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.3.0); sólo identifica a la terminal
24
+ // A mano a propósito: tiene que coincidir con package.json (0.6.0); sólo identifica a la terminal
25
25
  // en los logs del motor, no decide nada.
26
- const VERSION = "0.3.0";
26
+ const VERSION = "0.6.0";
27
27
  const PLAZO_POR_DEFECTO_MS = 15_000;
28
28
  // Los dos POST que crean algo: detrás, el motor acuña con Vercel Connect y le pregunta a GitHub varias
29
29
  // veces (instalación, repos, comandos, rama). Con 15 s una respuesta lenta pero buena se cortaba y
@@ -293,3 +293,47 @@ export async function esClaveDeConsultaDelProyecto(api, clave, proyectoId, f) {
293
293
  throw inesperada(r.status);
294
294
  return r.cuerpo.via === "clave_de_consulta" && r.cuerpo.proyecto === proyectoId;
295
295
  }
296
+ // ---- La guía (bloque 5) ----
297
+ // Devuelven la respuesta entera: qué significa cada 409/422 lo decide el comando (`guia_no_subida` es
298
+ // un código de salida propio; `guia_mal_armada` lleva la lista de errores por línea).
299
+ const rutaDeLaGuia = (proyectoId) => `/api/proyectos/${encodeURIComponent(proyectoId)}/guia`;
300
+ export function revisarGuia(api, clave, proyectoId, contenido, f) {
301
+ return conClave(api, clave, `${rutaDeLaGuia(proyectoId)}/revisar`, { method: "POST", headers: JSON_BODY, body: JSON.stringify({ contenido }) }, f);
302
+ }
303
+ export function publicarGuia(api, clave, proyectoId, cuerpo, f) {
304
+ // La vía de GitHub acuña con Connect y le pregunta a GitHub por la rama: el mismo plazo que las altas (la directa no
305
+ // llama a GitHub, pero comparte el plazo para no tener dos reglas).
306
+ return conClave(api, clave, `${rutaDeLaGuia(proyectoId)}/publicar`, { method: "POST", headers: JSON_BODY, body: JSON.stringify(cuerpo) }, f, PLAZO_DE_ALTA_MS);
307
+ }
308
+ // Siempre con `comprobar=1`: `guia ver` quiere el `al_dia` de la rama; el motor lo ignora si el origen no es el repo.
309
+ export function leerGuia(api, clave, proyectoId, f) {
310
+ return conClave(api, clave, `${rutaDeLaGuia(proyectoId)}?comprobar=1`, { method: "GET" }, f, PLAZO_DE_ALTA_MS);
311
+ }
312
+ export function elegirOrigen(api, clave, proyectoId, origen, f) {
313
+ return conClave(api, clave, `${rutaDeLaGuia(proyectoId)}/origen`, { method: "POST", headers: JSON_BODY, body: JSON.stringify({ origen }) }, f);
314
+ }
315
+ // Se valida campo a campo: lo que se le pasa a la persona (y de ahí a un archivo de su app) no puede
316
+ // ser algo a medias. `contenido` se guarda tal cual, sin tocarlo: los ids ya vienen puestos por el motor.
317
+ function aWidget(v, status) {
318
+ const n = (typeof v.nextjs === "object" && v.nextjs !== null ? v.nextjs : {});
319
+ const s = (typeof v.script === "object" && v.script !== null ? v.script : {});
320
+ const [tenantId, projectId, backendUrl, version, instalar, csp] = [v.tenant_id, v.project_id, v.backend_url, v.version, v.instalar, v.csp].map(texto);
321
+ const [archivo, contenido, uso] = [n.archivo_sugerido, n.contenido, n.uso].map(texto);
322
+ const linea = texto(s.linea);
323
+ if (!tenantId || !projectId || !backendUrl || !version || !instalar || !csp || v.paquete !== "@supcoflow/chat"
324
+ || (n.estado !== "probando" && n.estado !== "soportado") || !archivo || !contenido || !uso
325
+ || s.estado !== "disponible_sin_soporte" || !linea)
326
+ throw inesperada(status);
327
+ return {
328
+ tenant_id: tenantId, project_id: projectId, backend_url: backendUrl, paquete: "@supcoflow/chat", version, instalar,
329
+ nextjs: { estado: n.estado, archivo_sugerido: archivo, contenido, uso },
330
+ script: { estado: "disponible_sin_soporte", linea },
331
+ csp,
332
+ };
333
+ }
334
+ export async function leerWidget(api, clave, proyectoId, f) {
335
+ const r = await conClave(api, clave, `/api/proyectos/${encodeURIComponent(proyectoId)}/widget`, { method: "GET" }, f);
336
+ if (r.status !== 200)
337
+ throw rechazada(r);
338
+ return aWidget(r.cuerpo, r.status);
339
+ }
@@ -4,11 +4,13 @@ export const COMANDOS_COMPUESTOS = [
4
4
  "repo conectar", "proyecto crear", "proyecto ver",
5
5
  "chat prender", "chat apagar", "chat marca", "chat logo", "sitios agregar", "sitios quitar",
6
6
  "datos fijar", "politica fijar", "claves crear", "claves listar", "claves revocar",
7
+ "guia revisar", "guia publicar", "guia ver", "guia usar",
8
+ "instalacion anotar",
7
9
  ];
8
10
  // Sólo estas primeras palabras admiten una segunda: cualquier otro par sigue siendo «de más».
9
11
  const PRIMERAS_PALABRAS = new Set(COMANDOS_COMPUESTOS.map((c) => c.split(" ")[0]));
10
12
  const FLAGS_CON_VALOR = [
11
- "repo", "install", "test", "sitio", "titulo", "saludo", "archivo", "permitir", "modo", "nombre", "guardar-en", "id",
13
+ "repo", "install", "test", "sitio", "titulo", "saludo", "archivo", "permitir", "modo", "nombre", "guardar-en", "id", "origen", "skill", "app",
12
14
  ];
13
15
  // strict: un flag mal escrito (`--estart`) no se ignora en silencio: un asistente creería que
14
16
  // pidió algo que no pidió.
@@ -37,6 +39,10 @@ export function leerArgumentos(argv) {
37
39
  nombre: { type: "string" },
38
40
  "guardar-en": { type: "string" },
39
41
  id: { type: "string" },
42
+ origen: { type: "string" },
43
+ "desde-github": { type: "boolean" },
44
+ skill: { type: "string" },
45
+ app: { type: "string" },
40
46
  tema: { type: "string", multiple: true },
41
47
  },
42
48
  tokens: true,
@@ -92,6 +98,10 @@ export function leerArgumentos(argv) {
92
98
  nombre: v.nombre ?? null,
93
99
  guardarEn: v["guardar-en"] ?? null,
94
100
  id: v.id ?? null,
101
+ origen: v.origen ?? null,
102
+ desdeGithub: v["desde-github"] === true,
103
+ skill: v.skill ?? null,
104
+ app: v.app ?? null,
95
105
  tema: v.tema ?? [],
96
106
  },
97
107
  };
package/dist/cli.js CHANGED
@@ -6,6 +6,8 @@ import { revocarPropia } from "./api.js";
6
6
  import { leerArgumentos } from "./argumentos.js";
7
7
  import { clavesCrear, clavesListar, clavesRevocar } from "./comandos/claves.js";
8
8
  import { chatLogo, chatMarca, chatPrenderApagar, comandoConfiguracion, sitios } from "./comandos/configurar.js";
9
+ import { guiaPublicar, guiaRevisar, guiaUsar, guiaVer } from "./comandos/guia.js";
10
+ import { instalacionAnotar } from "./comandos/instalacion.js";
9
11
  import { comandoCatalogo, datosFijar, politicaFijar } from "./comandos/datos-y-politica.js";
10
12
  import { ayuda, comandoDescribir, DESCRIPCION } from "./comandos/describir.js";
11
13
  import { loginInteractivo, loginStart, loginWait } from "./comandos/login.js";
@@ -13,6 +15,7 @@ import { comandoLogout } from "./comandos/logout.js";
13
15
  import { proyectoCrear, proyectoVer } from "./comandos/proyecto.js";
14
16
  import { comandoQuienSoy } from "./comandos/quien-soy.js";
15
17
  import { repoConectarInteractivo, repoConectarStart, repoConectarWait } from "./comandos/repo-conectar.js";
18
+ import { widget } from "./comandos/widget.js";
16
19
  import { emitir, FalloDeLaTerminal, resultadoDeFallo, SALIDA } from "./contrato.js";
17
20
  import { entornoReal } from "./entorno.js";
18
21
  import { flagQueNoAplica } from "./flags-por-comando.js";
@@ -36,7 +39,8 @@ async function correr(argv) {
36
39
  start: flags.start, wait: flags.wait, repo: flags.repo !== null, install: flags.install !== null, test: flags.test !== null,
37
40
  sitio: flags.sitio !== null, titulo: flags.titulo !== null, saludo: flags.saludo !== null, tema: flags.tema.length > 0,
38
41
  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,
42
+ nombre: flags.nombre !== null, guardarEn: flags.guardarEn !== null, id: flags.id !== null, origen: flags.origen !== null, desdeGithub: flags.desdeGithub,
43
+ skill: flags.skill !== null, app: flags.app !== null,
40
44
  };
41
45
  const noAplica = flagQueNoAplica(comando, Object.keys(presentes).filter((k) => presentes[k]));
42
46
  if (noAplica !== null)
@@ -114,6 +118,18 @@ async function correr(argv) {
114
118
  return { r: await clavesListar(e.claves, flags), json };
115
119
  case "claves revocar":
116
120
  return { r: await clavesRevocar(e.claves, flags), json };
121
+ case "guia revisar":
122
+ return { r: await guiaRevisar(e.guia, flags), json };
123
+ case "guia publicar":
124
+ return { r: await guiaPublicar(e.guia, flags), json };
125
+ case "guia ver":
126
+ return { r: await guiaVer(e.guia, flags), json };
127
+ case "guia usar":
128
+ return { r: await guiaUsar(e.guia, flags), json };
129
+ case "widget":
130
+ return { r: await widget(e.widget, flags), json };
131
+ case "instalacion anotar":
132
+ return { r: await instalacionAnotar(e.instalacion, flags), json };
117
133
  }
118
134
  throw new Error(`comando sin despacho: ${comando}`);
119
135
  }
@@ -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.3.0";
6
+ const VERSION = "0.6.0";
7
7
  export const DESCRIPCION = {
8
8
  version: VERSION,
9
9
  comandos: [
@@ -89,7 +89,7 @@ export const DESCRIPCION = {
89
89
  },
90
90
  {
91
91
  nombre: "chat prender",
92
- resumen: "Prende el chat de soporte del proyecto. Sin skill aprobada hace falta una persona (sale 4).",
92
+ resumen: "Prende el chat de soporte del proyecto. Hace falta una guía vigente (la publicada del repo o la aprobada de nuestro agente); si no, sale 4 (skill_no_aprobada).",
93
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
94
  ejemplos: ["supcoflow chat prender --json"],
95
95
  codigos: [0, 1, 2, 3, 4],
@@ -182,17 +182,65 @@ export const DESCRIPCION = {
182
182
  ejemplos: ["supcoflow claves revocar --id cla_0123456789abcdef --json"],
183
183
  codigos: [0, 1, 2, 3, 4],
184
184
  },
185
+ {
186
+ nombre: "guia revisar",
187
+ resumen: "Revisa .supcoflow/guia.md (la raíz de este clon) contra el motor, sin publicar nada: dice si está bien armada y cuántas secciones tiene. Es el paso antes del commit.",
188
+ flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta (sin clon local sale 3 falta_guia: la guía se lee del archivo)", "--dry-run: dice qué mandaría sin mandarlo", "--json"],
189
+ ejemplos: ["supcoflow guia revisar --json"],
190
+ codigos: [0, 1, 2, 3, 4],
191
+ },
192
+ {
193
+ nombre: "guia publicar",
194
+ resumen: "Sube .supcoflow/guia.md directo: manda el contenido y el commit y la rama que declara este clon (si hay git y commits; si no, sin commit). El motor la revisa y la deja como guía activa; el commit queda como «declarado por la terminal, no verificado en GitHub». Con --desde-github conserva la vía anterior: manda la huella y el motor la compara con la de la rama principal; si todavía no está subida a esa rama (o es otra), sale 5.",
195
+ flags: ["--desde-github: no sube el archivo; verifica contra la rama principal de GitHub (sale 5 guia_no_subida / guia_distinta si no coincide)", "--repo owner/repo: el repo, si no es el «origin» de esta carpeta (sin clon local sale 3 falta_guia: la guía se lee del archivo)", "--dry-run: dice qué mandaría (o la huella con --desde-github), sin pedir nada", "--json"],
196
+ ejemplos: ["supcoflow guia publicar --json"],
197
+ codigos: [0, 1, 2, 3, 4, 5],
198
+ },
199
+ {
200
+ nombre: "guia ver",
201
+ resumen: "Muestra la guía activa del proyecto, de dónde viene y si la rama principal sigue teniendo esa versión (`al_dia` en el JSON).",
202
+ flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--json"],
203
+ ejemplos: ["supcoflow guia ver --json"],
204
+ codigos: [0, 1, 2, 3, 4],
205
+ },
206
+ {
207
+ nombre: "guia usar",
208
+ resumen: "Elige cuál guía queda activa cuando hay dos: la del repo o la de nuestro agente.",
209
+ flags: ["--origen repo|agente: cuál queda activa (obligatorio)", "--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
210
+ ejemplos: ["supcoflow guia usar --origen repo --json"],
211
+ codigos: [0, 1, 2, 3, 4],
212
+ },
213
+ {
214
+ nombre: "widget",
215
+ resumen: "Muestra lo que hay que pegar en la app para tener el chat, armado por el motor con los ids del proyecto: el paquete y su versión exacta, el archivo envoltorio de Next.js (`nextjs.contenido`, tal cual, sin reescribir los ids), cómo usarlo, la línea de CSP y la de <script> (no para Next.js). No escribe archivos. Sale 4 sin_proyecto si el repo no tiene proyecto; 1 respuesta_inesperada si el motor manda algo a medias.",
216
+ flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--json: el objeto del motor tal cual"],
217
+ ejemplos: ["supcoflow widget --json"],
218
+ codigos: [0, 1, 2, 3, 4],
219
+ },
220
+ {
221
+ nombre: "instalacion anotar",
222
+ resumen: "Anota en .supcoflow/instalacion.json (la raíz de este clon) con qué versiones se instaló el chat: la skill, esta terminal, el paquete del chat (declarado en el package.json de la app e instalado en node_modules) y la configuración del proyecto; nunca un secreto ni una fecha. Repetir sin cambios no lo toca (sin_cambios: true). Lo reescribe sólo si git ya guarda el anterior: si tiene cambios sin commit (o está borrado sin commit) sale 4 cambios_sin_commit; si existe sin estar versionado (o ignorado), 4 archivo_no_versionado. Otros: 3 falta_skill / skill_invalida / app_fuera_del_repo / app_inexistente / sin_clon; 4 sin_proyecto / acuerdo_ilegible; 1 paquete_ilegible / configuracion_inesperada / archivo_no_escrito.",
223
+ flags: [
224
+ "--skill versión: la metadata.version de la skill que instala (semver, obligatorio; sin él sale 3 falta_skill sin pedir nada)",
225
+ "--app carpeta: la carpeta de la app, relativa a la raíz del repo (por defecto la raíz; fuera del repo sale 3 app_fuera_del_repo)",
226
+ "--repo owner/repo: el repo, si no es el «origin» de esta carpeta (sin clon local sale 3 sin_clon: el archivo va en el clon)",
227
+ "--dry-run: muestra el archivo y la diferencia sin escribir nada",
228
+ "--json",
229
+ ],
230
+ ejemplos: ["supcoflow instalacion anotar --skill 0.1.0 --app web --json"],
231
+ codigos: [0, 1, 2, 3, 4],
232
+ },
185
233
  ],
186
234
  flags_globales: ["--json: el resultado en una línea de JSON por stdout; los avisos van por stderr", "--help, -h: la ayuda con ejemplos"],
187
235
  facultades: [
188
236
  {
189
237
  nombre: "consultar",
190
- incluye: "Identidad y equipo, proyectos, configuración, casos, issues, runs, costos, estado de la instalación",
238
+ incluye: "Identidad y equipo, proyectos, configuración, casos, issues, runs, costos, estado de la instalación, leer y revisar la guía (guia revisar, guia ver)",
191
239
  con_api_key: true,
192
240
  },
193
241
  {
194
242
  nombre: "configurar",
195
- incluye: "Crear/reusar proyecto, conectar o desconectar el repo, configurar y prender el chat, sitios permitidos, acuerdo de datos, política de problemas, publicar la guía, crear y revocar claves de consulta, perfiles de los agentes",
243
+ incluye: "Crear/reusar proyecto, conectar o desconectar el repo, configurar y prender el chat, sitios permitidos, acuerdo de datos, política de problemas, publicar la guía, elegir la guía activa (guia usar), crear y revocar claves de consulta, perfiles de los agentes",
196
244
  con_api_key: true,
197
245
  },
198
246
  { nombre: "probar", incluye: "La prueba hasta el issue (gasta, dentro de los topes)", con_api_key: true },
@@ -0,0 +1,244 @@
1
+ // src/comandos/guia.ts
2
+ // `guia revisar|publicar|ver|usar` (bloque 5): la guía del producto que vive en el repo del cliente
3
+ // (`.supcoflow/guia.md`). La terminal NO decide qué es una guía buena —eso lo decide el parser del motor—;
4
+ // lee el archivo, manda UN pedido y traduce la respuesta a los códigos de salida del contrato.
5
+ import path from "node:path";
6
+ import { resultadoDeFallo, SALIDA, taparSecretos } from "../contrato.js";
7
+ import { huellaDeGuia, MAX_BYTES_DE_LA_GUIA, RUTA_DE_LA_GUIA } from "../guia.js";
8
+ import { repoPedido } from "../repo-local.js";
9
+ import { desenlace, fallo, rutaDe } from "./configurar.js";
10
+ import { proyectoDelRepo } from "./proyecto-del-repo.js";
11
+ // Texto que viene del motor (o del repo, vía el motor) y sale a la terminal de una persona: sin secretos y sin
12
+ // caracteres de control (C0, DEL, C1), que un ESC movería el cursor. Texto y JSON salen del mismo texto ya limpio.
13
+ // Primero se quita el control y recién después se tapan los secretos: al revés, una clave partida por un
14
+ // control (U+0085, por ejemplo) quedaba entera al quitarlo y se imprimía sin tapar.
15
+ const limpio = (t) => taparSecretos(t.replace(/[\u0000-\u001f\u007f-\u009f]/g, ""));
16
+ const cadena = (v) => (typeof v === "string" ? limpio(v) : "");
17
+ const corto = (commit) => cadena(commit).slice(0, 7);
18
+ const secciones = (n) => (typeof n === "number" ? `${n} ${n === 1 ? "sección" : "secciones"}` : "secciones sin contar");
19
+ const NOMBRE_DEL_ORIGEN = { repo: "la del repo", agente: "la de nuestro agente" };
20
+ // ─── El archivo local ───
21
+ // Se busca en la raíz de ESTE clon, nunca en el cwd: parado en una subcarpeta sigue siendo la guía del repo.
22
+ // Con `--repo` y sin clon de ese repo no hay dónde leerla (publicar lee la rama, pero la huella sale del archivo).
23
+ async function leerLaGuia(d, flags) {
24
+ let raiz;
25
+ try {
26
+ raiz = (await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd })).raiz;
27
+ }
28
+ catch (e) {
29
+ return { ok: false, resultado: resultadoDeFallo(e) };
30
+ }
31
+ if (raiz === null) {
32
+ return { ok: false, resultado: fallo(SALIDA.entradaInvalida, "falta_guia", `No tengo el clon de ese repo acá, y la guía se lee de ${RUTA_DE_LA_GUIA}: parate en el clon del repo.`) };
33
+ }
34
+ const leido = d.leerArchivoChico(path.join(raiz, RUTA_DE_LA_GUIA), MAX_BYTES_DE_LA_GUIA);
35
+ if ("error" in leido) {
36
+ if (leido.error === "no existe")
37
+ return { ok: false, resultado: fallo(SALIDA.entradaInvalida, "falta_guia", `Falta ${RUTA_DE_LA_GUIA} en el repo: escribila (una sección por pantalla) y volvé a correr.`) };
38
+ if (leido.error === "es demasiado grande")
39
+ return { ok: false, resultado: fallo(SALIDA.entradaInvalida, "guia_muy_grande", `${RUTA_DE_LA_GUIA} pasa los ${MAX_BYTES_DE_LA_GUIA / 1024} KB: acortala.`) };
40
+ return { ok: false, resultado: fallo(SALIDA.entradaInvalida, "guia_ilegible", `No leo ${RUTA_DE_LA_GUIA}: ${limpio(leido.error)}.`) };
41
+ }
42
+ try {
43
+ // ignoreBOM: el BOM se CONSERVA en el texto; quitarlo es regla de la huella (`huellaDeGuia`), no de la lectura.
44
+ // fatal: un byte inválido no se reemplaza en silencio, porque la huella de GitHub no coincidiría nunca.
45
+ return { ok: true, raiz, texto: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(leido.bytes) };
46
+ }
47
+ catch {
48
+ return { ok: false, resultado: fallo(SALIDA.entradaInvalida, "guia_ilegible", `${RUTA_DE_LA_GUIA} no es UTF-8 válido.`) };
49
+ }
50
+ }
51
+ // ─── El desenlace propio de la guía, encima del de configurar ───
52
+ function desenlaceDeGuia(r, ok) {
53
+ const c = r.cuerpo;
54
+ // 2xx, 401, 5xx y cuerpos raros los resuelve el de configurar. Ojo: la terminal no distingue 500
55
+ // connect_no_disponible de 502 github_no_disponible (ambos llegan como falla del servicio): los dos son 1.
56
+ if (r.status < 400 || r.status === 401 || r.status >= 500 || typeof c !== "object" || c === null)
57
+ return desenlace(r, ok);
58
+ const error = typeof c.error === "string" ? c.error : "rechazado";
59
+ const message = limpio(typeof c.message === "string" ? c.message : `El motor rechazó el pedido (${r.status}).`);
60
+ const con = (codigo, extra = {}, texto = message) => ({ codigo, json: { ok: false, error, message: taparSecretos(message), ...extra }, texto });
61
+ switch (error) {
62
+ case "guia_no_subida":
63
+ case "guia_distinta": {
64
+ const rama = cadena(c.rama), commit = cadena(c.commit);
65
+ const donde = rama !== "" ? ` (rama ${rama}${commit !== "" ? `, commit ${corto(commit)}` : ""})` : "";
66
+ const que = error === "guia_no_subida" ? "La guía no está en la rama principal del repo" : "La guía de la rama principal es otra, no la de tu archivo";
67
+ return con(SALIDA.esperandoAUnaPersona, { rama: c.rama ?? null, commit: c.commit ?? null }, `${que}${donde}: subí la guía a la rama principal y volvé a correr.`);
68
+ }
69
+ case "instalacion_ausente":
70
+ case "repo_fuera_de_alcance":
71
+ case "sin_guia_del_agente":
72
+ return con(SALIDA.haceFaltaUnaPersona);
73
+ case "guia_mal_armada": {
74
+ const errores = Array.isArray(c.errores) ? c.errores : [];
75
+ const lineas = errores.map((e) => {
76
+ const x = (e ?? {});
77
+ return `línea ${typeof x.linea === "number" ? x.linea : "?"}: ${cadena(x.motivo)}`;
78
+ });
79
+ return con(SALIDA.entradaInvalida, { errores: errores.map((e) => { const x = (e ?? {}); return { linea: x.linea ?? null, motivo: taparSecretos(typeof x.motivo === "string" ? x.motivo : "") }; }) }, [`${RUTA_DE_LA_GUIA} está mal armada:`, ...lineas].join("\n"));
80
+ }
81
+ default:
82
+ return desenlace(r, ok);
83
+ }
84
+ }
85
+ export async function guiaRevisar(d, flags) {
86
+ const g = await leerLaGuia(d, flags);
87
+ if (!g.ok)
88
+ return g.resultado;
89
+ const p = await proyectoDelRepo(d, flags);
90
+ if (!p.ok)
91
+ return p.resultado;
92
+ if (flags.dryRun) {
93
+ const haria = `POST ${rutaDe(p.proyecto, "/guia/revisar")} (${new TextEncoder().encode(g.texto).length} bytes)`;
94
+ return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `Con --dry-run no hago nada; sin él: ${haria}.` };
95
+ }
96
+ try {
97
+ return desenlaceDeGuia(await d.revisar(d.api, p.clave, p.proyecto.id, g.texto, d.fetch), (c) => ({
98
+ codigo: SALIDA.ok, json: { ok: true, secciones: c.secciones ?? null }, texto: `La guía está bien armada: ${secciones(c.secciones)}.`,
99
+ }));
100
+ }
101
+ catch (e) {
102
+ return resultadoDeFallo(e);
103
+ }
104
+ }
105
+ // Commit y rama que la terminal DECLARA: son del clon donde está el archivo, no verificados contra GitHub. Cualquier
106
+ // falla (sin git, sin commits, HEAD suelto, plazo vencido) devuelve vacío: declarar es un extra que nunca bloquea la
107
+ // subida ni pregunta nada, y lo que git imprima no sale a la terminal.
108
+ async function declarado(d, raiz) {
109
+ const leer = async (args) => {
110
+ try {
111
+ const r = await d.gitDeclarado(args, raiz);
112
+ return r.codigo === 0 ? r.stdout.trim() : null;
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ };
118
+ const commit = await leer(["rev-parse", "HEAD"]);
119
+ // Sin un commit de 40 hex no se manda ni la rama: una rama sin commit no dice de dónde salió la guía.
120
+ if (commit === null || !/^[0-9a-f]{40}$/.test(commit))
121
+ return {};
122
+ const rama = await leer(["rev-parse", "--abbrev-ref", "HEAD"]);
123
+ // «HEAD» a secas es la cabeza suelta: no es una rama.
124
+ return rama !== null && rama !== "" && rama !== "HEAD" ? { commit, rama } : { commit };
125
+ }
126
+ export async function guiaPublicar(d, flags) {
127
+ const g = await leerLaGuia(d, flags);
128
+ if (!g.ok)
129
+ return g.resultado;
130
+ const p = await proyectoDelRepo(d, flags);
131
+ if (!p.ok)
132
+ return p.resultado;
133
+ const github = flags.desdeGithub === true;
134
+ // La vía de GitHub manda la huella del archivo local TAL CUAL (con BOM y \r\n: normalizarla es regla de
135
+ // `huellaDeGuia`). La directa manda el contenido crudo y el motor calcula la huella: la terminal no la necesita.
136
+ const huella = github ? huellaDeGuia(g.texto) : null;
137
+ const decl = github ? {} : await declarado(d, g.raiz);
138
+ const cuerpo = huella !== null ? { huella } : { contenido: g.texto, ...decl };
139
+ const cuando = decl.commit ? `commit ${decl.commit.slice(0, 7)}${decl.rama ? ` de ${cadena(decl.rama)}` : ""} declarado` : "sin commit";
140
+ // El JSON del dry-run pasa por la misma limpieza que el texto (control primero, secretos después): la rama la
141
+ // decide quien escribió el clon, y un ESC en ella no debe llegar a la terminal por ninguna de las dos salidas.
142
+ if (flags.dryRun) {
143
+ const haria = huella !== null
144
+ ? `POST ${rutaDe(p.proyecto, "/guia/publicar")} con la huella ${huella.slice(0, 12)}…`
145
+ : `POST ${rutaDe(p.proyecto, "/guia/publicar")} con el contenido (${new TextEncoder().encode(g.texto).length} bytes), ${cuando}`;
146
+ const json = huella !== null ? { ok: true, dry_run: true, huella, haria } : { ok: true, dry_run: true, commit: decl.commit ? cadena(decl.commit) : null, rama: decl.rama ? cadena(decl.rama) : null, haria };
147
+ return { codigo: SALIDA.ok, json, texto: `Con --dry-run no hago nada; sin él: ${haria}.` };
148
+ }
149
+ try {
150
+ return desenlaceDeGuia(await d.publicar(d.api, p.clave, p.proyecto.id, cuerpo, d.fetch), (c) => {
151
+ const guia = (c.guia ?? {});
152
+ const ya = c.ya_publicada === true;
153
+ const rem = c.reemplaza;
154
+ const reemplaza = !ya && rem && rem.origen === "agente" && typeof rem.version === "number" ? ` (reemplaza a la de nuestro agente v${rem.version})` : "";
155
+ const json = { ok: true, guia: c.guia ?? null, ya_publicada: ya, reemplaza: c.reemplaza ?? null };
156
+ if (github) {
157
+ const base = `commit ${corto(guia.commit)} de ${cadena(guia.rama)}, ${secciones(guia.secciones)}`;
158
+ // Republicar re-activa la guía del repo aunque antes rigiera la de nuestro agente: «sigue siendo» mentiría en ese caso.
159
+ const texto = ya ? `Ya estaba publicada: ${base}. Quedó como guía activa.` : `Guía publicada: ${base}. Es la guía activa${reemplaza}.`;
160
+ return { codigo: SALIDA.ok, json, texto };
161
+ }
162
+ // El commit se muestra como lo que es: lo que la terminal dijo, no lo que GitHub confirmó.
163
+ const origen = decl.commit ? `commit ${decl.commit.slice(0, 7)} declarado por la terminal (no verificado en GitHub)` : "sin commit";
164
+ const texto = ya
165
+ ? `Ya estaba subida: ${secciones(guia.secciones)}. Quedó como guía activa; ${origen}.`
166
+ : `Guía subida: ${secciones(guia.secciones)}. Es la guía activa${reemplaza}; ${origen}.`;
167
+ return { codigo: SALIDA.ok, json: { ...json, commit_declarado: decl.commit ?? null }, texto };
168
+ });
169
+ }
170
+ catch (e) {
171
+ return resultadoDeFallo(e);
172
+ }
173
+ }
174
+ export async function guiaVer(d, flags) {
175
+ const p = await proyectoDelRepo(d, flags);
176
+ if (!p.ok)
177
+ return p.resultado;
178
+ try {
179
+ return desenlaceDeGuia(await d.ver(d.api, p.clave, p.proyecto.id, d.fetch), (c) => {
180
+ const guia = c.guia;
181
+ const otra = c.otra;
182
+ const rama = c.rama;
183
+ const alDia = rama && typeof rama.al_dia === "boolean" ? rama.al_dia : null;
184
+ const json = { ok: true, guia: guia ?? null, otra: otra ?? null, al_dia: alDia, rama: rama ?? null };
185
+ if (!guia)
186
+ return { codigo: SALIDA.ok, json, texto: "Este proyecto no tiene guía activa: `supcoflow guia publicar` sube la del repo." };
187
+ const lineas = [];
188
+ if (guia.origen === "repo") {
189
+ lineas.push(`Guía activa: ${NOMBRE_DEL_ORIGEN.repo} (versión ${guia.version}, ${secciones(guia.secciones)}).`);
190
+ if (guia.procedencia === "terminal") {
191
+ // Subida desde la terminal: el motor NO comparó nada contra GitHub a propósito (al_dia null), así que "no pude
192
+ // comprobar la rama" sería falso. Commit y rama son lo que declaró quien subió; el id crudo de quien publicó
193
+ // (usr_…) no le dice nada a una persona y la terminal no resuelve nombres: queda sólo en el JSON.
194
+ const commit = cadena(guia.commit), rama = cadena(guia.rama);
195
+ const partes = [cadena(guia.repo), rama !== "" ? `rama ${rama}` : "", commit !== "" ? `commit ${corto(commit)} (declarado)` : "sin commit declarado"].filter((x) => x !== "");
196
+ lineas.push(`Procedencia: ${partes.join(", ")}.`);
197
+ lineas.push("Subida desde la terminal; el commit es declarado, no verificado en GitHub.");
198
+ }
199
+ else {
200
+ lineas.push(`Procedencia: ${cadena(guia.repo)}, rama ${cadena(guia.rama)}, commit ${corto(guia.commit)}, publicada por ${cadena(guia.publicada_por)}.`);
201
+ if (alDia === true)
202
+ lineas.push("Está al día con la rama.");
203
+ else if (alDia === false)
204
+ lineas.push("En la rama hay otra versión: corré `supcoflow guia publicar`.");
205
+ else
206
+ lineas.push("No pude comprobar la rama ahora.");
207
+ }
208
+ }
209
+ else {
210
+ lineas.push(`Guía activa: ${NOMBRE_DEL_ORIGEN.agente} (v${guia.version}, ${secciones(guia.secciones)}).`);
211
+ lineas.push("Procedencia: la escribió nuestro agente y la aprobó una persona.");
212
+ }
213
+ if (otra && (otra.origen === "repo" || otra.origen === "agente")) {
214
+ lineas.push(`También hay ${NOMBRE_DEL_ORIGEN[otra.origen]} (v${typeof otra.version === "number" ? otra.version : "?"}): \`supcoflow guia usar --origen ${otra.origen}\` la activa.`);
215
+ }
216
+ return { codigo: SALIDA.ok, json, texto: lineas.join("\n") };
217
+ });
218
+ }
219
+ catch (e) {
220
+ return resultadoDeFallo(e);
221
+ }
222
+ }
223
+ export async function guiaUsar(d, flags) {
224
+ const origen = flags.origen ?? null;
225
+ if (origen === null)
226
+ return fallo(SALIDA.entradaInvalida, "falta_origen", "Decí cuál: `supcoflow guia usar --origen repo` o `--origen agente`.");
227
+ if (origen !== "repo" && origen !== "agente")
228
+ return fallo(SALIDA.entradaInvalida, "origen_invalido", "--origen es `repo` o `agente`.");
229
+ const p = await proyectoDelRepo(d, flags);
230
+ if (!p.ok)
231
+ return p.resultado;
232
+ if (flags.dryRun) {
233
+ const haria = `POST ${rutaDe(p.proyecto, "/guia/origen")} {origen: ${origen}}`;
234
+ return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `Con --dry-run no hago nada; sin él: ${haria}.` };
235
+ }
236
+ try {
237
+ return desenlaceDeGuia(await d.elegir(d.api, p.clave, p.proyecto.id, origen, d.fetch), (c) => ({
238
+ codigo: SALIDA.ok, json: { ok: true, guia: c.guia ?? null }, texto: `Ahora la guía activa es ${NOMBRE_DEL_ORIGEN[origen]}.`,
239
+ }));
240
+ }
241
+ catch (e) {
242
+ return resultadoDeFallo(e);
243
+ }
244
+ }
@@ -0,0 +1,207 @@
1
+ // src/comandos/instalacion.ts
2
+ // `instalacion anotar --skill <versión> [--app <carpeta>] [--dry-run]` (bloque 6): deja en
3
+ // `.supcoflow/instalacion.json` con qué versiones se instaló el chat. Lee la configuración del motor y los
4
+ // `package.json` de la app y del paquete instalado; nunca ejecuta nada del repo (ni `npm ls` ni `node`).
5
+ //
6
+ // La regla de §13 («no pisa archivos ni cambios sin commit») adaptada a un archivo que tiene que poder cambiar
7
+ // (decisión 11): se reescribe sólo si git ya guarda el anterior. El control se hace ANTES de escribir; si la
8
+ // persona edita el archivo entre el control y el `rename`, su cambio se pierde. Esa carrera se acepta: es una
9
+ // persona instalando, y el archivo lo escribe sólo este comando.
10
+ import { realpathSync, statSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { resultadoDeFallo, SALIDA, taparSecretos } from "../contrato.js";
13
+ import { armarInstalacion, esSemver, PAQUETE_DEL_CHAT, RUTA_DE_LA_INSTALACION, serializar } from "../instalacion.js";
14
+ import { repoPedido } from "../repo-local.js";
15
+ import { fallo } from "./configurar.js";
16
+ import { proyectoDelRepo } from "./proyecto-del-repo.js";
17
+ export function esCarpetaReal(p) {
18
+ try {
19
+ return statSync(p).isDirectory();
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ export function rutaRealOEnNada(p) {
26
+ try {
27
+ return realpathSync(p);
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ // Los package.json son chicos; uno de más de 1 MB no es el de una app ni el de un paquete.
34
+ const TOPE_DEL_PACKAGE_JSON = 1024 * 1024;
35
+ const TOPE_DEL_ARCHIVO = 64 * 1024;
36
+ const adentro = (raiz, p) => p === raiz || p.startsWith(raiz + path.sep);
37
+ const limpio = (t) => taparSecretos(t.replace(/[\u0000-\u001f\u007f-\u009f]/g, ""));
38
+ function leerJson(d, p, nombre) {
39
+ const leido = d.leerArchivoChico(p, TOPE_DEL_PACKAGE_JSON);
40
+ if ("error" in leido) {
41
+ if (leido.error === "no existe")
42
+ return { ok: false, noExiste: true };
43
+ return { ok: false, noExiste: false, resultado: fallo(SALIDA.inesperado, "paquete_ilegible", `No leo ${nombre}: ${limpio(leido.error)}.`) };
44
+ }
45
+ try {
46
+ return { ok: true, valor: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(leido.bytes)) };
47
+ }
48
+ catch {
49
+ return { ok: false, noExiste: false, resultado: fallo(SALIDA.inesperado, "paquete_ilegible", `${nombre} no es JSON válido.`) };
50
+ }
51
+ }
52
+ // Como lo resuelve Node desde la app (revisión externa, hallazgo 4): el primer `node_modules/@supcoflow/chat`
53
+ // subiendo desde <app> hasta la raíz del repo. En un monorepo npm lo deja en la raíz; pnpm enlaza a
54
+ // `node_modules/.pnpm/…`, adentro del repo. Si la ruta real sale del repo, no se lee: lo que hay afuera no es
55
+ // de esta instalación y la terminal no lee fuera del repo por un enlace que el repo eligió.
56
+ function paqueteInstalado(d, raiz, app, avisos) {
57
+ for (let dir = app;; dir = path.dirname(dir)) {
58
+ const candidato = path.join(dir, "node_modules", ...PAQUETE_DEL_CHAT.split("/"), "package.json");
59
+ const real = d.rutaReal(candidato);
60
+ if (real !== null) {
61
+ if (!adentro(raiz, real)) {
62
+ const aviso = `${path.relative(raiz, candidato)} apunta fuera del repo; no lo leo y anoto el chat instalado como null.`;
63
+ avisos.push(aviso);
64
+ d.avisar(aviso);
65
+ return { ok: true, valor: null };
66
+ }
67
+ const j = leerJson(d, real, `el package.json de ${PAQUETE_DEL_CHAT} instalado`);
68
+ if (j.ok)
69
+ return j;
70
+ // Existía hace un instante: si ahora no está, tampoco se anota «no está».
71
+ return { ok: false, resultado: j.noExiste ? fallo(SALIDA.inesperado, "paquete_ilegible", `No leo el package.json de ${PAQUETE_DEL_CHAT} instalado.`) : j.resultado };
72
+ }
73
+ if (dir === raiz || path.dirname(dir) === dir)
74
+ return { ok: true, valor: null };
75
+ }
76
+ }
77
+ async function estadoDelArchivo(d, raizGit, existe) {
78
+ const git = async (args) => {
79
+ const r = await d.git(args, raizGit);
80
+ if (r.codigo !== 0)
81
+ throw new Error(`git ${args[0]} falló; no escribo ${RUTA_DE_LA_INSTALACION} sin saber si tiene cambios.`);
82
+ return r.stdout;
83
+ };
84
+ const lineas = (await git(["status", "--porcelain", "--ignored", "--", RUTA_DE_LA_INSTALACION])).split("\n").filter((l) => l.trim() !== "");
85
+ if (lineas.some((l) => !l.startsWith("??") && !l.startsWith("!!")))
86
+ return "cambios_sin_commit";
87
+ if (lineas.length > 0)
88
+ return "archivo_no_versionado";
89
+ const versionado = (await git(["ls-files", "--", RUTA_DE_LA_INSTALACION])).trim() !== "";
90
+ return versionado || !existe ? "escribir" : "archivo_no_versionado";
91
+ }
92
+ // Un diff de líneas (LCS) para el texto: los dos lados son chicos. El anterior lo escribió quien sea, así que
93
+ // sale limpio de control y de secretos; el JSON nunca lo lleva.
94
+ function diff(antes, despues) {
95
+ if (antes === null)
96
+ return ["--- (no había archivo)", "+++ " + RUTA_DE_LA_INSTALACION, ...despues.trimEnd().split("\n").map((l) => "+ " + l)].join("\n");
97
+ const a = antes.replace(/\r\n?/g, "\n").replace(/\n$/, "").split("\n").map(limpio);
98
+ const b = despues.trimEnd().split("\n");
99
+ const n = a.length, m = b.length;
100
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
101
+ for (let i = n - 1; i >= 0; i--)
102
+ for (let j = m - 1; j >= 0; j--)
103
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
104
+ const lineas = ["--- " + RUTA_DE_LA_INSTALACION + " (antes)", "+++ " + RUTA_DE_LA_INSTALACION];
105
+ let i = 0, j = 0;
106
+ while (i < n || j < m) {
107
+ if (i < n && j < m && a[i] === b[j])
108
+ lineas.push(" " + a[i++]), j++;
109
+ else if (j < m && (i === n || lcs[i][j + 1] >= lcs[i + 1][j]))
110
+ lineas.push("+ " + b[j++]);
111
+ else
112
+ lineas.push("- " + a[i++]);
113
+ }
114
+ return lineas.join("\n");
115
+ }
116
+ const resumen = (x) => `skill ${x.skill}, terminal ${x.terminal}, chat declarado ${x.chat.declarado ?? "—"} / instalado ${x.chat.instalado ?? "—"}`;
117
+ export async function instalacionAnotar(d, flags) {
118
+ if (flags.skill === null)
119
+ return fallo(SALIDA.entradaInvalida, "falta_skill", "Falta la versión de la skill: `supcoflow instalacion anotar --skill 0.1.0`.");
120
+ if (!esSemver(flags.skill))
121
+ return fallo(SALIDA.entradaInvalida, "skill_invalida", "--skill va con una versión semver (por ejemplo 0.1.0).");
122
+ // ─── La raíz y la app, antes de cualquier pedido ───
123
+ let raizGit;
124
+ try {
125
+ raizGit = (await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd })).raiz;
126
+ }
127
+ catch (e) {
128
+ return resultadoDeFallo(e);
129
+ }
130
+ if (raizGit === null)
131
+ return fallo(SALIDA.entradaInvalida, "sin_clon", `No tengo el clon de ese repo acá, y ${RUTA_DE_LA_INSTALACION} va en el clon: parate en el clon del repo.`);
132
+ const raiz = d.rutaReal(raizGit);
133
+ if (raiz === null)
134
+ return fallo(SALIDA.inesperado, "inesperado", "No pude resolver la raíz del repo.");
135
+ // `realpath` DESPUÉS de resolver: un `--app` que es un enlace hacia afuera tiene que quedar afuera (Review Focus 4).
136
+ const app = d.rutaReal(path.resolve(raiz, flags.app ?? "."));
137
+ if (app === null)
138
+ return fallo(SALIDA.entradaInvalida, "app_inexistente", "La carpeta de --app no existe (va relativa a la raíz del repo).");
139
+ if (!adentro(raiz, app))
140
+ return fallo(SALIDA.entradaInvalida, "app_fuera_del_repo", "--app tiene que ser una carpeta dentro del repo (relativa a su raíz).");
141
+ // Un archivo no es una app: sin esto, leer `<archivo>/package.json` daba ENOTDIR y salía `paquete_ilegible`.
142
+ if (!d.esCarpeta(app))
143
+ return fallo(SALIDA.entradaInvalida, "app_inexistente", "--app tiene que ser una carpeta (la de la app), no un archivo.");
144
+ // ─── Proyecto → configuración → package.json de la app → paquete instalado → armar ───
145
+ const p = await proyectoDelRepo(d, flags);
146
+ if (!p.ok)
147
+ return p.resultado;
148
+ let configuracion;
149
+ try {
150
+ configuracion = await d.leerConfiguracion(d.api, p.clave, p.proyecto.id);
151
+ }
152
+ catch (e) {
153
+ return resultadoDeFallo(e);
154
+ }
155
+ const deLaApp = leerJson(d, path.join(app, "package.json"), "el package.json de la app");
156
+ if (!deLaApp.ok && !deLaApp.noExiste)
157
+ return deLaApp.resultado;
158
+ const avisos = [];
159
+ const instalado = paqueteInstalado(d, raiz, app, avisos);
160
+ if (!instalado.ok)
161
+ return instalado.resultado;
162
+ let x;
163
+ try {
164
+ x = armarInstalacion({ skill: flags.skill, terminal: d.terminal, paqueteDeLaApp: deLaApp.ok ? deLaApp.valor : null, paqueteInstalado: instalado.valor, configuracion });
165
+ }
166
+ catch (e) {
167
+ return resultadoDeFallo(e);
168
+ }
169
+ const nuevo = serializar(x);
170
+ // ─── ¿Se puede escribir? ───
171
+ const archivo = path.join(raiz, RUTA_DE_LA_INSTALACION);
172
+ // Una `.supcoflow` que es un enlace hacia afuera: ni se lee (el diff mostraría un archivo de afuera) ni se escribe.
173
+ const carpeta = d.rutaReal(path.dirname(archivo));
174
+ if (carpeta !== null && !adentro(raiz, carpeta))
175
+ return fallo(SALIDA.inesperado, "inesperado", `${path.dirname(RUTA_DE_LA_INSTALACION)} no es una carpeta del repo (¿un enlace?); no escribo ahí.`);
176
+ const actual = d.leerArchivoChico(archivo, TOPE_DEL_ARCHIVO);
177
+ const existe = !("error" in actual) || actual.error !== "no existe";
178
+ const json = { ok: true, ruta: RUTA_DE_LA_INSTALACION, instalacion: x, ...(avisos.length > 0 ? { avisos } : {}) };
179
+ if ("bytes" in actual && Buffer.from(actual.bytes).equals(Buffer.from(nuevo, "utf8"))) {
180
+ return { codigo: SALIDA.ok, json: { ...json, ...(flags.dryRun ? { dry_run: true } : {}), sin_cambios: true }, texto: `${RUTA_DE_LA_INSTALACION} ya dice eso (${resumen(x)}); no lo toqué.` };
181
+ }
182
+ let estado;
183
+ try {
184
+ estado = await estadoDelArchivo(d, raizGit, existe);
185
+ }
186
+ catch (e) {
187
+ return resultadoDeFallo(e);
188
+ }
189
+ if (estado === "cambios_sin_commit") {
190
+ return fallo(SALIDA.haceFaltaUnaPersona, "cambios_sin_commit", `${RUTA_DE_LA_INSTALACION} tiene cambios sin commit (o se borró sin commit); no lo toqué. Commitealos o descartalos y volvé a correr.`);
191
+ }
192
+ if (estado === "archivo_no_versionado") {
193
+ return fallo(SALIDA.haceFaltaUnaPersona, "archivo_no_versionado", `Ya hay un ${RUTA_DE_LA_INSTALACION} que git no guarda (sin commit o ignorado); no lo pisé. Commitealo o borralo y volvé a correr.`);
194
+ }
195
+ const antes = "bytes" in actual ? Buffer.from(actual.bytes).toString("utf8") : null;
196
+ const cambios = existe && antes === null ? `(el archivo anterior no se lee)\n${diff(null, nuevo)}` : diff(antes, nuevo);
197
+ if (flags.dryRun) {
198
+ return { codigo: SALIDA.ok, json: { ...json, dry_run: true, sin_cambios: false }, texto: `Con --dry-run no escribo; sin él anotaría (${resumen(x)}):\n${cambios}` };
199
+ }
200
+ try {
201
+ d.escribir(archivo, nuevo);
202
+ }
203
+ catch {
204
+ return fallo(SALIDA.inesperado, "archivo_no_escrito", `No pude escribir ${RUTA_DE_LA_INSTALACION}. Volvé a correr \`supcoflow instalacion anotar\`.`);
205
+ }
206
+ return { codigo: SALIDA.ok, json: { ...json, sin_cambios: false }, texto: `Anoté ${RUTA_DE_LA_INSTALACION} (${resumen(x)}); commitealo cuando quieras.\n${cambios}` };
207
+ }
@@ -0,0 +1,32 @@
1
+ import { resultadoDeFallo, SALIDA } from "../contrato.js";
2
+ import { proyectoDelRepo } from "./proyecto-del-repo.js";
3
+ function textoDe(w) {
4
+ return [
5
+ "1. Instalá el paquete (con el gestor de la app, la versión exacta):",
6
+ ` ${w.instalar}`,
7
+ "",
8
+ `2. Creá el archivo ${w.nextjs.archivo_sugerido} con este contenido (Next.js, ${w.nextjs.estado}):`,
9
+ w.nextjs.contenido.trimEnd(),
10
+ "",
11
+ "3. Usalo en el layout que cubre las pantallas donde se traban los usuarios:",
12
+ ` ${w.nextjs.uso}`,
13
+ "",
14
+ "4. Política de contenido (CSP): si la app tiene `connect-src`, agregale este origen conservando lo que ya tiene:",
15
+ ` ${w.csp}`,
16
+ "",
17
+ "Alternativa con <script> (disponible, no probada en una app real):",
18
+ ` ${w.script.linea}`,
19
+ ].join("\n");
20
+ }
21
+ export async function widget(d, flags) {
22
+ const p = await proyectoDelRepo(d, flags);
23
+ if (!p.ok)
24
+ return p.resultado;
25
+ try {
26
+ const w = await d.leerWidget(d.api, p.clave, p.proyecto.id);
27
+ return { codigo: SALIDA.ok, json: { ok: true, ...w }, texto: textoDe(w) };
28
+ }
29
+ catch (e) {
30
+ return resultadoDeFallo(e);
31
+ }
32
+ }
package/dist/entorno.js CHANGED
@@ -5,8 +5,10 @@ import { randomUUID } from "node:crypto";
5
5
  import { chmodSync, closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeSync, } from "node:fs";
6
6
  import { homedir, hostname } from "node:os";
7
7
  import { basename, dirname, join } from "node:path";
8
- import { buscarProyecto, canjear, configurar, crearConexion, crearProyecto, esClaveDeConsultaDelProyecto, leerCatalogo, leerConfiguracion, leerProyecto, listarClaves, pedirCodigo, quienSoy, revocarPropia, subirLogo, ultimaConexion, urlDeLaApi, } from "./api.js";
8
+ import { buscarProyecto, canjear, configurar, leerWidget, crearConexion, crearProyecto, elegirOrigen, esClaveDeConsultaDelProyecto, leerCatalogo, leerConfiguracion, leerGuia, leerProyecto, listarClaves, pedirCodigo, quienSoy, revocarPropia, subirLogo, ultimaConexion, urlDeLaApi, publicarGuia, revisarGuia, } from "./api.js";
9
9
  import { crearCandados } from "./candado.js";
10
+ import { esCarpetaReal, rutaRealOEnNada } from "./comandos/instalacion.js";
11
+ import { DESCRIPCION } from "./comandos/describir.js";
10
12
  import { almacenDeArchivo } from "./guardado/archivo.js";
11
13
  import { crearGuardado } from "./guardado/index.js";
12
14
  import { almacenDeLlavero } from "./guardado/llavero.js";
@@ -228,15 +230,20 @@ export const correrReal = (bin, args, stdin, techoMs) => new Promise((resolve) =
228
230
  hijo.stdin.end(stdin ?? undefined);
229
231
  });
230
232
  // `git` con entorno mínimo (sólo PATH): ni GIT_DIR ni GIT_CONFIG del shell desvían la lectura, y
231
- // SUPCOFLOW_TOKEN no le llega. Un git ausente (ENOENT) es «sin git», no un fallo.
232
- export const gitReal = (args, cwd) => new Promise((resolve) => {
233
- execFile("git", args, { cwd, timeout: 5_000, env: { PATH: process.env.PATH ?? "" } }, (e, stdout) => {
233
+ // SUPCOFLOW_TOKEN no le llega. Un git ausente (ENOENT) es «sin git», no un fallo. El plazo es parámetro para no
234
+ // duplicar la función: lo que git imprima nunca sale a la terminal, sólo el código y el stdout.
235
+ const gitConPlazo = (plazoMs) => (args, cwd) => new Promise((resolve) => {
236
+ execFile("git", args, { cwd, timeout: plazoMs, env: { PATH: process.env.PATH ?? "" } }, (e, stdout) => {
234
237
  if (!e)
235
238
  return resolve({ codigo: 0, stdout: String(stdout) });
236
239
  const codigo = e.code;
237
240
  resolve({ codigo: typeof codigo === "number" ? codigo : null, stdout: "" });
238
241
  });
239
242
  });
243
+ export const gitReal = gitConPlazo(5_000);
244
+ // El commit y la rama que la terminal DECLARA al subir la guía: 3 segundos (no 5) porque esto es un extra que nunca
245
+ // debe hacer esperar a nadie.
246
+ export const gitRapido = gitConPlazo(3_000);
240
247
  // `.supcoflow/proyecto.json` vive DENTRO del repo, y el repo puede ser un clon ajeno: su contenido lo
241
248
  // decide quien lo escribió. Por eso ni la carpeta ni el archivo se siguen si son enlaces (uno hacia
242
249
  // `~/.ssh/…` haría que la terminal leyera, o escribiera, lo que el repo quiera), y se lee con tope.
@@ -450,11 +457,29 @@ export function entornoReal(env = process.env) {
450
457
  leerArchivoChico,
451
458
  leerCatalogo: (a, clave) => leerCatalogo(a, clave, fetch),
452
459
  },
460
+ guia: {
461
+ ...configurarEnElMotor,
462
+ leerArchivoChico, fetch, gitDeclarado: gitRapido,
463
+ revisar: revisarGuia, publicar: publicarGuia, ver: leerGuia, elegir: elegirOrigen,
464
+ },
453
465
  claves: {
454
466
  ...configurarEnElMotor,
455
467
  listarClaves: (a, clave, id) => listarClaves(a, clave, id, fetch),
456
468
  esClaveDeConsultaDelProyecto: (a, clave, id) => esClaveDeConsultaDelProyecto(a, clave, id, fetch),
457
469
  idempotencia, candados, secretos,
458
470
  },
471
+ widget: {
472
+ api, guardado, git: gitReal, cwd,
473
+ buscarProyecto: configurarEnElMotor.buscarProyecto,
474
+ leerWidget: (a, clave, id) => leerWidget(a, clave, id, fetch),
475
+ },
476
+ // La versión que se anota es la de `describir` (pinzada contra package.json por test): una sola fuente
477
+ // para lo que la terminal dice de sí misma.
478
+ instalacion: {
479
+ ...configurarEnElMotor,
480
+ leerArchivoChico, rutaReal: rutaRealOEnNada, esCarpeta: esCarpetaReal,
481
+ escribir: archivosDelRepo(avisar).escribir,
482
+ terminal: DESCRIPCION.version,
483
+ },
459
484
  };
460
485
  }
@@ -4,12 +4,13 @@
4
4
  const DE_CONFIGURAR_CON_REPO = [
5
5
  "configuracion", "chat prender", "chat apagar", "chat marca", "chat logo", "sitios agregar", "sitios quitar",
6
6
  "datos fijar", "politica fijar", "claves crear", "claves listar", "claves revocar",
7
+ "guia revisar", "guia publicar", "guia ver", "guia usar",
7
8
  ];
8
9
  export const FLAGS_POR_COMANDO = [
9
10
  { flags: ["start", "wait"], comandos: ["login", "repo conectar"], texto: "--start y --wait son de `supcoflow login` y `supcoflow repo conectar`." },
10
11
  {
11
- flags: ["repo"], comandos: ["repo conectar", "proyecto crear", "proyecto ver", ...DE_CONFIGURAR_CON_REPO],
12
- texto: "--repo es de `supcoflow repo conectar`, `proyecto crear|ver` y los comandos que configuran un proyecto (no de `catalogo`).",
12
+ flags: ["repo"], comandos: ["repo conectar", "proyecto crear", "proyecto ver", ...DE_CONFIGURAR_CON_REPO, "widget", "instalacion anotar"],
13
+ texto: "--repo es de `supcoflow repo conectar`, `proyecto crear|ver`, `widget`, `instalacion anotar` y los comandos que configuran un proyecto (no de `catalogo`).",
13
14
  },
14
15
  { flags: ["install", "test"], comandos: ["proyecto crear"], texto: "--install y --test son de `supcoflow proyecto crear`." },
15
16
  { flags: ["sitio"], comandos: ["sitios agregar", "sitios quitar"], texto: "--sitio es de `supcoflow sitios agregar|quitar`." },
@@ -18,7 +19,10 @@ export const FLAGS_POR_COMANDO = [
18
19
  { flags: ["permitir"], comandos: ["datos fijar"], texto: "--permitir es de `supcoflow datos fijar`." },
19
20
  { flags: ["modo"], comandos: ["politica fijar"], texto: "--modo es de `supcoflow politica fijar`." },
20
21
  { flags: ["nombre", "guardarEn"], comandos: ["claves crear"], texto: "--nombre y --guardar-en son de `supcoflow claves crear`." },
22
+ { flags: ["origen"], comandos: ["guia usar"], texto: "--origen es de `supcoflow guia usar`." },
23
+ { flags: ["desdeGithub"], comandos: ["guia publicar"], texto: "--desde-github es de `supcoflow guia publicar`." },
21
24
  { flags: ["id"], comandos: ["claves revocar"], texto: "--id es de `supcoflow claves revocar`." },
25
+ { flags: ["skill", "app"], comandos: ["instalacion anotar"], texto: "--skill y --app son de `supcoflow instalacion anotar`." },
22
26
  ];
23
27
  /** El mensaje del primer flag presente que el comando no acepta, o null si todo aplica. */
24
28
  export function flagQueNoAplica(comando, presentes) {
package/dist/guia.js ADDED
@@ -0,0 +1,11 @@
1
+ // La guía del producto que vive en el repo del cliente. Sin dependencias: sólo `node:crypto`.
2
+ import { createHash } from "node:crypto";
3
+ export const RUTA_DE_LA_GUIA = ".supcoflow/guia.md";
4
+ export const MAX_BYTES_DE_LA_GUIA = 64 * 1024;
5
+ // La misma regla que el motor (`src/guia/formato.ts`), copiada a propósito y clavada con los vectores
6
+ // de `test/vectores/huella.json` (idénticos en los dos repos). Se normaliza antes de hashear porque un
7
+ // editor de Windows pone BOM o \r\n, y si las dos puntas hashearan distinto, `guia_distinta` sería eterno.
8
+ export function huellaDeGuia(texto) {
9
+ const normalizado = texto.replace(/^/, "").replace(/\r\n?/g, "\n");
10
+ return createHash("sha256").update(normalizado, "utf8").digest("hex");
11
+ }
@@ -0,0 +1,86 @@
1
+ // `.supcoflow/instalacion.json` (bloque 6, decisión 3): con qué versiones se hizo la instalación, anotado en el
2
+ // repo de quien instala. Supcoflow no lo guarda ni lo ve. Puro: recibe lo ya leído y arma el archivo.
3
+ //
4
+ // El archivo queda en el repo y se commitea: un secreto acá se publica con el primer `push`. Por eso se arma con
5
+ // una LISTA CERRADA de campos, cada uno validado por separado; nunca un `...configuracion` (la configuración del
6
+ // motor trae `fijado_por`, `publicada_por`, huellas y lo que el motor agregue mañana: nada de eso es de este archivo).
7
+ import { FalloDeLaTerminal, SALIDA, taparSecretos } from "./contrato.js";
8
+ export const RUTA_DE_LA_INSTALACION = ".supcoflow/instalacion.json";
9
+ export const PAQUETE_DEL_CHAT = "@supcoflow/chat";
10
+ // SemVer 2.0 tal cual, con tope de largo: la versión de la skill la escribe un asistente y queda en el repo.
11
+ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
12
+ export const esSemver = (s) => s.length <= 64 && SEMVER.test(s);
13
+ const objeto = (v) => (typeof v === "object" && v !== null && !Array.isArray(v) ? v : null);
14
+ const entero = (v) => typeof v === "number" && Number.isInteger(v) && v >= 1;
15
+ const ilegible = (message) => new FalloDeLaTerminal(SALIDA.inesperado, "paquete_ilegible", message);
16
+ const inesperada = (que) => new FalloDeLaTerminal(SALIDA.inesperado, "configuracion_inesperada", `El motor contestó la configuración con ${que} que no entiendo; no anoto nada a medias. Probá de nuevo en un rato.`);
17
+ // Lo declarado en el `package.json` de la app lo escribió quien escribió el repo: puede ser un rango, un alias o
18
+ // cualquier cosa. Se copia tal cual sólo si es texto imprimible y corto; si no, no se adivina (`paquete_ilegible`).
19
+ // `taparSecretos` igual, por si alguien pegó una clave ahí: el archivo nuevo no la repite.
20
+ function declaradoEn(app) {
21
+ const a = objeto(app);
22
+ if (a === null)
23
+ return null;
24
+ const v = objeto(a.dependencies)?.[PAQUETE_DEL_CHAT] ?? objeto(a.devDependencies)?.[PAQUETE_DEL_CHAT];
25
+ if (v === undefined || v === null)
26
+ return null;
27
+ if (typeof v !== "string" || v.length === 0 || v.length > 200 || !/^[\x20-\x7e]+$/.test(v)) {
28
+ throw ilegible(`La versión de ${PAQUETE_DEL_CHAT} en el package.json de la app no se entiende; no la anoto.`);
29
+ }
30
+ return taparSecretos(v);
31
+ }
32
+ // `null` es «no está instalado». Un archivo que existe pero sin versión válida NO es null: diría que no está.
33
+ function instaladoEn(paquete) {
34
+ if (paquete === null)
35
+ return null;
36
+ const v = objeto(paquete)?.version;
37
+ if (typeof v !== "string" || !esSemver(v))
38
+ throw ilegible(`El package.json de ${PAQUETE_DEL_CHAT} instalado no trae una versión que entienda.`);
39
+ return v;
40
+ }
41
+ function acuerdoDe(c) {
42
+ if (c.acuerdo === null)
43
+ return null;
44
+ const a = objeto(c.acuerdo);
45
+ // Ilegible es un estado conocido del motor (aplica cerrado), no «sin acuerdo»: anotarlo como null mentiría.
46
+ if (a?.ilegible === true) {
47
+ throw new FalloDeLaTerminal(SALIDA.haceFaltaUnaPersona, "acuerdo_ilegible", "El acuerdo de datos está fijado pero no se lee: revisalo en la consola y volvé a correr.");
48
+ }
49
+ if (a === null || !entero(a.version))
50
+ throw inesperada("un acuerdo");
51
+ return a.version;
52
+ }
53
+ function politicaDe(c) {
54
+ const modo = objeto(c.politica)?.modo;
55
+ if (modo !== "relevar" && modo !== "guardar")
56
+ throw inesperada("una política");
57
+ return modo;
58
+ }
59
+ function guiaDe(c) {
60
+ if (c.guia === null)
61
+ return null;
62
+ const g = objeto(c.guia);
63
+ if (g?.origen === "repo") {
64
+ if (g.procedencia !== "terminal" && g.procedencia !== "github")
65
+ throw inesperada("una guía");
66
+ if (g.commit !== null && (typeof g.commit !== "string" || !/^[0-9a-f]{40}$/.test(g.commit)))
67
+ throw inesperada("una guía");
68
+ return { origen: "repo", procedencia: g.procedencia, commit: g.commit };
69
+ }
70
+ if (g?.origen === "agente" && entero(g.version))
71
+ return { origen: "agente", version: g.version };
72
+ throw inesperada("una guía");
73
+ }
74
+ export function armarInstalacion(i) {
75
+ return {
76
+ version: 1,
77
+ skill: i.skill,
78
+ terminal: i.terminal,
79
+ chat: { declarado: declaradoEn(i.paqueteDeLaApp), instalado: instaladoEn(i.paqueteInstalado) },
80
+ configuracion: { acuerdo_version: acuerdoDe(i.configuracion), politica: politicaDe(i.configuracion), guia: guiaDe(i.configuracion) },
81
+ };
82
+ }
83
+ // El orden de las claves es el de `armarInstalacion`: el mismo estado da los mismos bytes, y eso es `sin_cambios`.
84
+ export function serializar(x) {
85
+ return JSON.stringify(x, null, 2) + "\n";
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supcoflow",
3
- "version": "0.3.0",
3
+ "version": "0.6.0",
4
4
  "description": "La terminal de Supcoflow: iniciar sesión y manejar el chat de soporte desde la consola o un asistente.",
5
5
  "type": "module",
6
6
  "bin": { "supcoflow": "dist/cli.js" },