supcoflow 0.8.1 → 0.9.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
@@ -36,8 +36,8 @@ the English ones.
36
36
  | `supcoflow guide use --source repo\|agent` | Chooses which guide is active. |
37
37
  | `supcoflow guide versions` | The guide that governs now and the repo's publications, newest first; at most one says «live». |
38
38
  | `supcoflow widget` | What to paste into the app to get the chat (see «Put the chat in the app»). |
39
- | `supcoflow install record --skill 0.3.1 --file app/_supcoflow/support-chat.tsx` | Records in `.supcoflow/instalacion.json` which versions the chat was installed with and which file it lives in. |
40
- | `supcoflow diagnose` | Free: how far the installation is (N of 6) and whether a test would go in (including whether this machine can save the test request). |
39
+ | `supcoflow install record --skill 0.3.1 --file app/_supcoflow/support-chat.tsx` | Records in `.supcoflow/instalacion.json` which versions the chat was installed with, what was installed (the widget or the app's own interface), which files it lives in and which presentation contract it follows. |
40
+ | `supcoflow diagnose` | Free: how far the installation is (N of 7) and whether a test would go in (including whether this machine can save the test request). |
41
41
  | `supcoflow test` | The end-to-end test (see «Test the installation»). |
42
42
  | `supcoflow describe --json` | The whole contract (commands, flags, key capabilities, codes) in one line of JSON. |
43
43
 
@@ -191,13 +191,61 @@ wrongly copied id leaves the chat invisible without any error). It writes no fil
191
191
  The `<script>` line it also shows is available but not tested in a real app, and is not for Next.js.
192
192
  `supcoflow widget --json` returns the engine's object as is.
193
193
 
194
- Then, `supcoflow install record --skill <version> [--app <folder>] [--file <path>]` leaves in
194
+ ### Your own interface (the app builds the chat)
195
+
196
+ Supcoflow doesn't have to be the one that draws the chat. When the engine also describes the app's **own interface**
197
+ (`interfaz_propia` in `widget --json`; `Own interface` in the text), the code assistant builds the chat with the
198
+ app's own components, icons and styles, and Supcoflow only says what to install and which contract the interface
199
+ must fulfil. **There is no code to paste** in this form:
200
+
201
+ | Field (`interfaz_propia`) | What it is |
202
+ |---|---|
203
+ | `estado` | `disponible`, or `sin_publicar` when the package version isn't on npm yet. With `sin_publicar` the text doesn't offer the path (it would be an `npm i` that fails): it says «not published yet; use the widget». The JSON still carries everything. |
204
+ | `paquete`, `version`, `instalar` | The package and its exact version, and the install command. |
205
+ | `tenant_id`, `project_id`, `backend_url`, `opciones_del_nucleo` | The ids and the backend, as the engine has them: don't copy them by hand. |
206
+ | `csp` | The Content Security Policy line to add to `connect-src`. |
207
+ | `contrato` (`version`, `archivo`, `documento`) | The presentation contract the interface has to follow: its version, and where the installed package carries it (`contract.json` and `docs/presentation-contract.md`, paths relative to the folder where `npm i` was run), so it can be read offline. |
208
+ | `entradas`, `documentos` | The package entry points (`@supcoflow/chat/core`, `/react`, `/testing`, `/testing/server`) and the docs inside the package. Names and paths, not code. |
209
+
210
+ The field is additive: an engine that doesn't send it (production, until the engine's T8 ships) gives exactly the
211
+ object it always gave, and the terminal adds nothing. If the engine sends `interfaz_propia` halfway (a field missing,
212
+ an `estado` it doesn't know, a `contrato.version` that isn't a semver, a path outside the package), the terminal ignores
213
+ that whole part, says so on stderr and shows the widget as always: it never shows half of it, and an optional block never
214
+ takes the widget down. Anything else inside `interfaz_propia` that the terminal doesn't know is not passed on.
215
+
216
+ Then, `supcoflow install record --skill <version> [--app <folder>] [--shape widget|own-interface] [--file <path>]…` leaves in
195
217
  `.supcoflow/instalacion.json` (to commit) which versions it ended up with: the skill, the terminal, the chat declared
196
- and installed, the project configuration and, with `--file`, the file the chat lives in (a path relative to the repo
197
- root, with no `..`, and not a symlink). The file is version 2; a version 1 file (from 0.7.0) is read without error as
198
- «no file recorded». Running it again without `--file` keeps the file already recorded. It never contains a secret or
199
- a date; repeating it without changes doesn't touch it, and if the file has uncommitted changes it exits 4 and doesn't
200
- overwrite it.
218
+ and installed, the project configuration and, from version 3 (0.9.0), what the installation is:
219
+
220
+ - `forma`: `"widget"` or `"interfaz_propia"` (`--shape widget` / `--shape own-interface`), or `null` if not said.
221
+ - `archivos`: the files the chat lives in, relative to the repo root (no `..`, not absolute, not a symlink, up to 20).
222
+ `--file` can be repeated, in order, and replaces the whole list; the wrapper of the widget is one file, an own
223
+ interface usually several.
224
+ - `contrato`: the version of the presentation contract the **installed** package carries (its `contract.json`, found the
225
+ same way as its `package.json`: the closest `node_modules` going up to the repo root, never outside the repo), or `null`
226
+ if the package has none. A `contract.json` that is there but has no version is an error (`paquete_ilegible`).
227
+
228
+ Running it again without `--shape` or `--file` keeps the form and the files already recorded; changing `--shape` without
229
+ `--file` leaves `archivos` empty (the wrapper of the widget isn't the own interface, and the other way round). Versions 1 and
230
+ 2 are read without error and as a widget, the only form there was: a version 2 `archivo` is read as `archivos: [archivo]`
231
+ (and rewritten as version 3). A file of version 4 or more (written by a newer supcoflow) is never rewritten: `install record`
232
+ exits 4 `escrito_por_una_terminal_mas_nueva` and `diagnose` says to update supcoflow. The JSON of the command keeps `archivo` (the first file) for the skills before 0.9.0 and adds
233
+ `archivos`, `forma` and `contrato`. It never contains a secret or a date; repeating it without changes doesn't touch it,
234
+ and if the file has uncommitted changes it exits 4 and doesn't overwrite it.
235
+
236
+ **Point 7 of `diagnose` (`interfaz`).** It is local and only reads. If `.supcoflow/instalacion.json` records an own
237
+ interface, it checks that every recorded file is still there, that a contract is recorded and that it isn't older than the
238
+ one the project declares today (`interfaz_propia.contrato.version`, asked from the engine; if that can't be asked, or the
239
+ engine doesn't send it, nothing is compared). Only a new **major** version fails the point (a new requirement comes only with
240
+ a major one): then it says to run the skill again and check that the interface still conforms. A newer minor or patch only
241
+ adds optional points: the point stays ok and says so. To see that a recorded file is still there it doesn't read it. With no
242
+ own interface recorded (no file, the widget, or nothing said) the point is ok («Not applicable»). It is advice: when it
243
+ fails it lowers the «N of 7», it is marked `aviso: true` in the JSON, and it doesn't stop a test.
244
+
245
+ **Before publishing 0.9.0.** `diagnose` now says «N of 7», and the skill of today (`SKILL.md`, `referencias/prueba.md`)
246
+ asks literally for «6 of 6», but it pins the terminal to 0.8.1 (never `@latest`), so it won't see 0.9.0 until a skill pins it.
247
+ The skill that pins 0.9.0 has to read `instalacion.ok` / `listo` (or `aviso`) instead of a fixed number, and 0.9.0 has to be on
248
+ npm before or together with it.
201
249
 
202
250
  ## Test the installation
203
251
 
@@ -206,7 +254,7 @@ the repo from the `origin`, or `--repo owner/repo`.
206
254
 
207
255
  | Command | What it does |
208
256
  |---|---|
209
- | `supcoflow diagnose` | Free and writes nothing. It prints «Checked sign-in, repo, GitHub App, allowed sites, chat and guide: N of 6, at no cost.» and, apart, «Ready for a test: yes / no, because …» with the policy, the month's balance and open tests. Exits 0 if everything is ready, 4 if something needs fixing (with each point). `--json` gives `instalacion: { ok, total: 6, puntos }` and `prueba: { listo, puntos }`. |
257
+ | `supcoflow diagnose` | Free and writes nothing. It prints «Checked sign-in, repo, GitHub App, allowed sites, chat, guide and interface: N of 7, at no cost.» and, apart, «Ready for a test: yes / no, because …» with the policy, the month's balance and open tests. Exits 0 if everything is ready, 4 if something needs fixing (with each point). `--json` gives `instalacion: { ok, total: 7, puntos }` and `prueba: { listo, puntos }`. The seventh point, `interfaz`, is explained in «Put the chat in the app». |
210
258
  | `supcoflow test` | Without `--authorize-spend` it sends nothing: it exits 4 with the test's ceiling, the balance, the cap and what is committed this month, and the exact command to launch it. It also says, before you authorize anything, whether this machine can save the test request (`maquina: { ok, impedimentos }`) and any request still waiting for confirmation (`intento_pendiente: { techo_usd, politica }`). |
211
259
  | `supcoflow test --authorize-spend --ceiling 2 --expect-policy investigate` | Requests the test: the problem comes in through the same door as the chat and runs an investigation that **spends** up to the ceiling you authorize (with the `save` policy it goes as far as the saved case and doesn't spend). Exits 5 with the id. `--message "…"` uses your own text. |
212
260
  | `supcoflow test --wait` | Follows the last test up to 90 s: 5 while it runs (call it again), 0 with the issue, 4 if it needs a look in the console (it brings the link). The charge is shown as it is: `pendiente` right at the close, never 0. `--test-id prb_…` follows another one. |
package/dist/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Los pedidos de la terminal al motor. Sin dependencias: `fetch` viene de Node y se inyecta.
2
2
  import { FalloDeLaTerminal, SALIDA, taparSecretos } from "./contrato.js";
3
+ import { esSemver } from "./instalacion.js";
3
4
  // La URL viene FIJA (§5.2): si un archivo del repo pudiera cambiarla, clonar un repo ajeno y correr
4
5
  // `supcoflow` mandaría el token a un servidor ajeno. Sólo el entorno del proceso la mueve, y `http`
5
6
  // únicamente contra la propia máquina: contra un host remoto la clave viajaría en claro.
@@ -23,7 +24,7 @@ export function urlDeLaApi(env) {
23
24
  }
24
25
  // A mano a propósito: tiene que coincidir con package.json (lo pinza test/describir.test.ts); sólo
25
26
  // identifica a la terminal en los logs del motor, no decide nada.
26
- const VERSION = "0.8.1";
27
+ const VERSION = "0.9.0";
27
28
  const PLAZO_POR_DEFECTO_MS = 15_000;
28
29
  // Los dos POST que crean algo: detrás, el motor acuña con Vercel Connect y le pregunta a GitHub varias
29
30
  // veces (instalación, repos, comandos, rama). Con 15 s una respuesta lenta pero buena se cortaba y
@@ -318,7 +319,7 @@ export function elegirOrigen(api, clave, proyectoId, origen, f) {
318
319
  }
319
320
  // Se valida campo a campo: lo que se le pasa a la persona (y de ahí a un archivo de su app) no puede
320
321
  // ser algo a medias. `contenido` se guarda tal cual, sin tocarlo: los ids ya vienen puestos por el motor.
321
- function aWidget(v, status) {
322
+ function aWidget(v, status, avisar) {
322
323
  const n = (typeof v.nextjs === "object" && v.nextjs !== null ? v.nextjs : {});
323
324
  const s = (typeof v.script === "object" && v.script !== null ? v.script : {});
324
325
  const [tenantId, projectId, backendUrl, version, instalar, csp] = [v.tenant_id, v.project_id, v.backend_url, v.version, v.instalar, v.csp].map(texto);
@@ -332,11 +333,58 @@ function aWidget(v, status) {
332
333
  || (n.estado !== "probando" && n.estado !== "soportado") || !archivo || !contenido || !uso
333
334
  || s.estado !== "disponible_sin_soporte" || !linea)
334
335
  throw inesperada(status);
336
+ // Un bloque ADITIVO a medias no tumba el widget de siempre (revisión 1, P2-1): se descarta entero, con un aviso, y no se muestra
337
+ // nada de él (nunca la mitad).
338
+ let propia = aInterfazPropia(v.interfaz_propia);
339
+ if (propia === null) {
340
+ avisar?.("The engine described the app's own interface halfway; I'm ignoring that part.");
341
+ propia = undefined;
342
+ }
335
343
  return {
336
344
  tenant_id: tenantId, project_id: projectId, backend_url: backendUrl, paquete: "@supcoflow/chat", version, instalar,
337
345
  nextjs: { estado: n.estado, archivo_sugerido: archivo, archivos_anteriores: anteriores, contenido, uso },
338
346
  script: { estado: "disponible_sin_soporte", linea },
339
347
  csp,
348
+ ...(propia === undefined ? {} : { interfaz_propia: propia }),
349
+ };
350
+ }
351
+ // `undefined` es «el motor no la manda» (producción de hoy; también `null`); `null` es «la mandó a medias»; y una lista cerrada de
352
+ // campos, anidados incluidos: lo que el motor sume mañana dentro de `interfaz_propia` no se copia (como el resto del widget).
353
+ // Sin `\\` tampoco: un `..` escrito con barras invertidas no se vería al partir por `/`.
354
+ const SIN_ESPACIOS_RAROS = /^[\x21-\x5b\x5d-\x7e]+$/;
355
+ const EN_EL_PAQUETE = "node_modules/@supcoflow/chat/";
356
+ const ENTRADA_DEL_PAQUETE = "@supcoflow/chat/";
357
+ // Una ruta o una entrada: un texto sin espacios ni control, que empiece donde tiene que empezar y no suba con `..`.
358
+ const conPrefijo = (v, prefijo) => {
359
+ const t = texto(v);
360
+ return t !== null && SIN_ESPACIOS_RAROS.test(t) && t.startsWith(prefijo) && t.length > prefijo.length && !t.split("/").includes("..") ? t : null;
361
+ };
362
+ const objetoONull = (v) => (typeof v === "object" && v !== null && !Array.isArray(v) ? v : null);
363
+ function aInterfazPropia(v) {
364
+ if (v === undefined || v === null)
365
+ return undefined;
366
+ const o = objetoONull(v);
367
+ const c = objetoONull(o?.contrato), e = objetoONull(o?.entradas), d = objetoONull(o?.documentos), n = objetoONull(o?.opciones_del_nucleo);
368
+ if (o === null || c === null || e === null || d === null || n === null)
369
+ return null;
370
+ const [version, instalar, tenantId, projectId, backendUrl, csp, nucleoTenant, nucleoProyecto] = [o.version, o.instalar, o.tenant_id, o.project_id, o.backend_url, o.csp, n.tenantId, n.projectId].map(texto);
371
+ const cVersion = texto(c.version);
372
+ const [cArchivo, cDocumento] = [c.archivo, c.documento].map((x) => conPrefijo(x, EN_EL_PAQUETE));
373
+ const entradas = [e.nucleo, e.react, e.pruebas, e.servidor_de_pruebas].map((x) => conPrefijo(x, ENTRADA_DEL_PAQUETE));
374
+ const docs = [d.nucleo, d.react, d.pruebas, d.conformidad, d.protocolo].map((x) => conPrefijo(x, EN_EL_PAQUETE));
375
+ if ((o.estado !== "sin_publicar" && o.estado !== "disponible") || o.paquete !== "@supcoflow/chat"
376
+ || !version || !instalar || !tenantId || !projectId || !backendUrl || !csp || !nucleoTenant || !nucleoProyecto
377
+ || !cVersion || !esSemver(cVersion) || cArchivo === null || cDocumento === null
378
+ || entradas.includes(null) || docs.includes(null))
379
+ return null;
380
+ const [eNucleo, eReact, ePruebas, eServidor] = entradas;
381
+ const [dNucleo, dReact, dPruebas, dConformidad, dProtocolo] = docs;
382
+ return {
383
+ estado: o.estado, paquete: "@supcoflow/chat", version, instalar, tenant_id: tenantId, project_id: projectId, backend_url: backendUrl, csp,
384
+ opciones_del_nucleo: { tenantId: nucleoTenant, projectId: nucleoProyecto },
385
+ contrato: { version: cVersion, archivo: cArchivo, documento: cDocumento },
386
+ entradas: { nucleo: eNucleo, react: eReact, pruebas: ePruebas, servidor_de_pruebas: eServidor },
387
+ documentos: { nucleo: dNucleo, react: dReact, pruebas: dPruebas, conformidad: dConformidad, protocolo: dProtocolo },
340
388
  };
341
389
  }
342
390
  // Una lista de textos no vacíos; ausente es lista vacía (un motor sin el campo), y cualquier otra forma es null.
@@ -348,11 +396,12 @@ function listaDeTextos(v) {
348
396
  const textos = v.map(texto);
349
397
  return textos.every((t) => t !== null && t !== "") ? textos : null;
350
398
  }
351
- export async function leerWidget(api, clave, proyectoId, f) {
399
+ // `avisar` recibe lo que la terminal descarta sin tumbar el comando (hoy: una `interfaz_propia` a medias).
400
+ export async function leerWidget(api, clave, proyectoId, f, avisar) {
352
401
  const r = await conClave(api, clave, `/api/proyectos/${encodeURIComponent(proyectoId)}/widget`, { method: "GET" }, f);
353
402
  if (r.status !== 200)
354
403
  throw rechazada(r);
355
- return aWidget(r.cuerpo, r.status);
404
+ return aWidget(r.cuerpo, r.status, avisar);
356
405
  }
357
406
  // ---- La prueba (bloque 7) ----
358
407
  const rutaDelProyecto = (proyectoId) => `/api/proyectos/${encodeURIComponent(proyectoId)}`;
@@ -13,8 +13,10 @@ export const COMANDOS_COMPUESTOS = [
13
13
  const PRIMERAS_PALABRAS = new Set([...COMANDOS_COMPUESTOS, ...Object.keys(COMANDOS_VIEJOS)].filter((c) => c.includes(" ")).map((c) => c.split(" ")[0]));
14
14
  const FLAGS_CON_VALOR = [
15
15
  "repo", "install", "test", "site", "title", "greeting", "file", "allow", "mode", "name", "save-to", "id", "source", "skill", "app",
16
- "message", "test-id", "ceiling", "expect-policy",
16
+ "message", "test-id", "ceiling", "expect-policy", "shape",
17
17
  ];
18
+ // Los que sí se repiten (cada comando decide cuántos acepta: `cli.ts`, `archivosDeMas`); ninguno va vacío.
19
+ const FLAGS_REPETIBLES = new Set(["file"]);
18
20
  const FLAGS_BOOLEANOS = ["json", "dry-run", "help", "start", "wait", "from-github", "authorize-spend"];
19
21
  // El flag que el token nombró, ya llevado a su nombre canónico (el viejo y el nuevo cuentan como el mismo).
20
22
  const canonico = (nombre) => FLAGS_VIEJOS[nombre] ?? nombre;
@@ -55,7 +57,7 @@ export function leerArgumentos(argv) {
55
57
  // nombre viejo y el nuevo de un mismo flag) creería que se usó el primero. Se mira en los tokens y se rechaza.
56
58
  for (const nombre of FLAGS_CON_VALOR) {
57
59
  const toks = tokens.filter((t) => canonico(t.name) === nombre);
58
- if (toks.length > 1 || toks.some((t) => t.value === "")) {
60
+ if ((toks.length > 1 && !FLAGS_REPETIBLES.has(nombre)) || toks.some((t) => t.value === "")) {
59
61
  throw invalida("argumentos_invalidos", `--${nombre} goes once, with a value. Try \`supcoflow --help\`.`);
60
62
  }
61
63
  }
@@ -86,6 +88,7 @@ export function leerArgumentos(argv) {
86
88
  return t === undefined ? null : (t.value ?? null);
87
89
  };
88
90
  const hay = (nombre) => tokens.some((x) => canonico(x.name) === nombre);
91
+ const archivos = tokens.filter((t) => canonico(t.name) === "file").map((t) => t.value ?? "");
89
92
  return {
90
93
  comando,
91
94
  viejos,
@@ -111,6 +114,8 @@ export function leerArgumentos(argv) {
111
114
  fromGithub: hay("from-github"),
112
115
  skill: un("skill"),
113
116
  app: un("app"),
117
+ files: archivos,
118
+ shape: un("shape"),
114
119
  authorizeSpend: hay("authorize-spend"),
115
120
  message: un("message"),
116
121
  testId: un("test-id"),
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ import { repoConectarInteractivo, repoConectarStart, repoConectarWait } from "./
19
19
  import { widget } from "./comandos/widget.js";
20
20
  import { emitir, FalloDeLaTerminal, resultadoDeFallo, SALIDA } from "./contrato.js";
21
21
  import { entornoReal } from "./entorno.js";
22
- import { flagQueNoAplica } from "./flags-por-comando.js";
22
+ import { archivosDeMas, flagQueNoAplica } from "./flags-por-comando.js";
23
23
  const COMANDOS = new Set(DESCRIPCION.comandos.map((c) => c.nombre));
24
24
  const invalida = (error, message) => new FalloDeLaTerminal(SALIDA.entradaInvalida, error, message);
25
25
  // Los nombres que la terminal tenía antes de la 0.8.0 siguen valiendo (`src/alias.ts`), pero no salen en `describe`.
@@ -42,13 +42,16 @@ async function correr(argv) {
42
42
  site: flags.site !== null, title: flags.title !== null, greeting: flags.greeting !== null, theme: flags.theme.length > 0,
43
43
  file: flags.file !== null, allow: flags.allow !== null, mode: flags.mode !== null,
44
44
  name: flags.name !== null, saveTo: flags.saveTo !== null, id: flags.id !== null, source: flags.source !== null, fromGithub: flags.fromGithub,
45
- skill: flags.skill !== null, app: flags.app !== null,
45
+ skill: flags.skill !== null, app: flags.app !== null, shape: flags.shape !== null,
46
46
  authorizeSpend: flags.authorizeSpend, message: flags.message !== null, testId: flags.testId !== null,
47
47
  ceiling: flags.ceiling !== null, expectPolicy: flags.expectPolicy !== null,
48
48
  };
49
49
  const noAplica = flagQueNoAplica(comando, Object.keys(presentes).filter((k) => presentes[k]));
50
50
  if (noAplica !== null)
51
51
  throw invalida("flag_no_aplica", noAplica);
52
+ const deMas = archivosDeMas(comando, flags.files.length);
53
+ if (deMas !== null)
54
+ throw invalida("argumentos_invalidos", deMas);
52
55
  if (flags.start && flags.wait) {
53
56
  throw invalida("flags_incompatibles", "--start and --wait go separately: first one and then the other.");
54
57
  }
@@ -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.8.1";
6
+ const VERSION = "0.9.0";
7
7
  export const DESCRIPCION = {
8
8
  version: VERSION,
9
9
  comandos: [
@@ -219,17 +219,18 @@ export const DESCRIPCION = {
219
219
  },
220
220
  {
221
221
  nombre: "widget",
222
- resumen: "Shows what to paste into the app to get the chat, built by the engine with the project's ids: the package and its exact version, the Next.js wrapper file (`nextjs.contenido`, as is, without rewriting the ids), the file name to use (`nextjs.archivo_sugerido`) and the earlier names the chat may already be installed under (`nextjs.archivos_anteriores`), how to use it, the CSP line and the <script> one (not for Next.js). It writes no files. Exits 4 sin_proyecto if the repo has no project; 1 respuesta_inesperada if the engine sends something halfway.",
223
- flags: ["--repo owner/repo: the repo, if it isn't this folder's «origin»", "--json: the engine's object as is"],
222
+ resumen: "Shows what to paste into the app to get the chat, built by the engine with the project's ids: the package and its exact version, the Next.js wrapper file (`nextjs.contenido`, as is, without rewriting the ids), the file name to use (`nextjs.archivo_sugerido`) and the earlier names the chat may already be installed under (`nextjs.archivos_anteriores`), how to use it, the CSP line and the <script> one (not for Next.js). If the engine describes the app's own interface (`interfaz_propia`: the app builds the chat with its own components; there is no code to paste) it passes it on as the engine sent it: `estado` (`sin_publicar`: the version isn't on npm yet, so the text doesn't offer the path; `disponible`), the package with its exact version, the ids, the backend, the CSP line, the presentation contract (`contrato.version`, `contrato.archivo`, `contrato.documento`), the entry points (`entradas`) and the docs (`documentos`). A halfway `interfaz_propia` is ignored with a warning on stderr and the widget is shown as always. It writes no files. Exits 4 sin_proyecto if the repo has no project; 1 respuesta_inesperada if the engine sends the widget itself halfway.",
223
+ flags: ["--repo owner/repo: the repo, if it isn't this folder's «origin»", "--json: the engine's object as is (`interfaz_propia` only when the engine sends it)"],
224
224
  ejemplos: ["supcoflow widget --json"],
225
225
  codigos: [0, 1, 2, 3, 4],
226
226
  },
227
227
  {
228
228
  nombre: "install record",
229
- resumen: "Records in .supcoflow/instalacion.json (this clone's root, version 2) which versions the chat was installed with: the skill, this terminal, the chat package (declared in the app's package.json and installed in node_modules), the project configuration and, with --file, the file the chat lives in; never a secret or a date. Repeating without changes doesn't touch it (sin_cambios: true), and running again without --file keeps the file already recorded. It only rewrites it if git already keeps the previous one: with uncommitted changes (or deleted without a commit) it exits 4 cambios_sin_commit; if it exists without being tracked (or ignored), 4 archivo_no_versionado. Others: 3 falta_skill / skill_invalida / archivo_invalido / app_fuera_del_repo / app_inexistente / sin_clon; 4 sin_proyecto / acuerdo_ilegible; 1 paquete_ilegible / configuracion_inesperada / archivo_no_escrito.",
229
+ resumen: "Records in .supcoflow/instalacion.json (this clone's root, version 3) which versions the chat was installed with: the skill, this terminal, the chat package (declared in the app's package.json and installed in node_modules), the project configuration and what was installed: `forma` (`widget` or `interfaz_propia`, with --shape), `archivos` (the files the chat lives in, with --file) and `contrato` (the version of the presentation contract the installed package carries in its contract.json, or null if it has none); never a secret or a date. A version 1 or 2 file is read without error as a widget (the `archivo` of a 2 is its file). Changing --shape without --file leaves `archivos` empty (the files of the other form aren't inherited). Repeating without changes doesn't touch it (sin_cambios: true), and running again without --shape or --file keeps the form and the files already recorded. The JSON adds `archivos`, `forma` and `contrato`; `archivo` (the first file) stays for the skills before 0.9.0. It only rewrites it if git already keeps the previous one: with uncommitted changes (or deleted without a commit) it exits 4 cambios_sin_commit; if it exists without being tracked (or ignored), 4 archivo_no_versionado. Others: 3 falta_skill / skill_invalida / archivo_invalido / forma_invalida / app_fuera_del_repo / app_inexistente / sin_clon; 4 sin_proyecto / acuerdo_ilegible / escrito_por_una_terminal_mas_nueva (the file is version 4 or more: it doesn't rewrite it); 1 paquete_ilegible / configuracion_inesperada / archivo_no_escrito.",
230
230
  flags: [
231
231
  "--skill version: the metadata.version of the skill that installs it (semver, required; without it exits 3 falta_skill without requesting anything)",
232
- "--file path: the chat file, relative to the repo root (no `..`, not absolute, not a symlink or through one; otherwise exits 3 archivo_invalido and writes nothing)",
232
+ "--file path: a file of the chat, relative to the repo root (no `..`, not absolute, not a symlink or through one; otherwise exits 3 archivo_invalido and writes nothing). It can be repeated (up to 20; an own interface lives in several files) and replaces the list already recorded",
233
+ "--shape widget|own-interface: what was installed: the packaged widget, or a chat the app builds with its own components (`forma: interfaz_propia`); anything else exits 3 forma_invalida. Without it the recorded form is kept",
233
234
  "--app folder: the app's folder, relative to the repo root (the root by default; outside the repo it exits 3 app_fuera_del_repo)",
234
235
  "--repo owner/repo: the repo, if it isn't this folder's «origin» (the file goes in this clone only if it is that repo: its origin, or a `supcoflow` remote at https://github.com/owner/repo.git; otherwise it exits 3 sin_clon saying what this clone is and the exact `git remote add supcoflow …`, without asking the engine)",
235
236
  "--dry-run: shows the file and the difference without writing anything",
@@ -240,8 +241,8 @@ export const DESCRIPCION = {
240
241
  },
241
242
  {
242
243
  nombre: "diagnose",
243
- resumen: "Says, free of charge and writing nothing, how far the installation is (sign-in, repo, GitHub App, allowed sites, chat and guide: N of 6) and, apart, whether a test of the project would go in and how far it would get: policy, monthly balance against the test's ceiling, open tests, whether THIS machine can save the test request (the local point `maquina`, checked without writing anything) and whatever else the engine adds. Exits 0 if everything is ready and 4 if something needs fixing (no_listo), with each point; if `maquina` isn't ok, `listo` and `prueba.listo` are false and the N of 6 doesn't change.",
244
- flags: ["--repo owner/repo: the repo, if it isn't this folder's «origin»", "--json: { instalacion: { ok, total: 6, puntos }, prueba: { listo, puntos }, maquina: { ok, impedimentos: [{ codigo, detalle }] }, intento_pendiente: { techo_usd, politica } | null, … }"],
244
+ resumen: "Says, free of charge and writing nothing, how far the installation is (sign-in, repo, GitHub App, allowed sites, chat, guide and interface: N of 7) and, apart, whether a test of the project would go in and how far it would get: policy, monthly balance against the test's ceiling, open tests, whether THIS machine can save the test request (the local point `maquina`, checked without writing anything) and whatever else the engine adds. Exits 0 if everything is ready and 4 if something needs fixing (no_listo), with each point; if `maquina` isn't ok, `listo` and `prueba.listo` are false and the N of 7 doesn't change. The seventh point, `interfaz`, is local and only reads: if .supcoflow/instalacion.json records an own interface (`forma: interfaz_propia`) it checks that the recorded files are still there and that the recorded contract is not behind a new major version (the first number) of the one the project declares (`interfaz_propia.contrato.version` in `widget`; a newer minor or patch only adds optional points and stays ok, saying so); without an own interface it is ok («Not applicable»). It is advice, marked `aviso: true` in the JSON: it lowers the N of 7 but doesn't stop a test.",
245
+ flags: ["--repo owner/repo: the repo, if it isn't this folder's «origin»", "--json: { instalacion: { ok, total: 7, puntos }, prueba: { listo, puntos }, maquina: { ok, impedimentos: [{ codigo, detalle }] }, intento_pendiente: { techo_usd, politica } | null, … }"],
245
246
  ejemplos: ["supcoflow diagnose --json"],
246
247
  codigos: [0, 1, 2, 3, 4],
247
248
  },
@@ -1,8 +1,9 @@
1
1
  // src/comandos/instalacion.ts
2
- // `install record --skill <versión> [--app <carpeta>] [--file <ruta>] [--dry-run]` (bloque 6 y 0.8.0): deja en
3
- // `.supcoflow/instalacion.json` con qué versiones se instaló el chat y, desde la 0.8.0 (v2), en qué archivo quedó
4
- // (`--file`). Lee la configuración del motor y los `package.json` de la app y del paquete instalado; nunca ejecuta
5
- // nada del repo (ni `npm ls` ni `node`).
2
+ // `install record --skill <versión> [--app <carpeta>] [--file <ruta>]... [--shape widget|own-interface] [--dry-run]`
3
+ // (bloque 6, 0.8.0 y 0.9.0): deja en `.supcoflow/instalacion.json` con qué versiones se instaló el chat y, desde la 0.9.0
4
+ // (v3, T8), qué se instaló (`--shape`: el widget o una interfaz propia de la app), en qué archivos quedó (`--file`, repetible)
5
+ // y qué versión del contrato de presentación trae el paquete instalado (su `contract.json`). Lee la configuración del motor y
6
+ // los `package.json` y `contract.json` de la app y del paquete instalado; nunca ejecuta nada del repo (ni `npm ls` ni `node`).
6
7
  //
7
8
  // La regla de §13 («no pisa archivos ni cambios sin commit») adaptada a un archivo que tiene que poder cambiar
8
9
  // (decisión 11): se reescribe sólo si git ya guarda el anterior. El control se hace ANTES de escribir; si la
@@ -11,11 +12,13 @@
11
12
  import { lstatSync, realpathSync, statSync } from "node:fs";
12
13
  import path from "node:path";
13
14
  import { resultadoDeFallo, SALIDA, taparSecretos } from "../contrato.js";
14
- import { archivoAnotado, armarInstalacion, esSemver, PAQUETE_DEL_CHAT, RUTA_DE_LA_INSTALACION, serializar, validarElArchivoDelChat } from "../instalacion.js";
15
+ import { anotadoEn, armarInstalacion, leerInstalacionAnotada, esSemver, PAQUETE_DEL_CHAT, RUTA_DE_LA_INSTALACION, serializar, validarLosArchivos } from "../instalacion.js";
15
16
  import { NO_LEIDO } from "../lectura.js";
16
17
  import { repoPedido } from "../repo-local.js";
17
18
  import { fallo } from "./configurar.js";
18
19
  import { proyectoDelRepo } from "./proyecto-del-repo.js";
20
+ // `--shape` en inglés; al archivo va el nombre de la API (como `--mode save` y `guardar`).
21
+ const FORMA_DE_LA_FLAG = { widget: "widget", "own-interface": "interfaz_propia" };
19
22
  export function esEnlaceReal(p) {
20
23
  try {
21
24
  return lstatSync(p).isSymbolicLink();
@@ -59,10 +62,22 @@ function leerJson(d, p, nombre) {
59
62
  return { ok: false, noExiste: false, resultado: fallo(SALIDA.inesperado, "paquete_ilegible", `${nombre} isn't valid JSON.`) };
60
63
  }
61
64
  }
62
- // Como lo resuelve Node desde la app (revisión externa, hallazgo 4): el primer `node_modules/@supcoflow/chat`
63
- // subiendo desde <app> hasta la raíz del repo. En un monorepo npm lo deja en la raíz; pnpm enlaza a
64
- // `node_modules/.pnpm/…`, adentro del repo. Si la ruta real sale del repo, no se lee: lo que hay afuera no es
65
- // de esta instalación y la terminal no lee fuera del repo por un enlace que el repo eligió.
65
+ function contratoDelPaquete(d, raiz, carpetaReal, avisos) {
66
+ const candidato = path.join(carpetaReal, "contract.json");
67
+ const real = d.rutaReal(candidato);
68
+ if (real === null)
69
+ return { ok: true, valor: null };
70
+ if (!adentro(raiz, real)) {
71
+ const aviso = `${path.relative(raiz, candidato)} points outside the repo; I don't read it and I record the contract as null.`;
72
+ avisos.push(aviso);
73
+ d.avisar(aviso);
74
+ return { ok: true, valor: null };
75
+ }
76
+ const j = leerJson(d, real, `the contract.json of ${PAQUETE_DEL_CHAT}`);
77
+ if (j.ok)
78
+ return j;
79
+ return { ok: false, resultado: j.noExiste ? fallo(SALIDA.inesperado, "paquete_ilegible", `I can't read the contract.json of ${PAQUETE_DEL_CHAT}.`) : j.resultado };
80
+ }
66
81
  function paqueteInstalado(d, raiz, app, avisos) {
67
82
  for (let dir = app;; dir = path.dirname(dir)) {
68
83
  const candidato = path.join(dir, "node_modules", ...PAQUETE_DEL_CHAT.split("/"), "package.json");
@@ -72,16 +87,18 @@ function paqueteInstalado(d, raiz, app, avisos) {
72
87
  const aviso = `${path.relative(raiz, candidato)} points outside the repo; I don't read it and I record the installed chat as null.`;
73
88
  avisos.push(aviso);
74
89
  d.avisar(aviso);
75
- return { ok: true, valor: null };
90
+ return { ok: true, valor: null, contrato: null };
76
91
  }
77
92
  const j = leerJson(d, real, `the installed package.json of ${PAQUETE_DEL_CHAT}`);
78
- if (j.ok)
79
- return j;
80
- // Existía hace un instante: si ahora no está, tampoco se anota «no está».
81
- return { ok: false, resultado: j.noExiste ? fallo(SALIDA.inesperado, "paquete_ilegible", `I can't read the installed package.json of ${PAQUETE_DEL_CHAT}.`) : j.resultado };
93
+ if (!j.ok) {
94
+ // Existía hace un instante: si ahora no está, tampoco se anota «no está».
95
+ return { ok: false, resultado: j.noExiste ? fallo(SALIDA.inesperado, "paquete_ilegible", `I can't read the installed package.json of ${PAQUETE_DEL_CHAT}.`) : j.resultado };
96
+ }
97
+ const c = contratoDelPaquete(d, raiz, path.dirname(real), avisos);
98
+ return c.ok ? { ok: true, valor: j.valor, contrato: c.valor } : c;
82
99
  }
83
100
  if (dir === raiz || path.dirname(dir) === dir)
84
- return { ok: true, valor: null };
101
+ return { ok: true, valor: null, contrato: null };
85
102
  }
86
103
  }
87
104
  async function estadoDelArchivo(d, raizGit, existe) {
@@ -123,19 +140,29 @@ function diff(antes, despues) {
123
140
  }
124
141
  return lineas.join("\n");
125
142
  }
126
- const resumen = (x) => `skill ${x.skill}, terminal ${x.terminal}, chat declared ${x.chat.declarado ?? "—"} / installed ${x.chat.instalado ?? "—"}${x.archivo !== null ? `, file ${x.archivo}` : ""}`;
143
+ const resumen = (x) => `skill ${x.skill}, terminal ${x.terminal}, chat declared ${x.chat.declarado ?? "—"} / installed ${x.chat.instalado ?? "—"}`
144
+ + `${x.forma === null ? "" : `, ${x.forma === "widget" ? "widget" : "own interface"}`}`
145
+ + `${x.archivos.length === 0 ? "" : `, ${x.archivos.length === 1 ? "file" : "files"} ${x.archivos.join(", ")}`}`
146
+ + `${x.contrato === null ? "" : `, contract ${x.contrato}`}`;
127
147
  export async function instalacionAnotar(d, flags) {
128
148
  if (flags.skill === null)
129
149
  return fallo(SALIDA.entradaInvalida, "falta_skill", "The skill version is missing: `supcoflow install record --skill 0.1.0`.");
130
150
  if (!esSemver(flags.skill))
131
151
  return fallo(SALIDA.entradaInvalida, "skill_invalida", "--skill goes with a semver version (for example 0.1.0).");
132
- // El archivo del chat se mira antes de cualquier pedido: una ruta imposible no gasta una vuelta al motor.
133
- let archivoPedido = null;
134
- if (flags.file !== undefined && flags.file !== null) {
135
- const v = validarElArchivoDelChat(flags.file);
152
+ // La forma y los archivos del chat se miran antes de cualquier pedido: algo imposible no gasta una vuelta al motor.
153
+ let formaPedida = null;
154
+ if (flags.shape !== undefined && flags.shape !== null) {
155
+ formaPedida = Object.hasOwn(FORMA_DE_LA_FLAG, flags.shape) ? FORMA_DE_LA_FLAG[flags.shape] : null;
156
+ if (formaPedida === null)
157
+ return fallo(SALIDA.entradaInvalida, "forma_invalida", "--shape goes with `widget` (the packaged widget) or `own-interface` (a chat the app builds with its own components).");
158
+ }
159
+ const crudos = flags.files !== undefined && flags.files.length > 0 ? flags.files : flags.file !== undefined && flags.file !== null ? [flags.file] : [];
160
+ let archivosPedidos = null;
161
+ if (crudos.length > 0) {
162
+ const v = validarLosArchivos(crudos);
136
163
  if (!v.ok)
137
164
  return fallo(SALIDA.entradaInvalida, "archivo_invalido", v.motivo);
138
- archivoPedido = v.ruta;
165
+ archivosPedidos = v.rutas;
139
166
  }
140
167
  // ─── La raíz y la app, antes de cualquier pedido ───
141
168
  let raizGit;
@@ -162,7 +189,7 @@ export async function instalacionAnotar(d, flags) {
162
189
  return fallo(SALIDA.entradaInvalida, "app_inexistente", "--app has to be a folder (the app's), not a file.");
163
190
  // `--file`: ni un enlace simbólico ni un camino que pase por uno (saldría del repo sin que se vea). El archivo todavía
164
191
  // puede no existir (con `--dry-run` se anota antes de escribirlo), pero ninguna carpeta del camino puede ser un enlace.
165
- if (archivoPedido !== null) {
192
+ for (const archivoPedido of archivosPedidos ?? []) {
166
193
  const partes = archivoPedido.split("/");
167
194
  for (let i = 1; i <= partes.length; i++) {
168
195
  if (d.esEnlace(path.join(raiz, ...partes.slice(0, i)))) {
@@ -197,18 +224,32 @@ export async function instalacionAnotar(d, flags) {
197
224
  const actual = d.leerArchivoChico(archivo, TOPE_DEL_ARCHIVO);
198
225
  const existe = !("error" in actual) || actual.error !== NO_LEIDO.noExiste;
199
226
  const antes = "bytes" in actual ? Buffer.from(actual.bytes).toString("utf8") : null;
200
- // Re-correr sin `--file` no borra el archivo que ya se anotó; un v1 (sin `archivo`) da null, sin error.
201
- const archivoDelChat = archivoPedido ?? archivoAnotado(antes);
227
+ // Re-correr sin `--shape` ni `--file` no borra lo que ya se anotó (v2: su `archivo` es el widget; v1: nada, sin error).
228
+ // `--file` reemplaza la lista entera y `--shape` la forma: lo que se pide manda, lo que no, se conserva.
229
+ // Un archivo de una terminal más nueva no se reescribe: se perdería lo que esa versión anotó.
230
+ if (leerInstalacionAnotada(antes).estado === "mas_nueva") {
231
+ return fallo(SALIDA.haceFaltaUnaPersona, "escrito_por_una_terminal_mas_nueva", `${RUTA_DE_LA_INSTALACION} was written by a newer supcoflow than this one; I didn't touch it. Update supcoflow (\`npx supcoflow@latest\`) and run again.`);
232
+ }
233
+ const anotado = anotadoEn(antes);
234
+ // Cambiar la forma sin decir los archivos no hereda los de la forma anterior (el envoltorio del widget no es la interfaz propia).
235
+ const cambiaLaForma = formaPedida !== null && anotado.forma !== null && formaPedida !== anotado.forma;
202
236
  let x;
203
237
  try {
204
- x = armarInstalacion({ skill: flags.skill, terminal: d.terminal, paqueteDeLaApp: deLaApp.ok ? deLaApp.valor : null, paqueteInstalado: instalado.valor, configuracion, archivo: archivoDelChat });
238
+ x = armarInstalacion({
239
+ skill: flags.skill, terminal: d.terminal, paqueteDeLaApp: deLaApp.ok ? deLaApp.valor : null, paqueteInstalado: instalado.valor, configuracion,
240
+ forma: formaPedida ?? anotado.forma, archivos: archivosPedidos ?? (cambiaLaForma ? [] : anotado.archivos), contrato: instalado.contrato,
241
+ });
205
242
  }
206
243
  catch (e) {
207
244
  return resultadoDeFallo(e);
208
245
  }
209
246
  const nuevo = serializar(x);
210
247
  // ─── ¿Se puede escribir? ───
211
- const json = { ok: true, ruta: RUTA_DE_LA_INSTALACION, instalacion: x, archivo: x.archivo, ...(avisos.length > 0 ? { avisos } : {}) };
248
+ // `archivo` (el primero) sigue en el JSON por las skills de antes de la 0.9.0; lo nuevo es `archivos`, `forma` y `contrato`.
249
+ const json = {
250
+ ok: true, ruta: RUTA_DE_LA_INSTALACION, instalacion: x, archivo: x.archivos[0] ?? null, archivos: x.archivos, forma: x.forma, contrato: x.contrato,
251
+ ...(avisos.length > 0 ? { avisos } : {}),
252
+ };
212
253
  if ("bytes" in actual && Buffer.from(actual.bytes).equals(Buffer.from(nuevo, "utf8"))) {
213
254
  return { codigo: SALIDA.ok, json: { ...json, ...(flags.dryRun ? { dry_run: true } : {}), sin_cambios: true }, texto: `${RUTA_DE_LA_INSTALACION} already says that (${resumen(x)}); I didn't touch it.` };
214
255
  }
@@ -1,5 +1,30 @@
1
+ // `supcoflow diagnose` y `supcoflow test` (bloque 7, la prueba): ¿un problema que entra por el
2
+ // chat llega hasta un issue? El diagnóstico es gratis y sólo lee. La prueba gasta un relevamiento (techo
3
+ // del motor, hoy US$ 2): sin `--authorize-spend` no se manda nada que escriba (R6).
4
+ //
5
+ // Lo que la persona vio es lo que viaja (R5, 0.8.0): `--authorize-spend` exige `--ceiling` (el número que se le
6
+ // mostró) y `--expect-policy` (la política con la que se le ofreció) y manda esos dos valores; no relee el
7
+ // diagnóstico para elegirlos. El motor los compara con los de ahora: `techo_distinto` / `politica_distinta` salen
8
+ // 4 con los valores nuevos y sin reintentar, y la persona decide de nuevo. El alias viejo `probar --autorizo-gasto`
9
+ // exige también `--ceiling` (un alias no puede ampliar lo autorizado) pero no `--expect-policy`.
10
+ //
11
+ // El ciclo de la clave (hallazgo 5), en el orden en que se lee abajo:
12
+ // 1. Sin intento guardado: clave nueva, y el intento ENTERO guardado antes del POST (estricto).
13
+ // 2. Con intento guardado: es un reintento; misma clave, mismo mensaje, mismo techo, misma política. Otro
14
+ // `--message`, otro `--ceiling` u otra `--expect-policy` sale 4 `intento_pendiente` sin mandar nada.
15
+ // 3. 202: la última prueba = la del motor y el intento cerrado, en una escritura (src/pruebas-guardadas.ts).
16
+ // Si falla, sale 1 con el id y el intento sigue abierto: el próximo recibe la misma prueba.
17
+ // 4. Ambigua (sin respuesta, 5xx, pausa, otro intento en curso): el intento se conserva. Rechazo
18
+ // definitivo: se cierra, el motor no creó nada con esa clave.
19
+ // 5. Una prueba nueva es `test --authorize-spend` sin intento abierto: clave nueva aunque el mensaje
20
+ // sea el mismo.
21
+ //
22
+ // `--wait` sigue la prueba por tramos (≤ 90 s el comando entero, como `login --wait`): el estado lo deriva
23
+ // el motor en cada llamada y acá sólo se traduce a un código de salida con la tabla de la prueba.
24
+ import path from "node:path";
1
25
  import { resultadoDeFallo, SALIDA } from "../contrato.js";
2
- import { agruparPuntos, contarBuenos, lineaDeLoRevisado, nombreDelPunto, puntoDeSesion, puntoDelRepo, TOTAL_DE_INSTALACION } from "../diagnostico.js";
26
+ import { agruparPuntos, contarBuenos, lineaDeLoRevisado, nombreDelPunto, puntoDeLaInterfaz, puntoDeSesion, puntoDelRepo, TOTAL_DE_INSTALACION } from "../diagnostico.js";
27
+ import { leerInstalacionAnotada, RUTA_DE_LA_INSTALACION } from "../instalacion.js";
3
28
  import { leerRepoLocal } from "../repo-local.js";
4
29
  import { PLAZO_MINIMO_DE_SONDEO_MS, TRAMO_MAXIMO_MS } from "../sondeo.js";
5
30
  import { fallo, rutaDe } from "./configurar.js";
@@ -64,6 +89,41 @@ function puntoDeLaMaquina(r) {
64
89
  ? { punto: "maquina", ok: true, mensaje: "No obstacle found to saving the test request on this machine.", grupo: "prueba" }
65
90
  : { punto: "maquina", ok: false, mensaje: `This machine can't save the test request yet: ${detalles(r)}`, grupo: "prueba" };
66
91
  }
92
+ // El punto 7 (T8): lee `.supcoflow/instalacion.json` del clon y, sólo si hay una interfaz propia anotada, mira que sus archivos
93
+ // sigan y le pregunta al proyecto qué contrato declara hoy. Todo lo que no se pueda mirar queda «sin mirar», nunca en contra.
94
+ async function puntoDeLaInterfazDeEsteClon(d, local, proyecto, clave) {
95
+ const esElClon = local.raiz !== null && local.repo !== null && local.repo.toLowerCase() === proyecto.repo.toLowerCase();
96
+ if (!esElClon || d.archivos === undefined)
97
+ return puntoDeLaInterfaz({ lectura: null, faltan: [], delProyecto: null });
98
+ let lectura;
99
+ try {
100
+ lectura = leerInstalacionAnotada(d.archivos.leer(path.join(local.raiz, RUTA_DE_LA_INSTALACION)));
101
+ }
102
+ catch {
103
+ lectura = { estado: "ilegible" };
104
+ }
105
+ if (lectura.estado !== "leida" || lectura.forma !== "interfaz_propia")
106
+ return puntoDeLaInterfaz({ lectura, faltan: [], delProyecto: null });
107
+ const faltan = lectura.archivos.filter((f) => {
108
+ try {
109
+ const completo = path.join(local.raiz, f);
110
+ return d.existe !== undefined ? !d.existe(completo) : d.archivos.leer(completo) === null;
111
+ }
112
+ catch {
113
+ return true;
114
+ }
115
+ });
116
+ let delProyecto = null;
117
+ if (d.leerWidget !== undefined) {
118
+ try {
119
+ delProyecto = (await d.leerWidget(d.api, clave, proyecto.id)).interfaz_propia?.contrato.version ?? null;
120
+ }
121
+ catch {
122
+ delProyecto = null; // no se pudo preguntar: no se compara
123
+ }
124
+ }
125
+ return puntoDeLaInterfaz({ lectura, faltan, delProyecto });
126
+ }
67
127
  export async function diagnosticar(d, flags) {
68
128
  const p = await proyectoDelRepo(d, flags);
69
129
  if (!p.ok)
@@ -84,10 +144,10 @@ export async function diagnosticar(d, flags) {
84
144
  catch {
85
145
  local = { repo: null, raiz: null, motivo: "sin_git" };
86
146
  }
87
- const agrupados = agruparPuntos(g, [puntoDeSesion(), puntoDelRepo(local, p.proyecto.repo)]);
147
+ const agrupados = agruparPuntos(g, [puntoDeSesion(), puntoDelRepo(local, p.proyecto.repo), await puntoDeLaInterfazDeEsteClon(d, local, p.proyecto, p.clave)]);
88
148
  const instalacion = agrupados.instalacion;
89
149
  // El punto local de la prueba (1A.1): ¿esta máquina puede guardar el pedido? Si no, la prueba no entra, y se dice acá y
90
- // no después del OK de gasto. No cuenta en el «N of 6» de la instalación.
150
+ // no después del OK de gasto. No cuenta en el «N of 7» de la instalación.
91
151
  const revision = await revisarLaMaquina(d, p.proyecto.id);
92
152
  const prueba = [puntoDeLaMaquina(revision), ...agrupados.prueba];
93
153
  const buenos = contarBuenos(instalacion);
@@ -1,5 +1,30 @@
1
1
  import { resultadoDeFallo, SALIDA } from "../contrato.js";
2
2
  import { proyectoDelRepo } from "./proyecto-del-repo.js";
3
+ // La forma «interfaz propia» (T8): sin código para pegar. La app arma el chat con sus propios componentes; esto es lo que
4
+ // hay que instalar y el contrato que la interfaz tiene que cumplir. Todo sale del motor, tal cual (también las rutas). Con
5
+ // `sin_publicar` la versión todavía no está en npm: no se ofrece el camino (ni un `npm i` que fallaría); el JSON sí lo dice.
6
+ function textoDeLaInterfazPropia(p) {
7
+ if (p.estado !== "disponible") {
8
+ return ["", `Own interface: not published yet (${p.paquete}@${p.version} is not on npm); use the widget above.`];
9
+ }
10
+ return [
11
+ "",
12
+ "Own interface (build the chat with the app's own components, instead of steps 2 and 3; there is no code to paste):",
13
+ " Install the package (the exact version):",
14
+ ` ${p.instalar}`,
15
+ ` tenantId: ${p.opciones_del_nucleo.tenantId}`,
16
+ ` projectId: ${p.opciones_del_nucleo.projectId}`,
17
+ ` backendUrl: ${p.backend_url}`,
18
+ " Content Security Policy (CSP): if the app has `connect-src`, add this origin and keep what it already has:",
19
+ ` ${p.csp}`,
20
+ ` Presentation contract: ${p.contrato.version}. Once the package is installed, read it offline (paths relative to the folder where you ran npm i):`,
21
+ ` ${p.contrato.archivo}`,
22
+ ` ${p.contrato.documento}`,
23
+ " What the interface must do is in the contract; how it looks is the app's.",
24
+ ` Entry points: ${p.entradas.nucleo} (core), ${p.entradas.react} (React), ${p.entradas.pruebas} and ${p.entradas.servidor_de_pruebas} (test bench).`,
25
+ ` Docs: ${p.documentos.nucleo}, ${p.documentos.react}, ${p.documentos.pruebas}, ${p.documentos.conformidad}, ${p.documentos.protocolo}.`,
26
+ ];
27
+ }
3
28
  function textoDe(w) {
4
29
  const anteriores = w.nextjs.archivos_anteriores;
5
30
  return [
@@ -20,6 +45,7 @@ function textoDe(w) {
20
45
  "",
21
46
  "Alternative with <script> (available, not tested in a real app):",
22
47
  ` ${w.script.linea}`,
48
+ ...(w.interfaz_propia === undefined ? [] : textoDeLaInterfazPropia(w.interfaz_propia)),
23
49
  ].join("\n");
24
50
  }
25
51
  export async function widget(d, flags) {
@@ -1,5 +1,5 @@
1
+ import { compararSemver, RUTA_DE_LA_INSTALACION } from "./instalacion.js";
1
2
  import { frasePorQueNoAtaClon } from "./repo-local.js";
2
- export const TOTAL_DE_INSTALACION = 6;
3
3
  // El orden y los nombres con que se dicen: los ids de los puntos del motor son datos suyos y no cambian.
4
4
  const DE_LA_INSTALACION = [
5
5
  { punto: "sign-in", como: "sign-in" },
@@ -8,7 +8,9 @@ const DE_LA_INSTALACION = [
8
8
  { punto: "sitios", como: "allowed sites" },
9
9
  { punto: "chat", como: "chat" },
10
10
  { punto: "guia", como: "guide" },
11
+ { punto: "interfaz", como: "interface" },
11
12
  ];
13
+ export const TOTAL_DE_INSTALACION = DE_LA_INSTALACION.length;
12
14
  const DEL_MOTOR = new Set(["instalacion", "sitios", "chat", "guia"]);
13
15
  export const lineaDeLoRevisado = (n) => `Checked ${DE_LA_INSTALACION.map((x) => x.como).slice(0, -1).join(", ")} and ${DE_LA_INSTALACION.at(-1).como}: ${n} of ${TOTAL_DE_INSTALACION}, at no cost.`;
14
16
  export const nombreDelPunto = (punto) => DE_LA_INSTALACION.find((x) => x.punto === punto)?.como ?? punto;
@@ -31,6 +33,46 @@ export function puntoDelRepo(local, repoDelProyecto) {
31
33
  }
32
34
  return { punto: "repo", ok: true, mensaje: `The origin of this folder is ${local.repo}, the project's repo.`, grupo: "instalacion" };
33
35
  }
36
+ /**
37
+ * El punto 7 (T8): la interfaz propia de la app. Puro: recibe lo ya leído. Es un AVISO local: baja el «N of 7» pero no frena una
38
+ * prueba (la prueba mira que un problema llegue a un issue, no cómo se ve el chat). Sin interfaz propia anotada no aplica y
39
+ * cuenta como ok. El contenido de `instalacion.json` nunca se copia al mensaje.
40
+ * · `lectura`: lo que dice el archivo (o `null` si no se miró: el clon no es el del repo del proyecto).
41
+ * · `faltan`: los archivos anotados que ya no están.
42
+ * · `delProyecto`: el contrato que declara el proyecto hoy (`interfaz_propia.contrato.version`), o null si no se supo.
43
+ */
44
+ const mayor = (v) => Number(v.split(".")[0]);
45
+ export function puntoDeLaInterfaz(i) {
46
+ const punto = (ok, mensaje) => ({ punto: "interfaz", ok, mensaje, grupo: "instalacion", aviso: true });
47
+ if (i.lectura === null)
48
+ return punto(true, "Not checked: this folder isn't a clone of the project's repo, so I don't read its installation record.");
49
+ const l = i.lectura;
50
+ if (l.estado === "ausente")
51
+ return punto(true, `Not applicable: there's no ${RUTA_DE_LA_INSTALACION}, so no own interface is recorded.`);
52
+ if (l.estado === "mas_nueva")
53
+ return punto(false, `${RUTA_DE_LA_INSTALACION} was written by a newer supcoflow than this one: update supcoflow (\`npm i -g supcoflow@latest\` or \`npx supcoflow@latest\`) to read it.`);
54
+ if (l.estado === "ilegible")
55
+ return punto(false, `I can't read ${RUTA_DE_LA_INSTALACION}: run \`supcoflow install record\` again.`);
56
+ if (l.forma !== "interfaz_propia") {
57
+ return punto(true, l.forma === "widget" ? "Not applicable: the chat is recorded as the packaged widget, which has no interface contract to check." : "Not applicable: no own interface is recorded.");
58
+ }
59
+ if (l.archivos.length === 0)
60
+ return punto(false, "The installation says the chat is an own interface but records none of its files: run `supcoflow install record --file …` again.");
61
+ if (i.faltan.length > 0)
62
+ return punto(false, `These files of the interface are recorded but gone: ${i.faltan.join(", ")}. Run the skill again to update the record.`);
63
+ if (l.contrato === null) {
64
+ return punto(false, "The interface doesn't record which presentation contract it follows (the installed @supcoflow/chat has no contract.json): update the package and run `supcoflow install record` again.");
65
+ }
66
+ const archivos = `${l.archivos.length} ${l.archivos.length === 1 ? "file" : "files"}`;
67
+ if (i.delProyecto !== null && compararSemver(i.delProyecto, l.contrato) > 0) {
68
+ // Un obligatorio nuevo sólo llega con una versión MAYOR del contrato (plan §2.5.8): una menor o de parche suma lo opcional.
69
+ if (mayor(i.delProyecto) > mayor(l.contrato)) {
70
+ return punto(false, `The interface (${archivos}) follows contract ${l.contrato}, and the project now declares ${i.delProyecto}, a new major version: run the skill again to see what changed and check that the interface still conforms.`);
71
+ }
72
+ return punto(true, `The interface (${archivos}) follows contract ${l.contrato}; the project declares ${i.delProyecto}, which only adds optional points: run the skill again to see them.`);
73
+ }
74
+ return punto(true, `The interface (${archivos}) follows contract ${l.contrato}${i.delProyecto === l.contrato ? ", the one the project declares" : ""}.`);
75
+ }
34
76
  /** Reparte los puntos del motor en los dos grupos y suma los dos locales al de la instalación. */
35
77
  export function agruparPuntos(g, locales) {
36
78
  const delMotor = g.puntos.map((p) => ({ ...p, grupo: p.grupo ?? (DEL_MOTOR.has(p.punto) ? "instalacion" : "prueba") }));
package/dist/entorno.js CHANGED
@@ -508,7 +508,7 @@ export function entornoReal(env = process.env) {
508
508
  widget: {
509
509
  api, guardado, git: gitReal, cwd, archivos: archivosDelRepo(avisar),
510
510
  buscarProyecto: configurarEnElMotor.buscarProyecto,
511
- leerWidget: (a, clave, id) => leerWidget(a, clave, id, fetch),
511
+ leerWidget: (a, clave, id) => leerWidget(a, clave, id, fetch, avisar),
512
512
  },
513
513
  // La versión que se anota es la de `describir` (pinzada contra package.json por test): una sola fuente
514
514
  // para lo que la terminal dice de sí misma.
@@ -522,6 +522,14 @@ export function entornoReal(env = process.env) {
522
522
  api, guardado, git: gitReal, cwd, avisar, archivos: archivosDelRepo(avisar), pruebas, generar: randomUUID, ahora: Date.now, dormir, tramoMs,
523
523
  buscarProyecto: configurarEnElMotor.buscarProyecto,
524
524
  leerDiagnostico: (a, clave, id) => leerDiagnostico(a, clave, id, fetch),
525
+ leerWidget: (a, clave, id) => leerWidget(a, clave, id, fetch, avisar),
526
+ existe: (p) => { try {
527
+ lstatSync(p);
528
+ return true;
529
+ }
530
+ catch {
531
+ return false;
532
+ } },
525
533
  pedirPrueba: (a, clave, id, intento) => pedirPrueba(a, clave, id, intento, fetch),
526
534
  leerPrueba: (a, clave, id, pruebaId, plazoMs) => leerPrueba(a, clave, id, pruebaId, fetch, plazoMs),
527
535
  },
@@ -28,8 +28,12 @@ export const FLAGS_POR_COMANDO = [
28
28
  flags: ["authorizeSpend", "ceiling", "expectPolicy", "message", "testId"], comandos: ["test"],
29
29
  texto: "--authorize-spend, --ceiling, --expect-policy, --message and --test-id belong to `supcoflow test`.",
30
30
  },
31
- { flags: ["skill", "app"], comandos: ["install record"], texto: "--skill and --app belong to `supcoflow install record`." },
31
+ { flags: ["skill", "app", "shape"], comandos: ["install record"], texto: "--skill, --app and --shape belong to `supcoflow install record`." },
32
32
  ];
33
+ /** `--file` se repite sólo en `install record` (la interfaz propia vive en varios archivos); en los demás va una vez. */
34
+ export function archivosDeMas(comando, cuantos) {
35
+ return cuantos > 1 && comando !== "install record" ? "--file goes once, with a value. Try `supcoflow --help`." : null;
36
+ }
33
37
  /** El mensaje del primer flag presente que el comando no acepta, o null si todo aplica. */
34
38
  export function flagQueNoAplica(comando, presentes) {
35
39
  for (const regla of FLAGS_POR_COMANDO) {
@@ -1,4 +1,4 @@
1
- // `.supcoflow/instalacion.json` (bloque 6, decisión 3; v2 en la 0.8.0): con qué versiones se hizo la instalación, anotado en el
1
+ // `.supcoflow/instalacion.json` (bloque 6, decisión 3; v2 en la 0.8.0; v3 en la 0.9.0): con qué versiones se hizo la instalación, anotado en el
2
2
  // repo de quien instala. Supcoflow no lo guarda ni lo ve. Puro: recibe lo ya leído y arma el archivo.
3
3
  //
4
4
  // El archivo queda en el repo y se commitea: un secreto acá se publica con el primer `push`. Por eso se arma con
@@ -7,9 +7,22 @@
7
7
  import { FalloDeLaTerminal, SALIDA, taparSecretos } from "./contrato.js";
8
8
  export const RUTA_DE_LA_INSTALACION = ".supcoflow/instalacion.json";
9
9
  export const PAQUETE_DEL_CHAT = "@supcoflow/chat";
10
+ export const FORMAS = ["widget", "interfaz_propia"];
11
+ export const MAX_ARCHIVOS_ANOTADOS = 20;
10
12
  // 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
13
  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
14
  export const esSemver = (s) => s.length <= 64 && SEMVER.test(s);
15
+ /** Compara `major.minor.patch` número a número (1.10.0 > 1.9.0); lo que sigue al guion (prerelease) o al `+` no cuenta. */
16
+ export function compararSemver(a, b) {
17
+ const partes = (v) => v.split(/[-+]/)[0].split(".").map(Number);
18
+ const [x, y] = [partes(a), partes(b)];
19
+ for (let i = 0; i < 3; i++) {
20
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
21
+ if (d !== 0)
22
+ return d < 0 ? -1 : 1;
23
+ }
24
+ return 0;
25
+ }
13
26
  const objeto = (v) => (typeof v === "object" && v !== null && !Array.isArray(v) ? v : null);
14
27
  const entero = (v) => typeof v === "number" && Number.isInteger(v) && v >= 1;
15
28
  const ilegible = (message) => new FalloDeLaTerminal(SALIDA.inesperado, "paquete_ilegible", message);
@@ -71,14 +84,26 @@ function guiaDe(c) {
71
84
  return { origen: "agente", version: g.version };
72
85
  throw inesperada("a guide");
73
86
  }
87
+ // El `contract.json` del paquete instalado (T1 del sdk): sólo se copia su `version`, nunca el resto. Ausente es null (un
88
+ // paquete anterior al contrato); presente sin una versión que se entienda NO es null: diría que no hay contrato.
89
+ function contratoDe(contrato) {
90
+ if (contrato === null || contrato === undefined)
91
+ return null;
92
+ const v = objeto(contrato)?.version;
93
+ if (typeof v !== "string" || !esSemver(v))
94
+ throw ilegible(`The contract.json of ${PAQUETE_DEL_CHAT} has no version I understand.`);
95
+ return v;
96
+ }
74
97
  export function armarInstalacion(i) {
75
98
  return {
76
- version: 2,
99
+ version: 3,
77
100
  skill: i.skill,
78
101
  terminal: i.terminal,
79
102
  chat: { declarado: declaradoEn(i.paqueteDeLaApp), instalado: instaladoEn(i.paqueteInstalado) },
80
103
  configuracion: { acuerdo_version: acuerdoDe(i.configuracion), politica: politicaDe(i.configuracion), guia: guiaDe(i.configuracion) },
81
- archivo: i.archivo ?? null,
104
+ forma: i.forma ?? null,
105
+ archivos: [...(i.archivos ?? [])],
106
+ contrato: contratoDe(i.contrato),
82
107
  };
83
108
  }
84
109
  // ─── El archivo del chat (`install record --file`) ───
@@ -99,25 +124,73 @@ export function validarElArchivoDelChat(crudo) {
99
124
  return { ok: false, motivo: "--file is the chat file, not a folder: say its path." };
100
125
  return { ok: true, ruta: partes.join("/") };
101
126
  }
102
- /** El `archivo` anotado en un `instalacion.json` ya existente: v1 y cualquier cosa ilegible o inválida dan null (nunca un error). */
103
- export function archivoAnotado(texto) {
127
+ const SIN_NADA = { forma: null, archivos: [], contrato: null };
128
+ // Una lista de rutas anotadas: cada una tiene que ser válida Y estar ya normalizada (es como la escribe esta terminal); lo
129
+ // que no, se descarta de a una, sin error. Sin repetidas y con tope.
130
+ function rutasAnotadas(v) {
131
+ if (!Array.isArray(v))
132
+ return [];
133
+ const rutas = [];
134
+ for (const a of v) {
135
+ if (typeof a !== "string")
136
+ continue;
137
+ const r = validarElArchivoDelChat(a);
138
+ if (r.ok && r.ruta === a && !rutas.includes(a) && rutas.length < MAX_ARCHIVOS_ANOTADOS)
139
+ rutas.push(a);
140
+ }
141
+ return rutas;
142
+ }
143
+ /**
144
+ * Lo que un `instalacion.json` ya existente dice de la forma, los archivos y el contrato. v1 y cualquier cosa ilegible o inválida
145
+ * dan «nada»; una versión que esta terminal no conoce (4 o más) tampoco se adivina. Nunca un error.
146
+ */
147
+ export function leerInstalacionAnotada(texto) {
104
148
  if (texto === null)
105
- return null;
149
+ return { estado: "ausente" };
106
150
  let v;
107
151
  try {
108
152
  v = JSON.parse(texto);
109
153
  }
110
154
  catch {
111
- return null;
155
+ return { estado: "ilegible" };
112
156
  }
113
- // AD9: un v1 se reconoce por `version: 1` (lo escribe la terminal 0.7.0) y no tiene archivo, aunque el campo aparezca.
114
- if (objeto(v)?.version === 1)
115
- return null;
116
- const a = objeto(v)?.archivo;
117
- if (typeof a !== "string")
118
- return null;
119
- const r = validarElArchivoDelChat(a);
120
- return r.ok && r.ruta === a ? a : null;
157
+ const o = objeto(v);
158
+ if (o === null)
159
+ return { estado: "ilegible" };
160
+ // Antes de la 0.9.0 la única forma que existía era el widget: una v1 o una v2 son un widget, con el archivo que la v2 anotó (si lo
161
+ // anotó; la v1 no tiene archivo aunque el campo aparezca).
162
+ if (o.version === 1)
163
+ return { estado: "leida", forma: "widget", archivos: [], contrato: null };
164
+ if (o.version === 2) {
165
+ const a = typeof o.archivo === "string" ? validarElArchivoDelChat(o.archivo) : null;
166
+ return { estado: "leida", forma: "widget", archivos: a !== null && a.ok && a.ruta === o.archivo ? [a.ruta] : [], contrato: null };
167
+ }
168
+ if (o.version === 3) {
169
+ const forma = FORMAS.find((f) => f === o.forma) ?? null;
170
+ const contrato = typeof o.contrato === "string" && esSemver(o.contrato) ? o.contrato : null;
171
+ return { estado: "leida", forma, archivos: rutasAnotadas(o.archivos), contrato };
172
+ }
173
+ if (typeof o.version === "number" && Number.isInteger(o.version) && o.version > 3)
174
+ return { estado: "mas_nueva" };
175
+ return { estado: "leida", ...SIN_NADA };
176
+ }
177
+ export function anotadoEn(texto) {
178
+ const l = leerInstalacionAnotada(texto);
179
+ return l.estado === "leida" ? { forma: l.forma, archivos: l.archivos, contrato: l.contrato } : SIN_NADA;
180
+ }
181
+ /** Valida y normaliza la lista de `--file`: cada ruta con `validarElArchivoDelChat`, sin repetidas, con tope. */
182
+ export function validarLosArchivos(crudos) {
183
+ const rutas = [];
184
+ for (const crudo of crudos) {
185
+ const r = validarElArchivoDelChat(crudo);
186
+ if (!r.ok)
187
+ return r;
188
+ if (!rutas.includes(r.ruta))
189
+ rutas.push(r.ruta);
190
+ }
191
+ if (rutas.length > MAX_ARCHIVOS_ANOTADOS)
192
+ return { ok: false, motivo: `--file goes up to ${MAX_ARCHIVOS_ANOTADOS} times: the files of the chat, not the whole app.` };
193
+ return { ok: true, rutas };
121
194
  }
122
195
  // El orden de las claves es el de `armarInstalacion`: el mismo estado da los mismos bytes, y eso es `sin_cambios`.
123
196
  export function serializar(x) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supcoflow",
3
- "version": "0.8.1",
3
+ "version": "0.9.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" },