supcoflow 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,30 +1,34 @@
1
1
  // Qué comandos aceptan cada flag que no es global. Un flag que no aplica sale 3 en vez de ignorarse:
2
- // un asistente que pasa `--repo` a `quien-soy` creería que preguntó por ese repo. Vive aparte de
3
- // `cli.ts` (que corre al importarse) para poder probarse sin lanzar el binario.
2
+ // un asistente que pasa `--repo` a `whoami` creería que preguntó por ese repo. Vive aparte de
3
+ // `cli.ts` (que corre al importarse) para poder probarse sin lanzar el binario. Los nombres de los
4
+ // campos son los de `Flags`; los textos nombran los flags y comandos canónicos.
4
5
  const DE_CONFIGURAR_CON_REPO = [
5
- "configuracion", "chat prender", "chat apagar", "chat marca", "chat logo", "sitios agregar", "sitios quitar",
6
- "datos fijar", "politica fijar", "claves crear", "claves listar", "claves revocar",
7
- "guia revisar", "guia publicar", "guia ver", "guia usar",
6
+ "config", "chat on", "chat off", "chat brand", "chat logo", "sites add", "sites remove",
7
+ "data set", "policy set", "keys create", "keys list", "keys revoke",
8
+ "guide check", "guide publish", "guide show", "guide use", "guide versions",
8
9
  ];
9
10
  export const FLAGS_POR_COMANDO = [
10
- { flags: ["start"], comandos: ["login", "repo conectar"], texto: "--start es de `supcoflow login` y `supcoflow repo conectar`." },
11
- { flags: ["wait"], comandos: ["login", "repo conectar", "probar"], texto: "--wait es de `supcoflow login`, `supcoflow repo conectar` y `supcoflow probar`." },
11
+ { flags: ["start"], comandos: ["login", "repo connect"], texto: "--start belongs to `supcoflow login` and `supcoflow repo connect`." },
12
+ { flags: ["wait"], comandos: ["login", "repo connect", "test"], texto: "--wait belongs to `supcoflow login`, `supcoflow repo connect` and `supcoflow test`." },
12
13
  {
13
- flags: ["repo"], comandos: ["repo conectar", "proyecto crear", "proyecto ver", ...DE_CONFIGURAR_CON_REPO, "widget", "instalacion anotar", "diagnosticar", "probar"],
14
- texto: "--repo es de `supcoflow repo conectar`, `proyecto crear|ver`, `widget`, `instalacion anotar`, `diagnosticar`, `probar` y los comandos que configuran un proyecto (no de `catalogo`).",
14
+ flags: ["repo"], comandos: ["repo connect", "project create", "project show", ...DE_CONFIGURAR_CON_REPO, "widget", "install record", "diagnose", "test"],
15
+ texto: "--repo belongs to `supcoflow repo connect`, `project create|show`, `widget`, `install record`, `diagnose`, `test` and the commands that configure a project (not to `catalog`).",
15
16
  },
16
- { flags: ["install", "test"], comandos: ["proyecto crear"], texto: "--install y --test son de `supcoflow proyecto crear`." },
17
- { flags: ["sitio"], comandos: ["sitios agregar", "sitios quitar"], texto: "--sitio es de `supcoflow sitios agregar|quitar`." },
18
- { flags: ["titulo", "saludo", "tema"], comandos: ["chat marca"], texto: "--titulo, --saludo y --tema son de `supcoflow chat marca`." },
19
- { flags: ["archivo"], comandos: ["chat logo"], texto: "--archivo es de `supcoflow chat logo`." },
20
- { flags: ["permitir"], comandos: ["datos fijar"], texto: "--permitir es de `supcoflow datos fijar`." },
21
- { flags: ["modo"], comandos: ["politica fijar"], texto: "--modo es de `supcoflow politica fijar`." },
22
- { flags: ["nombre", "guardarEn"], comandos: ["claves crear"], texto: "--nombre y --guardar-en son de `supcoflow claves crear`." },
23
- { flags: ["origen"], comandos: ["guia usar"], texto: "--origen es de `supcoflow guia usar`." },
24
- { flags: ["desdeGithub"], comandos: ["guia publicar"], texto: "--desde-github es de `supcoflow guia publicar`." },
25
- { flags: ["id"], comandos: ["claves revocar"], texto: "--id es de `supcoflow claves revocar`." },
26
- { flags: ["autorizoGasto", "mensaje", "prueba"], comandos: ["probar"], texto: "--autorizo-gasto, --mensaje y --prueba son de `supcoflow probar`." },
27
- { flags: ["skill", "app"], comandos: ["instalacion anotar"], texto: "--skill y --app son de `supcoflow instalacion anotar`." },
17
+ { flags: ["install", "test"], comandos: ["project create"], texto: "--install and --test belong to `supcoflow project create`." },
18
+ { flags: ["site"], comandos: ["sites add", "sites remove"], texto: "--site belongs to `supcoflow sites add|remove`." },
19
+ { flags: ["title", "greeting", "theme"], comandos: ["chat brand"], texto: "--title, --greeting and --theme belong to `supcoflow chat brand`." },
20
+ { flags: ["file"], comandos: ["chat logo", "install record"], texto: "--file belongs to `supcoflow chat logo` and `supcoflow install record`." },
21
+ { flags: ["allow"], comandos: ["data set"], texto: "--allow belongs to `supcoflow data set`." },
22
+ { flags: ["mode"], comandos: ["policy set"], texto: "--mode belongs to `supcoflow policy set`." },
23
+ { flags: ["name", "saveTo"], comandos: ["keys create"], texto: "--name and --save-to belong to `supcoflow keys create`." },
24
+ { flags: ["source"], comandos: ["guide use"], texto: "--source belongs to `supcoflow guide use`." },
25
+ { flags: ["fromGithub"], comandos: ["guide publish"], texto: "--from-github belongs to `supcoflow guide publish`." },
26
+ { flags: ["id"], comandos: ["keys revoke"], texto: "--id belongs to `supcoflow keys revoke`." },
27
+ {
28
+ flags: ["authorizeSpend", "ceiling", "expectPolicy", "message", "testId"], comandos: ["test"],
29
+ texto: "--authorize-spend, --ceiling, --expect-policy, --message and --test-id belong to `supcoflow test`.",
30
+ },
31
+ { flags: ["skill", "app"], comandos: ["install record"], texto: "--skill and --app belong to `supcoflow install record`." },
28
32
  ];
29
33
  /** El mensaje del primer flag presente que el comando no acepta, o null si todo aplica. */
30
34
  export function flagQueNoAplica(comando, presentes) {
@@ -21,7 +21,7 @@ export function almacenDeArchivo(ruta, fs) {
21
21
  v = undefined;
22
22
  }
23
23
  if (typeof v !== "object" || v === null || Array.isArray(v)) {
24
- throw new Error(`No entiendo el archivo de credenciales ${ruta}; borralo y volvé a iniciar sesión.`);
24
+ throw new Error(`I don't understand the credentials file ${ruta}; delete it and log in again.`);
25
25
  }
26
26
  return v;
27
27
  }
@@ -44,7 +44,7 @@ export function almacenDeArchivo(ruta, fs) {
44
44
  return null;
45
45
  const r = t[cuenta];
46
46
  if (!esRegistro(r))
47
- throw new Error(`No entiendo el archivo de credenciales ${ruta}; borralo y volvé a iniciar sesión.`);
47
+ throw new Error(`I don't understand the credentials file ${ruta}; delete it and log in again.`);
48
48
  return { valor: r.valor, escritoEn: r.escritoEn };
49
49
  },
50
50
  async escribir(cuenta, r) {
@@ -4,6 +4,10 @@ import path from "node:path";
4
4
  import { FalloDeLaTerminal, SALIDA } from "../contrato.js";
5
5
  import { LlaveroAusente, TECHO_DEL_LLAVERO_MS } from "./llavero.js";
6
6
  import { dentroDeUnWorkingTree } from "./repo.js";
7
+ // Cómo se nombra cada fuente en un texto para la persona; el valor `fuente` del JSON sigue siendo el de arriba.
8
+ export const NOMBRE_DE_LA_FUENTE = {
9
+ llavero: "the system keychain", archivo: "the credentials file", env: "SUPCOFLOW_TOKEN",
10
+ };
7
11
  // La cuenta ES el origen de la API que emitió lo guardado (Review Focus 3): una clave acuñada por
8
12
  // un motor en `localhost` queda en otra cuenta que la de producción, y la terminal apuntada a
9
13
  // producción simplemente no la encuentra (sale 2) en vez de mandarla a un servidor que no la emitió.
@@ -71,8 +75,8 @@ export function crearGuardado(d) {
71
75
  }
72
76
  const leerCuenta = async (cuenta, queda) => (await leerCuentaCompleta(cuenta, queda)).mejor;
73
77
  const errorDeBorrado = (fallidos) => {
74
- const donde = fallidos.map((n) => (n === "llavero" ? "del llavero del sistema" : `de ${d.rutaDelArchivo}`)).join(" ni ");
75
- return new FalloDeLaTerminal(SALIDA.inesperado, "no_se_pudo_borrar", `No pude borrar la clave guardada ${donde}.`);
78
+ const donde = fallidos.map((n) => (n === "llavero" ? NOMBRE_DE_LA_FUENTE.llavero : d.rutaDelArchivo)).join(" or from ");
79
+ return new FalloDeLaTerminal(SALIDA.inesperado, "no_se_pudo_borrar", `I couldn't delete the saved key from ${donde}.`);
76
80
  };
77
81
  // El chequeo del repo va ANTES de escribir (Review Focus 1): una vez escrita, la clave ya está a
78
82
  // un `git add -A` de publicarse, y borrarla después no deshace un commit. Mira la ruta REAL además
@@ -81,10 +85,10 @@ export function crearGuardado(d) {
81
85
  async function escribirEnArchivo(cuenta, r, que) {
82
86
  const raiz = dentroDeUnWorkingTree(path.dirname(d.rutaDelArchivo), d.fs);
83
87
  if (raiz !== null) {
84
- throw new FalloDeLaTerminal(SALIDA.inesperado, "destino_dentro_de_un_repo", `No guardo ${que} dentro de un repo de git (${raiz}). Usá el llavero del sistema o exportá SUPCOFLOW_TOKEN.`);
88
+ throw new FalloDeLaTerminal(SALIDA.inesperado, "destino_dentro_de_un_repo", `I won't save ${que} inside a git repo (${raiz}). Use the system keychain or export SUPCOFLOW_TOKEN.`);
85
89
  }
86
90
  await d.archivo.escribir(cuenta, r);
87
- d.avisar(`No hay llavero del sistema: guardé ${que} en ${d.rutaDelArchivo} con permisos 0600.`);
91
+ d.avisar(`There is no system keychain: I saved ${que} in ${d.rutaDelArchivo} with 0600 permissions.`);
88
92
  }
89
93
  async function escribirCuenta(cuenta, valor, que, techoMs) {
90
94
  const queda = plazo(techoMs);
@@ -145,7 +149,7 @@ export function crearGuardado(d) {
145
149
  return { valor: mejor === null ? null : { clave: mejor.r.valor, fuente: mejor.fuente }, completa };
146
150
  },
147
151
  async guardarClave(origen, clave, techoMs) {
148
- return { donde: await escribirCuenta(cuentaDeClave(origen), clave, "la clave", techoMs) };
152
+ return { donde: await escribirCuenta(cuentaDeClave(origen), clave, "the key", techoMs) };
149
153
  },
150
154
  async borrarClave(origen) {
151
155
  // Un borrado a medias de una clave no se informa como hecho: la que quedó sigue sirviendo.
@@ -174,7 +178,7 @@ export function crearGuardado(d) {
174
178
  }
175
179
  },
176
180
  async guardarPendiente(origen, p, techoMs) {
177
- await escribirCuenta(cuentaDePendiente(origen), JSON.stringify(p), "el inicio de sesión en curso", techoMs);
181
+ await escribirCuenta(cuentaDePendiente(origen), JSON.stringify(p), "the login in progress", techoMs);
178
182
  },
179
183
  async borrarPendiente(origen, techoMs) {
180
184
  // Nunca lanza: un pendiente viejo es inofensivo (su device_code ya se consumió o vence en
@@ -217,7 +221,7 @@ export function crearGuardado(d) {
217
221
  const r = { valor: JSON.stringify([...l.valor, clave]), escritoEn: d.ahora() };
218
222
  if (a.nombre === "archivo") {
219
223
  try {
220
- await escribirEnArchivo(cuenta, r, "las claves por revocar");
224
+ await escribirEnArchivo(cuenta, r, "the keys to revoke");
221
225
  return;
222
226
  }
223
227
  catch (e) {
@@ -229,7 +233,7 @@ export function crearGuardado(d) {
229
233
  if ((await intentar(a, queda, (x, t) => x.escribir(cuenta, r, t))).estado === "ok")
230
234
  return;
231
235
  }
232
- throw new FalloDeLaTerminal(SALIDA.inesperado, "no_se_pudo_anotar", "No pude anotar la clave anterior como «por revocar» ni en el llavero ni en el archivo.");
236
+ throw new FalloDeLaTerminal(SALIDA.inesperado, "no_se_pudo_anotar", "I couldn't note down the previous key as \"to revoke\" in either the keychain or the file.");
233
237
  },
234
238
  // Quitar sólo achica la lista, así que no pasa por el control del repo: no agrega ningún secreto.
235
239
  // Un almacén presente que falla deja `completo = false`: la clave puede seguir anotada ahí, el
@@ -9,7 +9,7 @@ export const TECHO_DEL_LLAVERO_MS = 5_000;
9
9
  // nada que borrar en él; uno que está y falla puede tener datos adentro.
10
10
  export class LlaveroAusente extends Error {
11
11
  constructor(bin) {
12
- super(`No hay llavero del sistema (falta ${bin}).`);
12
+ super(`There is no system keychain (${bin} is missing).`);
13
13
  this.name = "LlaveroAusente";
14
14
  }
15
15
  }
@@ -26,14 +26,14 @@ function decodificar(s) {
26
26
  v = undefined;
27
27
  }
28
28
  if (!esRegistro(v))
29
- throw new Error("El llavero devolvió algo que no entiendo.");
29
+ throw new Error("The keychain returned something I don't understand.");
30
30
  return { valor: v.valor, escritoEn: v.escritoEn };
31
31
  }
32
32
  // La cuenta viaja en una línea de `security -i`, que también parte por espacios y entiende comillas:
33
33
  // una cuenta con algo de eso se leería distinta de lo que se escribió. Un origen nunca lo tiene.
34
34
  function cuentaSegura(cuenta) {
35
35
  if (!/^[A-Za-z0-9:/.\[\]#_-]+$/.test(cuenta))
36
- throw new Error("Cuenta del llavero inválida.");
36
+ throw new Error("Invalid keychain account.");
37
37
  return cuenta;
38
38
  }
39
39
  // La ruta ABSOLUTA: un `security` del PATH podría ser otro programa (un `node_modules/.bin`, un
@@ -79,10 +79,10 @@ export function almacenDeLlavero(plataforma, correr, existe = existsSync) {
79
79
  if (r.ausente)
80
80
  throw new LlaveroAusente(bin);
81
81
  if (r.vencido)
82
- throw new Error(`El llavero no contestó en ${Math.round(techoMs / 1000)} s.`);
82
+ throw new Error(`The keychain did not respond within ${Math.round(techoMs / 1000)} s.`);
83
83
  return r;
84
84
  }
85
- const fallo = (codigo) => new Error(`El llavero falló (código ${codigo}).`);
85
+ const fallo = (codigo) => new Error(`The keychain failed (code ${codigo}).`);
86
86
  return {
87
87
  nombre: "llavero",
88
88
  async leer(cuenta, techoMs = TECHO_DEL_LLAVERO_MS) {
@@ -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
+ }
package/dist/guia.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // La guía del producto que vive en el repo del cliente. Sin dependencias: sólo `node:crypto`.
2
2
  import { createHash } from "node:crypto";
3
- export const RUTA_DE_LA_GUIA = ".supcoflow/guia.md";
3
+ // El nombre canónico desde la 0.8.0; `guia.md` es el viejo y se sigue leyendo SÓLO si `guide.md` no existe.
4
+ export const RUTA_DE_LA_GUIA = ".supcoflow/guide.md";
5
+ export const RUTA_VIEJA_DE_LA_GUIA = ".supcoflow/guia.md";
4
6
  export const MAX_BYTES_DE_LA_GUIA = 64 * 1024;
5
7
  // La misma regla que el motor (`src/guia/formato.ts`), copiada a propósito y clavada con los vectores
6
8
  // de `test/vectores/huella.json` (idénticos en los dos repos). Se normaliza antes de hashear porque un
@@ -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", `No pude guardar la clave de reintento en ${d.rutaDelArchivo}; sin ella, un reintento crearía otra clave. Revisá ese archivo (no puede estar dentro de un repo) y probá de nuevo.`);
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);
@@ -29,38 +28,25 @@ export function crearRegistroDeIdempotencia(d) {
29
28
  throw noGuardada();
30
29
  // Un archivo que no se entiende no se reescribe (perdería otras claves); se sigue sin guardar.
31
30
  const nueva = d.generar();
32
- d.avisar(`No pude leer ${d.rutaDelArchivo}: sigo con una clave de idempotencia sin guardar.`);
31
+ d.avisar(`I couldn't read ${d.rutaDelArchivo}: I'm going on with an idempotency key that isn't saved.`);
33
32
  return nueva;
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(`No pude resolver la ruta de ${d.rutaDelArchivo}: sigo con una clave de idempotencia sin guardar.`);
49
- return nueva;
50
- }
51
- if (raiz !== null) {
52
- if (opciones.estricta)
53
- throw noGuardada();
54
- d.avisar(`No guardo la clave de idempotencia dentro de un repo de git (${raiz}): un reintento mandará otra.`);
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(`No pude guardar la clave de idempotencia en ${d.rutaDelArchivo}: un reintento mandará otra.`);
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(`No pude borrar la clave de idempotencia de ${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): 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): 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
@@ -13,7 +13,7 @@ export const esSemver = (s) => s.length <= 64 && SEMVER.test(s);
13
13
  const objeto = (v) => (typeof v === "object" && v !== null && !Array.isArray(v) ? v : null);
14
14
  const entero = (v) => typeof v === "number" && Number.isInteger(v) && v >= 1;
15
15
  const ilegible = (message) => new FalloDeLaTerminal(SALIDA.inesperado, "paquete_ilegible", message);
16
- const inesperada = (que) => new FalloDeLaTerminal(SALIDA.inesperado, "configuracion_inesperada", `El motor contestó la configuración con ${que} que no entiendo; no anoto nada a medias. Probá de nuevo en un rato.`);
16
+ const inesperada = (que) => new FalloDeLaTerminal(SALIDA.inesperado, "configuracion_inesperada", `The engine answered the configuration with ${que} I don't understand; I'm not recording anything halfway. Try again in a while.`);
17
17
  // Lo declarado en el `package.json` de la app lo escribió quien escribió el repo: puede ser un rango, un alias o
18
18
  // cualquier cosa. Se copia tal cual sólo si es texto imprimible y corto; si no, no se adivina (`paquete_ilegible`).
19
19
  // `taparSecretos` igual, por si alguien pegó una clave ahí: el archivo nuevo no la repite.
@@ -25,7 +25,7 @@ function declaradoEn(app) {
25
25
  if (v === undefined || v === null)
26
26
  return null;
27
27
  if (typeof v !== "string" || v.length === 0 || v.length > 200 || !/^[\x20-\x7e]+$/.test(v)) {
28
- throw ilegible(`La versión de ${PAQUETE_DEL_CHAT} en el package.json de la app no se entiende; no la anoto.`);
28
+ throw ilegible(`The version of ${PAQUETE_DEL_CHAT} in the app's package.json isn't understandable; I'm not recording it.`);
29
29
  }
30
30
  return taparSecretos(v);
31
31
  }
@@ -35,7 +35,7 @@ function instaladoEn(paquete) {
35
35
  return null;
36
36
  const v = objeto(paquete)?.version;
37
37
  if (typeof v !== "string" || !esSemver(v))
38
- throw ilegible(`El package.json de ${PAQUETE_DEL_CHAT} instalado no trae una versión que entienda.`);
38
+ throw ilegible(`The installed package.json of ${PAQUETE_DEL_CHAT} has no version I understand.`);
39
39
  return v;
40
40
  }
41
41
  function acuerdoDe(c) {
@@ -44,16 +44,16 @@ function acuerdoDe(c) {
44
44
  const a = objeto(c.acuerdo);
45
45
  // Ilegible es un estado conocido del motor (aplica cerrado), no «sin acuerdo»: anotarlo como null mentiría.
46
46
  if (a?.ilegible === true) {
47
- throw new FalloDeLaTerminal(SALIDA.haceFaltaUnaPersona, "acuerdo_ilegible", "El acuerdo de datos está fijado pero no se lee: revisalo en la consola y volvé a correr.");
47
+ throw new FalloDeLaTerminal(SALIDA.haceFaltaUnaPersona, "acuerdo_ilegible", "The data agreement is set but can't be read: check it in the console and run again.");
48
48
  }
49
49
  if (a === null || !entero(a.version))
50
- throw inesperada("un acuerdo");
50
+ throw inesperada("an agreement");
51
51
  return a.version;
52
52
  }
53
53
  function politicaDe(c) {
54
54
  const modo = objeto(c.politica)?.modo;
55
55
  if (modo !== "relevar" && modo !== "guardar")
56
- throw inesperada("una política");
56
+ throw inesperada("a policy");
57
57
  return modo;
58
58
  }
59
59
  function guiaDe(c) {
@@ -62,24 +62,63 @@ function guiaDe(c) {
62
62
  const g = objeto(c.guia);
63
63
  if (g?.origen === "repo") {
64
64
  if (g.procedencia !== "terminal" && g.procedencia !== "github")
65
- throw inesperada("una guía");
65
+ throw inesperada("a guide");
66
66
  if (g.commit !== null && (typeof g.commit !== "string" || !/^[0-9a-f]{40}$/.test(g.commit)))
67
- throw inesperada("una guía");
67
+ throw inesperada("a guide");
68
68
  return { origen: "repo", procedencia: g.procedencia, commit: g.commit };
69
69
  }
70
70
  if (g?.origen === "agente" && entero(g.version))
71
71
  return { origen: "agente", version: g.version };
72
- throw inesperada("una guía");
72
+ throw inesperada("a guide");
73
73
  }
74
74
  export function armarInstalacion(i) {
75
75
  return {
76
- version: 1,
76
+ version: 2,
77
77
  skill: i.skill,
78
78
  terminal: i.terminal,
79
79
  chat: { declarado: declaradoEn(i.paqueteDeLaApp), instalado: instaladoEn(i.paqueteInstalado) },
80
80
  configuracion: { acuerdo_version: acuerdoDe(i.configuracion), politica: politicaDe(i.configuracion), guia: guiaDe(i.configuracion) },
81
+ archivo: i.archivo ?? null,
81
82
  };
82
83
  }
84
+ // ─── El archivo del chat (`install record --file`) ───
85
+ const MAX_LARGO_DEL_ARCHIVO = 300;
86
+ // Pura: una ruta RELATIVA a la raíz del repo, sin `..`, sin ruta absoluta. Se devuelve normalizada (separador `/`, sin
87
+ // `./` ni barras de más), que es como queda anotada. Que no sea un enlace simbólico lo mira el comando, con el disco.
88
+ export function validarElArchivoDelChat(crudo) {
89
+ if (crudo.length === 0 || crudo.length > MAX_LARGO_DEL_ARCHIVO || /[\u0000-\u001f\u007f]/.test(crudo)) {
90
+ return { ok: false, motivo: `--file goes with a path of up to ${MAX_LARGO_DEL_ARCHIVO} printable characters.` };
91
+ }
92
+ if (crudo.startsWith("/") || crudo.startsWith("\\") || /^[A-Za-z]:/.test(crudo)) {
93
+ return { ok: false, motivo: "--file is a path relative to the repo root, not an absolute one." };
94
+ }
95
+ const partes = crudo.split(/[\\/]/).filter((x) => x !== "" && x !== ".");
96
+ if (partes.includes(".."))
97
+ return { ok: false, motivo: "--file can't go out of the repo: no `..` in the path." };
98
+ if (partes.length === 0)
99
+ return { ok: false, motivo: "--file is the chat file, not a folder: say its path." };
100
+ return { ok: true, ruta: partes.join("/") };
101
+ }
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) {
104
+ if (texto === null)
105
+ return null;
106
+ let v;
107
+ try {
108
+ v = JSON.parse(texto);
109
+ }
110
+ catch {
111
+ return null;
112
+ }
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;
121
+ }
83
122
  // El orden de las claves es el de `armarInstalacion`: el mismo estado da los mismos bytes, y eso es `sin_cambios`.
84
123
  export function serializar(x) {
85
124
  return JSON.stringify(x, null, 2) + "\n";
@@ -0,0 +1,10 @@
1
+ // Por qué `leerArchivoChico` no pudo leer un archivo. Son frases sueltas que van después de «I can't read X:», y
2
+ // los comandos comparan contra estas constantes (nunca contra el texto a mano).
3
+ export const NO_LEIDO = {
4
+ noExiste: "it doesn't exist",
5
+ esUnEnlace: "it's a symlink",
6
+ noSePudoAbrir: "it couldn't be opened",
7
+ noEsComun: "it isn't a regular file",
8
+ demasiadoGrande: "it's too large",
9
+ noSePudoLeer: "it couldn't be read",
10
+ };