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/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: almacenDeArchivo(rutaDeIdempotencia, fsReal),
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 y la misma regla: fuera de un repo.
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: almacenDeArchivo(rutaDePruebas, fsReal), rutaDelArchivo: rutaDePruebas, fs: fsReal, ahora: Date.now, avisar,
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 --app belong to `supcoflow install record`." },
31
+ { flags: ["skill", "app", "shape"], comandos: ["install record"], texto: "--skill, --app and --shape belong to `supcoflow install record`." },
32
32
  ];
33
+ /** `--file` se repite sólo en `install record` (la interfaz propia vive en varios archivos); en los demás va una vez. */
34
+ export function archivosDeMas(comando, cuantos) {
35
+ return cuantos > 1 && comando !== "install record" ? "--file goes once, with a value. Try `supcoflow --help`." : null;
36
+ }
33
37
  /** El mensaje del primer flag presente que el comando no acepta, o null si todo aplica. */
34
38
  export function flagQueNoAplica(comando, presentes) {
35
39
  for (const regla of FLAGS_POR_COMANDO) {
@@ -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
+ }
@@ -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
+ }
@@ -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 { dentroDeUnWorkingTree } from "./guardado/repo.js";
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 (it can't be inside a repo) and try again.`);
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 (misma regla que las credenciales: un `git add -A` la publicaría) o un disco
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
- d.avisar(`I couldn't save the idempotency key in ${d.rutaDelArchivo}: a retry will send a different one.`);
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(`I couldn't delete the idempotency key from ${d.rutaDelArchivo}.`);
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
  };
@@ -1,4 +1,4 @@
1
- // `.supcoflow/instalacion.json` (bloque 6, decisión 3; v2 en la 0.8.0): con qué versiones se hizo la instalación, anotado en el
1
+ // `.supcoflow/instalacion.json` (bloque 6, decisión 3; v2 en la 0.8.0; v3 en la 0.9.0): con qué versiones se hizo la instalación, anotado en el
2
2
  // repo de quien instala. Supcoflow no lo guarda ni lo ve. Puro: recibe lo ya leído y arma el archivo.
3
3
  //
4
4
  // El archivo queda en el repo y se commitea: un secreto acá se publica con el primer `push`. Por eso se arma con
@@ -7,9 +7,22 @@
7
7
  import { FalloDeLaTerminal, SALIDA, taparSecretos } from "./contrato.js";
8
8
  export const RUTA_DE_LA_INSTALACION = ".supcoflow/instalacion.json";
9
9
  export const PAQUETE_DEL_CHAT = "@supcoflow/chat";
10
+ export const FORMAS = ["widget", "interfaz_propia"];
11
+ export const MAX_ARCHIVOS_ANOTADOS = 20;
10
12
  // SemVer 2.0 tal cual, con tope de largo: la versión de la skill la escribe un asistente y queda en el repo.
11
13
  const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
12
14
  export const esSemver = (s) => s.length <= 64 && SEMVER.test(s);
15
+ /** Compara `major.minor.patch` número a número (1.10.0 > 1.9.0); lo que sigue al guion (prerelease) o al `+` no cuenta. */
16
+ export function compararSemver(a, b) {
17
+ const partes = (v) => v.split(/[-+]/)[0].split(".").map(Number);
18
+ const [x, y] = [partes(a), partes(b)];
19
+ for (let i = 0; i < 3; i++) {
20
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
21
+ if (d !== 0)
22
+ return d < 0 ? -1 : 1;
23
+ }
24
+ return 0;
25
+ }
13
26
  const objeto = (v) => (typeof v === "object" && v !== null && !Array.isArray(v) ? v : null);
14
27
  const entero = (v) => typeof v === "number" && Number.isInteger(v) && v >= 1;
15
28
  const ilegible = (message) => new FalloDeLaTerminal(SALIDA.inesperado, "paquete_ilegible", message);
@@ -71,14 +84,26 @@ function guiaDe(c) {
71
84
  return { origen: "agente", version: g.version };
72
85
  throw inesperada("a guide");
73
86
  }
87
+ // El `contract.json` del paquete instalado (T1 del sdk): sólo se copia su `version`, nunca el resto. Ausente es null (un
88
+ // paquete anterior al contrato); presente sin una versión que se entienda NO es null: diría que no hay contrato.
89
+ function contratoDe(contrato) {
90
+ if (contrato === null || contrato === undefined)
91
+ return null;
92
+ const v = objeto(contrato)?.version;
93
+ if (typeof v !== "string" || !esSemver(v))
94
+ throw ilegible(`The contract.json of ${PAQUETE_DEL_CHAT} has no version I understand.`);
95
+ return v;
96
+ }
74
97
  export function armarInstalacion(i) {
75
98
  return {
76
- version: 2,
99
+ version: 3,
77
100
  skill: i.skill,
78
101
  terminal: i.terminal,
79
102
  chat: { declarado: declaradoEn(i.paqueteDeLaApp), instalado: instaladoEn(i.paqueteInstalado) },
80
103
  configuracion: { acuerdo_version: acuerdoDe(i.configuracion), politica: politicaDe(i.configuracion), guia: guiaDe(i.configuracion) },
81
- archivo: i.archivo ?? null,
104
+ forma: i.forma ?? null,
105
+ archivos: [...(i.archivos ?? [])],
106
+ contrato: contratoDe(i.contrato),
82
107
  };
83
108
  }
84
109
  // ─── El archivo del chat (`install record --file`) ───
@@ -99,25 +124,73 @@ export function validarElArchivoDelChat(crudo) {
99
124
  return { ok: false, motivo: "--file is the chat file, not a folder: say its path." };
100
125
  return { ok: true, ruta: partes.join("/") };
101
126
  }
102
- /** El `archivo` anotado en un `instalacion.json` ya existente: v1 y cualquier cosa ilegible o inválida dan null (nunca un error). */
103
- export function archivoAnotado(texto) {
127
+ const SIN_NADA = { forma: null, archivos: [], contrato: null };
128
+ // Una lista de rutas anotadas: cada una tiene que ser válida Y estar ya normalizada (es como la escribe esta terminal); lo
129
+ // que no, se descarta de a una, sin error. Sin repetidas y con tope.
130
+ function rutasAnotadas(v) {
131
+ if (!Array.isArray(v))
132
+ return [];
133
+ const rutas = [];
134
+ for (const a of v) {
135
+ if (typeof a !== "string")
136
+ continue;
137
+ const r = validarElArchivoDelChat(a);
138
+ if (r.ok && r.ruta === a && !rutas.includes(a) && rutas.length < MAX_ARCHIVOS_ANOTADOS)
139
+ rutas.push(a);
140
+ }
141
+ return rutas;
142
+ }
143
+ /**
144
+ * Lo que un `instalacion.json` ya existente dice de la forma, los archivos y el contrato. v1 y cualquier cosa ilegible o inválida
145
+ * dan «nada»; una versión que esta terminal no conoce (4 o más) tampoco se adivina. Nunca un error.
146
+ */
147
+ export function leerInstalacionAnotada(texto) {
104
148
  if (texto === null)
105
- return null;
149
+ return { estado: "ausente" };
106
150
  let v;
107
151
  try {
108
152
  v = JSON.parse(texto);
109
153
  }
110
154
  catch {
111
- return null;
155
+ return { estado: "ilegible" };
112
156
  }
113
- // AD9: un v1 se reconoce por `version: 1` (lo escribe la terminal 0.7.0) y no tiene archivo, aunque el campo aparezca.
114
- if (objeto(v)?.version === 1)
115
- return null;
116
- const a = objeto(v)?.archivo;
117
- if (typeof a !== "string")
118
- return null;
119
- const r = validarElArchivoDelChat(a);
120
- return r.ok && r.ruta === a ? a : null;
157
+ const o = objeto(v);
158
+ if (o === null)
159
+ return { estado: "ilegible" };
160
+ // Antes de la 0.9.0 la única forma que existía era el widget: una v1 o una v2 son un widget, con el archivo que la v2 anotó (si lo
161
+ // anotó; la v1 no tiene archivo aunque el campo aparezca).
162
+ if (o.version === 1)
163
+ return { estado: "leida", forma: "widget", archivos: [], contrato: null };
164
+ if (o.version === 2) {
165
+ const a = typeof o.archivo === "string" ? validarElArchivoDelChat(o.archivo) : null;
166
+ return { estado: "leida", forma: "widget", archivos: a !== null && a.ok && a.ruta === o.archivo ? [a.ruta] : [], contrato: null };
167
+ }
168
+ if (o.version === 3) {
169
+ const forma = FORMAS.find((f) => f === o.forma) ?? null;
170
+ const contrato = typeof o.contrato === "string" && esSemver(o.contrato) ? o.contrato : null;
171
+ return { estado: "leida", forma, archivos: rutasAnotadas(o.archivos), contrato };
172
+ }
173
+ if (typeof o.version === "number" && Number.isInteger(o.version) && o.version > 3)
174
+ return { estado: "mas_nueva" };
175
+ return { estado: "leida", ...SIN_NADA };
176
+ }
177
+ export function anotadoEn(texto) {
178
+ const l = leerInstalacionAnotada(texto);
179
+ return l.estado === "leida" ? { forma: l.forma, archivos: l.archivos, contrato: l.contrato } : SIN_NADA;
180
+ }
181
+ /** Valida y normaliza la lista de `--file`: cada ruta con `validarElArchivoDelChat`, sin repetidas, con tope. */
182
+ export function validarLosArchivos(crudos) {
183
+ const rutas = [];
184
+ for (const crudo of crudos) {
185
+ const r = validarElArchivoDelChat(crudo);
186
+ if (!r.ok)
187
+ return r;
188
+ if (!rutas.includes(r.ruta))
189
+ rutas.push(r.ruta);
190
+ }
191
+ if (rutas.length > MAX_ARCHIVOS_ANOTADOS)
192
+ return { ok: false, motivo: `--file goes up to ${MAX_ARCHIVOS_ANOTADOS} times: the files of the chat, not the whole app.` };
193
+ return { ok: true, rutas };
121
194
  }
122
195
  // El orden de las claves es el de `armarInstalacion`: el mismo estado da los mismos bytes, y eso es `sin_cambios`.
123
196
  export function serializar(x) {
@@ -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 { dentroDeUnWorkingTree } from "./guardado/repo.js";
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 (it can't be inside a repo) and try again.`);
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 });