supcoflow 0.6.0 → 0.8.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 +210 -162
- package/dist/alias.js +60 -0
- package/dist/api.js +121 -16
- package/dist/argumentos.js +92 -78
- package/dist/cli.js +42 -31
- package/dist/comandos/clave-vigente.js +2 -2
- package/dist/comandos/claves.js +25 -25
- package/dist/comandos/configurar.js +40 -40
- package/dist/comandos/datos-y-politica.js +35 -23
- package/dist/comandos/describir.js +170 -138
- package/dist/comandos/guia.js +148 -61
- package/dist/comandos/instalacion.js +71 -40
- package/dist/comandos/login.js +25 -24
- package/dist/comandos/logout.js +17 -16
- package/dist/comandos/probar.js +359 -0
- package/dist/comandos/proyecto-del-repo.js +1 -1
- package/dist/comandos/proyecto.js +16 -16
- package/dist/comandos/quien-soy.js +8 -7
- package/dist/comandos/repo-conectar.js +9 -9
- package/dist/comandos/widget.js +9 -5
- package/dist/diagnostico.js +48 -0
- package/dist/entorno.js +32 -18
- package/dist/flags-por-comando.js +25 -19
- package/dist/guardado/archivo.js +2 -2
- package/dist/guardado/index.js +12 -8
- package/dist/guardado/llavero.js +5 -5
- package/dist/guia.js +3 -1
- package/dist/idempotencia.js +6 -6
- package/dist/instalacion.js +50 -11
- package/dist/lectura.js +10 -0
- package/dist/pruebas-guardadas.js +101 -0
- package/dist/repo-local.js +6 -6
- package/dist/secreto-a-archivo.js +6 -6
- package/package.json +1 -1
package/dist/guardado/index.js
CHANGED
|
@@ -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" ?
|
|
75
|
-
return new FalloDeLaTerminal(SALIDA.inesperado, "no_se_pudo_borrar", `
|
|
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", `
|
|
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(`
|
|
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, "
|
|
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), "
|
|
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, "
|
|
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", "
|
|
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
|
package/dist/guardado/llavero.js
CHANGED
|
@@ -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(`
|
|
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("
|
|
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("
|
|
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(`
|
|
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(`
|
|
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) {
|
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
|
-
|
|
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
|
package/dist/idempotencia.js
CHANGED
|
@@ -15,7 +15,7 @@ export function crearRegistroDeIdempotencia(d) {
|
|
|
15
15
|
// repo) y el motor lo reusa. Una clave de consulta no tiene clave natural: sin la de idempotencia
|
|
16
16
|
// guardada, el reintento tras una respuesta perdida crea OTRA fila. Mejor no mandar nada y decir qué
|
|
17
17
|
// arreglar. Por eso, en estricto, cada «aviso y sigo» de abajo lanza.
|
|
18
|
-
const noGuardada = () => new FalloDeLaTerminal(SALIDA.inesperado, "idempotencia_no_guardada", `
|
|
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.`);
|
|
19
19
|
return {
|
|
20
20
|
async claveDe(operacion, alcance, opciones = {}) {
|
|
21
21
|
const c = cuenta(operacion, alcance);
|
|
@@ -29,7 +29,7 @@ export function crearRegistroDeIdempotencia(d) {
|
|
|
29
29
|
throw noGuardada();
|
|
30
30
|
// Un archivo que no se entiende no se reescribe (perdería otras claves); se sigue sin guardar.
|
|
31
31
|
const nueva = d.generar();
|
|
32
|
-
d.avisar(`
|
|
32
|
+
d.avisar(`I couldn't read ${d.rutaDelArchivo}: I'm going on with an idempotency key that isn't saved.`);
|
|
33
33
|
return nueva;
|
|
34
34
|
}
|
|
35
35
|
const nueva = d.generar();
|
|
@@ -45,13 +45,13 @@ export function crearRegistroDeIdempotencia(d) {
|
|
|
45
45
|
catch {
|
|
46
46
|
if (opciones.estricta)
|
|
47
47
|
throw noGuardada();
|
|
48
|
-
d.avisar(`
|
|
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
49
|
return nueva;
|
|
50
50
|
}
|
|
51
51
|
if (raiz !== null) {
|
|
52
52
|
if (opciones.estricta)
|
|
53
53
|
throw noGuardada();
|
|
54
|
-
d.avisar(`
|
|
54
|
+
d.avisar(`I'm not saving the idempotency key inside a git repo (${raiz}): a retry will send a different one.`);
|
|
55
55
|
return nueva;
|
|
56
56
|
}
|
|
57
57
|
try {
|
|
@@ -60,7 +60,7 @@ export function crearRegistroDeIdempotencia(d) {
|
|
|
60
60
|
catch {
|
|
61
61
|
if (opciones.estricta)
|
|
62
62
|
throw noGuardada();
|
|
63
|
-
d.avisar(`
|
|
63
|
+
d.avisar(`I couldn't save the idempotency key in ${d.rutaDelArchivo}: a retry will send a different one.`);
|
|
64
64
|
}
|
|
65
65
|
return nueva;
|
|
66
66
|
},
|
|
@@ -81,7 +81,7 @@ export function crearRegistroDeIdempotencia(d) {
|
|
|
81
81
|
await d.almacen.borrar(cuenta(operacion, alcance));
|
|
82
82
|
}
|
|
83
83
|
catch {
|
|
84
|
-
d.avisar(`
|
|
84
|
+
d.avisar(`I couldn't delete the idempotency key from ${d.rutaDelArchivo}.`);
|
|
85
85
|
}
|
|
86
86
|
},
|
|
87
87
|
};
|
package/dist/instalacion.js
CHANGED
|
@@ -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", `
|
|
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(`
|
|
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(`
|
|
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", "
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
72
|
+
throw inesperada("a guide");
|
|
73
73
|
}
|
|
74
74
|
export function armarInstalacion(i) {
|
|
75
75
|
return {
|
|
76
|
-
version:
|
|
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";
|
package/dist/lectura.js
ADDED
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Lo que `probar` guarda en esta máquina, por origen y por proyecto (bloque 7, hallazgo 5): el INTENTO
|
|
2
|
+
// (`{clave, mensaje, techo_usd}`) y la última prueba confirmada. Fuera del repo, al lado de las credenciales.
|
|
3
|
+
//
|
|
4
|
+
// Por qué no alcanza el registro de idempotencia (src/idempotencia.ts): ése guarda sólo la clave. El motor
|
|
5
|
+
// compara la huella del pedido (proyecto, techo, mensaje) y una misma clave con otro mensaje es
|
|
6
|
+
// `idempotency_key_reusada`; para que el reintento tras una respuesta perdida recupere la MISMA prueba hay
|
|
7
|
+
// que reenviar la clave con el mensaje y el techo que llevó. Por eso se guarda el intento entero.
|
|
8
|
+
//
|
|
9
|
+
// Por qué una sola cuenta con las dos cosas: «guardar el id» y «cerrar el intento» son UNA escritura
|
|
10
|
+
// atómica (el `rename` del almacén). Si falla, el archivo queda como estaba: el intento sigue abierto, el
|
|
11
|
+
// próximo comando reenvía la misma clave, recibe la misma prueba y vuelve a intentar (R14d). Con dos
|
|
12
|
+
// escrituras, un corte entre medio dejaba la última guardada y el intento abierto, y la próxima «prueba
|
|
13
|
+
// nueva» sería en realidad un reintento de la vieja.
|
|
14
|
+
//
|
|
15
|
+
// Estricto en todo, como `claves crear` en el registro de idempotencia: una prueba no tiene clave natural
|
|
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
|
+
import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
|
|
19
|
+
import { dentroDeUnWorkingTree } from "./guardado/repo.js";
|
|
20
|
+
function esIntento(v) {
|
|
21
|
+
if (typeof v !== "object" || v === null)
|
|
22
|
+
return false;
|
|
23
|
+
const i = v;
|
|
24
|
+
const politica = i.politica_esperada === undefined || i.politica_esperada === null || i.politica_esperada === "relevar" || i.politica_esperada === "guardar";
|
|
25
|
+
return typeof i.clave === "string" && i.clave.length > 0 && (i.mensaje === null || typeof i.mensaje === "string") && typeof i.techo_usd === "number" && politica;
|
|
26
|
+
}
|
|
27
|
+
export function crearRegistroDePruebas(d) {
|
|
28
|
+
// Por origen, como la clave y el registro de idempotencia: un motor local (o el proxy de P4) y
|
|
29
|
+
// producción no comparten intentos. Cambiar de origen entre dos comandos es otra clave.
|
|
30
|
+
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.`);
|
|
32
|
+
async function leer(proyectoId) {
|
|
33
|
+
let crudo;
|
|
34
|
+
try {
|
|
35
|
+
crudo = (await d.almacen.leer(cuenta(proyectoId)))?.valor ?? null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw noGuardada("read");
|
|
39
|
+
}
|
|
40
|
+
if (crudo === null)
|
|
41
|
+
return { intento: null, ultima: null };
|
|
42
|
+
let v;
|
|
43
|
+
try {
|
|
44
|
+
v = JSON.parse(crudo);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw noGuardada("understand");
|
|
48
|
+
}
|
|
49
|
+
const o = (typeof v === "object" && v !== null ? v : {});
|
|
50
|
+
const intentoOk = o.intento === null || esIntento(o.intento);
|
|
51
|
+
const ultimaOk = o.ultima === null || typeof o.ultima === "string";
|
|
52
|
+
if (!intentoOk || !ultimaOk)
|
|
53
|
+
throw noGuardada("understand");
|
|
54
|
+
const i = o.intento;
|
|
55
|
+
return {
|
|
56
|
+
intento: i === null ? null : { clave: i.clave, mensaje: i.mensaje, techoUsd: i.techo_usd, politicaEsperada: i.politica_esperada ?? null },
|
|
57
|
+
ultima: o.ultima,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
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
|
+
const valor = JSON.stringify({
|
|
74
|
+
intento: g.intento === null
|
|
75
|
+
? null
|
|
76
|
+
: { clave: g.intento.clave, mensaje: g.intento.mensaje, techo_usd: g.intento.techoUsd, politica_esperada: g.intento.politicaEsperada },
|
|
77
|
+
ultima: g.ultima,
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
await d.almacen.escribir(cuenta(proyectoId), { valor, escritoEn: d.ahora() });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw noGuardada("save to");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
leer,
|
|
88
|
+
async abrir(proyectoId, intento) {
|
|
89
|
+
const antes = await leer(proyectoId);
|
|
90
|
+
await escribir(proyectoId, { intento, ultima: antes.ultima });
|
|
91
|
+
},
|
|
92
|
+
async confirmar(proyectoId, pruebaId) {
|
|
93
|
+
await leer(proyectoId);
|
|
94
|
+
await escribir(proyectoId, { intento: null, ultima: pruebaId });
|
|
95
|
+
},
|
|
96
|
+
async descartar(proyectoId) {
|
|
97
|
+
const antes = await leer(proyectoId);
|
|
98
|
+
await escribir(proyectoId, { intento: null, ultima: antes.ultima });
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
package/dist/repo-local.js
CHANGED
|
@@ -45,15 +45,15 @@ export async function leerRepoLocal(git, cwd) {
|
|
|
45
45
|
return { repo, raiz };
|
|
46
46
|
}
|
|
47
47
|
const MOTIVOS = {
|
|
48
|
-
sin_git: "
|
|
49
|
-
sin_origin: "
|
|
50
|
-
origin_no_es_github: "
|
|
48
|
+
sin_git: "this folder isn't a git repo",
|
|
49
|
+
sin_origin: "the repo has no remote called \"origin\"",
|
|
50
|
+
origin_no_es_github: "the \"origin\" remote isn't a GitHub repo",
|
|
51
51
|
};
|
|
52
52
|
export async function repoPedido(d) {
|
|
53
53
|
const local = await leerRepoLocal(d.git, d.cwd);
|
|
54
54
|
if (d.flagRepo !== null) {
|
|
55
55
|
if (!esRepoValido(d.flagRepo)) {
|
|
56
|
-
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "repo_invalido", "
|
|
56
|
+
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "repo_invalido", "The value of --repo must be owner/repo (for example owner/my-app), without the URL.");
|
|
57
57
|
}
|
|
58
58
|
// La raíz sólo se da si el origin local es ESE repo: con otro, lo que se escriba en esta
|
|
59
59
|
// carpeta (el archivo del proyecto) no le pertenece.
|
|
@@ -61,8 +61,8 @@ export async function repoPedido(d) {
|
|
|
61
61
|
return { repo: d.flagRepo, raiz: mismo ? local.raiz : null };
|
|
62
62
|
}
|
|
63
63
|
if (local.repo === null) {
|
|
64
|
-
const porque = local.motivo ? MOTIVOS[local.motivo] : "
|
|
65
|
-
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "sin_repo", `
|
|
64
|
+
const porque = local.motivo ? MOTIVOS[local.motivo] : "the folder couldn't be read";
|
|
65
|
+
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "sin_repo", `I couldn't find a GitHub repo here (${porque}). Pass --repo owner/repo.`);
|
|
66
66
|
}
|
|
67
67
|
return { repo: local.repo, raiz: local.raiz };
|
|
68
68
|
}
|
|
@@ -22,7 +22,7 @@ import { dentroDeUnWorkingTree } from "./guardado/repo.js";
|
|
|
22
22
|
// Una clave ocupa 85 caracteres: 4 KB es de sobra, y algo más grande ya no es una clave.
|
|
23
23
|
const TOPE = 4 * 1024;
|
|
24
24
|
const CLAVE = /^scf_([0-9a-f]{16})_[0-9a-f]{64}$/;
|
|
25
|
-
const OCUPADO = { error: "
|
|
25
|
+
const OCUPADO = { error: "the file already has something else; delete it or pick another destination", codigo: 4 };
|
|
26
26
|
export function crearGuardaDeSecretos(fs) {
|
|
27
27
|
function borrarTemporal(tmp) {
|
|
28
28
|
try {
|
|
@@ -50,10 +50,10 @@ export function crearGuardaDeSecretos(fs) {
|
|
|
50
50
|
raiz = dentroDeUnWorkingTree(path.dirname(destino), fs);
|
|
51
51
|
}
|
|
52
52
|
catch {
|
|
53
|
-
return { error: "
|
|
53
|
+
return { error: "I couldn't resolve the folder's real path (a broken link?)", codigo: 3 };
|
|
54
54
|
}
|
|
55
55
|
if (raiz !== null)
|
|
56
|
-
return { error: `
|
|
56
|
+
return { error: `it falls inside the git repo ${raiz}, and a key in a repo ends up committed; pick a destination outside it`, codigo: 3 };
|
|
57
57
|
return null;
|
|
58
58
|
}
|
|
59
59
|
return {
|
|
@@ -67,7 +67,7 @@ export function crearGuardaDeSecretos(fs) {
|
|
|
67
67
|
return invalido;
|
|
68
68
|
const hay = fs.leerSinSeguir(destino, TOPE);
|
|
69
69
|
if (hay.tipo === "enlace")
|
|
70
|
-
return { error: "
|
|
70
|
+
return { error: "the destination is a symlink; pick a regular file", codigo: 3 };
|
|
71
71
|
if (hay.tipo !== "ausente") {
|
|
72
72
|
const actual = leer(destino);
|
|
73
73
|
if (actual.estado !== "clave" || actual.claveId !== claveId)
|
|
@@ -90,7 +90,7 @@ export function crearGuardaDeSecretos(fs) {
|
|
|
90
90
|
catch {
|
|
91
91
|
// El mensaje del sistema no se reenvía: podría traer la ruta del temporal o algo peor.
|
|
92
92
|
borrarTemporal(tmp);
|
|
93
|
-
return { error: "
|
|
93
|
+
return { error: "it couldn't be written there (does the folder exist, and do you have permission?)", codigo: 3 };
|
|
94
94
|
}
|
|
95
95
|
// Después de publicar, el temporal es un nombre de más (con `link`) o ya no existe (con `rename`): no
|
|
96
96
|
// borrarlo no cambia que la clave quedó en el destino, y convertir eso en un fallo dejaría una clave
|
|
@@ -101,7 +101,7 @@ export function crearGuardaDeSecretos(fs) {
|
|
|
101
101
|
return OCUPADO;
|
|
102
102
|
// Sin caer a `rename`: eso reabre la carrera que `link` cierra. Otro destino, en otro disco, sirve.
|
|
103
103
|
if (publicado === "sin_enlaces") {
|
|
104
|
-
return { error: "
|
|
104
|
+
return { error: "that folder's disk doesn't support hard links (FAT, exFAT or a network folder?), and without them I can't write without risking overwriting another key; pick a destination on another disk", codigo: 3 };
|
|
105
105
|
}
|
|
106
106
|
return "guardado";
|
|
107
107
|
},
|
package/package.json
CHANGED