supcoflow 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -2
- package/dist/api.js +295 -0
- package/dist/argumentos.js +98 -0
- package/dist/candado.js +57 -0
- package/dist/cli.js +128 -0
- package/dist/comandos/clave-vigente.js +18 -0
- package/dist/comandos/claves.js +161 -0
- package/dist/comandos/configurar.js +187 -0
- package/dist/comandos/datos-y-politica.js +112 -0
- package/dist/comandos/describir.js +248 -0
- package/dist/comandos/login.js +254 -0
- package/dist/comandos/logout.js +96 -0
- package/dist/comandos/proyecto-del-repo.js +26 -0
- package/dist/comandos/proyecto.js +213 -0
- package/dist/comandos/quien-soy.js +43 -0
- package/dist/comandos/repo-conectar.js +126 -0
- package/dist/contrato.js +38 -0
- package/dist/entorno.js +460 -0
- package/dist/flags-por-comando.js +30 -0
- package/dist/guardado/archivo.js +63 -0
- package/dist/guardado/index.js +258 -0
- package/dist/guardado/llavero.js +110 -0
- package/dist/guardado/repo.js +35 -0
- package/dist/idempotencia.js +88 -0
- package/dist/repo-local.js +68 -0
- package/dist/secreto-a-archivo.js +109 -0
- package/dist/sondeo.js +47 -0
- package/package.json +11 -4
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Dónde vive la clave de la terminal (§5.2): el llavero del sistema; si no hay, un archivo 0600
|
|
2
|
+
// avisando; `SUPCOFLOW_TOKEN` por encima de los dos; y nunca un destino dentro de un repo de git.
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { FalloDeLaTerminal, SALIDA } from "../contrato.js";
|
|
5
|
+
import { LlaveroAusente, TECHO_DEL_LLAVERO_MS } from "./llavero.js";
|
|
6
|
+
import { dentroDeUnWorkingTree } from "./repo.js";
|
|
7
|
+
// La cuenta ES el origen de la API que emitió lo guardado (Review Focus 3): una clave acuñada por
|
|
8
|
+
// un motor en `localhost` queda en otra cuenta que la de producción, y la terminal apuntada a
|
|
9
|
+
// producción simplemente no la encuentra (sale 2) en vez de mandarla a un servidor que no la emitió.
|
|
10
|
+
// Y la clave y el login en curso son cuentas DISTINTAS (Review Focus 7): empezar o abandonar un login
|
|
11
|
+
// escribe sólo `#pendiente` y nunca puede pisar la clave vigente.
|
|
12
|
+
const cuentaDeClave = (origen) => origen;
|
|
13
|
+
const cuentaDePendiente = (origen) => `${origen}#pendiente`;
|
|
14
|
+
const cuentaPorRevocar = (origen) => `${origen}#por_revocar`;
|
|
15
|
+
function esPendiente(v) {
|
|
16
|
+
if (typeof v !== "object" || v === null)
|
|
17
|
+
return false;
|
|
18
|
+
const p = v;
|
|
19
|
+
return typeof p.deviceCode === "string" && typeof p.userCode === "string" && typeof p.verificationUriComplete === "string"
|
|
20
|
+
&& typeof p.venceEn === "number" && typeof p.intervaloS === "number"
|
|
21
|
+
&& (p.ultimoSondeoEn === null || typeof p.ultimoSondeoEn === "number");
|
|
22
|
+
}
|
|
23
|
+
function listaDeClaves(s) {
|
|
24
|
+
try {
|
|
25
|
+
const v = JSON.parse(s);
|
|
26
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string") ? v : null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function crearGuardado(d) {
|
|
33
|
+
const almacenes = () => (d.llavero ? [d.llavero, d.archivo] : [d.archivo]);
|
|
34
|
+
// El techo es de la operación ENTERA (Review Focus 10): un vencimiento absoluto calculado al
|
|
35
|
+
// entrar, que leer, escribir y limpiar se reparten. Si cada llamada tuviera su propio techo, una
|
|
36
|
+
// operación «de 5 s» podían ser 10 y la reserva del tramo del login no cerraba.
|
|
37
|
+
function plazo(techoMs = TECHO_DEL_LLAVERO_MS) {
|
|
38
|
+
const fin = d.ahora() + techoMs;
|
|
39
|
+
return () => fin - d.ahora();
|
|
40
|
+
}
|
|
41
|
+
// El plazo sólo corta al llavero: el archivo es local y no se cuelga, y es el último recurso para
|
|
42
|
+
// guardar una clave que no tiene otra copia. Un plazo agotado no espera al llavero: es una falla.
|
|
43
|
+
async function intentar(a, queda, op) {
|
|
44
|
+
if (a === null)
|
|
45
|
+
return { estado: "ausente" };
|
|
46
|
+
const techo = queda();
|
|
47
|
+
if (a.nombre === "llavero" && techo <= 0)
|
|
48
|
+
return { estado: "fallo" };
|
|
49
|
+
try {
|
|
50
|
+
return { estado: "ok", valor: await op(a, Math.max(techo, 1)) };
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
return e instanceof LlaveroAusente ? { estado: "ausente" } : { estado: "fallo" };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Gana el `escritoEn` más alto, no el almacén (Review Focus 8): si el llavero falla una vez, la
|
|
57
|
+
// clave nueva cae al archivo; cuando el llavero vuelve, todavía tiene la VIEJA. Preferir el
|
|
58
|
+
// llavero por ser el llavero devolvería una clave ya reemplazada (y quizás revocada).
|
|
59
|
+
async function leerCuentaCompleta(cuenta, queda) {
|
|
60
|
+
let mejor = null;
|
|
61
|
+
let completa = true;
|
|
62
|
+
for (const a of almacenes()) {
|
|
63
|
+
const i = await intentar(a, queda, (x, t) => x.leer(cuenta, t));
|
|
64
|
+
if (i.estado === "fallo")
|
|
65
|
+
completa = false;
|
|
66
|
+
if (i.estado === "ok" && i.valor !== null && (mejor === null || i.valor.escritoEn > mejor.r.escritoEn)) {
|
|
67
|
+
mejor = { r: i.valor, fuente: a.nombre };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { mejor, completa };
|
|
71
|
+
}
|
|
72
|
+
const leerCuenta = async (cuenta, queda) => (await leerCuentaCompleta(cuenta, queda)).mejor;
|
|
73
|
+
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}.`);
|
|
76
|
+
};
|
|
77
|
+
// El chequeo del repo va ANTES de escribir (Review Focus 1): una vez escrita, la clave ya está a
|
|
78
|
+
// un `git add -A` de publicarse, y borrarla después no deshace un commit. Mira la ruta REAL además
|
|
79
|
+
// de la escrita (Review Focus 6): con `~/.config` enlazado a un repo de dotfiles, la escrita no
|
|
80
|
+
// cae en ningún repo y la real sí.
|
|
81
|
+
async function escribirEnArchivo(cuenta, r, que) {
|
|
82
|
+
const raiz = dentroDeUnWorkingTree(path.dirname(d.rutaDelArchivo), d.fs);
|
|
83
|
+
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.`);
|
|
85
|
+
}
|
|
86
|
+
await d.archivo.escribir(cuenta, r);
|
|
87
|
+
d.avisar(`No hay llavero del sistema: guardé ${que} en ${d.rutaDelArchivo} con permisos 0600.`);
|
|
88
|
+
}
|
|
89
|
+
async function escribirCuenta(cuenta, valor, que, techoMs) {
|
|
90
|
+
const queda = plazo(techoMs);
|
|
91
|
+
const r = { valor, escritoEn: d.ahora() };
|
|
92
|
+
const ll = await intentar(d.llavero, queda, (x, t) => x.escribir(cuenta, r, t));
|
|
93
|
+
if (ll.estado === "ok") {
|
|
94
|
+
// Best-effort: si queda algo viejo en el archivo, la regla del `escritoEn` lo deja atrás.
|
|
95
|
+
await intentar(d.archivo, queda, (x, t) => x.borrar(cuenta, t));
|
|
96
|
+
return "llavero";
|
|
97
|
+
}
|
|
98
|
+
await escribirEnArchivo(cuenta, r, que);
|
|
99
|
+
// No se limpia el llavero: si acaba de fallar (o vencer su techo) no se lo vuelve a esperar, y
|
|
100
|
+
// si está ausente no tiene nada. Lo viejo que quede ahí pierde por `escritoEn`.
|
|
101
|
+
return "archivo";
|
|
102
|
+
}
|
|
103
|
+
// Devuelve de qué almacenes PRESENTES no se pudo borrar; un llavero ausente no guarda nada.
|
|
104
|
+
async function borrarCuenta(cuenta, techoMs) {
|
|
105
|
+
const queda = plazo(techoMs);
|
|
106
|
+
const fallidos = [];
|
|
107
|
+
for (const a of almacenes()) {
|
|
108
|
+
if ((await intentar(a, queda, (x, t) => x.borrar(cuenta, t))).estado === "fallo")
|
|
109
|
+
fallidos.push(a.nombre);
|
|
110
|
+
}
|
|
111
|
+
return fallidos;
|
|
112
|
+
}
|
|
113
|
+
async function leerLista(a, cuenta, queda) {
|
|
114
|
+
const i = await intentar(a, queda, (x, t) => x.leer(cuenta, t));
|
|
115
|
+
if (i.estado !== "ok")
|
|
116
|
+
return i;
|
|
117
|
+
if (i.valor === null)
|
|
118
|
+
return { estado: "ok", valor: [] };
|
|
119
|
+
const lista = listaDeClaves(i.valor.valor);
|
|
120
|
+
return lista === null ? { estado: "fallo" } : { estado: "ok", valor: lista };
|
|
121
|
+
}
|
|
122
|
+
const guardado = {
|
|
123
|
+
async leerClave(origen) {
|
|
124
|
+
// Quien exporta la variable decide: se usa sin mirar el origen.
|
|
125
|
+
const env = d.env.SUPCOFLOW_TOKEN;
|
|
126
|
+
if (env)
|
|
127
|
+
return { clave: env, fuente: "env" };
|
|
128
|
+
return guardado.leerClaveGuardada(origen);
|
|
129
|
+
},
|
|
130
|
+
async leerClaveGuardada(origen) {
|
|
131
|
+
const l = await leerCuenta(cuentaDeClave(origen), plazo());
|
|
132
|
+
return l === null ? null : { clave: l.r.valor, fuente: l.fuente };
|
|
133
|
+
},
|
|
134
|
+
async leerClaveCompleta(origen) {
|
|
135
|
+
const env = d.env.SUPCOFLOW_TOKEN;
|
|
136
|
+
if (env)
|
|
137
|
+
return { valor: { clave: env, fuente: "env" }, completa: true };
|
|
138
|
+
return guardado.leerClaveGuardadaCompleta(origen);
|
|
139
|
+
},
|
|
140
|
+
// Las de arriba tragan un almacén que no contesta como «no hay»; ésta lo dice, porque quien va a
|
|
141
|
+
// PISAR o BORRAR la clave necesita saber si leyó todo (Review Focus 12 y 14): con A en un llavero
|
|
142
|
+
// bloqueado, «no hay» deja que un login la reemplace sin revocarla.
|
|
143
|
+
async leerClaveGuardadaCompleta(origen, techoMs) {
|
|
144
|
+
const { mejor, completa } = await leerCuentaCompleta(cuentaDeClave(origen), plazo(techoMs));
|
|
145
|
+
return { valor: mejor === null ? null : { clave: mejor.r.valor, fuente: mejor.fuente }, completa };
|
|
146
|
+
},
|
|
147
|
+
async guardarClave(origen, clave, techoMs) {
|
|
148
|
+
return { donde: await escribirCuenta(cuentaDeClave(origen), clave, "la clave", techoMs) };
|
|
149
|
+
},
|
|
150
|
+
async borrarClave(origen) {
|
|
151
|
+
// Un borrado a medias de una clave no se informa como hecho: la que quedó sigue sirviendo.
|
|
152
|
+
const fallidos = await borrarCuenta(cuentaDeClave(origen));
|
|
153
|
+
if (fallidos.length > 0)
|
|
154
|
+
throw errorDeBorrado(fallidos);
|
|
155
|
+
},
|
|
156
|
+
// Para cuando la lectura fue incompleta: se borra sólo la copia que se leyó y se revocó. La del
|
|
157
|
+
// almacén que no contestó puede ser OTRA clave, viva; borrarla a ciegas la olvidaría sin revocar.
|
|
158
|
+
async borrarClaveDe(origen, almacen) {
|
|
159
|
+
const a = almacen === "llavero" ? d.llavero : d.archivo;
|
|
160
|
+
if ((await intentar(a, plazo(), (x, t) => x.borrar(cuentaDeClave(origen), t))).estado === "fallo") {
|
|
161
|
+
throw errorDeBorrado([almacen]);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
async leerPendiente(origen) {
|
|
165
|
+
const l = await leerCuenta(cuentaDePendiente(origen), plazo());
|
|
166
|
+
if (l === null)
|
|
167
|
+
return null;
|
|
168
|
+
try {
|
|
169
|
+
const v = JSON.parse(l.r.valor);
|
|
170
|
+
return esPendiente(v) ? v : null;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
async guardarPendiente(origen, p, techoMs) {
|
|
177
|
+
await escribirCuenta(cuentaDePendiente(origen), JSON.stringify(p), "el inicio de sesión en curso", techoMs);
|
|
178
|
+
},
|
|
179
|
+
async borrarPendiente(origen, techoMs) {
|
|
180
|
+
// Nunca lanza: un pendiente viejo es inofensivo (su device_code ya se consumió o vence en
|
|
181
|
+
// 10 min; el próximo `--start` lo pisa y un `--wait` sobre él recibe `invalid_grant` y lo limpia).
|
|
182
|
+
try {
|
|
183
|
+
return (await borrarCuenta(cuentaDePendiente(origen), techoMs)).length === 0;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
// La lista «por revocar» NO sigue la regla del `escritoEn` (Review Focus 15): es la UNIÓN de los
|
|
190
|
+
// dos almacenes. Con «gana la más reciente», A anotada en el llavero y B en el archivo mientras
|
|
191
|
+
// el llavero no respondía daría [B], y A quedaría vigente para siempre sin que nadie la revoque.
|
|
192
|
+
async leerPorRevocar(origen) {
|
|
193
|
+
const queda = plazo();
|
|
194
|
+
const claves = new Set();
|
|
195
|
+
let completa = true;
|
|
196
|
+
for (const a of almacenes()) {
|
|
197
|
+
const l = await leerLista(a, cuentaPorRevocar(origen), queda);
|
|
198
|
+
if (l.estado === "ok")
|
|
199
|
+
l.valor.forEach((c) => claves.add(c));
|
|
200
|
+
else if (l.estado === "fallo")
|
|
201
|
+
completa = false;
|
|
202
|
+
}
|
|
203
|
+
return { claves: [...claves], completa };
|
|
204
|
+
},
|
|
205
|
+
// Se escribe SÓLO en un almacén, sobre la lista que se acaba de leer de ESE almacén: nunca se
|
|
206
|
+
// reemplaza la del otro, que puede tener claves que ahora no se pueden leer. Y un almacén que no
|
|
207
|
+
// se pudo leer no se pisa a ciegas.
|
|
208
|
+
async anotarPorRevocar(origen, clave, techoMs) {
|
|
209
|
+
const queda = plazo(techoMs);
|
|
210
|
+
const cuenta = cuentaPorRevocar(origen);
|
|
211
|
+
for (const a of almacenes()) {
|
|
212
|
+
const l = await leerLista(a, cuenta, queda);
|
|
213
|
+
if (l.estado !== "ok")
|
|
214
|
+
continue;
|
|
215
|
+
if (l.valor.includes(clave))
|
|
216
|
+
return;
|
|
217
|
+
const r = { valor: JSON.stringify([...l.valor, clave]), escritoEn: d.ahora() };
|
|
218
|
+
if (a.nombre === "archivo") {
|
|
219
|
+
try {
|
|
220
|
+
await escribirEnArchivo(cuenta, r, "las claves por revocar");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
if (e instanceof FalloDeLaTerminal)
|
|
225
|
+
throw e;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if ((await intentar(a, queda, (x, t) => x.escribir(cuenta, r, t))).estado === "ok")
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
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.");
|
|
233
|
+
},
|
|
234
|
+
// Quitar sólo achica la lista, así que no pasa por el control del repo: no agrega ningún secreto.
|
|
235
|
+
// Un almacén presente que falla deja `completo = false`: la clave puede seguir anotada ahí, el
|
|
236
|
+
// próximo reintento la vuelve a revocar, el motor contesta `ya_no_valia` y se quita entonces.
|
|
237
|
+
async quitarPorRevocar(origen, clave) {
|
|
238
|
+
const queda = plazo();
|
|
239
|
+
const cuenta = cuentaPorRevocar(origen);
|
|
240
|
+
let completo = true;
|
|
241
|
+
for (const a of almacenes()) {
|
|
242
|
+
const l = await leerLista(a, cuenta, queda);
|
|
243
|
+
if (l.estado === "fallo")
|
|
244
|
+
completo = false;
|
|
245
|
+
if (l.estado !== "ok" || !l.valor.includes(clave))
|
|
246
|
+
continue;
|
|
247
|
+
const resto = l.valor.filter((c) => c !== clave);
|
|
248
|
+
const op = resto.length === 0
|
|
249
|
+
? (x, t) => x.borrar(cuenta, t)
|
|
250
|
+
: (x, t) => x.escribir(cuenta, { valor: JSON.stringify(resto), escritoEn: d.ahora() }, t);
|
|
251
|
+
if ((await intentar(a, queda, op)).estado !== "ok")
|
|
252
|
+
completo = false;
|
|
253
|
+
}
|
|
254
|
+
return { completo };
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
return guardado;
|
|
258
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// El llavero del sistema: Keychain en macOS (`security`), Secret Service en Linux (`secret-tool`).
|
|
2
|
+
// Sin dependencias: se habla con el binario del sistema, inyectado como `Correr` para los tests.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { esRegistro } from "./archivo.js";
|
|
5
|
+
// Un anillo bloqueado que pide desbloqueo, o un Linux sin D-Bus, deja al binario esperando para
|
|
6
|
+
// siempre: cada llamada tiene techo y vencerlo es una falla, nunca un comando colgado (Review Focus 2).
|
|
7
|
+
export const TECHO_DEL_LLAVERO_MS = 5_000;
|
|
8
|
+
// AUSENTE no es FALLIDO (Review Focus 12): un binario que no existe no guarda nada, así que no hay
|
|
9
|
+
// nada que borrar en él; uno que está y falla puede tener datos adentro.
|
|
10
|
+
export class LlaveroAusente extends Error {
|
|
11
|
+
constructor(bin) {
|
|
12
|
+
super(`No hay llavero del sistema (falta ${bin}).`);
|
|
13
|
+
this.name = "LlaveroAusente";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const SERVICIO = "supcoflow";
|
|
17
|
+
// El base64url no tiene espacios: el modo `-i` de `security` parte la línea por espacios, y una
|
|
18
|
+
// clave en JSON los tiene (o podría tenerlos). Con base64url el secreto es un único argumento.
|
|
19
|
+
const codificar = (r) => Buffer.from(JSON.stringify(r)).toString("base64url");
|
|
20
|
+
function decodificar(s) {
|
|
21
|
+
let v;
|
|
22
|
+
try {
|
|
23
|
+
v = JSON.parse(Buffer.from(s.trim(), "base64url").toString("utf8"));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
v = undefined;
|
|
27
|
+
}
|
|
28
|
+
if (!esRegistro(v))
|
|
29
|
+
throw new Error("El llavero devolvió algo que no entiendo.");
|
|
30
|
+
return { valor: v.valor, escritoEn: v.escritoEn };
|
|
31
|
+
}
|
|
32
|
+
// La cuenta viaja en una línea de `security -i`, que también parte por espacios y entiende comillas:
|
|
33
|
+
// una cuenta con algo de eso se leería distinta de lo que se escribió. Un origen nunca lo tiene.
|
|
34
|
+
function cuentaSegura(cuenta) {
|
|
35
|
+
if (!/^[A-Za-z0-9:/.\[\]#_-]+$/.test(cuenta))
|
|
36
|
+
throw new Error("Cuenta del llavero inválida.");
|
|
37
|
+
return cuenta;
|
|
38
|
+
}
|
|
39
|
+
// La ruta ABSOLUTA: un `security` del PATH podría ser otro programa (un `node_modules/.bin`, un
|
|
40
|
+
// directorio del repo en el PATH) y recibiría la clave. El secreto va con `-i` por stdin porque en
|
|
41
|
+
// `argv` lo ve cualquiera con `ps` mientras el comando corre; leer y borrar no llevan secreto.
|
|
42
|
+
const MACOS = {
|
|
43
|
+
bins: ["/usr/bin/security"],
|
|
44
|
+
escribir: (cuenta, secreto) => ({
|
|
45
|
+
args: ["-i"],
|
|
46
|
+
stdin: `add-generic-password -U -s ${SERVICIO} -a ${cuenta} -w ${secreto}\n`,
|
|
47
|
+
}),
|
|
48
|
+
leer: (cuenta) => ["find-generic-password", "-s", SERVICIO, "-a", cuenta, "-w"],
|
|
49
|
+
borrar: (cuenta) => ["delete-generic-password", "-s", SERVICIO, "-a", cuenta],
|
|
50
|
+
// 44 = errSecItemNotFound.
|
|
51
|
+
noEncontrado: (codigo) => codigo === 44,
|
|
52
|
+
};
|
|
53
|
+
// `secret-tool store` lee el secreto de stdin cuando no hay terminal: nunca en `argv`.
|
|
54
|
+
//
|
|
55
|
+
// Rutas ABSOLUTAS y no `secret-tool` por el PATH, por la misma razón que `security` arriba: un
|
|
56
|
+
// `node_modules/.bin/secret-tool` plantado recibiría cada clave por stdin.
|
|
57
|
+
const LINUX = {
|
|
58
|
+
bins: ["/usr/bin/secret-tool", "/bin/secret-tool", "/usr/local/bin/secret-tool"],
|
|
59
|
+
escribir: (cuenta, secreto) => ({
|
|
60
|
+
args: ["store", "--label=Supcoflow", "servicio", SERVICIO, "cuenta", cuenta],
|
|
61
|
+
stdin: secreto,
|
|
62
|
+
}),
|
|
63
|
+
leer: (cuenta) => ["lookup", "servicio", SERVICIO, "cuenta", cuenta],
|
|
64
|
+
borrar: (cuenta) => ["clear", "servicio", SERVICIO, "cuenta", cuenta],
|
|
65
|
+
// `secret-tool` sale 1 sin salida también sin D-Bus o con el anillo bloqueado: lo único que
|
|
66
|
+
// distingue «no está» de «no pude mirar» es que, cuando no está, no dice nada por stderr.
|
|
67
|
+
noEncontrado: (codigo, stdout, stderr) => codigo === 1 && stdout.trim() === "" && stderr.trim() === "",
|
|
68
|
+
};
|
|
69
|
+
export function almacenDeLlavero(plataforma, correr, existe = existsSync) {
|
|
70
|
+
const c = plataforma === "darwin" ? MACOS : plataforma === "linux" ? LINUX : null;
|
|
71
|
+
if (c === null)
|
|
72
|
+
return null;
|
|
73
|
+
// Ni el stdout, ni el stdin, ni el stderr van al mensaje: pueden llevar el secreto.
|
|
74
|
+
async function ejecutar(args, stdin, techoMs) {
|
|
75
|
+
const bin = c.bins.find((b) => existe(b));
|
|
76
|
+
if (bin === undefined)
|
|
77
|
+
throw new LlaveroAusente(c.bins[0]);
|
|
78
|
+
const r = await correr(bin, args, stdin, techoMs);
|
|
79
|
+
if (r.ausente)
|
|
80
|
+
throw new LlaveroAusente(bin);
|
|
81
|
+
if (r.vencido)
|
|
82
|
+
throw new Error(`El llavero no contestó en ${Math.round(techoMs / 1000)} s.`);
|
|
83
|
+
return r;
|
|
84
|
+
}
|
|
85
|
+
const fallo = (codigo) => new Error(`El llavero falló (código ${codigo}).`);
|
|
86
|
+
return {
|
|
87
|
+
nombre: "llavero",
|
|
88
|
+
async leer(cuenta, techoMs = TECHO_DEL_LLAVERO_MS) {
|
|
89
|
+
const r = await ejecutar(c.leer(cuentaSegura(cuenta)), null, techoMs);
|
|
90
|
+
if (c.noEncontrado(r.codigo, r.stdout, r.stderr ?? ""))
|
|
91
|
+
return null;
|
|
92
|
+
if (r.codigo !== 0)
|
|
93
|
+
throw fallo(r.codigo);
|
|
94
|
+
return decodificar(r.stdout);
|
|
95
|
+
},
|
|
96
|
+
async escribir(cuenta, registro, techoMs = TECHO_DEL_LLAVERO_MS) {
|
|
97
|
+
const { args, stdin } = c.escribir(cuentaSegura(cuenta), codificar(registro));
|
|
98
|
+
const r = await ejecutar(args, stdin, techoMs);
|
|
99
|
+
if (r.codigo !== 0)
|
|
100
|
+
throw fallo(r.codigo);
|
|
101
|
+
},
|
|
102
|
+
async borrar(cuenta, techoMs = TECHO_DEL_LLAVERO_MS) {
|
|
103
|
+
const r = await ejecutar(c.borrar(cuentaSegura(cuenta)), null, techoMs);
|
|
104
|
+
if (c.noEncontrado(r.codigo, r.stdout, r.stderr ?? ""))
|
|
105
|
+
return;
|
|
106
|
+
if (r.codigo !== 0)
|
|
107
|
+
throw fallo(r.codigo);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// ¿Cae este destino dentro de un repo de git? Si la respuesta es sí, la clave no se escribe ahí:
|
|
2
|
+
// un `git add -A` distraído (o un repo de dotfiles que se sube a GitHub) la publicaría (§5.2).
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
// La ruta escrita engaña cuando un tramo es un enlace (`~/.config` armado con `stow` desde un repo
|
|
5
|
+
// de dotfiles): se resuelve el ancestro existente más cercano y se le suman los segmentos que todavía
|
|
6
|
+
// no existen, porque `realpath` de algo que no existe lanza (Review Focus 6).
|
|
7
|
+
export function rutaReal(p, fs) {
|
|
8
|
+
let actual = path.resolve(p);
|
|
9
|
+
const faltan = [];
|
|
10
|
+
while (!fs.existe(actual)) {
|
|
11
|
+
const padre = path.dirname(actual);
|
|
12
|
+
if (padre === actual)
|
|
13
|
+
break;
|
|
14
|
+
faltan.unshift(path.basename(actual));
|
|
15
|
+
actual = padre;
|
|
16
|
+
}
|
|
17
|
+
return path.join(fs.realpath(actual), ...faltan);
|
|
18
|
+
}
|
|
19
|
+
// Seguido o no: basta que exista `.git`, directorio o archivo (un worktree tiene un archivo `.git`).
|
|
20
|
+
// Se miran las dos rutas porque cualquiera de las dos puede ser la que cae dentro del repo.
|
|
21
|
+
export function dentroDeUnWorkingTree(dir, fs) {
|
|
22
|
+
const escrita = path.resolve(dir);
|
|
23
|
+
for (const inicio of new Set([escrita, rutaReal(escrita, fs)])) {
|
|
24
|
+
let actual = inicio;
|
|
25
|
+
for (;;) {
|
|
26
|
+
if (fs.existe(path.join(actual, ".git")))
|
|
27
|
+
return actual;
|
|
28
|
+
const padre = path.dirname(actual);
|
|
29
|
+
if (padre === actual)
|
|
30
|
+
break;
|
|
31
|
+
actual = padre;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// La clave de idempotencia de los pedidos que crean algo en el motor (`proyecto crear`), guardada en
|
|
2
|
+
// `~/.config/supcoflow/idempotencia.json` (Review Focus 4): si la respuesta se pierde en el camino, el
|
|
3
|
+
// reintento tiene que mandar la MISMA clave para que el motor devuelva lo que ya hizo en vez de
|
|
4
|
+
// hacerlo dos veces. Por eso se escribe ANTES de devolverla, y sólo se olvida cuando el motor ya dio
|
|
5
|
+
// un desenlace que no hace falta repetir.
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
|
|
8
|
+
import { dentroDeUnWorkingTree } from "./guardado/repo.js";
|
|
9
|
+
export function crearRegistroDeIdempotencia(d) {
|
|
10
|
+
// Por origen, como la clave (un motor local y producción no comparten claves), y el alcance en
|
|
11
|
+
// minúsculas: `D/r` y `d/r` son el mismo repo para GitHub y para el motor.
|
|
12
|
+
const cuenta = (operacion, alcance) => `${d.origen}#${operacion}#${alcance.toLowerCase()}`;
|
|
13
|
+
// ─── El modo estricto (`claves crear`; primera revisión externa del 2026-10-16, hallazgo 6) ───
|
|
14
|
+
// `proyecto crear` puede seguir con una clave sin guardar porque el proyecto tiene clave natural (el
|
|
15
|
+
// repo) y el motor lo reusa. Una clave de consulta no tiene clave natural: sin la de idempotencia
|
|
16
|
+
// guardada, el reintento tras una respuesta perdida crea OTRA fila. Mejor no mandar nada y decir qué
|
|
17
|
+
// 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.`);
|
|
19
|
+
return {
|
|
20
|
+
async claveDe(operacion, alcance, opciones = {}) {
|
|
21
|
+
const c = cuenta(operacion, alcance);
|
|
22
|
+
try {
|
|
23
|
+
const guardada = await d.almacen.leer(c);
|
|
24
|
+
if (guardada !== null)
|
|
25
|
+
return guardada.valor;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
if (opciones.estricta)
|
|
29
|
+
throw noGuardada();
|
|
30
|
+
// Un archivo que no se entiende no se reescribe (perdería otras claves); se sigue sin guardar.
|
|
31
|
+
const nueva = d.generar();
|
|
32
|
+
d.avisar(`No pude leer ${d.rutaDelArchivo}: sigo con una clave de idempotencia sin guardar.`);
|
|
33
|
+
return nueva;
|
|
34
|
+
}
|
|
35
|
+
const nueva = d.generar();
|
|
36
|
+
// 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
|
|
38
|
+
// 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
|
+
try {
|
|
58
|
+
await d.almacen.escribir(c, { valor: nueva, escritoEn: d.ahora() });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
if (opciones.estricta)
|
|
62
|
+
throw noGuardada();
|
|
63
|
+
d.avisar(`No pude guardar la clave de idempotencia en ${d.rutaDelArchivo}: un reintento mandará otra.`);
|
|
64
|
+
}
|
|
65
|
+
return nueva;
|
|
66
|
+
},
|
|
67
|
+
async tiene(operacion, alcance) {
|
|
68
|
+
// Sólo lo usa el modo estricto: «no sé si hay un intento a medias» no puede leerse como «no hay».
|
|
69
|
+
try {
|
|
70
|
+
return (await d.almacen.leer(cuenta(operacion, alcance))) !== null;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw noGuardada();
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
async olvidar(operacion, alcance) {
|
|
77
|
+
// Best-effort: una clave que queda guardada nunca da un proyecto de más. Tras un 2xx el próximo
|
|
78
|
+
// `crear` encuentra el proyecto por su repo antes de mandarla; tras un rechazo el motor ya la
|
|
79
|
+
// soltó; y un 409 `idempotency_key_reusada` la vuelve a olvidar.
|
|
80
|
+
try {
|
|
81
|
+
await d.almacen.borrar(cuenta(operacion, alcance));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
d.avisar(`No pude borrar la clave de idempotencia de ${d.rutaDelArchivo}.`);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// El repo de GitHub de la carpeta donde se corre la terminal, o el que pide `--repo`.
|
|
2
|
+
import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
|
|
3
|
+
// La misma forma que acepta el motor: si la terminal dejara pasar algo que el motor rechaza, el
|
|
4
|
+
// error llegaría como un 400 del servidor en vez de una frase clara acá.
|
|
5
|
+
const FORMA_DEL_REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
|
|
6
|
+
function esRepoValido(s) {
|
|
7
|
+
if (!FORMA_DEL_REPO.test(s))
|
|
8
|
+
return false;
|
|
9
|
+
const nombre = s.split("/")[1];
|
|
10
|
+
return nombre !== "." && nombre !== "..";
|
|
11
|
+
}
|
|
12
|
+
// El host tiene que ser EXACTAMENTE github.com y va anclado al principio: un origin como
|
|
13
|
+
// `https://evil.com/github.com/o/r` contiene «github.com» en la ruta y no es GitHub. Tampoco se
|
|
14
|
+
// acepta usuario:clave@ delante del host (una URL con token no se interpreta, se descarta).
|
|
15
|
+
// No se sigue `url.<base>.insteadOf` de la config de git: lo que importa es qué repo es, y eso
|
|
16
|
+
// lo vuelve a comprobar el motor contra la instalación de la App; acá sólo se arma el pedido.
|
|
17
|
+
const FORMAS_DE_ORIGIN = [
|
|
18
|
+
/^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
19
|
+
/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
20
|
+
/^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
21
|
+
];
|
|
22
|
+
export function repoDelOrigin(url) {
|
|
23
|
+
const limpia = url.trim();
|
|
24
|
+
for (const forma of FORMAS_DE_ORIGIN) {
|
|
25
|
+
const m = forma.exec(limpia);
|
|
26
|
+
if (!m)
|
|
27
|
+
continue;
|
|
28
|
+
const repo = `${m[1]}/${m[2]}`;
|
|
29
|
+
return esRepoValido(repo) ? repo : null;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
export async function leerRepoLocal(git, cwd) {
|
|
34
|
+
const top = await git(["rev-parse", "--show-toplevel"], cwd);
|
|
35
|
+
const raiz = top.codigo === 0 ? top.stdout.trim() : "";
|
|
36
|
+
if (!raiz)
|
|
37
|
+
return { repo: null, raiz: null, motivo: "sin_git" };
|
|
38
|
+
const origin = await git(["remote", "get-url", "origin"], cwd);
|
|
39
|
+
const url = origin.codigo === 0 ? origin.stdout.trim() : "";
|
|
40
|
+
if (!url)
|
|
41
|
+
return { repo: null, raiz, motivo: "sin_origin" };
|
|
42
|
+
const repo = repoDelOrigin(url);
|
|
43
|
+
if (!repo)
|
|
44
|
+
return { repo: null, raiz, motivo: "origin_no_es_github" };
|
|
45
|
+
return { repo, raiz };
|
|
46
|
+
}
|
|
47
|
+
const MOTIVOS = {
|
|
48
|
+
sin_git: "esta carpeta no es un repo de git",
|
|
49
|
+
sin_origin: "el repo no tiene un remoto «origin»",
|
|
50
|
+
origin_no_es_github: "el «origin» no es un repo de GitHub",
|
|
51
|
+
};
|
|
52
|
+
export async function repoPedido(d) {
|
|
53
|
+
const local = await leerRepoLocal(d.git, d.cwd);
|
|
54
|
+
if (d.flagRepo !== null) {
|
|
55
|
+
if (!esRepoValido(d.flagRepo)) {
|
|
56
|
+
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "repo_invalido", "El valor de --repo tiene que ser owner/repo (por ejemplo duenio/mi-app), sin la URL.");
|
|
57
|
+
}
|
|
58
|
+
// La raíz sólo se da si el origin local es ESE repo: con otro, lo que se escriba en esta
|
|
59
|
+
// carpeta (el archivo del proyecto) no le pertenece.
|
|
60
|
+
const mismo = local.repo !== null && local.repo.toLowerCase() === d.flagRepo.toLowerCase();
|
|
61
|
+
return { repo: d.flagRepo, raiz: mismo ? local.raiz : null };
|
|
62
|
+
}
|
|
63
|
+
if (local.repo === null) {
|
|
64
|
+
const porque = local.motivo ? MOTIVOS[local.motivo] : "no se pudo leer";
|
|
65
|
+
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "sin_repo", `No encontré un repo de GitHub acá (${porque}). Pasá --repo owner/repo.`);
|
|
66
|
+
}
|
|
67
|
+
return { repo: local.repo, raiz: local.raiz };
|
|
68
|
+
}
|