supcoflow 0.0.0-stage → 0.1.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 +82 -2
- package/dist/api.js +133 -0
- package/dist/argumentos.js +43 -0
- package/dist/cli.js +76 -0
- package/dist/comandos/describir.js +106 -0
- package/dist/comandos/login.js +254 -0
- package/dist/comandos/logout.js +96 -0
- package/dist/comandos/quien-soy.js +50 -0
- package/dist/contrato.js +38 -0
- package/dist/entorno.js +152 -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/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
|
+
}
|
package/dist/sondeo.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { TECHO_DEL_LLAVERO_MS } from "./guardado/llavero.js";
|
|
2
|
+
// 90 s para el comando ENTERO, guardado y revocación incluidos, no sólo para los sondeos: quien
|
|
3
|
+
// llama suele ser un asistente cuya herramienta de terminal corta a los 2 min (Claude Code), y un
|
|
4
|
+
// comando cortado a mitad de cierre puede dejar la clave nueva sin guardar o la vieja sin revocar.
|
|
5
|
+
// Por la misma razón (P13 del motor) nada espera en silencio más que eso: se parte en tramos.
|
|
6
|
+
export const TRAMO_MAXIMO_MS = 90_000;
|
|
7
|
+
// Lo que se aparta del tramo para cerrar después de recibir el token. La secuencia de cierre tiene a
|
|
8
|
+
// lo sumo cuatro operaciones lentas, cada una con techo de 5 s: guardar la nueva (el llavero; si
|
|
9
|
+
// vence, el archivo es local e inmediato), revocar la anterior en el motor, anotarla «por revocar»
|
|
10
|
+
// si no se pudo, y borrar el pendiente. 4 × 5 s + 2 s de margen = 22 s. La clave anterior se lee
|
|
11
|
+
// ANTES de sondear (no consume reserva), y el pendiente sólo se persiste tras respuestas no finales.
|
|
12
|
+
export const RESERVA_PARA_CERRAR_MS = 22_000;
|
|
13
|
+
// Una operación de cierre opcional (revocar la anterior, borrar el pendiente) con menos que esto
|
|
14
|
+
// no se intenta: con medio segundo no contesta ni un motor sano, y lo que no se hace queda anotado.
|
|
15
|
+
export const PLAZO_MINIMO_MS = 500;
|
|
16
|
+
// Un sondeo con menos plazo que esto casi seguro vence sin respuesta: mejor salir 5 y que el
|
|
17
|
+
// próximo `--wait` sondee con un tramo entero.
|
|
18
|
+
export const PLAZO_MINIMO_DE_SONDEO_MS = 2_000;
|
|
19
|
+
const PLAZO_MAXIMO_DE_CANJE_MS = 15_000;
|
|
20
|
+
export async function sondearUnTramo(inicial, d) {
|
|
21
|
+
let p = inicial;
|
|
22
|
+
for (;;) {
|
|
23
|
+
// El intervalo se cuenta desde el último sondeo GUARDADO, no desde que arrancó este comando
|
|
24
|
+
// (Review Focus 4): un `--wait` enseguida de otro espera lo que falta, en vez de ganarse un
|
|
25
|
+
// `slow_down` del motor.
|
|
26
|
+
const ahora = d.ahora();
|
|
27
|
+
const proximo = p.ultimoSondeoEn === null ? ahora : Math.max(ahora, p.ultimoSondeoEn + p.intervaloS * 1000);
|
|
28
|
+
if (proximo >= p.venceEn)
|
|
29
|
+
return { tipo: "vencido" };
|
|
30
|
+
if (d.finDeSondeos - proximo < PLAZO_MINIMO_DE_SONDEO_MS)
|
|
31
|
+
return { tipo: "sigue", pendiente: p };
|
|
32
|
+
if (proximo > ahora)
|
|
33
|
+
await d.dormir(proximo - ahora);
|
|
34
|
+
const c = await d.canjear(p.deviceCode, Math.min(PLAZO_MAXIMO_DE_CANJE_MS, d.finDeSondeos - d.ahora()));
|
|
35
|
+
// El token es la ÚNICA copia de la clave (el motor no la reentrega): no espera a ninguna
|
|
36
|
+
// escritura del pendiente, que podría colgarse o lanzar y perderla (Review Focus 13).
|
|
37
|
+
if (c.tipo === "token")
|
|
38
|
+
return { tipo: "token", clave: c.clave, equipo: c.equipo };
|
|
39
|
+
if (c.tipo !== "pendiente" && c.tipo !== "slow_down")
|
|
40
|
+
return { tipo: c.tipo };
|
|
41
|
+
// El último sondeo se fecha con la RESPUESTA: el motor lo vio a más tardar entonces, así que
|
|
42
|
+
// contar desde ahí nunca adelanta el próximo. Y se guarda ANTES de seguir (Review Focus 11):
|
|
43
|
+
// si la red se cae en el sondeo siguiente, el intervalo nuevo de un `slow_down` ya quedó escrito.
|
|
44
|
+
p = { ...p, ultimoSondeoEn: d.ahora(), intervaloS: p.intervaloS + (c.tipo === "slow_down" ? 5 : 0) };
|
|
45
|
+
await d.persistir(p, Math.min(TECHO_DEL_LLAVERO_MS, Math.max(PLAZO_MINIMO_MS, d.finDeSondeos - d.ahora())));
|
|
46
|
+
}
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "supcoflow",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "La terminal de Supcoflow: iniciar sesión y manejar el chat de soporte desde la consola o un asistente.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "supcoflow": "dist/cli.js" },
|
|
7
|
+
"files": ["dist"],
|
|
8
|
+
"engines": { "node": ">=24" },
|
|
9
|
+
"scripts": { "build": "tsc -p tsconfig.json && chmod +x dist/cli.js", "test": "vitest run", "typecheck": "tsc --noEmit" },
|
|
10
|
+
"dependencies": {},
|
|
11
|
+
"devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.9.0", "vitest": "^4.0.0" },
|
|
12
|
+
"license": "UNLICENSED"
|
|
13
|
+
}
|