supcoflow 0.8.0 → 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.
@@ -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,30 +140,41 @@ 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;
169
+ let sinClon;
142
170
  try {
143
- raizGit = (await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd })).raiz;
171
+ ({ raiz: raizGit, sinClon } = await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd }));
144
172
  }
145
173
  catch (e) {
146
174
  return resultadoDeFallo(e);
147
175
  }
148
176
  if (raizGit === null)
149
- return fallo(SALIDA.entradaInvalida, "sin_clon", `I don't have that repo's clone here, and ${RUTA_DE_LA_INSTALACION} goes in the clone: stand in the repo's clone.`);
177
+ return fallo(SALIDA.entradaInvalida, "sin_clon", `${sinClon ?? "I don't have that repo's clone here."} ${RUTA_DE_LA_INSTALACION} goes in the repo's clone: stand in it.`);
150
178
  const raiz = d.rutaReal(raizGit);
151
179
  if (raiz === null)
152
180
  return fallo(SALIDA.inesperado, "inesperado", "I couldn't resolve the repo's root.");
@@ -161,7 +189,7 @@ export async function instalacionAnotar(d, flags) {
161
189
  return fallo(SALIDA.entradaInvalida, "app_inexistente", "--app has to be a folder (the app's), not a file.");
162
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
163
191
  // puede no existir (con `--dry-run` se anota antes de escribirlo), pero ninguna carpeta del camino puede ser un enlace.
164
- if (archivoPedido !== null) {
192
+ for (const archivoPedido of archivosPedidos ?? []) {
165
193
  const partes = archivoPedido.split("/");
166
194
  for (let i = 1; i <= partes.length; i++) {
167
195
  if (d.esEnlace(path.join(raiz, ...partes.slice(0, i)))) {
@@ -196,18 +224,32 @@ export async function instalacionAnotar(d, flags) {
196
224
  const actual = d.leerArchivoChico(archivo, TOPE_DEL_ARCHIVO);
197
225
  const existe = !("error" in actual) || actual.error !== NO_LEIDO.noExiste;
198
226
  const antes = "bytes" in actual ? Buffer.from(actual.bytes).toString("utf8") : null;
199
- // Re-correr sin `--file` no borra el archivo que ya se anotó; un v1 (sin `archivo`) da null, sin error.
200
- 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;
201
236
  let x;
202
237
  try {
203
- 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
+ });
204
242
  }
205
243
  catch (e) {
206
244
  return resultadoDeFallo(e);
207
245
  }
208
246
  const nuevo = serializar(x);
209
247
  // ─── ¿Se puede escribir? ───
210
- 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
+ };
211
253
  if ("bytes" in actual && Buffer.from(actual.bytes).equals(Buffer.from(nuevo, "utf8"))) {
212
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.` };
213
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";
@@ -44,6 +69,61 @@ function datosDelDiagnostico(g) {
44
69
  };
45
70
  }
46
71
  const lineasDePuntos = (puntos, nombre = (p) => p) => puntos.map((p) => ` [${p.ok ? "ok" : "missing"}] ${nombre(p.punto)}: ${p.mensaje}`);
72
+ // Lo que esta máquina puede o no hacer con el pedido de la prueba, visto ANTES del OK de gasto y sin escribir nada. Un
73
+ // prechequeo que falla es «no ok», nunca un error: avisar tarde es justo lo que esto evita.
74
+ async function revisarLaMaquina(d, proyectoId) {
75
+ try {
76
+ return await d.pruebas.revisar(proyectoId);
77
+ }
78
+ catch {
79
+ return { maquina: { ok: false, impedimentos: [{ codigo: "no_se_pudo_revisar", detalle: "I couldn't check whether this machine can save the test request." }] }, intentoPendiente: null };
80
+ }
81
+ }
82
+ const datosDeLaMaquina = (r) => ({ maquina: r.maquina });
83
+ const datosDelIntentoPendiente = (r) => ({
84
+ intento_pendiente: r.intentoPendiente === null ? null : { techo_usd: r.intentoPendiente.techoUsd, politica: r.intentoPendiente.politica },
85
+ });
86
+ const detalles = (r) => r.maquina.impedimentos.map((i) => i.detalle).join(" ");
87
+ function puntoDeLaMaquina(r) {
88
+ return r.maquina.ok
89
+ ? { punto: "maquina", ok: true, mensaje: "No obstacle found to saving the test request on this machine.", grupo: "prueba" }
90
+ : { punto: "maquina", ok: false, mensaje: `This machine can't save the test request yet: ${detalles(r)}`, grupo: "prueba" };
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
+ }
47
127
  export async function diagnosticar(d, flags) {
48
128
  const p = await proyectoDelRepo(d, flags);
49
129
  if (!p.ok)
@@ -64,16 +144,23 @@ export async function diagnosticar(d, flags) {
64
144
  catch {
65
145
  local = { repo: null, raiz: null, motivo: "sin_git" };
66
146
  }
67
- const { instalacion, prueba } = 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)]);
148
+ const instalacion = agrupados.instalacion;
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
150
+ // no después del OK de gasto. No cuenta en el «N of 7» de la instalación.
151
+ const revision = await revisarLaMaquina(d, p.proyecto.id);
152
+ const prueba = [puntoDeLaMaquina(revision), ...agrupados.prueba];
68
153
  const buenos = contarBuenos(instalacion);
69
- const listo = g.listo && instalacion.every((x) => x.ok || !["sign-in", "repo"].includes(x.punto));
154
+ const listo = g.listo && revision.maquina.ok && instalacion.every((x) => x.ok || !["sign-in", "repo"].includes(x.punto));
70
155
  const datos = {
71
156
  proyecto: { id: p.proyecto.id, repo: p.proyecto.repo },
72
157
  ...datosDelDiagnostico(g),
73
158
  listo,
74
159
  puntos: [...instalacion, ...prueba],
75
160
  instalacion: { ok: buenos, total: TOTAL_DE_INSTALACION, puntos: instalacion },
76
- prueba: { listo: g.listo, puntos: prueba },
161
+ prueba: { listo: g.listo && revision.maquina.ok, puntos: prueba },
162
+ ...datosDeLaMaquina(revision),
163
+ ...datosDelIntentoPendiente(revision),
77
164
  };
78
165
  const resumen = `Monthly balance ${usdONull(g.saldoUsd)} of ${usdONull(g.topeUsd)}; a test authorizes up to ${usd(g.techoUsd)}. Policy: ${enInglesLaPolitica(g.politica)}.`;
79
166
  const motivos = [
@@ -155,7 +242,9 @@ export async function probar(d, flags) {
155
242
  catch (e) {
156
243
  return resultadoDeFallo(e);
157
244
  }
158
- const datos = { proyecto: { id: proyecto.id, repo: proyecto.repo }, ...datosDelDiagnostico(g) };
245
+ // Lo que esta máquina puede hacer con el pedido, visto ANTES del sí de gasto (fricción 3).
246
+ const revision = await revisarLaMaquina(d, proyecto.id);
247
+ const datos = { proyecto: { id: proyecto.id, repo: proyecto.repo }, ...datosDelDiagnostico(g), ...datosDeLaMaquina(revision) };
159
248
  const gasto = g.politica === "guardar"
160
249
  ? `With the \`save\` policy the test goes as far as the saved case and spends nothing; the ceiling it authorizes is still ${usd(g.techoUsd)}.`
161
250
  : `A test runs an investigation that spends up to ${usd(g.techoUsd)} (monthly balance ${usdONull(g.saldoUsd)} of ${usdONull(g.topeUsd)}).`;
@@ -180,8 +269,13 @@ export async function probar(d, flags) {
180
269
  };
181
270
  }
182
271
  const lanzar = `supcoflow test --authorize-spend --ceiling ${g.techoUsd} --expect-policy ${enInglesLaPolitica(g.politica)}`;
183
- const message = `${gasto} To launch it: \`${lanzar}\`. \`supcoflow diagnose\` first says whether everything is ready.`;
184
- return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "falta_autorizacion", message, ...datos }, texto: message };
272
+ // Primero lo que impide, si algo: es lo que la persona tiene que saber antes de decir que sí.
273
+ const impide = revision.maquina.ok ? "" : `This machine can't save the test request yet: ${detalles(revision)} Fix that before authorizing. `;
274
+ const previo = revision.intentoPendiente;
275
+ const pendiente = previo === null ? ""
276
+ : ` There's a test request waiting for confirmation (ceiling ${usd(previo.techoUsd)}, policy ${enInglesLaPolitica(previo.politica)}): to recover that same request, repeat \`${comandoDeReintento({ clave: "", mensaje: null, techoUsd: previo.techoUsd, politicaEsperada: previo.politica })}\`.`;
277
+ const message = `${impide}${gasto} To launch it: \`${lanzar}\`. \`supcoflow diagnose\` first says whether everything is ready.${pendiente}`;
278
+ return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "falta_autorizacion", message, ...datos, ...datosDelIntentoPendiente(revision) }, texto: message };
185
279
  }
186
280
  // ── Con --authorize-spend: el ciclo de la clave.
187
281
  let intento;
@@ -1,10 +1,12 @@
1
1
  import { resultadoDeFallo, SALIDA } from "../contrato.js";
2
2
  import { repoPedido } from "../repo-local.js";
3
3
  import { claveVigente } from "./clave-vigente.js";
4
+ import { coherenciaDelClon, leerElArchivoDelClon } from "./contexto.js";
4
5
  export async function proyectoDelRepo(d, flags) {
5
6
  let repo;
7
+ let raiz;
6
8
  try {
7
- repo = (await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd })).repo;
9
+ ({ repo, raiz } = await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd }));
8
10
  }
9
11
  catch (e) {
10
12
  return { ok: false, resultado: resultadoDeFallo(e) };
@@ -12,8 +14,16 @@ export async function proyectoDelRepo(d, flags) {
12
14
  const leida = await claveVigente(d.guardado, d.api.origin);
13
15
  if (!leida.ok)
14
16
  return { ok: false, resultado: leida.resultado };
17
+ // El archivo del clon se lee ANTES de actuar: un `proyecto.json` que no se entiende frena sin tocar nada, y uno que anota
18
+ // otro proyecto que el que la clave ve también (ningún pedido parcial actúa sobre un proyecto distinto del que anota el clon).
19
+ const archivo = leerElArchivoDelClon(d.archivos, raiz);
20
+ if (archivo.estado === "ilegible")
21
+ return { ok: false, resultado: coherenciaDelClon(archivo, null, repo) };
15
22
  try {
16
23
  const proyecto = await d.buscarProyecto(d.api, leida.vigente.clave, repo);
24
+ const incoherente = coherenciaDelClon(archivo, proyecto, repo);
25
+ if (incoherente !== null)
26
+ return { ok: false, resultado: incoherente };
17
27
  if (proyecto === null) {
18
28
  const message = `The repo ${repo} doesn't have a project yet. Create the project: \`supcoflow project create\`.`;
19
29
  return { ok: false, resultado: { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "sin_proyecto", message, repo }, texto: message } };
@@ -5,11 +5,9 @@ import path from "node:path";
5
5
  import { resultadoDeFallo, SALIDA } from "../contrato.js";
6
6
  import { repoPedido } from "../repo-local.js";
7
7
  import { claveVigente } from "./clave-vigente.js";
8
+ import { armarElContexto, coherenciaAlCrear, coherenciaDelClon, idDelArchivo, leerElArchivoDelClon, lineaDelContexto } from "./contexto.js";
8
9
  const PLAZO_DE_LECTURA_MS = 15_000;
9
10
  const OPERACION = "proyecto_crear";
10
- // Lo único que se lee de un `.supcoflow/proyecto.json` ajeno es su id, y sólo si tiene esta forma:
11
- // así lo que salga a la terminal no puede ser otra cosa que un id (Review Focus 11).
12
- const FORMA_DEL_ID = /^proj_[a-z0-9-]{1,80}$/;
13
11
  // Los 422/409 del alta que el plan no listaba (alta-desde-conexion.ts del motor).
14
12
  const RECHAZOS_DE_UNA_PERSONA = new Set(["instalacion_no_verificada", "repo_fuera_de_la_instalacion"]);
15
13
  const RECHAZOS_TRANSITORIOS = new Set(["slug_ocupado", "repo_ya_conectado"]);
@@ -23,18 +21,6 @@ const comillas = (s) => `"${s.replace(/["\\]/g, "\\$&")}"`;
23
21
  function contenidoDelArchivo(p) {
24
22
  return JSON.stringify({ id: p.id, slug: p.slug, repo: p.repo }, null, 2) + "\n";
25
23
  }
26
- function idDelArchivo(crudo) {
27
- try {
28
- const v = JSON.parse(crudo);
29
- if (typeof v !== "object" || v === null)
30
- return null;
31
- const id = v.id;
32
- return typeof id === "string" && FORMA_DEL_ID.test(id) ? id : null;
33
- }
34
- catch {
35
- return null;
36
- }
37
- }
38
24
  // Mismo contenido aunque el formato cambie (otra indentación, otro orden): no es «otro proyecto».
39
25
  function mismoArchivo(crudo, p) {
40
26
  if (crudo === contenidoDelArchivo(p))
@@ -96,12 +82,24 @@ export async function proyectoCrear(d, flags) {
96
82
  if (!leida.ok)
97
83
  return leida.resultado;
98
84
  const clave = leida.vigente.clave;
85
+ // Antes de conectar o crear nada (fricción 6): el archivo del clon contra lo que la clave ve, y, si va a haber un alta, el
86
+ // dueño del repo contra la cuenta. Sale 4 `contexto_incoherente` sin tocar nada.
87
+ const archivo = leerElArchivoDelClon(d.archivos, raiz);
88
+ if (archivo.estado === "ilegible")
89
+ return coherenciaDelClon(archivo, null, repo);
99
90
  let existente;
100
91
  let conexion = null;
101
92
  try {
102
93
  existente = await d.buscarProyecto(d.api, clave, repo);
103
- if (existente === null)
94
+ const incoherente = coherenciaDelClon(archivo, existente, repo);
95
+ if (incoherente !== null)
96
+ return incoherente;
97
+ if (existente === null) {
98
+ const alCrear = coherenciaAlCrear({ yo: await d.quienSoy(d.api, clave), fuente: leida.vigente.fuente, repo });
99
+ if (alCrear !== null)
100
+ return alCrear;
104
101
  conexion = await d.ultimaConexion(d.api, clave, repo, PLAZO_DE_LECTURA_MS);
102
+ }
105
103
  }
106
104
  catch (e) {
107
105
  return resultadoDeFallo(e);
@@ -184,8 +182,9 @@ export async function proyectoCrear(d, flags) {
184
182
  }
185
183
  export async function proyectoVer(d, flags) {
186
184
  let repo;
185
+ let raiz;
187
186
  try {
188
- repo = (await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd })).repo;
187
+ ({ repo, raiz } = await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd }));
189
188
  }
190
189
  catch (e) {
191
190
  return resultadoDeFallo(e);
@@ -194,17 +193,32 @@ export async function proyectoVer(d, flags) {
194
193
  if (!leida.ok)
195
194
  return leida.resultado;
196
195
  const clave = leida.vigente.clave;
196
+ const archivo = leerElArchivoDelClon(d.archivos, raiz);
197
+ if (archivo.estado === "ilegible")
198
+ return coherenciaDelClon(archivo, null, repo);
197
199
  try {
198
200
  const encontrado = await d.buscarProyecto(d.api, clave, repo);
201
+ const incoherente = coherenciaDelClon(archivo, encontrado, repo);
202
+ if (incoherente !== null)
203
+ return incoherente;
204
+ // Quién soy, en qué equipo, qué repo y qué proyecto: la línea que la persona lee al empezar, también cuando todo cierra.
205
+ const yo = await d.quienSoy(d.api, clave);
206
+ const contexto = armarElContexto({ yo, fuente: leida.vigente.fuente, repo, proyecto: encontrado, archivo });
207
+ const linea = lineaDelContexto(contexto, yo.proyecto);
199
208
  if (encontrado === null) {
200
- return { codigo: SALIDA.ok, json: { ok: true, repo, proyecto: null }, texto: "This repo doesn't have a project yet: `supcoflow project create`." };
209
+ // Sin proyecto, el recorrido sigue con `repo connect`: el freno por cuenta (dueño distinto, sin GitHub) tiene que llegar ANTES,
210
+ // no al crear. Con proyecto existente no se compara nada, y con una clave de consulta tampoco.
211
+ const alCrear = coherenciaAlCrear({ yo, fuente: leida.vigente.fuente, repo });
212
+ if (alCrear !== null)
213
+ return alCrear;
214
+ return { codigo: SALIDA.ok, json: { ok: true, repo, proyecto: null, contexto }, texto: `${linea} This repo doesn't have a project yet: \`supcoflow project create\`.` };
201
215
  }
202
216
  const { proyecto: p, github } = await d.leerProyecto(d.api, clave, encontrado.id);
203
217
  const proyecto = { id: p.id, slug: p.slug, repo: p.repo, rama: p.rama, install_command: p.installCommand, test_command: p.testCommand };
204
218
  return {
205
219
  codigo: SALIDA.ok,
206
- json: { ok: true, proyecto, github: { estado: github.estado, verificado_en: github.verificadoEn } },
207
- texto: `${p.id} (${p.repo}, branch ${p.rama}): installs with \`${p.installCommand}\`, tests with \`${p.testCommand}\`. GitHub: ${github.estado ?? "not verified"}.`,
220
+ json: { ok: true, proyecto, github: { estado: github.estado, verificado_en: github.verificadoEn }, contexto },
221
+ texto: `${linea} ${p.id} (${p.repo}, branch ${p.rama}): installs with \`${p.installCommand}\`, tests with \`${p.testCommand}\`. GitHub: ${github.estado ?? "not verified"}.`,
208
222
  };
209
223
  }
210
224
  catch (e) {
@@ -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,4 +1,5 @@
1
- export const TOTAL_DE_INSTALACION = 6;
1
+ import { compararSemver, RUTA_DE_LA_INSTALACION } from "./instalacion.js";
2
+ import { frasePorQueNoAtaClon } from "./repo-local.js";
2
3
  // El orden y los nombres con que se dicen: los ids de los puntos del motor son datos suyos y no cambian.
3
4
  const DE_LA_INSTALACION = [
4
5
  { punto: "sign-in", como: "sign-in" },
@@ -7,7 +8,9 @@ const DE_LA_INSTALACION = [
7
8
  { punto: "sitios", como: "allowed sites" },
8
9
  { punto: "chat", como: "chat" },
9
10
  { punto: "guia", como: "guide" },
11
+ { punto: "interfaz", como: "interface" },
10
12
  ];
13
+ export const TOTAL_DE_INSTALACION = DE_LA_INSTALACION.length;
11
14
  const DEL_MOTOR = new Set(["instalacion", "sitios", "chat", "guia"]);
12
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.`;
13
16
  export const nombreDelPunto = (punto) => DE_LA_INSTALACION.find((x) => x.punto === punto)?.como ?? punto;
@@ -21,6 +24,8 @@ export function puntoDelRepo(local, repoDelProyecto) {
21
24
  return mal("This folder isn't a git repo, so I can't tell which repo it is.");
22
25
  if (local.motivo === "sin_origin")
23
26
  return mal("This repo has no «origin» remote, so I can't tell which repo it is.");
27
+ if (local.motivo === "origin_alias_ssh")
28
+ return mal(`The «origin» of this repo isn't a GitHub repo. ${frasePorQueNoAtaClon(local, null)}`);
24
29
  return mal("The «origin» of this repo isn't a GitHub repo.");
25
30
  }
26
31
  if (local.repo.toLowerCase() !== repoDelProyecto.toLowerCase()) {
@@ -28,6 +33,46 @@ export function puntoDelRepo(local, repoDelProyecto) {
28
33
  }
29
34
  return { punto: "repo", ok: true, mensaje: `The origin of this folder is ${local.repo}, the project's repo.`, grupo: "instalacion" };
30
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
+ }
31
76
  /** Reparte los puntos del motor en los dos grupos y suma los dos locales al de la instalación. */
32
77
  export function agruparPuntos(g, locales) {
33
78
  const delMotor = g.puntos.map((p) => ({ ...p, grupo: p.grupo ?? (DEL_MOTOR.has(p.punto) ? "instalacion" : "prueba") }));