supcoflow 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,213 @@
1
+ // `supcoflow proyecto crear` y `proyecto ver`: el proyecto del repo de esta carpeta (o de `--repo`).
2
+ // `crear` sale de una conexión ya confirmada (`repo conectar`) y deja `.supcoflow/proyecto.json` en la
3
+ // raíz del repo para que la próxima herramienta sepa de qué proyecto se trata sin preguntarle al motor.
4
+ import path from "node:path";
5
+ import { resultadoDeFallo, SALIDA } from "../contrato.js";
6
+ import { repoPedido } from "../repo-local.js";
7
+ import { claveVigente } from "./clave-vigente.js";
8
+ const PLAZO_DE_LECTURA_MS = 15_000;
9
+ 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
+ // Los 422/409 del alta que el plan no listaba (alta-desde-conexion.ts del motor).
14
+ const RECHAZOS_DE_UNA_PERSONA = new Set(["instalacion_no_verificada", "repo_fuera_de_la_instalacion"]);
15
+ const RECHAZOS_TRANSITORIOS = new Set(["slug_ocupado", "repo_ya_conectado"]);
16
+ const fallo = (codigo, error, message, extra = {}) => ({
17
+ codigo, json: { ok: false, error, message, ...extra }, texto: message,
18
+ });
19
+ const comillas = (s) => `"${s.replace(/["\\]/g, "\\$&")}"`;
20
+ // El archivo va al repo y se commitea (§5.3): por eso lleva sólo lo que identifica al proyecto. Ni la
21
+ // URL de la API (un repo clonado que la trajera mandaría la clave a otro servidor, §5.2) ni nada
22
+ // secreto: cualquiera que lea el repo lo lee.
23
+ function contenidoDelArchivo(p) {
24
+ return JSON.stringify({ id: p.id, slug: p.slug, repo: p.repo }, null, 2) + "\n";
25
+ }
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
+ // Mismo contenido aunque el formato cambie (otra indentación, otro orden): no es «otro proyecto».
39
+ function mismoArchivo(crudo, p) {
40
+ if (crudo === contenidoDelArchivo(p))
41
+ return true;
42
+ try {
43
+ const v = JSON.parse(crudo);
44
+ if (typeof v !== "object" || v === null || Array.isArray(v))
45
+ return false;
46
+ const o = v;
47
+ return Object.keys(o).length === 3 && o.id === p.id && o.slug === p.slug && o.repo === p.repo;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ function resultadoDelProyecto(p, reusado, raiz, d) {
54
+ const nuevo = { id: p.id, slug: p.slug, repo: p.repo };
55
+ const queda = reusado ? `El proyecto ${p.id} ya existía y lo reusé` : `Creé el proyecto ${p.id}`;
56
+ let archivo = null;
57
+ if (raiz !== null) {
58
+ archivo = path.join(raiz, ".supcoflow", "proyecto.json");
59
+ const actual = d.archivos.leer(archivo);
60
+ if (actual !== null && !mismoArchivo(actual, p)) {
61
+ // Un segundo `go` no pisa archivos (§13): el que está puede ser de otro proyecto a propósito, y
62
+ // pisarlo cambiaría en silencio a qué proyecto apunta el repo. Se sale 4 para que una persona
63
+ // decida. Su contenido NUNCA se copia a la salida (puede traer cualquier cosa, una clave
64
+ // incluida, y `emitir` no sanea): sólo el id, si tiene forma de id.
65
+ const idActual = idDelArchivo(actual);
66
+ const message = `Ya hay un \`.supcoflow/proyecto.json\` de otro proyecto (${idActual ?? "que no reconozco"}); no lo toqué. `
67
+ + `${queda} igual (${p.id}): si es el que querés, borrá el archivo y volvé a correr \`supcoflow proyecto crear\`.`;
68
+ return fallo(SALIDA.haceFaltaUnaPersona, "archivo_distinto", message, { archivo, id_actual: idActual, nuevo, reusado });
69
+ }
70
+ if (actual === null) {
71
+ try {
72
+ d.archivos.escribir(archivo, contenidoDelArchivo(p));
73
+ }
74
+ catch {
75
+ const message = `${queda} (${p.id}), pero no pude escribir ${archivo}. Volvé a correr \`supcoflow proyecto crear\`.`;
76
+ return fallo(SALIDA.inesperado, "archivo_no_escrito", message, { archivo, nuevo, reusado });
77
+ }
78
+ }
79
+ }
80
+ return {
81
+ codigo: SALIDA.ok,
82
+ json: { ok: true, proyecto: { id: p.id, slug: p.slug, repo: p.repo, rama: p.rama }, reusado, archivo },
83
+ texto: `${queda}: ${p.repo}, rama ${p.rama}.${archivo !== null ? ` Lo anoté en ${archivo}.` : ""}`,
84
+ };
85
+ }
86
+ export async function proyectoCrear(d, flags) {
87
+ let repo;
88
+ let raiz;
89
+ try {
90
+ ({ repo, raiz } = await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd }));
91
+ }
92
+ catch (e) {
93
+ return resultadoDeFallo(e);
94
+ }
95
+ const leida = await claveVigente(d.guardado, d.api.origin);
96
+ if (!leida.ok)
97
+ return leida.resultado;
98
+ const clave = leida.vigente.clave;
99
+ let existente;
100
+ let conexion = null;
101
+ try {
102
+ existente = await d.buscarProyecto(d.api, clave, repo);
103
+ if (existente === null)
104
+ conexion = await d.ultimaConexion(d.api, clave, repo, PLAZO_DE_LECTURA_MS);
105
+ }
106
+ catch (e) {
107
+ return resultadoDeFallo(e);
108
+ }
109
+ // Un repo con proyecto no vuelve a pasar por la conexión: el proyecto ya está atado a su instalación.
110
+ if (existente !== null) {
111
+ // Una clave guardada de un intento anterior (respuesta perdida, `en_curso`) ya no sirve: el proyecto
112
+ // existe. Si quedara, un alta futura del mismo repo (borrado y vuelto a crear) mandaría una clave
113
+ // vieja con otro contenido y el motor contestaría 409. Con --dry-run no se toca ni lo local.
114
+ if (!flags.dryRun)
115
+ await d.idempotencia.olvidar(OPERACION, repo);
116
+ if (flags.install !== null || flags.test !== null) {
117
+ // Sin copiar los valores: son texto del usuario y el aviso sólo dice qué pasó con ellos. Callarlo
118
+ // haría creer que el proyecto quedó con los comandos pedidos.
119
+ d.avisar("El proyecto ya existe: ignoré --install/--test. Para cambiar sus comandos hay que configurarlo.");
120
+ }
121
+ return resultadoDelProyecto(existente, true, raiz, d);
122
+ }
123
+ if (conexion === null || conexion.estado !== "conectado") {
124
+ return fallo(SALIDA.haceFaltaUnaPersona, "repo_sin_conectar", "Conectá el repo primero: `supcoflow repo conectar`.", {
125
+ repo, ...(conexion !== null ? { estado: conexion.estado } : {}),
126
+ });
127
+ }
128
+ if (flags.dryRun) {
129
+ const haria = `POST /api/conexiones/${conexion.id}/proyecto`;
130
+ return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, repo, haria }, texto: `Con --dry-run no hago nada; sin él, crearía el proyecto de ${repo} (${haria}).` };
131
+ }
132
+ const cuerpo = {};
133
+ if (flags.install !== null)
134
+ cuerpo.install_command = flags.install;
135
+ if (flags.test !== null)
136
+ cuerpo.test_command = flags.test;
137
+ const key = await d.idempotencia.claveDe(OPERACION, repo);
138
+ let alta;
139
+ try {
140
+ alta = await d.crearProyecto(d.api, clave, conexion.id, cuerpo, key);
141
+ }
142
+ catch (e) {
143
+ // La clave NO se olvida (Review Focus 4): sin respuesta no se sabe si el motor creó el proyecto, y
144
+ // el reintento tiene que mandar la misma para recibir el mismo en vez de otro.
145
+ return resultadoDeFallo(e);
146
+ }
147
+ switch (alta.tipo) {
148
+ case "ok":
149
+ await d.idempotencia.olvidar(OPERACION, repo);
150
+ return resultadoDelProyecto(alta.proyecto, alta.reusado, raiz, d);
151
+ case "comandos_faltantes": {
152
+ // Otro intento lleva otro contenido (los flags): con la misma clave el motor contestaría 409.
153
+ await d.idempotencia.olvidar(OPERACION, repo);
154
+ const install = flags.install ?? "…";
155
+ const test = flags.test ?? "…";
156
+ const message = `${alta.message || "Faltan comandos del proyecto."} Pasalos a mano: \`supcoflow proyecto crear --install ${comillas(install)} --test ${comillas(test)}\`.`;
157
+ return fallo(SALIDA.entradaInvalida, "comandos_faltantes", message, { faltantes: alta.faltantes });
158
+ }
159
+ case "no_conectada":
160
+ await d.idempotencia.olvidar(OPERACION, repo);
161
+ return fallo(SALIDA.haceFaltaUnaPersona, "repo_sin_conectar", alta.message || "Conectá el repo primero: `supcoflow repo conectar`.", { repo });
162
+ case "en_curso":
163
+ // Se conserva: el pedido en curso puede ser el nuestro y el reintento tiene que poder retomarlo.
164
+ return fallo(SALIDA.inesperado, "en_curso", "Otro pedido con esta clave está en curso; probá en un minuto.");
165
+ case "clave_reusada":
166
+ // No debería pasar (la clave es por repo y un reintento manda lo mismo); olvidarla deja reintentar limpio.
167
+ await d.idempotencia.olvidar(OPERACION, repo);
168
+ return fallo(SALIDA.inesperado, "clave_reusada", "El motor rechazó la clave de idempotencia guardada; la descarté, probá de nuevo.");
169
+ case "rechazo": {
170
+ // La clave se conserva: tras un rechazo el motor ya la soltó, y el próximo intento la reusa sin
171
+ // chocar. Lo que cambia es el código: no todo rechazo es culpa de lo que se escribió.
172
+ const message = alta.message || "El motor rechazó el pedido.";
173
+ if (RECHAZOS_DE_UNA_PERSONA.has(alta.error)) {
174
+ // Lo arregla alguien con permisos en GitHub (instalar la App, sumarle el repo): reintentar o
175
+ // cambiar flags no sirve, y un 3 haría que un asistente siguiera probando entradas.
176
+ return fallo(SALIDA.haceFaltaUnaPersona, alta.error, `${message} Revisá la instalación de la App en GitHub y volvé a conectar con \`supcoflow repo conectar\`.`);
177
+ }
178
+ // Dos altas al mismo tiempo, o un proyecto borrado en el medio: el mismo pedido otra vez anda.
179
+ if (RECHAZOS_TRANSITORIOS.has(alta.error))
180
+ return fallo(SALIDA.inesperado, alta.error, message);
181
+ return fallo(SALIDA.entradaInvalida, alta.error, message);
182
+ }
183
+ }
184
+ }
185
+ export async function proyectoVer(d, flags) {
186
+ let repo;
187
+ try {
188
+ repo = (await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd })).repo;
189
+ }
190
+ catch (e) {
191
+ return resultadoDeFallo(e);
192
+ }
193
+ const leida = await claveVigente(d.guardado, d.api.origin);
194
+ if (!leida.ok)
195
+ return leida.resultado;
196
+ const clave = leida.vigente.clave;
197
+ try {
198
+ const encontrado = await d.buscarProyecto(d.api, clave, repo);
199
+ if (encontrado === null) {
200
+ return { codigo: SALIDA.ok, json: { ok: true, repo, proyecto: null }, texto: "Este repo todavía no tiene proyecto: `supcoflow proyecto crear`." };
201
+ }
202
+ const { proyecto: p, github } = await d.leerProyecto(d.api, clave, encontrado.id);
203
+ const proyecto = { id: p.id, slug: p.slug, repo: p.repo, rama: p.rama, install_command: p.installCommand, test_command: p.testCommand };
204
+ return {
205
+ codigo: SALIDA.ok,
206
+ json: { ok: true, proyecto, github: { estado: github.estado, verificado_en: github.verificadoEn } },
207
+ texto: `${p.id} (${p.repo}, rama ${p.rama}): instala con \`${p.installCommand}\`, prueba con \`${p.testCommand}\`. GitHub: ${github.estado ?? "sin verificar"}.`,
208
+ };
209
+ }
210
+ catch (e) {
211
+ return resultadoDeFallo(e);
212
+ }
213
+ }
@@ -0,0 +1,43 @@
1
+ import { FalloDeLaTerminal, resultadoDeFallo, SALIDA } from "../contrato.js";
2
+ import { claveVigente } from "./clave-vigente.js";
3
+ export async function comandoQuienSoy(d) {
4
+ const origen = d.api.origin;
5
+ const leida = await claveVigente(d.guardado, origen);
6
+ if (!leida.ok)
7
+ return leida.resultado;
8
+ const vigente = leida.vigente;
9
+ if (!vigente.completa) {
10
+ const otro = vigente.fuente === "archivo" ? "el llavero del sistema" : "el archivo de credenciales";
11
+ d.avisar(`Uso la clave de ${vigente.fuente}, pero no pude leer ${otro}: puede haber guardada otra más nueva.`);
12
+ }
13
+ // Leer no cuesta nada y avisa de algo que la persona no vería de otro modo: claves viejas vivas.
14
+ try {
15
+ const { claves, completa } = await d.guardado.leerPorRevocar(origen);
16
+ if (claves.length > 0) {
17
+ d.avisar(`Hay ${claves.length} clave(s) anterior(es) anotadas para revocar; se reintenta en el próximo login o logout.`);
18
+ }
19
+ if (!completa)
20
+ d.avisar("No pude leer entera la lista de claves por revocar.");
21
+ }
22
+ catch {
23
+ d.avisar("No pude leer entera la lista de claves por revocar.");
24
+ }
25
+ let yo;
26
+ try {
27
+ yo = await d.quienSoy(d.api, vigente.clave);
28
+ }
29
+ catch (e) {
30
+ // Con la variable definida, «iniciá sesión de nuevo» no arregla nada: la variable sigue
31
+ // ganándole a la clave nueva. Hay que decir cuál es la que no vale.
32
+ if (e instanceof FalloDeLaTerminal && e.codigo === SALIDA.faltaLogin && vigente.fuente === "env") {
33
+ return resultadoDeFallo(new FalloDeLaTerminal(e.codigo, e.error, "La clave de SUPCOFLOW_TOKEN no vale: sacala de tu entorno o reemplazala por una vigente."));
34
+ }
35
+ return resultadoDeFallo(e);
36
+ }
37
+ const quien = yo.usuario?.login ?? yo.usuario?.email ?? yo.via;
38
+ return {
39
+ codigo: SALIDA.ok,
40
+ json: { ok: true, via: yo.via, equipo: yo.equipo, usuario: yo.usuario, proyecto: yo.proyecto, fuente: vigente.fuente },
41
+ texto: `${quien} en el equipo ${yo.equipo} (clave en ${vigente.fuente})`,
42
+ };
43
+ }
@@ -0,0 +1,126 @@
1
+ import { resultadoDeFallo, SALIDA } from "../contrato.js";
2
+ import { repoPedido } from "../repo-local.js";
3
+ import { PLAZO_MINIMO_DE_SONDEO_MS, TRAMO_MAXIMO_MS } from "../sondeo.js";
4
+ import { claveVigente } from "./clave-vigente.js";
5
+ export const INTERVALO_DE_CONEXION_MS = 5_000;
6
+ const PLAZO_MAXIMO_DE_SONDEO_MS = 15_000;
7
+ const PAUSA_ENTRE_TRAMOS_MS = 1_000;
8
+ const fallo = (codigo, error, message, extra = {}) => ({
9
+ codigo, json: { ok: false, error, message, ...extra }, texto: message,
10
+ });
11
+ const datosDelPedido = (p) => ({ id: p.id, estado: p.estado, url: p.url, vence_en: p.venceEn });
12
+ // El desenlace de un pedido ya leído. El JSON nunca lleva la clave: sólo el pedido y el repo.
13
+ function resultadoDelPedido(repo, p, avisar) {
14
+ const pedido = datosDelPedido(p);
15
+ switch (p.estado) {
16
+ case "conectado":
17
+ return {
18
+ codigo: SALIDA.ok,
19
+ json: { ok: true, estado: "conectado", repo, repo_canonico: p.repoCanonico, pedido },
20
+ texto: `${p.repoCanonico ?? repo} está conectado.`,
21
+ };
22
+ case "repo_fuera_de_alcance": {
23
+ const message = `La App está instalada pero no alcanza a ${repo}: sumalo en GitHub y volvé a tocar Confirmar (${p.url}).`;
24
+ return fallo(SALIDA.haceFaltaUnaPersona, "repo_fuera_de_alcance", message, { repo, url: p.url, pedido });
25
+ }
26
+ case "vencido":
27
+ return fallo(SALIDA.haceFaltaUnaPersona, "pedido_vencido", "El pedido venció. Pedí otro con `supcoflow repo conectar --start`.", { repo, pedido });
28
+ case "pendiente": {
29
+ // Un motivo anotado no cierra el pedido: la persona puede reintentar desde la misma página.
30
+ if (p.fallo !== null)
31
+ avisar(`La última confirmación falló: ${p.fallo}.`);
32
+ return {
33
+ codigo: SALIDA.esperandoAUnaPersona,
34
+ json: { ok: false, esperando: "persona", repo, url: p.url, pedido, ...(p.fallo !== null ? { fallo: p.fallo } : {}) },
35
+ texto: `Abrí ${p.url}, instalá la App en GitHub si hace falta y tocá Confirmar.`,
36
+ };
37
+ }
38
+ }
39
+ }
40
+ export async function repoConectarStart(d, flags) {
41
+ let repo;
42
+ try {
43
+ repo = (await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd })).repo;
44
+ }
45
+ catch (e) {
46
+ return resultadoDeFallo(e);
47
+ }
48
+ if (flags.dryRun) {
49
+ return {
50
+ codigo: SALIDA.ok,
51
+ json: { ok: true, dry_run: true, repo, haria: "POST /api/conexiones" },
52
+ texto: `Con --dry-run no hago nada; sin él, pediría conectar ${repo} (POST /api/conexiones).`,
53
+ };
54
+ }
55
+ const leida = await claveVigente(d.guardado, d.api.origin);
56
+ if (!leida.ok)
57
+ return leida.resultado;
58
+ let p;
59
+ try {
60
+ p = await d.crearConexion(d.api, leida.vigente.clave, repo);
61
+ }
62
+ catch (e) {
63
+ return resultadoDeFallo(e);
64
+ }
65
+ return resultadoDelPedido(repo, p, d.avisar);
66
+ }
67
+ export async function repoConectarWait(d, flags) {
68
+ let repo;
69
+ try {
70
+ repo = (await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd })).repo;
71
+ }
72
+ catch (e) {
73
+ return resultadoDeFallo(e);
74
+ }
75
+ const leida = await claveVigente(d.guardado, d.api.origin);
76
+ if (!leida.ok)
77
+ return leida.resultado;
78
+ const clave = leida.vigente.clave;
79
+ const limite = d.ahora() + Math.min(d.tramoMs ?? TRAMO_MAXIMO_MS, TRAMO_MAXIMO_MS);
80
+ let ultimo = null;
81
+ for (;;) {
82
+ const plazoMs = Math.min(PLAZO_MAXIMO_DE_SONDEO_MS, limite - d.ahora());
83
+ if (plazoMs < PLAZO_MINIMO_DE_SONDEO_MS && ultimo !== null)
84
+ break;
85
+ let p;
86
+ try {
87
+ // Un fallo del motor en medio del tramo sale 1 y no se reintenta en silencio (P13): quien llama
88
+ // decide si vuelve.
89
+ p = await d.ultimaConexion(d.api, clave, repo, Math.max(plazoMs, PLAZO_MINIMO_DE_SONDEO_MS));
90
+ }
91
+ catch (e) {
92
+ return resultadoDeFallo(e);
93
+ }
94
+ if (p === null) {
95
+ return fallo(SALIDA.entradaInvalida, "sin_conexion_en_curso", `No hay una conexión en curso para ${repo}: empezá con \`supcoflow repo conectar --start\`.`, { repo });
96
+ }
97
+ ultimo = p;
98
+ if (p.estado !== "pendiente")
99
+ break;
100
+ // Dormir sólo si después cabe otro sondeo con plazo útil: dormir de más pasaría del tramo.
101
+ if (limite - (d.ahora() + INTERVALO_DE_CONEXION_MS) < PLAZO_MINIMO_DE_SONDEO_MS)
102
+ break;
103
+ await d.dormir(INTERVALO_DE_CONEXION_MS);
104
+ }
105
+ return resultadoDelPedido(repo, ultimo, d.avisar);
106
+ }
107
+ // La conexión de una persona frente a la terminal: los dos pasos encadenados. Con `--json` (o sin TTY)
108
+ // no se espera a nadie aunque haya terminal (§13, Review Focus 9): un asistente con PTY recibiría un
109
+ // comando que se cuelga hasta 30 minutos. Y se decide ANTES de tocar el motor, para no dejar un pedido
110
+ // que nadie va a mirar.
111
+ export async function repoConectarInteractivo(d, flags) {
112
+ if (!d.esTty || flags.json) {
113
+ return fallo(SALIDA.entradaInvalida, "hace_falta_tty", "Sin terminal interactiva usá `supcoflow repo conectar --start --json` y después `supcoflow repo conectar --wait --json`.");
114
+ }
115
+ const inicio = await repoConectarStart(d, flags);
116
+ if (inicio.codigo !== SALIDA.esperandoAUnaPersona)
117
+ return inicio;
118
+ d.mostrar(`${inicio.texto} Espero acá…`);
119
+ let r;
120
+ do {
121
+ r = await repoConectarWait(d, flags);
122
+ if (r.codigo === SALIDA.esperandoAUnaPersona)
123
+ await d.dormir(PAUSA_ENTRE_TRAMOS_MS);
124
+ } while (r.codigo === SALIDA.esperandoAUnaPersona);
125
+ return r;
126
+ }
@@ -0,0 +1,38 @@
1
+ // El contrato de salida de la terminal (§13). Quien la llama es, muchas veces, un asistente
2
+ // que lee el código de salida y la ÚLTIMA línea de stdout: por eso un resultado es siempre
3
+ // UNA línea de JSON, y un 5 no es un error sino «volvé a llamarme cuando la persona termine».
4
+ export const SALIDA = {
5
+ ok: 0,
6
+ inesperado: 1,
7
+ faltaLogin: 2,
8
+ entradaInvalida: 3,
9
+ haceFaltaUnaPersona: 4,
10
+ esperandoAUnaPersona: 5,
11
+ topeAlcanzado: 6,
12
+ };
13
+ export class FalloDeLaTerminal extends Error {
14
+ codigo;
15
+ error;
16
+ constructor(codigo, error, message) {
17
+ super(message);
18
+ this.codigo = codigo;
19
+ this.error = error;
20
+ this.name = "FalloDeLaTerminal";
21
+ }
22
+ }
23
+ // JSON.stringify escapa los saltos de línea de los valores, así que la salida cabe en una línea.
24
+ export function emitir(r, modoJson, out) {
25
+ out(modoJson ? JSON.stringify(r.json) + "\n" : r.texto + "\n");
26
+ }
27
+ // Un mensaje de error de afuera (fetch, el sistema) podría traer la clave o el device_code
28
+ // tal cual; la clave se tapa ANTES que los 64 hex porque los contiene.
29
+ export function taparSecretos(s) {
30
+ return s.replace(/scf_[0-9a-f]{16}_[0-9a-f]{64}/g, "scf_…").replace(/\b[0-9a-f]{64}\b/g, "[device_code]");
31
+ }
32
+ export function resultadoDeFallo(e) {
33
+ if (e instanceof FalloDeLaTerminal) {
34
+ return { codigo: e.codigo, json: { ok: false, error: e.error, message: e.message }, texto: e.message };
35
+ }
36
+ const message = taparSecretos(e instanceof Error ? e.message : String(e));
37
+ return { codigo: SALIDA.inesperado, json: { ok: false, error: "inesperado", message }, texto: message };
38
+ }