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.
- package/README.md +122 -2
- package/dist/api.js +295 -0
- package/dist/argumentos.js +98 -0
- package/dist/candado.js +57 -0
- package/dist/cli.js +128 -0
- package/dist/comandos/clave-vigente.js +18 -0
- package/dist/comandos/claves.js +161 -0
- package/dist/comandos/configurar.js +187 -0
- package/dist/comandos/datos-y-politica.js +112 -0
- package/dist/comandos/describir.js +248 -0
- package/dist/comandos/login.js +254 -0
- package/dist/comandos/logout.js +96 -0
- package/dist/comandos/proyecto-del-repo.js +26 -0
- package/dist/comandos/proyecto.js +213 -0
- package/dist/comandos/quien-soy.js +43 -0
- package/dist/comandos/repo-conectar.js +126 -0
- package/dist/contrato.js +38 -0
- package/dist/entorno.js +460 -0
- package/dist/flags-por-comando.js +30 -0
- package/dist/guardado/archivo.js +63 -0
- package/dist/guardado/index.js +258 -0
- package/dist/guardado/llavero.js +110 -0
- package/dist/guardado/repo.js +35 -0
- package/dist/idempotencia.js +88 -0
- package/dist/repo-local.js +68 -0
- package/dist/secreto-a-archivo.js +109 -0
- package/dist/sondeo.js +47 -0
- package/package.json +11 -4
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// `supcoflow describir --json`: el contrato entero en una línea (§13), para que un asistente sepa
|
|
2
|
+
// qué comandos hay, con qué flags, qué puede la clave y qué quiere decir cada código, sin leer la
|
|
3
|
+
// ayuda pensada para personas. La ayuda se arma desde acá: una sola fuente, así no se desfasan.
|
|
4
|
+
import { SALIDA } from "../contrato.js";
|
|
5
|
+
// A mano a propósito, como en api.ts: tiene que coincidir con package.json (lo pinza un test).
|
|
6
|
+
const VERSION = "0.3.0";
|
|
7
|
+
export const DESCRIPCION = {
|
|
8
|
+
version: VERSION,
|
|
9
|
+
comandos: [
|
|
10
|
+
{
|
|
11
|
+
nombre: "login",
|
|
12
|
+
resumen: "Inicia sesión aprobando un código desde la consola. Sin terminal interactiva, en dos pasos: --start y después --wait.",
|
|
13
|
+
flags: [
|
|
14
|
+
"--start: pide el código y sale 5 con la URL para aprobarlo",
|
|
15
|
+
"--wait: espera la aprobación hasta 90 s; sale 5 si todavía no llegó (volvé a llamarlo)",
|
|
16
|
+
"--dry-run: dice qué haría sin pedir nada (no vale con --wait)",
|
|
17
|
+
"--json",
|
|
18
|
+
],
|
|
19
|
+
ejemplos: ["supcoflow login", "supcoflow login --start --json", "supcoflow login --wait --json"],
|
|
20
|
+
codigos: [0, 1, 2, 3, 4, 5],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
nombre: "logout",
|
|
24
|
+
resumen: "Revoca la clave en uso y la borra de esta máquina; reintenta las que quedaron por revocar.",
|
|
25
|
+
flags: ["--dry-run: dice qué revocaría sin revocar nada", "--json"],
|
|
26
|
+
ejemplos: ["supcoflow logout", "supcoflow logout --json"],
|
|
27
|
+
codigos: [0, 1, 3],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
nombre: "quien-soy",
|
|
31
|
+
resumen: "Muestra con qué clave anda la terminal, a nombre de quién y en qué equipo.",
|
|
32
|
+
flags: ["--json"],
|
|
33
|
+
ejemplos: ["supcoflow quien-soy", "supcoflow quien-soy --json"],
|
|
34
|
+
codigos: [0, 1, 2, 3],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
nombre: "describir",
|
|
38
|
+
resumen: "Describe comandos, flags, facultades y códigos de salida, para un asistente.",
|
|
39
|
+
flags: ["--json"],
|
|
40
|
+
ejemplos: ["supcoflow describir --json"],
|
|
41
|
+
codigos: [0],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
nombre: "repo conectar",
|
|
45
|
+
resumen: "Conecta el repo de esta carpeta (o el de --repo) con la App de GitHub: una persona lo confirma en la consola. Sin terminal interactiva, en dos pasos: --start y después --wait.",
|
|
46
|
+
flags: [
|
|
47
|
+
"--start: pide la conexión y sale 5 con la URL para confirmarla",
|
|
48
|
+
"--wait: espera la confirmación hasta 90 s; sale 5 si todavía no llegó (volvé a llamarlo)",
|
|
49
|
+
"--repo owner/repo: el repo, si no es el «origin» de esta carpeta",
|
|
50
|
+
"--dry-run: dice qué pediría sin pedir nada (--wait sólo lee)",
|
|
51
|
+
"--json",
|
|
52
|
+
],
|
|
53
|
+
ejemplos: ["supcoflow repo conectar", "supcoflow repo conectar --start --json", "supcoflow repo conectar --wait --json"],
|
|
54
|
+
codigos: [0, 1, 2, 3, 4, 5],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
nombre: "proyecto crear",
|
|
58
|
+
resumen: "Crea (o reusa) el proyecto del repo ya conectado y lo anota en .supcoflow/proyecto.json; un archivo distinto nunca se pisa (sale 4).",
|
|
59
|
+
flags: [
|
|
60
|
+
"--install \"…\": el comando de instalación, cuando el motor no lo puede deducir",
|
|
61
|
+
"--test \"…\": el comando de pruebas, ídem",
|
|
62
|
+
"--repo owner/repo: el repo, si no es el «origin» de esta carpeta (sin escribir el archivo)",
|
|
63
|
+
"--dry-run: dice qué crearía sin crear nada",
|
|
64
|
+
"--json",
|
|
65
|
+
],
|
|
66
|
+
ejemplos: ["supcoflow proyecto crear --json", "supcoflow proyecto crear --install \"pnpm i\" --test \"pnpm test\""],
|
|
67
|
+
codigos: [0, 1, 2, 3, 4],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
nombre: "proyecto ver",
|
|
71
|
+
resumen: "Muestra el proyecto del repo: rama, comandos y el estado de la App de GitHub; null si todavía no tiene.",
|
|
72
|
+
flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--json"],
|
|
73
|
+
ejemplos: ["supcoflow proyecto ver --json"],
|
|
74
|
+
codigos: [0, 1, 2, 3],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
nombre: "catalogo",
|
|
78
|
+
resumen: "Lista lo que se puede configurar en el chat (claves y valores admitidos) y el estado del contrato de la App.",
|
|
79
|
+
flags: ["--json"],
|
|
80
|
+
ejemplos: ["supcoflow catalogo --json"],
|
|
81
|
+
codigos: [0, 1, 2, 3],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
nombre: "configuracion",
|
|
85
|
+
resumen: "Muestra la configuración del proyecto del repo: chat, sitios, marca, política de problemas y acuerdo de datos. El acuerdo frena lo que se guarda y lo que lee la IA que releva; no cubre lo que ve la IA del chat mientras conversa.",
|
|
86
|
+
flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--json"],
|
|
87
|
+
ejemplos: ["supcoflow configuracion --json"],
|
|
88
|
+
codigos: [0, 1, 2, 3, 4],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
nombre: "chat prender",
|
|
92
|
+
resumen: "Prende el chat de soporte del proyecto. Sin skill aprobada hace falta una persona (sale 4).",
|
|
93
|
+
flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
|
|
94
|
+
ejemplos: ["supcoflow chat prender --json"],
|
|
95
|
+
codigos: [0, 1, 2, 3, 4],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
nombre: "chat apagar",
|
|
99
|
+
resumen: "Apaga el chat de soporte del proyecto.",
|
|
100
|
+
flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
|
|
101
|
+
ejemplos: ["supcoflow chat apagar --json"],
|
|
102
|
+
codigos: [0, 1, 2, 3, 4],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
nombre: "chat marca",
|
|
106
|
+
resumen: "Cambia título, saludo y colores del chat; lo que no nombrás queda como estaba.",
|
|
107
|
+
flags: [
|
|
108
|
+
"--titulo \"…\": el título del chat",
|
|
109
|
+
"--saludo \"…\": el saludo inicial",
|
|
110
|
+
"--tema clave=valor: un color o ajuste del tema (se repite, uno por clave)",
|
|
111
|
+
"--repo owner/repo: el repo, si no es el «origin» de esta carpeta",
|
|
112
|
+
"--dry-run: dice qué haría sin hacerlo",
|
|
113
|
+
"--json",
|
|
114
|
+
],
|
|
115
|
+
ejemplos: ["supcoflow chat marca --titulo \"Ayuda\" --tema acento=#0a7"],
|
|
116
|
+
codigos: [0, 1, 2, 3, 4],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
nombre: "chat logo",
|
|
120
|
+
resumen: "Sube el logo del chat (.png, .jpg o .webp, hasta 200 KB).",
|
|
121
|
+
flags: ["--archivo ruta: la imagen", "--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
|
|
122
|
+
ejemplos: ["supcoflow chat logo --archivo logo.png --json"],
|
|
123
|
+
codigos: [0, 1, 2, 3, 4],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
nombre: "sitios agregar",
|
|
127
|
+
resumen: "Agrega un sitio desde el que el chat puede cargarse.",
|
|
128
|
+
flags: ["--sitio https://…: el sitio", "--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
|
|
129
|
+
ejemplos: ["supcoflow sitios agregar --sitio https://app.ejemplo.com"],
|
|
130
|
+
codigos: [0, 1, 2, 3, 4],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
nombre: "sitios quitar",
|
|
134
|
+
resumen: "Quita un sitio de los que pueden cargar el chat.",
|
|
135
|
+
flags: ["--sitio https://…: el sitio", "--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
|
|
136
|
+
ejemplos: ["supcoflow sitios quitar --sitio https://app.ejemplo.com"],
|
|
137
|
+
codigos: [0, 1, 2, 3, 4],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
nombre: "datos fijar",
|
|
141
|
+
resumen: "Fija el acuerdo de datos: qué datos pasan. Frena lo que se guarda y lo que lee la IA que releva el repo; no cubre lo que ve la IA del chat mientras conversa.",
|
|
142
|
+
flags: [
|
|
143
|
+
"--permitir lista: datos que pasan, separados por coma (usuario_id, usuario_rol, pagina_y_navegador, capturas, elementos, email, atributo:nombre) o `nada`",
|
|
144
|
+
"--repo owner/repo: el repo, si no es el «origin» de esta carpeta",
|
|
145
|
+
"--dry-run: dice qué haría sin hacerlo",
|
|
146
|
+
"--json",
|
|
147
|
+
],
|
|
148
|
+
ejemplos: ["supcoflow datos fijar --permitir usuario_id,atributo:plan"],
|
|
149
|
+
codigos: [0, 1, 2, 3, 4],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
nombre: "politica fijar",
|
|
153
|
+
resumen: "Fija qué hace el motor con un problema que llega por el chat: relevarlo en el acto (gasta) o guardarlo. Guardar frena sólo lo automático del chat: desde la consola una persona puede pedir el relevamiento, y cuesta.",
|
|
154
|
+
flags: ["--modo relevar|guardar", "--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
|
|
155
|
+
ejemplos: ["supcoflow politica fijar --modo guardar"],
|
|
156
|
+
codigos: [0, 1, 2, 3, 4],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
nombre: "claves crear",
|
|
160
|
+
resumen: "Crea una clave de consulta (sólo lee su proyecto) y la guarda en un archivo 0600 fuera de todo repo; el secreto nunca sale por la salida.",
|
|
161
|
+
flags: [
|
|
162
|
+
"--nombre ci: para qué es la clave",
|
|
163
|
+
"--guardar-en ruta: el archivo donde queda (fuera de un repo; si ya tiene otra cosa sale 4 y no lo toca)",
|
|
164
|
+
"--repo owner/repo: el repo, si no es el «origin» de esta carpeta",
|
|
165
|
+
"--dry-run: dice qué haría sin hacerlo",
|
|
166
|
+
"--json",
|
|
167
|
+
],
|
|
168
|
+
ejemplos: ["supcoflow claves crear --nombre ci --guardar-en ~/ci.key --json"],
|
|
169
|
+
codigos: [0, 1, 2, 3, 4],
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
nombre: "claves listar",
|
|
173
|
+
resumen: "Lista las claves de consulta activas del proyecto (id, nombre, fecha; nunca el secreto).",
|
|
174
|
+
flags: ["--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--json"],
|
|
175
|
+
ejemplos: ["supcoflow claves listar --json"],
|
|
176
|
+
codigos: [0, 1, 2, 3, 4],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
nombre: "claves revocar",
|
|
180
|
+
resumen: "Revoca una clave de consulta por su id; revocar dos veces deja lo mismo.",
|
|
181
|
+
flags: ["--id cla_…: la clave (la da `claves listar`)", "--repo owner/repo: el repo, si no es el «origin» de esta carpeta", "--dry-run: dice qué haría sin hacerlo", "--json"],
|
|
182
|
+
ejemplos: ["supcoflow claves revocar --id cla_0123456789abcdef --json"],
|
|
183
|
+
codigos: [0, 1, 2, 3, 4],
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
flags_globales: ["--json: el resultado en una línea de JSON por stdout; los avisos van por stderr", "--help, -h: la ayuda con ejemplos"],
|
|
187
|
+
facultades: [
|
|
188
|
+
{
|
|
189
|
+
nombre: "consultar",
|
|
190
|
+
incluye: "Identidad y equipo, proyectos, configuración, casos, issues, runs, costos, estado de la instalación",
|
|
191
|
+
con_api_key: true,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
nombre: "configurar",
|
|
195
|
+
incluye: "Crear/reusar proyecto, conectar o desconectar el repo, configurar y prender el chat, sitios permitidos, acuerdo de datos, política de problemas, publicar la guía, crear y revocar claves de consulta, perfiles de los agentes",
|
|
196
|
+
con_api_key: true,
|
|
197
|
+
},
|
|
198
|
+
{ nombre: "probar", incluye: "La prueba hasta el issue (gasta, dentro de los topes)", con_api_key: true },
|
|
199
|
+
{
|
|
200
|
+
nombre: "solo_consola",
|
|
201
|
+
incluye: "Subir el tope de gasto, borrar el proyecto, encender la fábrica, dar o sacar acceso a personas: sólo con sesión en la consola",
|
|
202
|
+
con_api_key: false,
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
nombre: "operador",
|
|
206
|
+
incluye: "Solicitudes, /maquinaria, cerrar runs, la fábrica de operador: nunca por clave, aunque la persona sea operadora",
|
|
207
|
+
con_api_key: false,
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
codigos: {
|
|
211
|
+
[SALIDA.ok]: "ok",
|
|
212
|
+
[SALIDA.inesperado]: "inesperado",
|
|
213
|
+
[SALIDA.faltaLogin]: "falta login",
|
|
214
|
+
[SALIDA.entradaInvalida]: "entrada inválida",
|
|
215
|
+
[SALIDA.haceFaltaUnaPersona]: "hace falta una persona",
|
|
216
|
+
[SALIDA.esperandoAUnaPersona]: "esperando a una persona (volvé a llamar)",
|
|
217
|
+
[SALIDA.topeAlcanzado]: "tope alcanzado",
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
export function comandoDescribir() {
|
|
221
|
+
return { codigo: SALIDA.ok, json: { ok: true, ...DESCRIPCION }, texto: ayuda(null) };
|
|
222
|
+
}
|
|
223
|
+
function bloque(c) {
|
|
224
|
+
return [
|
|
225
|
+
` ${c.nombre}: ${c.resumen}`,
|
|
226
|
+
...c.flags.map((f) => ` ${f}`),
|
|
227
|
+
].join("\n");
|
|
228
|
+
}
|
|
229
|
+
export function ayuda(comando) {
|
|
230
|
+
const elegidos = comando === null ? DESCRIPCION.comandos : DESCRIPCION.comandos.filter((c) => c.nombre === comando);
|
|
231
|
+
const uso = comando === null ? "supcoflow <comando> [flags]" : `supcoflow ${comando} [flags]`;
|
|
232
|
+
const codigos = Object.entries(DESCRIPCION.codigos).map(([n, t]) => ` ${n} ${t}`);
|
|
233
|
+
return [
|
|
234
|
+
`Uso: ${uso}`,
|
|
235
|
+
"",
|
|
236
|
+
comando === null ? "Comandos:" : "Comando:",
|
|
237
|
+
...elegidos.map(bloque),
|
|
238
|
+
"",
|
|
239
|
+
"Flags de todos los comandos:",
|
|
240
|
+
...DESCRIPCION.flags_globales.map((f) => ` ${f}`),
|
|
241
|
+
"",
|
|
242
|
+
"Ejemplos:",
|
|
243
|
+
...elegidos.flatMap((c) => c.ejemplos.map((e) => ` ${e}`)),
|
|
244
|
+
"",
|
|
245
|
+
"Códigos de salida:",
|
|
246
|
+
...codigos,
|
|
247
|
+
].join("\n");
|
|
248
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { FalloDeLaTerminal, resultadoDeFallo, SALIDA, taparSecretos } from "../contrato.js";
|
|
2
|
+
import { PLAZO_MINIMO_MS, RESERVA_PARA_CERRAR_MS, sondearUnTramo, TRAMO_MAXIMO_MS } from "../sondeo.js";
|
|
3
|
+
const TECHO_DE_UN_PASO_MS = 5_000;
|
|
4
|
+
const VOLVER_A_EMPEZAR = "Volvé a empezar con `supcoflow login --start`.";
|
|
5
|
+
const fallo = (codigo, error, message, extra = {}) => ({
|
|
6
|
+
codigo, json: { ok: false, error, message, ...extra }, texto: message,
|
|
7
|
+
});
|
|
8
|
+
// Un error crudo del sistema (fs, el llavero) puede traer cualquier cosa: se tapa antes de mostrarlo.
|
|
9
|
+
const motivo = (e) => e instanceof FalloDeLaTerminal ? e.message : taparSecretos(e instanceof Error ? e.message : String(e));
|
|
10
|
+
// Las claves que un login o logout anterior no pudo revocar (Review Focus 14). Nunca lanza: es
|
|
11
|
+
// limpieza de lo de antes y no puede trabar un login nuevo.
|
|
12
|
+
export async function reintentarPorRevocar(d, limite) {
|
|
13
|
+
const origen = d.api.origin;
|
|
14
|
+
const noSe = () => {
|
|
15
|
+
d.avisar("No pude leer entera la lista de claves por revocar (el llavero no respondió); la reintento la próxima vez.");
|
|
16
|
+
};
|
|
17
|
+
let lectura;
|
|
18
|
+
try {
|
|
19
|
+
lectura = await d.guardado.leerPorRevocar(origen);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
noSe();
|
|
23
|
+
return { revocadas: 0, quedan: null };
|
|
24
|
+
}
|
|
25
|
+
// «Cuántas quedan» sólo es un número si se pudo comprobar entero (Review Focus 15): con un
|
|
26
|
+
// almacén que no respondió puede haber claves que no se vieron, y decir 0 sería mentir.
|
|
27
|
+
let sabido = lectura.completa;
|
|
28
|
+
let revocadas = 0;
|
|
29
|
+
for (const clave of lectura.claves) {
|
|
30
|
+
const queda = limite - d.ahora();
|
|
31
|
+
if (queda < PLAZO_MINIMO_MS)
|
|
32
|
+
break;
|
|
33
|
+
try {
|
|
34
|
+
await d.revocarPropia(d.api, clave, Math.min(TECHO_DE_UN_PASO_MS, queda));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
continue; // sigue anotada; el próximo login o logout la reintenta
|
|
38
|
+
}
|
|
39
|
+
revocadas++;
|
|
40
|
+
try {
|
|
41
|
+
if (!(await d.guardado.quitarPorRevocar(origen, clave)).completo)
|
|
42
|
+
sabido = false;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
sabido = false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!sabido) {
|
|
49
|
+
noSe();
|
|
50
|
+
return { revocadas, quedan: null };
|
|
51
|
+
}
|
|
52
|
+
return { revocadas, quedan: lectura.claves.length - revocadas };
|
|
53
|
+
}
|
|
54
|
+
export async function loginStart(d, flags) {
|
|
55
|
+
const origen = d.api.origin;
|
|
56
|
+
if (flags.dryRun) {
|
|
57
|
+
const haria = `pedir un código de dispositivo a ${origen}`;
|
|
58
|
+
return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `Con --dry-run no hago nada; sin él, iría a ${haria}.` };
|
|
59
|
+
}
|
|
60
|
+
await reintentarPorRevocar(d, d.ahora() + 15_000);
|
|
61
|
+
let c;
|
|
62
|
+
try {
|
|
63
|
+
c = await d.pedirCodigo(d.api, d.maquina);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
return resultadoDeFallo(e);
|
|
67
|
+
}
|
|
68
|
+
// Sólo el pendiente, en su propia cuenta: la clave vigente sigue sirviendo hasta que la nueva
|
|
69
|
+
// quede guardada (Review Focus 7), así un login rechazado o abandonado no deja a nadie sin sesión.
|
|
70
|
+
const pendiente = {
|
|
71
|
+
deviceCode: c.deviceCode, userCode: c.userCode, verificationUriComplete: c.verificationUriComplete,
|
|
72
|
+
venceEn: d.ahora() + c.expiresIn * 1000, intervaloS: c.interval, ultimoSondeoEn: null,
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
await d.guardado.guardarPendiente(origen, pendiente);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
return fallo(SALIDA.inesperado, e instanceof FalloDeLaTerminal ? e.error : "no_se_pudo_guardar", `No pude guardar el inicio de sesión en curso: ${motivo(e)}`);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
codigo: SALIDA.esperandoAUnaPersona,
|
|
82
|
+
json: {
|
|
83
|
+
ok: false, esperando: "persona", verification_uri: c.verificationUri,
|
|
84
|
+
verification_uri_complete: c.verificationUriComplete, user_code: c.userCode, expires_in: c.expiresIn,
|
|
85
|
+
},
|
|
86
|
+
texto: `Abrí ${c.verificationUriComplete} y confirmá el código ${c.userCode}. Después corré \`supcoflow login --wait\`.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export async function loginWait(d) {
|
|
90
|
+
const origen = d.api.origin;
|
|
91
|
+
const inicio = d.ahora();
|
|
92
|
+
const tramo = d.tramoMs ?? TRAMO_MAXIMO_MS;
|
|
93
|
+
const limite = inicio + tramo;
|
|
94
|
+
// La proporción sólo pesa con el tramo corto de las pruebas; con 90 s la reserva es la entera.
|
|
95
|
+
const finDeSondeos = limite - Math.min(RESERVA_PARA_CERRAR_MS, tramo / 3);
|
|
96
|
+
const techo = () => Math.max(0, Math.min(TECHO_DE_UN_PASO_MS, limite - d.ahora()));
|
|
97
|
+
const p = await d.guardado.leerPendiente(origen);
|
|
98
|
+
if (p === null) {
|
|
99
|
+
return fallo(SALIDA.entradaInvalida, "sin_login_en_curso", "No hay un inicio de sesión en curso: empezá con `supcoflow login --start`.");
|
|
100
|
+
}
|
|
101
|
+
// La anterior se lee ANTES de sondear, así no consume la reserva del cierre. Se pide por
|
|
102
|
+
// `leerClaveCompleta` para saber de paso si SUPCOFLOW_TOKEN está definida (sin costo: la variable
|
|
103
|
+
// se devuelve sin tocar los almacenes); en ese caso la anterior es la GUARDADA, nunca la del
|
|
104
|
+
// entorno, que no es de este login y no se revoca acá.
|
|
105
|
+
const vigente = await d.guardado.leerClaveCompleta(origen);
|
|
106
|
+
const conVariable = vigente.valor?.fuente === "env";
|
|
107
|
+
const lectura = conVariable ? await d.guardado.leerClaveGuardadaCompleta(origen) : vigente;
|
|
108
|
+
// Un llavero que no contestó no es «no había clave» (Review Focus 12 y 14): seguir guardaría la
|
|
109
|
+
// nueva encima de la anterior sin revocarla, viva y olvidada. Se corta antes de canjear el código
|
|
110
|
+
// y sin borrar el pendiente, así el mismo `--wait` sirve cuando el llavero vuelva.
|
|
111
|
+
if (!lectura.completa) {
|
|
112
|
+
const salida = conVariable
|
|
113
|
+
? "desbloqueá el llavero y volvé a llamar `supcoflow login --wait`."
|
|
114
|
+
: "desbloqueá el llavero y volvé a llamar `supcoflow login --wait` (o, sin login, exportá SUPCOFLOW_TOKEN con una clave vigente).";
|
|
115
|
+
return fallo(SALIDA.inesperado, "no_pude_leer_la_clave", `No pude leer tu clave guardada (el llavero no respondió), y sin saber cuál es no la puedo reemplazar sin dejarla viva: ${salida}`);
|
|
116
|
+
}
|
|
117
|
+
const anterior = lectura.valor;
|
|
118
|
+
let fin;
|
|
119
|
+
try {
|
|
120
|
+
fin = await sondearUnTramo(p, {
|
|
121
|
+
ahora: d.ahora, dormir: d.dormir, finDeSondeos,
|
|
122
|
+
canjear: (deviceCode, plazoMs) => d.canjear(d.api, deviceCode, plazoMs),
|
|
123
|
+
persistir: (q, techoMs) => d.guardado.guardarPendiente(origen, q, techoMs),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
// El progreso de los sondeos anteriores ya quedó guardado: el próximo `--wait` sigue de ahí.
|
|
128
|
+
return resultadoDeFallo(e);
|
|
129
|
+
}
|
|
130
|
+
switch (fin.tipo) {
|
|
131
|
+
case "sigue":
|
|
132
|
+
return {
|
|
133
|
+
codigo: SALIDA.esperandoAUnaPersona,
|
|
134
|
+
json: { ok: false, esperando: "persona", user_code: fin.pendiente.userCode, verification_uri_complete: fin.pendiente.verificationUriComplete },
|
|
135
|
+
texto: `Todavía no se confirmó. Abrí ${fin.pendiente.verificationUriComplete} y confirmá el código ${fin.pendiente.userCode}; después volvé a correr \`supcoflow login --wait\`.`,
|
|
136
|
+
};
|
|
137
|
+
case "token": {
|
|
138
|
+
const r = await reemplazarClave(d, { clave: fin.clave, equipo: fin.equipo }, anterior?.clave ?? null, limite);
|
|
139
|
+
// Va acá y no adentro de `reemplazarClave` porque es acá donde se supo, antes de sondear, sin
|
|
140
|
+
// gastar reserva: preguntarlo al final costaría otra lectura lenta del guardado.
|
|
141
|
+
if (conVariable && r.json.guardada_en !== undefined) {
|
|
142
|
+
d.avisar("SUPCOFLOW_TOKEN está definida y tiene prioridad sobre la clave que acabo de guardar.");
|
|
143
|
+
}
|
|
144
|
+
return r;
|
|
145
|
+
}
|
|
146
|
+
case "rechazado":
|
|
147
|
+
await d.guardado.borrarPendiente(origen, techo());
|
|
148
|
+
return fallo(SALIDA.haceFaltaUnaPersona, "acceso_rechazado", "Rechazaron el acceso desde la consola; tu sesión anterior, si había, sigue igual.");
|
|
149
|
+
case "vencido":
|
|
150
|
+
await d.guardado.borrarPendiente(origen, techo());
|
|
151
|
+
return fallo(SALIDA.faltaLogin, "codigo_vencido", `El código venció. ${VOLVER_A_EMPEZAR}`);
|
|
152
|
+
case "invalido":
|
|
153
|
+
await d.guardado.borrarPendiente(origen, techo());
|
|
154
|
+
return fallo(SALIDA.faltaLogin, "codigo_invalido", `El código ya no vale. ${VOLVER_A_EMPEZAR}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// El login de una persona frente a la terminal: los mismos dos pasos, encadenados. Sin TTY (o con
|
|
158
|
+
// `--json`) no pide código: nadie iba a ver la URL, y un pedido que nadie aprueba sólo gasta el tope
|
|
159
|
+
// de pedidos por IP del motor. A un asistente le corresponde el camino partido.
|
|
160
|
+
const PAUSA_ENTRE_TRAMOS_MS = 1_000;
|
|
161
|
+
export async function loginInteractivo(d, flags) {
|
|
162
|
+
if (!d.esTty || flags.json) {
|
|
163
|
+
return fallo(SALIDA.entradaInvalida, "hace_falta_tty", "Sin terminal interactiva usá `supcoflow login --start --json` y después `supcoflow login --wait --json`.");
|
|
164
|
+
}
|
|
165
|
+
const inicio = await loginStart(d, flags);
|
|
166
|
+
if (inicio.codigo !== SALIDA.esperandoAUnaPersona)
|
|
167
|
+
return inicio;
|
|
168
|
+
// El texto de `--start` le dice a la persona que corra `--wait`; acá eso lo hace el comando solo.
|
|
169
|
+
const { verification_uri_complete: url, user_code: codigo } = inicio.json;
|
|
170
|
+
d.mostrar(`Abrí ${String(url)} y confirmá el código ${String(codigo)}. Espero acá…`);
|
|
171
|
+
// Cada tramo termina por su cuenta (≤ 90 s) y el código vence a los pocos minutos, así que el
|
|
172
|
+
// bucle tiene fin aunque la persona nunca conteste: termina en `codigo_vencido`.
|
|
173
|
+
let r;
|
|
174
|
+
do {
|
|
175
|
+
r = await loginWait(d);
|
|
176
|
+
// Un `loginWait` que devuelve 5 sin haber sondeado (el intervalo era más largo que la ventana
|
|
177
|
+
// que quedaba) volvería a girar sin esperar nada: este segundo evita quemar CPU en el vacío.
|
|
178
|
+
if (r.codigo === SALIDA.esperandoAUnaPersona)
|
|
179
|
+
await d.dormir(PAUSA_ENTRE_TRAMOS_MS);
|
|
180
|
+
} while (r.codigo === SALIDA.esperandoAUnaPersona);
|
|
181
|
+
return r;
|
|
182
|
+
}
|
|
183
|
+
// Por qué este orden (guardar → revocar → anotar → borrar el pendiente), Review Focus 13 y 14:
|
|
184
|
+
// la nueva se guarda primero porque es la única copia; la anterior se revoca recién con la nueva a
|
|
185
|
+
// salvo (si no, un fallo al guardar dejaba a la persona sin ninguna); si no se pudo revocar, se anota
|
|
186
|
+
// para no olvidarla viva; y el borrado del pendiente va último y no lanza, porque ubicado antes era el
|
|
187
|
+
// que podía impedir revocar la anterior, y un pendiente viejo es inofensivo.
|
|
188
|
+
export async function reemplazarClave(d, nueva, anterior, limite) {
|
|
189
|
+
const origen = d.api.origin;
|
|
190
|
+
const queda = () => limite - d.ahora();
|
|
191
|
+
const techo = () => Math.max(0, Math.min(TECHO_DE_UN_PASO_MS, queda()));
|
|
192
|
+
// 1. Guardar la nueva. Con el techo agotado el guardado salta el llavero y escribe el archivo, que
|
|
193
|
+
// es local: la clave se guarda igual aunque el tramo esté por vencer.
|
|
194
|
+
let guardadaEn;
|
|
195
|
+
try {
|
|
196
|
+
guardadaEn = (await d.guardado.guardarClave(origen, nueva.clave, techo())).donde;
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
// Una clave que nadie guardó no puede quedar viva: se revoca, aunque haga falta un plazo mínimo
|
|
200
|
+
// más allá del tramo, porque nadie más la va a poder revocar.
|
|
201
|
+
let revocada = true;
|
|
202
|
+
try {
|
|
203
|
+
await d.revocarPropia(d.api, nueva.clave, Math.min(TECHO_DE_UN_PASO_MS, Math.max(PLAZO_MINIMO_MS, queda())));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
revocada = false;
|
|
207
|
+
}
|
|
208
|
+
const despues = revocada
|
|
209
|
+
? "La revoqué para que no quede viva; tu sesión anterior, si había, sigue como estaba."
|
|
210
|
+
: "Tampoco pude revocarla: revocala en la consola, en «Accesos desde la terminal».";
|
|
211
|
+
return fallo(SALIDA.inesperado, "no_se_pudo_guardar", `No pude guardar la clave nueva (${motivo(e)}). ${despues}`);
|
|
212
|
+
}
|
|
213
|
+
// 2. Revocar la anterior, o anotarla.
|
|
214
|
+
let anteriorRevocada = null;
|
|
215
|
+
let quedaPorRevocar = false;
|
|
216
|
+
if (anterior !== null && anterior !== nueva.clave) {
|
|
217
|
+
anteriorRevocada = false;
|
|
218
|
+
if (queda() >= PLAZO_MINIMO_MS) {
|
|
219
|
+
try {
|
|
220
|
+
await d.revocarPropia(d.api, anterior, Math.min(TECHO_DE_UN_PASO_MS, queda()));
|
|
221
|
+
anteriorRevocada = true;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// cae a anotarla
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (!anteriorRevocada) {
|
|
228
|
+
try {
|
|
229
|
+
await d.guardado.anotarPorRevocar(origen, anterior, techo());
|
|
230
|
+
quedaPorRevocar = true;
|
|
231
|
+
d.avisar("No pude revocar tu clave anterior; quedó anotada y la reintento en el próximo login o logout.");
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
const message = "No pude revocar ni anotar tu clave anterior: revocala en la consola, en «Accesos desde la terminal».";
|
|
235
|
+
d.avisar(message);
|
|
236
|
+
if (queda() >= PLAZO_MINIMO_MS)
|
|
237
|
+
await d.guardado.borrarPendiente(origen, techo());
|
|
238
|
+
// La nueva SÍ quedó guardada y anda: el json lo dice para que nadie vuelva a hacer login.
|
|
239
|
+
return fallo(SALIDA.inesperado, "clave_anterior_sin_revocar", message, {
|
|
240
|
+
equipo: nueva.equipo, guardada_en: guardadaEn, anterior_revocada: false, queda_por_revocar: false,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// 3. Borrar el pendiente; su `false` no cambia nada.
|
|
246
|
+
if (queda() >= PLAZO_MINIMO_MS)
|
|
247
|
+
await d.guardado.borrarPendiente(origen, techo());
|
|
248
|
+
const donde = guardadaEn === "llavero" ? "el llavero del sistema" : "el archivo de credenciales";
|
|
249
|
+
return {
|
|
250
|
+
codigo: SALIDA.ok,
|
|
251
|
+
json: { ok: true, equipo: nueva.equipo, guardada_en: guardadaEn, anterior_revocada: anteriorRevocada, queda_por_revocar: quedaPorRevocar },
|
|
252
|
+
texto: `Sesión iniciada en el equipo ${nueva.equipo}; la clave quedó en ${donde}.`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// `supcoflow logout`: cierra UNA credencial, la que se está usando, y nunca olvida una clave viva
|
|
2
|
+
// (Review Focus 9 y 14). Borrar sin revocar dejaría la clave sirviendo en el motor y sin nadie que
|
|
3
|
+
// la recuerde: por eso, si el motor no confirma, no se borra nada y el comando sale 1.
|
|
4
|
+
import { FalloDeLaTerminal, SALIDA, taparSecretos } from "../contrato.js";
|
|
5
|
+
import { reintentarPorRevocar } from "./login.js";
|
|
6
|
+
const motivo = (e) => e instanceof FalloDeLaTerminal ? e.message : taparSecretos(e instanceof Error ? e.message : String(e));
|
|
7
|
+
export async function comandoLogout(d, flags) {
|
|
8
|
+
const origen = d.api.origin;
|
|
9
|
+
// Sin nada legible y un almacén que no contestó, no se sabe si hay sesión: decir «no había»
|
|
10
|
+
// dejaría creer que no queda ninguna clave viva (Review Focus 12).
|
|
11
|
+
const noPudeLeer = (extra = {}) => {
|
|
12
|
+
const message = "No pude leer la clave guardada (el llavero no respondió); no cerré nada. Probá de nuevo.";
|
|
13
|
+
return { codigo: SALIDA.inesperado, json: { ok: false, error: "no_pude_leer_la_clave", message, ...extra }, texto: message };
|
|
14
|
+
};
|
|
15
|
+
if (flags.dryRun) {
|
|
16
|
+
const lectura = await d.guardado.leerClaveCompleta(origen);
|
|
17
|
+
const vigente = lectura.valor;
|
|
18
|
+
if (vigente === null && !lectura.completa)
|
|
19
|
+
return noPudeLeer({ dry_run: true });
|
|
20
|
+
if (vigente === null) {
|
|
21
|
+
return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, habia_sesion: false }, texto: "No hay sesión iniciada: no haría nada." };
|
|
22
|
+
}
|
|
23
|
+
const haria = `revocar la clave de ${vigente.fuente} en ${origen}` + (vigente.fuente !== "env" ? " y borrarla" : "");
|
|
24
|
+
return { codigo: SALIDA.ok, json: { ok: true, dry_run: true, haria }, texto: `Con --dry-run no hago nada; sin él, iría a ${haria}.` };
|
|
25
|
+
}
|
|
26
|
+
// Lo pendiente de antes va primero: un logout es justo cuando la persona espera que no quede
|
|
27
|
+
// ninguna clave suya viva, y es la última oportunidad que tiene la terminal de revocarlas.
|
|
28
|
+
const { quedan } = await reintentarPorRevocar(d, d.ahora() + 15_000);
|
|
29
|
+
// `null` es «no lo pude comprobar» y se dice así: un 0 haría creer que no queda nada vivo.
|
|
30
|
+
if (quedan === null) {
|
|
31
|
+
d.avisar("No pude comprobar cuántas claves quedan por revocar; las que haya siguen anotadas y las reintento en el próximo login o logout.");
|
|
32
|
+
}
|
|
33
|
+
else if (quedan > 0) {
|
|
34
|
+
d.avisar(`Quedan ${quedan} clave(s) anterior(es) sin revocar; siguen anotadas y las reintento en el próximo login o logout.`);
|
|
35
|
+
}
|
|
36
|
+
const lectura = await d.guardado.leerClaveCompleta(origen);
|
|
37
|
+
const vigente = lectura.valor;
|
|
38
|
+
if (vigente === null && !lectura.completa)
|
|
39
|
+
return noPudeLeer({ quedan_por_revocar: quedan });
|
|
40
|
+
const conPendientes = (json) => ({ ...json, quedan_por_revocar: quedan, ...(lectura.completa ? {} : { lectura_completa: false }) });
|
|
41
|
+
if (vigente === null) {
|
|
42
|
+
await d.guardado.borrarPendiente(origen);
|
|
43
|
+
return { codigo: SALIDA.ok, json: conPendientes({ ok: true, habia_sesion: false }), texto: "No había sesión iniciada." };
|
|
44
|
+
}
|
|
45
|
+
let revocada;
|
|
46
|
+
try {
|
|
47
|
+
revocada = (await d.revocarPropia(d.api, vigente.clave)) === "revocada";
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
const cual = vigente.fuente === "env" ? "la clave de SUPCOFLOW_TOKEN sigue viva" : "la clave sigue guardada";
|
|
51
|
+
const message = `No pude revocar la clave, así que no cerré la sesión (${motivo(e)}); ${cual}. Probá de nuevo.`;
|
|
52
|
+
const error = e instanceof FalloDeLaTerminal ? e.error : "inesperado";
|
|
53
|
+
return { codigo: SALIDA.inesperado, json: conPendientes({ ok: false, error, message }), texto: message };
|
|
54
|
+
}
|
|
55
|
+
// La variable no es nuestra: se revoca la clave que trae, pero lo guardado es OTRA credencial,
|
|
56
|
+
// que sólo se cierra revocándola a ella. Borrarla acá la dejaría viva y olvidada.
|
|
57
|
+
if (vigente.fuente === "env") {
|
|
58
|
+
d.avisar("Revoqué la clave de SUPCOFLOW_TOKEN; sacala de tu entorno.");
|
|
59
|
+
// `undefined` = no se pudo leer: no se afirma que no haya, el json lleva `null` en vez de mentir.
|
|
60
|
+
let guardada;
|
|
61
|
+
try {
|
|
62
|
+
const l = await d.guardado.leerClaveGuardadaCompleta(origen);
|
|
63
|
+
guardada = l.valor ?? (l.completa ? null : undefined);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
guardada = undefined;
|
|
67
|
+
}
|
|
68
|
+
if (guardada) {
|
|
69
|
+
d.avisar(`Sigue guardada otra clave en ${guardada.fuente}; para cerrarla, corré \`supcoflow logout\` sin SUPCOFLOW_TOKEN.`);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
codigo: SALIDA.ok,
|
|
73
|
+
json: conPendientes({ ok: true, revocada, fuente: "env", queda_clave_guardada: guardada === undefined ? null : guardada !== null }),
|
|
74
|
+
texto: "Revoqué la clave de SUPCOFLOW_TOKEN.",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// Con la lectura incompleta se borra sólo la copia que se leyó y se acaba de revocar: la del
|
|
78
|
+
// almacén que no contestó puede ser otra clave, viva, y borrarla sin revocarla la olvidaría.
|
|
79
|
+
if (!lectura.completa) {
|
|
80
|
+
const otro = vigente.fuente === "archivo" ? "el llavero del sistema" : "el archivo de credenciales";
|
|
81
|
+
d.avisar(`Cierro la clave de ${vigente.fuente}, pero no pude leer ${otro}: puede quedar otra copia guardada ahí; cuando responda, volvé a correr \`supcoflow logout\`.`);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
if (lectura.completa)
|
|
85
|
+
await d.guardado.borrarClave(origen);
|
|
86
|
+
else
|
|
87
|
+
await d.guardado.borrarClaveDe(origen, vigente.fuente);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
// La clave ya no sirve en el motor, pero quedó escrita: el comando no puede decir que terminó bien.
|
|
91
|
+
const message = `La clave quedó revocada pero no pude borrarla: ${motivo(e)}`;
|
|
92
|
+
return { codigo: SALIDA.inesperado, json: conPendientes({ ok: false, error: "no_se_pudo_borrar", message }), texto: message };
|
|
93
|
+
}
|
|
94
|
+
await d.guardado.borrarPendiente(origen);
|
|
95
|
+
return { codigo: SALIDA.ok, json: conPendientes({ ok: true, revocada }), texto: "Sesión cerrada." };
|
|
96
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { resultadoDeFallo, SALIDA } from "../contrato.js";
|
|
2
|
+
import { repoPedido } from "../repo-local.js";
|
|
3
|
+
import { claveVigente } from "./clave-vigente.js";
|
|
4
|
+
export async function proyectoDelRepo(d, flags) {
|
|
5
|
+
let repo;
|
|
6
|
+
try {
|
|
7
|
+
repo = (await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd })).repo;
|
|
8
|
+
}
|
|
9
|
+
catch (e) {
|
|
10
|
+
return { ok: false, resultado: resultadoDeFallo(e) };
|
|
11
|
+
}
|
|
12
|
+
const leida = await claveVigente(d.guardado, d.api.origin);
|
|
13
|
+
if (!leida.ok)
|
|
14
|
+
return { ok: false, resultado: leida.resultado };
|
|
15
|
+
try {
|
|
16
|
+
const proyecto = await d.buscarProyecto(d.api, leida.vigente.clave, repo);
|
|
17
|
+
if (proyecto === null) {
|
|
18
|
+
const message = `El repo ${repo} todavía no tiene proyecto. Creá el proyecto: \`supcoflow proyecto crear\`.`;
|
|
19
|
+
return { ok: false, resultado: { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "sin_proyecto", message, repo }, texto: message } };
|
|
20
|
+
}
|
|
21
|
+
return { ok: true, clave: leida.vigente.clave, proyecto };
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
return { ok: false, resultado: resultadoDeFallo(e) };
|
|
25
|
+
}
|
|
26
|
+
}
|