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.
- package/README.md +112 -16
- package/dist/api.js +53 -4
- package/dist/argumentos.js +7 -2
- package/dist/cli.js +5 -2
- package/dist/comandos/contexto.js +102 -0
- package/dist/comandos/describir.js +18 -17
- package/dist/comandos/guia.js +3 -2
- package/dist/comandos/instalacion.js +70 -28
- package/dist/comandos/probar.js +101 -7
- package/dist/comandos/proyecto-del-repo.js +11 -1
- package/dist/comandos/proyecto.js +34 -20
- package/dist/comandos/widget.js +26 -0
- package/dist/diagnostico.js +46 -1
- package/dist/entorno.js +47 -9
- package/dist/flags-por-comando.js +5 -1
- package/dist/guardado/lugar.js +63 -0
- package/dist/guardado/protegido.js +38 -0
- package/dist/guardado/repo.js +98 -0
- package/dist/idempotencia.js +15 -27
- package/dist/instalacion.js +88 -15
- package/dist/pruebas-guardadas.js +28 -16
- package/dist/repo-local.js +90 -8
- package/package.json +1 -1
package/dist/entorno.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// demás recibe estas piezas inyectadas y se prueba con dobles; acá sólo se enchufan.
|
|
3
3
|
import { execFile, spawn } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { chmodSync, closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeSync, } from "node:fs";
|
|
5
|
+
import { accessSync, chmodSync, closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, statSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeSync, } from "node:fs";
|
|
6
6
|
import { NO_LEIDO } from "./lectura.js";
|
|
7
7
|
import { homedir, hostname } from "node:os";
|
|
8
8
|
import { basename, dirname, join } from "node:path";
|
|
@@ -12,6 +12,8 @@ import { esCarpetaReal, esEnlaceReal, rutaRealOEnNada } from "./comandos/instala
|
|
|
12
12
|
import { DESCRIPCION } from "./comandos/describir.js";
|
|
13
13
|
import { almacenDeArchivo } from "./guardado/archivo.js";
|
|
14
14
|
import { crearGuardado } from "./guardado/index.js";
|
|
15
|
+
import { almacenProtegido } from "./guardado/protegido.js";
|
|
16
|
+
import { revisarElLugar } from "./guardado/lugar.js";
|
|
15
17
|
import { almacenDeLlavero } from "./guardado/llavero.js";
|
|
16
18
|
import { crearGuardaDeSecretos } from "./secreto-a-archivo.js";
|
|
17
19
|
import { crearRegistroDeIdempotencia } from "./idempotencia.js";
|
|
@@ -68,6 +70,29 @@ export const fsReal = {
|
|
|
68
70
|
}
|
|
69
71
|
},
|
|
70
72
|
};
|
|
73
|
+
// Lo que el prechequeo de la máquina (src/guardado/lugar.ts) ve del disco, sin escribir. `estado` sigue los enlaces (`stat`): un
|
|
74
|
+
// enlace roto es «no existe» para él, y `existe` (que es `lstat`) lo distingue.
|
|
75
|
+
export const fsDelLugarReal = {
|
|
76
|
+
...fsReal,
|
|
77
|
+
estado(p) {
|
|
78
|
+
try {
|
|
79
|
+
const st = statSync(p);
|
|
80
|
+
return { existe: true, tipo: st.isDirectory() ? "carpeta" : st.isFile() ? "archivo" : "otro", propio: typeof process.getuid !== "function" || st.uid === process.getuid() };
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return { existe: false };
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
puede(p, que) {
|
|
87
|
+
try {
|
|
88
|
+
accessSync(p, que === "leer" ? constants.R_OK : constants.W_OK | constants.X_OK);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
71
96
|
// El candado de `claves crear` (src/candado.ts). `wx` es la exclusión: SÓLO `EEXIST` es «ya hay uno»;
|
|
72
97
|
// cualquier otro error (permisos, disco) se relanza y sale `no_disponible`, nunca «ocupado». La lectura no
|
|
73
98
|
// sigue enlaces: un `.lock` que alguien cambió por un enlace no se lee como si fuera el nuestro.
|
|
@@ -396,27 +421,31 @@ export function entornoReal(env = process.env) {
|
|
|
396
421
|
// Al lado de las credenciales y con el mismo almacén (carpeta 0700, archivo 0600, `.tmp` en
|
|
397
422
|
// exclusiva): nunca dentro del repo donde se corre el comando, que la publicaría con un `git add -A`.
|
|
398
423
|
const rutaDeIdempotencia = join(dirname(rutaDelArchivo), "idempotencia.json");
|
|
424
|
+
// Estos dos registros (y sólo estos) pueden vivir bajo una carpeta personal que es un repo de git: el almacén
|
|
425
|
+
// protegido comprueba con git, antes de cada escritura y cada borrado, que los deja afuera del repo.
|
|
426
|
+
const protegido = (ruta) => almacenProtegido(almacenDeArchivo(ruta, fsReal), { ruta, fs: fsReal, git: gitReal });
|
|
399
427
|
const idempotencia = crearRegistroDeIdempotencia({
|
|
400
428
|
origen: api.origin,
|
|
401
|
-
almacen:
|
|
429
|
+
almacen: protegido(rutaDeIdempotencia),
|
|
402
430
|
rutaDelArchivo: rutaDeIdempotencia,
|
|
403
|
-
fs: fsReal,
|
|
404
431
|
generar: randomUUID,
|
|
405
432
|
ahora: Date.now,
|
|
406
433
|
avisar,
|
|
407
434
|
});
|
|
408
|
-
// El pedido de `probar` (intento + última prueba), con el mismo almacén
|
|
435
|
+
// El pedido de `probar` (intento + última prueba), con el mismo almacén protegido.
|
|
409
436
|
const rutaDePruebas = join(dirname(rutaDelArchivo), "pruebas.json");
|
|
410
437
|
const pruebas = crearRegistroDePruebas({
|
|
411
|
-
origen: api.origin, almacen:
|
|
438
|
+
origen: api.origin, almacen: protegido(rutaDePruebas), rutaDelArchivo: rutaDePruebas, ahora: Date.now, avisar,
|
|
439
|
+
revisarElLugar: () => revisarElLugar({ ruta: rutaDePruebas, fs: fsDelLugarReal, git: gitReal }),
|
|
412
440
|
});
|
|
413
441
|
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
414
442
|
const cwd = process.cwd();
|
|
415
443
|
// Los candados de `claves crear` viven al lado de las credenciales, nunca en un repo.
|
|
416
444
|
const candados = crearCandados(fsDelCandadoReal, join(dirname(rutaDelArchivo), "candados"), process.pid, procesoVivo);
|
|
417
445
|
const secretos = crearGuardaDeSecretos(fsDeSecretosReal);
|
|
446
|
+
// `archivos`: `proyectoDelRepo` lee `.supcoflow/proyecto.json` del clon con él (fricción 6).
|
|
418
447
|
const configurarEnElMotor = {
|
|
419
|
-
api, guardado, git: gitReal, cwd, avisar,
|
|
448
|
+
api, guardado, git: gitReal, cwd, avisar, archivos: archivosDelRepo(avisar),
|
|
420
449
|
buscarProyecto: (a, clave, repo) => buscarProyecto(a, clave, repo, fetch),
|
|
421
450
|
leerConfiguracion: (a, clave, id) => leerConfiguracion(a, clave, id, fetch),
|
|
422
451
|
configurar: (a, clave, ruta, cuerpo, extra) => configurar(a, clave, ruta, cuerpo, fetch, extra),
|
|
@@ -454,6 +483,7 @@ export function entornoReal(env = process.env) {
|
|
|
454
483
|
proyecto: {
|
|
455
484
|
api, guardado, git: gitReal, cwd, idempotencia, archivos: archivosDelRepo(avisar), avisar,
|
|
456
485
|
buscarProyecto: (a, clave, repo) => buscarProyecto(a, clave, repo, fetch),
|
|
486
|
+
quienSoy: (a, clave) => quienSoy(a, clave, fetch),
|
|
457
487
|
ultimaConexion: (a, clave, repo, plazoMs) => ultimaConexion(a, clave, repo, fetch, plazoMs),
|
|
458
488
|
crearProyecto: (a, clave, conexionId, cuerpo, key) => crearProyecto(a, clave, conexionId, cuerpo, key, fetch),
|
|
459
489
|
leerProyecto: (a, clave, id) => leerProyecto(a, clave, id, fetch),
|
|
@@ -476,9 +506,9 @@ export function entornoReal(env = process.env) {
|
|
|
476
506
|
idempotencia, candados, secretos,
|
|
477
507
|
},
|
|
478
508
|
widget: {
|
|
479
|
-
api, guardado, git: gitReal, cwd,
|
|
509
|
+
api, guardado, git: gitReal, cwd, archivos: archivosDelRepo(avisar),
|
|
480
510
|
buscarProyecto: configurarEnElMotor.buscarProyecto,
|
|
481
|
-
leerWidget: (a, clave, id) => leerWidget(a, clave, id, fetch),
|
|
511
|
+
leerWidget: (a, clave, id) => leerWidget(a, clave, id, fetch, avisar),
|
|
482
512
|
},
|
|
483
513
|
// La versión que se anota es la de `describir` (pinzada contra package.json por test): una sola fuente
|
|
484
514
|
// para lo que la terminal dice de sí misma.
|
|
@@ -489,9 +519,17 @@ export function entornoReal(env = process.env) {
|
|
|
489
519
|
terminal: DESCRIPCION.version,
|
|
490
520
|
},
|
|
491
521
|
prueba: {
|
|
492
|
-
api, guardado, git: gitReal, cwd, avisar, pruebas, generar: randomUUID, ahora: Date.now, dormir, tramoMs,
|
|
522
|
+
api, guardado, git: gitReal, cwd, avisar, archivos: archivosDelRepo(avisar), pruebas, generar: randomUUID, ahora: Date.now, dormir, tramoMs,
|
|
493
523
|
buscarProyecto: configurarEnElMotor.buscarProyecto,
|
|
494
524
|
leerDiagnostico: (a, clave, id) => leerDiagnostico(a, clave, id, fetch),
|
|
525
|
+
leerWidget: (a, clave, id) => leerWidget(a, clave, id, fetch, avisar),
|
|
526
|
+
existe: (p) => { try {
|
|
527
|
+
lstatSync(p);
|
|
528
|
+
return true;
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return false;
|
|
532
|
+
} },
|
|
495
533
|
pedirPrueba: (a, clave, id, intento) => pedirPrueba(a, clave, id, intento, fetch),
|
|
496
534
|
leerPrueba: (a, clave, id, pruebaId, plazoMs) => leerPrueba(a, clave, id, pruebaId, fetch, plazoMs),
|
|
497
535
|
},
|
|
@@ -28,8 +28,12 @@ export const FLAGS_POR_COMANDO = [
|
|
|
28
28
|
flags: ["authorizeSpend", "ceiling", "expectPolicy", "message", "testId"], comandos: ["test"],
|
|
29
29
|
texto: "--authorize-spend, --ceiling, --expect-policy, --message and --test-id belong to `supcoflow test`.",
|
|
30
30
|
},
|
|
31
|
-
{ flags: ["skill", "app"], comandos: ["install record"], texto: "--skill and --
|
|
31
|
+
{ flags: ["skill", "app", "shape"], comandos: ["install record"], texto: "--skill, --app and --shape belong to `supcoflow install record`." },
|
|
32
32
|
];
|
|
33
|
+
/** `--file` se repite sólo en `install record` (la interfaz propia vive en varios archivos); en los demás va una vez. */
|
|
34
|
+
export function archivosDeMas(comando, cuantos) {
|
|
35
|
+
return cuantos > 1 && comando !== "install record" ? "--file goes once, with a value. Try `supcoflow --help`." : null;
|
|
36
|
+
}
|
|
33
37
|
/** El mensaje del primer flag presente que el comando no acepta, o null si todo aplica. */
|
|
34
38
|
export function flagQueNoAplica(comando, presentes) {
|
|
35
39
|
for (const regla of FLAGS_POR_COMANDO) {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// El prechequeo SIN ESCRITURAS de lo que guardar un registro necesita (1A.1, fricción 3): se llama antes del OK de gasto
|
|
2
|
+
// para que la persona sepa ya si esta máquina va a poder guardar el pedido de la prueba, y no después. Mira lo mismo que
|
|
3
|
+
// `persistir` (src/guardado/archivo.ts) va a pedirle al disco, y lo de git de la fricción 2c.
|
|
4
|
+
//
|
|
5
|
+
// Qué promete y qué no: «no se detectaron impedimentos» no es «se va a poder escribir». Un disco que se llena después, o
|
|
6
|
+
// un cambio al mismo tiempo, no se pueden garantizar sin escribir. El guardado estricto, justo antes del POST, se queda.
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { mirarProteccionDeGit, rutaReal } from "./repo.js";
|
|
9
|
+
export async function revisarElLugar(d) {
|
|
10
|
+
const impedimentos = [];
|
|
11
|
+
const dir = path.dirname(d.ruta);
|
|
12
|
+
const nombre = path.basename(d.ruta);
|
|
13
|
+
const tmp = d.ruta + ".tmp";
|
|
14
|
+
let real;
|
|
15
|
+
try {
|
|
16
|
+
real = rutaReal(path.resolve(dir), d.fs);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// Un enlace roto en el camino: no se sabe dónde se escribiría, y `persistir` fallaría.
|
|
20
|
+
return [{ codigo: "ruta_irresoluble", detalle: `I can't tell where ${dir} really is (a broken symlink?).` }];
|
|
21
|
+
}
|
|
22
|
+
const carpeta = d.fs.estado(real);
|
|
23
|
+
if (carpeta.existe) {
|
|
24
|
+
if (carpeta.tipo !== "carpeta") {
|
|
25
|
+
impedimentos.push({ codigo: "carpeta_no_es_carpeta", detalle: `${dir} exists but isn't a folder.` });
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
if (!carpeta.propio)
|
|
29
|
+
impedimentos.push({ codigo: "carpeta_ajena", detalle: `${dir} isn't yours, so I can't set its permissions to 0700.` });
|
|
30
|
+
if (!d.fs.puede(real, "escribir"))
|
|
31
|
+
impedimentos.push({ codigo: "carpeta_sin_permiso", detalle: `I can't write in ${dir}.` });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
// Todavía no existe: el ancestro más cercano que existe tiene que ser una carpeta donde se pueda crear.
|
|
36
|
+
let ancestro = path.dirname(real);
|
|
37
|
+
while (!d.fs.estado(ancestro).existe && path.dirname(ancestro) !== ancestro)
|
|
38
|
+
ancestro = path.dirname(ancestro);
|
|
39
|
+
const a = d.fs.estado(ancestro);
|
|
40
|
+
if (!a.existe || a.tipo !== "carpeta" || !d.fs.puede(ancestro, "escribir")) {
|
|
41
|
+
impedimentos.push({ codigo: "padre_sin_permiso", detalle: `${dir} doesn't exist and I can't create it: ${ancestro} isn't a folder I can write in.` });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const archivo = d.fs.estado(path.join(real, nombre));
|
|
45
|
+
if (archivo.existe) {
|
|
46
|
+
if (archivo.tipo !== "archivo")
|
|
47
|
+
impedimentos.push({ codigo: "archivo_no_comun", detalle: `${d.ruta} isn't a regular file.` });
|
|
48
|
+
else if (!d.fs.puede(path.join(real, nombre), "leer"))
|
|
49
|
+
impedimentos.push({ codigo: "archivo_ilegible", detalle: `I can't read ${d.ruta}.` });
|
|
50
|
+
}
|
|
51
|
+
else if (d.fs.existe(path.join(real, nombre))) {
|
|
52
|
+
impedimentos.push({ codigo: "archivo_no_comun", detalle: `${d.ruta} is a broken symlink.` });
|
|
53
|
+
}
|
|
54
|
+
// El `.tmp` se borra antes de escribir (`borrarSiExiste`, sin recursión): si es una carpeta, no se puede.
|
|
55
|
+
const temporal = d.fs.estado(path.join(real, nombre + ".tmp"));
|
|
56
|
+
if (temporal.existe && temporal.tipo === "carpeta") {
|
|
57
|
+
impedimentos.push({ codigo: "tmp_es_carpeta", detalle: `${tmp} is a folder: I remove that name before saving, and I can't remove a folder.` });
|
|
58
|
+
}
|
|
59
|
+
const git = await mirarProteccionDeGit({ dir, archivos: [nombre, nombre + ".tmp"], fs: d.fs, git: d.git });
|
|
60
|
+
if (!git.protegido)
|
|
61
|
+
impedimentos.push(git.impedimento);
|
|
62
|
+
return impedimentos;
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// El almacén de archivo de los dos registros de la terminal que PUEDEN vivir bajo una carpeta personal que es un repo
|
|
2
|
+
// (`pruebas.json` e `idempotencia.json`; 1A.1, fricción 2c). Es un envoltorio fino: cada `escribir` y cada `borrar`
|
|
3
|
+
// pasan por `asegurarProteccionDeGit` ANTES de tocar el disco, porque borrar una entrada reescribe el archivo entero
|
|
4
|
+
// por su `.tmp`. Las credenciales no lo usan: dentro de un repo se siguen negando.
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { asegurarProteccionDeGit } from "./repo.js";
|
|
7
|
+
/** Git no garantiza que este archivo quede fuera del repo: no se escribió (ni se borró) nada. */
|
|
8
|
+
export class SinProteccionDeGit extends Error {
|
|
9
|
+
impedimento;
|
|
10
|
+
constructor(impedimento) {
|
|
11
|
+
super(impedimento.detalle);
|
|
12
|
+
this.impedimento = impedimento;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function almacenProtegido(base, d) {
|
|
16
|
+
const dir = path.dirname(d.ruta);
|
|
17
|
+
const archivos = [path.basename(d.ruta), path.basename(d.ruta) + ".tmp"];
|
|
18
|
+
async function proteger() {
|
|
19
|
+
const p = await asegurarProteccionDeGit({ dir, archivos, fs: d.fs, git: d.git });
|
|
20
|
+
if (!p.protegido)
|
|
21
|
+
throw new SinProteccionDeGit(p.impedimento);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
nombre: base.nombre,
|
|
25
|
+
leer: (cuenta, techoMs) => base.leer(cuenta, techoMs),
|
|
26
|
+
async escribir(cuenta, r, techoMs) {
|
|
27
|
+
await proteger();
|
|
28
|
+
await base.escribir(cuenta, r, techoMs);
|
|
29
|
+
},
|
|
30
|
+
async borrar(cuenta, techoMs) {
|
|
31
|
+
// Si no hay nada que borrar, `borrar` no reescribe nada: no hay nada que proteger.
|
|
32
|
+
if ((await base.leer(cuenta, techoMs)) === null)
|
|
33
|
+
return;
|
|
34
|
+
await proteger();
|
|
35
|
+
await base.borrar(cuenta, techoMs);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
package/dist/guardado/repo.js
CHANGED
|
@@ -33,3 +33,101 @@ export function dentroDeUnWorkingTree(dir, fs) {
|
|
|
33
33
|
}
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
|
+
const CONTENIDO_DEL_GITIGNORE = "# Written by supcoflow: nothing in this folder belongs in git.\n*\n";
|
|
37
|
+
// La raíz del repo (si hay) de cada una de las dos rutas: la escrita y la real. Un tramo enlazado (`stow`)
|
|
38
|
+
// puede hacer que sean dos repos distintos, y cualquiera de los dos publicaría el archivo.
|
|
39
|
+
function raicesQueContienen(dir, fs) {
|
|
40
|
+
const escrita = path.resolve(dir);
|
|
41
|
+
const rutas = new Set([escrita, rutaReal(escrita, fs)]);
|
|
42
|
+
const raices = new Map();
|
|
43
|
+
for (const ruta of rutas) {
|
|
44
|
+
let actual = ruta;
|
|
45
|
+
for (;;) {
|
|
46
|
+
if (fs.existe(path.join(actual, ".git"))) {
|
|
47
|
+
raices.set(actual, [...(raices.get(actual) ?? []), ruta]);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
const padre = path.dirname(actual);
|
|
51
|
+
if (padre === actual)
|
|
52
|
+
break;
|
|
53
|
+
actual = padre;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return raices;
|
|
57
|
+
}
|
|
58
|
+
const sinProteger = (codigo, detalle) => ({ protegido: false, impedimento: { codigo, detalle } });
|
|
59
|
+
function queHacer(raiz, archivo) {
|
|
60
|
+
const rel = path.relative(raiz, archivo);
|
|
61
|
+
return `Take it out of the repo with \`git -C ${raiz} rm --cached -- ${rel}\` or have git ignore it; I won't touch your repo myself.`;
|
|
62
|
+
}
|
|
63
|
+
async function mirar(d, conGitignore) {
|
|
64
|
+
let raices;
|
|
65
|
+
try {
|
|
66
|
+
raices = raicesQueContienen(d.dir, d.fs);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return sinProteger("ruta_irresoluble", `I can't tell where ${d.dir} really is (a broken symlink?).`);
|
|
70
|
+
}
|
|
71
|
+
for (const [raiz, carpetas] of raices) {
|
|
72
|
+
for (const carpeta of carpetas) {
|
|
73
|
+
const hayGitignore = conGitignore(carpeta);
|
|
74
|
+
for (const nombre of d.archivos) {
|
|
75
|
+
const archivo = path.join(carpeta, nombre);
|
|
76
|
+
if (hayGitignore) {
|
|
77
|
+
// 0 = ignorado y no seguido; 1 = no lo es (o está seguido o agregado). Cualquier otra cosa (git ausente,
|
|
78
|
+
// `fatal`, tiempo) es «no sé», y no saber no es «protegido».
|
|
79
|
+
const r = await d.git(["check-ignore", "-q", "--", archivo], raiz);
|
|
80
|
+
if (r.codigo === 1) {
|
|
81
|
+
return sinProteger("git_no_lo_ignora", `Git doesn't ignore ${archivo} in the repo ${raiz} (it is tracked or staged, or a .gitignore doesn't cover it). ${queHacer(raiz, archivo)}`);
|
|
82
|
+
}
|
|
83
|
+
if (r.codigo !== 0)
|
|
84
|
+
return sinProteger("git_no_contesta", `I couldn't ask git whether it ignores ${archivo} in the repo ${raiz}.`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// Sin `.gitignore` todavía: se crearía uno con `*`, que ignora todo lo que no esté seguido ni en el índice.
|
|
88
|
+
const r = await d.git(["ls-files", "--", archivo], raiz);
|
|
89
|
+
if (r.codigo !== 0)
|
|
90
|
+
return sinProteger("git_no_contesta", `I couldn't ask git whether it tracks ${archivo} in the repo ${raiz}.`);
|
|
91
|
+
if (r.stdout.trim() !== "") {
|
|
92
|
+
return sinProteger("git_lo_sigue", `Git tracks ${archivo} in the repo ${raiz}. ${queHacer(raiz, archivo)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { protegido: true };
|
|
99
|
+
}
|
|
100
|
+
/** Sin escribir nada: lo mismo que `asegurarProteccionDeGit` va a comprobar, para avisar antes. */
|
|
101
|
+
export function mirarProteccionDeGit(d) {
|
|
102
|
+
return mirar(d, (carpeta) => d.fs.existe(path.join(carpeta, ".gitignore")));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Antes de CADA escritura o borrado de un registro. Si la carpeta cae en un repo y no tiene `.gitignore`, crea
|
|
106
|
+
* uno con `*` (en exclusiva: si aparece otro al mismo tiempo no se pisa; y nunca se edita uno que ya estaba),
|
|
107
|
+
* y después le pregunta a git por cada archivo y su `.tmp`. Falla cerrada.
|
|
108
|
+
*/
|
|
109
|
+
export async function asegurarProteccionDeGit(d) {
|
|
110
|
+
let raices;
|
|
111
|
+
try {
|
|
112
|
+
raices = raicesQueContienen(d.dir, d.fs);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return sinProteger("ruta_irresoluble", `I can't tell where ${d.dir} really is (a broken symlink?).`);
|
|
116
|
+
}
|
|
117
|
+
if (raices.size === 0)
|
|
118
|
+
return { protegido: true };
|
|
119
|
+
const gitignore = path.join(path.resolve(d.dir), ".gitignore");
|
|
120
|
+
if (!d.fs.existe(gitignore)) {
|
|
121
|
+
try {
|
|
122
|
+
d.fs.crearDir(path.resolve(d.dir), 0o700);
|
|
123
|
+
d.fs.escribirExclusivo(gitignore, CONTENIDO_DEL_GITIGNORE, 0o600);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
// `EEXIST`: otro proceso lo creó entre medio; sirve igual, y no se toca.
|
|
127
|
+
if (e.code !== "EEXIST") {
|
|
128
|
+
return sinProteger("no_se_pudo_crear_gitignore", `I couldn't create ${gitignore} to keep this folder out of git.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return mirar(d, () => true);
|
|
133
|
+
}
|
package/dist/idempotencia.js
CHANGED
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
// reintento tiene que mandar la MISMA clave para que el motor devuelva lo que ya hizo en vez de
|
|
4
4
|
// hacerlo dos veces. Por eso se escribe ANTES de devolverla, y sólo se olvida cuando el motor ya dio
|
|
5
5
|
// un desenlace que no hace falta repetir.
|
|
6
|
-
import path from "node:path";
|
|
7
6
|
import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
|
|
8
|
-
import {
|
|
7
|
+
import { SinProteccionDeGit } from "./guardado/protegido.js";
|
|
9
8
|
export function crearRegistroDeIdempotencia(d) {
|
|
10
9
|
// Por origen, como la clave (un motor local y producción no comparten claves), y el alcance en
|
|
11
10
|
// minúsculas: `D/r` y `d/r` son el mismo repo para GitHub y para el motor.
|
|
@@ -15,7 +14,7 @@ export function crearRegistroDeIdempotencia(d) {
|
|
|
15
14
|
// repo) y el motor lo reusa. Una clave de consulta no tiene clave natural: sin la de idempotencia
|
|
16
15
|
// guardada, el reintento tras una respuesta perdida crea OTRA fila. Mejor no mandar nada y decir qué
|
|
17
16
|
// arreglar. Por eso, en estricto, cada «aviso y sigo» de abajo lanza.
|
|
18
|
-
const noGuardada = () => new FalloDeLaTerminal(SALIDA.inesperado, "idempotencia_no_guardada", `I couldn't save the retry key in ${d.rutaDelArchivo}; without it, a retry would create another key. Check that file
|
|
17
|
+
const noGuardada = (causa) => new FalloDeLaTerminal(SALIDA.inesperado, "idempotencia_no_guardada", `I couldn't save the retry key in ${d.rutaDelArchivo}; without it, a retry would create another key. Check that file and try again.${causa instanceof SinProteccionDeGit ? ` ${causa.message}` : ""}`);
|
|
19
18
|
return {
|
|
20
19
|
async claveDe(operacion, alcance, opciones = {}) {
|
|
21
20
|
const c = cuenta(operacion, alcance);
|
|
@@ -34,33 +33,20 @@ export function crearRegistroDeIdempotencia(d) {
|
|
|
34
33
|
}
|
|
35
34
|
const nueva = d.generar();
|
|
36
35
|
// Perder la clave no duplica nada: el motor reusa el proyecto por su repo. Por eso un destino
|
|
37
|
-
// dentro de un repo
|
|
36
|
+
// dentro de un repo que git no garantiza que ignore (el almacén protegido, fricción 2c) o un disco
|
|
38
37
|
// que falla avisan y siguen, en vez de frenar el comando.
|
|
39
|
-
// `realpath` lanza con un tramo que es un enlace roto (`~/.config/supcoflow` apuntando a un repo
|
|
40
|
-
// de dotfiles que ya no está): no saber dónde cae el destino es otro «no se puede guardar».
|
|
41
|
-
let raiz;
|
|
42
|
-
try {
|
|
43
|
-
raiz = dentroDeUnWorkingTree(path.dirname(d.rutaDelArchivo), d.fs);
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
if (opciones.estricta)
|
|
47
|
-
throw noGuardada();
|
|
48
|
-
d.avisar(`I couldn't resolve the path of ${d.rutaDelArchivo}: I'm going on with an idempotency key that isn't saved.`);
|
|
49
|
-
return nueva;
|
|
50
|
-
}
|
|
51
|
-
if (raiz !== null) {
|
|
52
|
-
if (opciones.estricta)
|
|
53
|
-
throw noGuardada();
|
|
54
|
-
d.avisar(`I'm not saving the idempotency key inside a git repo (${raiz}): a retry will send a different one.`);
|
|
55
|
-
return nueva;
|
|
56
|
-
}
|
|
57
38
|
try {
|
|
58
39
|
await d.almacen.escribir(c, { valor: nueva, escritoEn: d.ahora() });
|
|
59
40
|
}
|
|
60
|
-
catch {
|
|
41
|
+
catch (e) {
|
|
61
42
|
if (opciones.estricta)
|
|
62
|
-
throw noGuardada();
|
|
63
|
-
|
|
43
|
+
throw noGuardada(e);
|
|
44
|
+
if (e instanceof SinProteccionDeGit) {
|
|
45
|
+
d.avisar(`I'm not saving the idempotency key in ${d.rutaDelArchivo}: a retry will send a different one. ${e.message}`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
d.avisar(`I couldn't save the idempotency key in ${d.rutaDelArchivo}: a retry will send a different one.`);
|
|
49
|
+
}
|
|
64
50
|
}
|
|
65
51
|
return nueva;
|
|
66
52
|
},
|
|
@@ -80,8 +66,10 @@ export function crearRegistroDeIdempotencia(d) {
|
|
|
80
66
|
try {
|
|
81
67
|
await d.almacen.borrar(cuenta(operacion, alcance));
|
|
82
68
|
}
|
|
83
|
-
catch {
|
|
84
|
-
d.avisar(
|
|
69
|
+
catch (e) {
|
|
70
|
+
d.avisar(e instanceof SinProteccionDeGit
|
|
71
|
+
? `I didn't delete the idempotency key from ${d.rutaDelArchivo}, so as not to rewrite a file git doesn't ignore. ${e.message}`
|
|
72
|
+
: `I couldn't delete the idempotency key from ${d.rutaDelArchivo}.`);
|
|
85
73
|
}
|
|
86
74
|
},
|
|
87
75
|
};
|
package/dist/instalacion.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `.supcoflow/instalacion.json` (bloque 6, decisión 3; v2 en la 0.8.0): con qué versiones se hizo la instalación, anotado en el
|
|
1
|
+
// `.supcoflow/instalacion.json` (bloque 6, decisión 3; v2 en la 0.8.0; v3 en la 0.9.0): con qué versiones se hizo la instalación, anotado en el
|
|
2
2
|
// repo de quien instala. Supcoflow no lo guarda ni lo ve. Puro: recibe lo ya leído y arma el archivo.
|
|
3
3
|
//
|
|
4
4
|
// El archivo queda en el repo y se commitea: un secreto acá se publica con el primer `push`. Por eso se arma con
|
|
@@ -7,9 +7,22 @@
|
|
|
7
7
|
import { FalloDeLaTerminal, SALIDA, taparSecretos } from "./contrato.js";
|
|
8
8
|
export const RUTA_DE_LA_INSTALACION = ".supcoflow/instalacion.json";
|
|
9
9
|
export const PAQUETE_DEL_CHAT = "@supcoflow/chat";
|
|
10
|
+
export const FORMAS = ["widget", "interfaz_propia"];
|
|
11
|
+
export const MAX_ARCHIVOS_ANOTADOS = 20;
|
|
10
12
|
// SemVer 2.0 tal cual, con tope de largo: la versión de la skill la escribe un asistente y queda en el repo.
|
|
11
13
|
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
12
14
|
export const esSemver = (s) => s.length <= 64 && SEMVER.test(s);
|
|
15
|
+
/** Compara `major.minor.patch` número a número (1.10.0 > 1.9.0); lo que sigue al guion (prerelease) o al `+` no cuenta. */
|
|
16
|
+
export function compararSemver(a, b) {
|
|
17
|
+
const partes = (v) => v.split(/[-+]/)[0].split(".").map(Number);
|
|
18
|
+
const [x, y] = [partes(a), partes(b)];
|
|
19
|
+
for (let i = 0; i < 3; i++) {
|
|
20
|
+
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
|
21
|
+
if (d !== 0)
|
|
22
|
+
return d < 0 ? -1 : 1;
|
|
23
|
+
}
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
13
26
|
const objeto = (v) => (typeof v === "object" && v !== null && !Array.isArray(v) ? v : null);
|
|
14
27
|
const entero = (v) => typeof v === "number" && Number.isInteger(v) && v >= 1;
|
|
15
28
|
const ilegible = (message) => new FalloDeLaTerminal(SALIDA.inesperado, "paquete_ilegible", message);
|
|
@@ -71,14 +84,26 @@ function guiaDe(c) {
|
|
|
71
84
|
return { origen: "agente", version: g.version };
|
|
72
85
|
throw inesperada("a guide");
|
|
73
86
|
}
|
|
87
|
+
// El `contract.json` del paquete instalado (T1 del sdk): sólo se copia su `version`, nunca el resto. Ausente es null (un
|
|
88
|
+
// paquete anterior al contrato); presente sin una versión que se entienda NO es null: diría que no hay contrato.
|
|
89
|
+
function contratoDe(contrato) {
|
|
90
|
+
if (contrato === null || contrato === undefined)
|
|
91
|
+
return null;
|
|
92
|
+
const v = objeto(contrato)?.version;
|
|
93
|
+
if (typeof v !== "string" || !esSemver(v))
|
|
94
|
+
throw ilegible(`The contract.json of ${PAQUETE_DEL_CHAT} has no version I understand.`);
|
|
95
|
+
return v;
|
|
96
|
+
}
|
|
74
97
|
export function armarInstalacion(i) {
|
|
75
98
|
return {
|
|
76
|
-
version:
|
|
99
|
+
version: 3,
|
|
77
100
|
skill: i.skill,
|
|
78
101
|
terminal: i.terminal,
|
|
79
102
|
chat: { declarado: declaradoEn(i.paqueteDeLaApp), instalado: instaladoEn(i.paqueteInstalado) },
|
|
80
103
|
configuracion: { acuerdo_version: acuerdoDe(i.configuracion), politica: politicaDe(i.configuracion), guia: guiaDe(i.configuracion) },
|
|
81
|
-
|
|
104
|
+
forma: i.forma ?? null,
|
|
105
|
+
archivos: [...(i.archivos ?? [])],
|
|
106
|
+
contrato: contratoDe(i.contrato),
|
|
82
107
|
};
|
|
83
108
|
}
|
|
84
109
|
// ─── El archivo del chat (`install record --file`) ───
|
|
@@ -99,25 +124,73 @@ export function validarElArchivoDelChat(crudo) {
|
|
|
99
124
|
return { ok: false, motivo: "--file is the chat file, not a folder: say its path." };
|
|
100
125
|
return { ok: true, ruta: partes.join("/") };
|
|
101
126
|
}
|
|
102
|
-
|
|
103
|
-
|
|
127
|
+
const SIN_NADA = { forma: null, archivos: [], contrato: null };
|
|
128
|
+
// Una lista de rutas anotadas: cada una tiene que ser válida Y estar ya normalizada (es como la escribe esta terminal); lo
|
|
129
|
+
// que no, se descarta de a una, sin error. Sin repetidas y con tope.
|
|
130
|
+
function rutasAnotadas(v) {
|
|
131
|
+
if (!Array.isArray(v))
|
|
132
|
+
return [];
|
|
133
|
+
const rutas = [];
|
|
134
|
+
for (const a of v) {
|
|
135
|
+
if (typeof a !== "string")
|
|
136
|
+
continue;
|
|
137
|
+
const r = validarElArchivoDelChat(a);
|
|
138
|
+
if (r.ok && r.ruta === a && !rutas.includes(a) && rutas.length < MAX_ARCHIVOS_ANOTADOS)
|
|
139
|
+
rutas.push(a);
|
|
140
|
+
}
|
|
141
|
+
return rutas;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Lo que un `instalacion.json` ya existente dice de la forma, los archivos y el contrato. v1 y cualquier cosa ilegible o inválida
|
|
145
|
+
* dan «nada»; una versión que esta terminal no conoce (4 o más) tampoco se adivina. Nunca un error.
|
|
146
|
+
*/
|
|
147
|
+
export function leerInstalacionAnotada(texto) {
|
|
104
148
|
if (texto === null)
|
|
105
|
-
return
|
|
149
|
+
return { estado: "ausente" };
|
|
106
150
|
let v;
|
|
107
151
|
try {
|
|
108
152
|
v = JSON.parse(texto);
|
|
109
153
|
}
|
|
110
154
|
catch {
|
|
111
|
-
return
|
|
155
|
+
return { estado: "ilegible" };
|
|
112
156
|
}
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
return
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
157
|
+
const o = objeto(v);
|
|
158
|
+
if (o === null)
|
|
159
|
+
return { estado: "ilegible" };
|
|
160
|
+
// Antes de la 0.9.0 la única forma que existía era el widget: una v1 o una v2 son un widget, con el archivo que la v2 anotó (si lo
|
|
161
|
+
// anotó; la v1 no tiene archivo aunque el campo aparezca).
|
|
162
|
+
if (o.version === 1)
|
|
163
|
+
return { estado: "leida", forma: "widget", archivos: [], contrato: null };
|
|
164
|
+
if (o.version === 2) {
|
|
165
|
+
const a = typeof o.archivo === "string" ? validarElArchivoDelChat(o.archivo) : null;
|
|
166
|
+
return { estado: "leida", forma: "widget", archivos: a !== null && a.ok && a.ruta === o.archivo ? [a.ruta] : [], contrato: null };
|
|
167
|
+
}
|
|
168
|
+
if (o.version === 3) {
|
|
169
|
+
const forma = FORMAS.find((f) => f === o.forma) ?? null;
|
|
170
|
+
const contrato = typeof o.contrato === "string" && esSemver(o.contrato) ? o.contrato : null;
|
|
171
|
+
return { estado: "leida", forma, archivos: rutasAnotadas(o.archivos), contrato };
|
|
172
|
+
}
|
|
173
|
+
if (typeof o.version === "number" && Number.isInteger(o.version) && o.version > 3)
|
|
174
|
+
return { estado: "mas_nueva" };
|
|
175
|
+
return { estado: "leida", ...SIN_NADA };
|
|
176
|
+
}
|
|
177
|
+
export function anotadoEn(texto) {
|
|
178
|
+
const l = leerInstalacionAnotada(texto);
|
|
179
|
+
return l.estado === "leida" ? { forma: l.forma, archivos: l.archivos, contrato: l.contrato } : SIN_NADA;
|
|
180
|
+
}
|
|
181
|
+
/** Valida y normaliza la lista de `--file`: cada ruta con `validarElArchivoDelChat`, sin repetidas, con tope. */
|
|
182
|
+
export function validarLosArchivos(crudos) {
|
|
183
|
+
const rutas = [];
|
|
184
|
+
for (const crudo of crudos) {
|
|
185
|
+
const r = validarElArchivoDelChat(crudo);
|
|
186
|
+
if (!r.ok)
|
|
187
|
+
return r;
|
|
188
|
+
if (!rutas.includes(r.ruta))
|
|
189
|
+
rutas.push(r.ruta);
|
|
190
|
+
}
|
|
191
|
+
if (rutas.length > MAX_ARCHIVOS_ANOTADOS)
|
|
192
|
+
return { ok: false, motivo: `--file goes up to ${MAX_ARCHIVOS_ANOTADOS} times: the files of the chat, not the whole app.` };
|
|
193
|
+
return { ok: true, rutas };
|
|
121
194
|
}
|
|
122
195
|
// El orden de las claves es el de `armarInstalacion`: el mismo estado da los mismos bytes, y eso es `sin_cambios`.
|
|
123
196
|
export function serializar(x) {
|
|
@@ -14,9 +14,8 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Estricto en todo, como `claves crear` en el registro de idempotencia: una prueba no tiene clave natural
|
|
16
16
|
// del lado de la terminal, así que mandar sin el intento guardado arriesga una segunda prueba (que gasta).
|
|
17
|
-
import path from "node:path";
|
|
18
17
|
import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
|
|
19
|
-
import {
|
|
18
|
+
import { SinProteccionDeGit } from "./guardado/protegido.js";
|
|
20
19
|
function esIntento(v) {
|
|
21
20
|
if (typeof v !== "object" || v === null)
|
|
22
21
|
return false;
|
|
@@ -28,7 +27,7 @@ export function crearRegistroDePruebas(d) {
|
|
|
28
27
|
// Por origen, como la clave y el registro de idempotencia: un motor local (o el proxy de P4) y
|
|
29
28
|
// producción no comparten intentos. Cambiar de origen entre dos comandos es otra clave.
|
|
30
29
|
const cuenta = (proyectoId) => `${d.origen}#prueba#${proyectoId}`;
|
|
31
|
-
const noGuardada = (que) => new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `I couldn't ${que} ${d.rutaDelArchivo}; without that, a retry could ask for another test (and spend twice). Check that file
|
|
30
|
+
const noGuardada = (que) => new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `I couldn't ${que} ${d.rutaDelArchivo}; without that, a retry could ask for another test (and spend twice). Check that file and try again.`);
|
|
32
31
|
async function leer(proyectoId) {
|
|
33
32
|
let crudo;
|
|
34
33
|
try {
|
|
@@ -58,18 +57,6 @@ export function crearRegistroDePruebas(d) {
|
|
|
58
57
|
};
|
|
59
58
|
}
|
|
60
59
|
async function escribir(proyectoId, g) {
|
|
61
|
-
// Misma regla que las credenciales: un destino dentro de un repo se publicaría con un `git add -A`.
|
|
62
|
-
// No saber dónde cae (un enlace roto) es otro «no se puede guardar».
|
|
63
|
-
let raiz;
|
|
64
|
-
try {
|
|
65
|
-
raiz = dentroDeUnWorkingTree(path.dirname(d.rutaDelArchivo), d.fs);
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
throw noGuardada("resolve the path of");
|
|
69
|
-
}
|
|
70
|
-
if (raiz !== null) {
|
|
71
|
-
throw new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `I'm not saving the test request inside a git repo (${raiz}); without saving it I won't send it. Point XDG_CONFIG_HOME outside a repo.`);
|
|
72
|
-
}
|
|
73
60
|
const valor = JSON.stringify({
|
|
74
61
|
intento: g.intento === null
|
|
75
62
|
? null
|
|
@@ -79,12 +66,37 @@ export function crearRegistroDePruebas(d) {
|
|
|
79
66
|
try {
|
|
80
67
|
await d.almacen.escribir(cuenta(proyectoId), { valor, escritoEn: d.ahora() });
|
|
81
68
|
}
|
|
82
|
-
catch {
|
|
69
|
+
catch (e) {
|
|
70
|
+
// El almacén protegido no escribe dentro de un repo si git no garantiza que lo deja afuera (fricción 2c):
|
|
71
|
+
// el mensaje dice qué archivo, qué repo y qué hacer.
|
|
72
|
+
if (e instanceof SinProteccionDeGit) {
|
|
73
|
+
throw new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `I'm not saving the test request in ${d.rutaDelArchivo}; without saving it I won't send it. ${e.message}`);
|
|
74
|
+
}
|
|
83
75
|
throw noGuardada("save to");
|
|
84
76
|
}
|
|
85
77
|
}
|
|
78
|
+
async function revisar(proyectoId) {
|
|
79
|
+
const impedimentos = [];
|
|
80
|
+
try {
|
|
81
|
+
impedimentos.push(...(await d.revisarElLugar?.() ?? []));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
impedimentos.push({ codigo: "no_se_pudo_revisar", detalle: `I couldn't check whether ${d.rutaDelArchivo} can be saved.` });
|
|
85
|
+
}
|
|
86
|
+
let intentoPendiente = null;
|
|
87
|
+
try {
|
|
88
|
+
const { intento } = await leer(proyectoId);
|
|
89
|
+
if (intento !== null)
|
|
90
|
+
intentoPendiente = { techoUsd: intento.techoUsd, politica: intento.politicaEsperada };
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
impedimentos.push({ codigo: "archivo_ilegible", detalle: `I can't read or understand ${d.rutaDelArchivo}.` });
|
|
94
|
+
}
|
|
95
|
+
return { maquina: { ok: impedimentos.length === 0, impedimentos }, intentoPendiente };
|
|
96
|
+
}
|
|
86
97
|
return {
|
|
87
98
|
leer,
|
|
99
|
+
revisar,
|
|
88
100
|
async abrir(proyectoId, intento) {
|
|
89
101
|
const antes = await leer(proyectoId);
|
|
90
102
|
await escribir(proyectoId, { intento, ultima: antes.ultima });
|