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,109 @@
|
|
|
1
|
+
// El secreto de una clave de consulta, a un archivo que nombró la persona (§6.5, decisión 5 del bloque 4).
|
|
2
|
+
// Nunca sale por stdout: quien corre `claves crear` suele ser un asistente, y lo que lee queda en su
|
|
3
|
+
// contexto. El archivo es la ÚNICA copia, así que se escribe con las mismas defensas que las credenciales:
|
|
4
|
+
// fuera de un repo de git (un `git add -A` la publicaría), `0600`, sin atravesar enlaces y vía `.tmp` +
|
|
5
|
+
// `rename` (nunca queda un archivo a medio escribir).
|
|
6
|
+
//
|
|
7
|
+
// Lo que ya hay en el destino no se pisa salvo que sea una clave de LA MISMA fila (mismo sufijo): eso es
|
|
8
|
+
// la recuperación de un intento a medias, y `claves crear` sólo llega con el candado tomado. Cualquier otra
|
|
9
|
+
// cosa podría ser una clave vigente de otro proyecto. Y nada de lo que hay ahí llega a un mensaje.
|
|
10
|
+
//
|
|
11
|
+
// ─── Un destino ausente se publica sin pisar (revisión del 2026-10-16) ──────────
|
|
12
|
+
//
|
|
13
|
+
// El candado es por proyecto + NOMBRE de la clave, no por destino. Dos `claves crear` con nombres distintos y
|
|
14
|
+
// el mismo `--guardar-en` leían los dos «ausente» y hacían los dos `rename`: el segundo pisaba el archivo del
|
|
15
|
+
// primero, que igual confirmaba su clave, activa y sin nadie que tuviera el secreto. Por eso, cuando el
|
|
16
|
+
// destino estaba ausente, el temporal se publica con una operación que FALLA si ya existe (`link`); quien
|
|
17
|
+
// llega segundo recibe el mismo 4 de «ya tiene otra cosa» y no confirma. El `rename` que pisa queda sólo
|
|
18
|
+
// para la recuperación de la MISMA fila, que sí está bajo el candado de ese nombre.
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { dentroDeUnWorkingTree } from "./guardado/repo.js";
|
|
22
|
+
// Una clave ocupa 85 caracteres: 4 KB es de sobra, y algo más grande ya no es una clave.
|
|
23
|
+
const TOPE = 4 * 1024;
|
|
24
|
+
const CLAVE = /^scf_([0-9a-f]{16})_[0-9a-f]{64}$/;
|
|
25
|
+
const OCUPADO = { error: "el archivo ya tiene otra cosa; borralo o elegí otro destino", codigo: 4 };
|
|
26
|
+
export function crearGuardaDeSecretos(fs) {
|
|
27
|
+
function borrarTemporal(tmp) {
|
|
28
|
+
try {
|
|
29
|
+
fs.borrarSiExiste(tmp);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* best-effort */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function leer(ruta) {
|
|
36
|
+
const l = fs.leerSinSeguir(ruta, TOPE);
|
|
37
|
+
if (l.tipo === "ausente")
|
|
38
|
+
return { estado: "ausente" };
|
|
39
|
+
if (l.tipo !== "contenido")
|
|
40
|
+
return { estado: "otra_cosa" };
|
|
41
|
+
// Un salto final (el que escribe `guardar`, o el de un editor) se tolera; cualquier otra cosa no.
|
|
42
|
+
const m = CLAVE.exec(l.texto.endsWith("\n") ? l.texto.slice(0, -1) : l.texto);
|
|
43
|
+
return m ? { estado: "clave", claveId: `cla_${m[1]}`, secreto: m[0] } : { estado: "otra_cosa" };
|
|
44
|
+
}
|
|
45
|
+
function validarDestino(ruta) {
|
|
46
|
+
const destino = path.resolve(ruta);
|
|
47
|
+
// `realpath` lanza con un tramo que es un enlace roto: sin saber dónde cae, no se escribe.
|
|
48
|
+
let raiz;
|
|
49
|
+
try {
|
|
50
|
+
raiz = dentroDeUnWorkingTree(path.dirname(destino), fs);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return { error: "no pude resolver la ruta real de la carpeta (¿un enlace roto?)", codigo: 3 };
|
|
54
|
+
}
|
|
55
|
+
if (raiz !== null)
|
|
56
|
+
return { error: `cae dentro del repo de git ${raiz}, y una clave en un repo termina commiteada; elegí un destino fuera`, codigo: 3 };
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
leer,
|
|
61
|
+
validarDestino,
|
|
62
|
+
guardar(ruta, claveId, secreto) {
|
|
63
|
+
const destino = path.resolve(ruta);
|
|
64
|
+
// También acá, aunque `claves crear` ya lo miró antes de pedir: entre una cosa y otra pasa tiempo.
|
|
65
|
+
const invalido = validarDestino(destino);
|
|
66
|
+
if (invalido !== null)
|
|
67
|
+
return invalido;
|
|
68
|
+
const hay = fs.leerSinSeguir(destino, TOPE);
|
|
69
|
+
if (hay.tipo === "enlace")
|
|
70
|
+
return { error: "el destino es un enlace; elegí un archivo común", codigo: 3 };
|
|
71
|
+
if (hay.tipo !== "ausente") {
|
|
72
|
+
const actual = leer(destino);
|
|
73
|
+
if (actual.estado !== "clave" || actual.claveId !== claveId)
|
|
74
|
+
return OCUPADO;
|
|
75
|
+
}
|
|
76
|
+
// El temporal en la MISMA carpeta (el `rename` sólo es atómico dentro del mismo disco), con nombre al
|
|
77
|
+
// azar y en exclusiva: `wx` no atraviesa un enlace que alguien dejara con ese nombre.
|
|
78
|
+
const tmp = path.join(path.dirname(destino), `.${path.basename(destino)}.${randomUUID()}.tmp`);
|
|
79
|
+
let publicado;
|
|
80
|
+
try {
|
|
81
|
+
fs.escribirExclusivo(tmp, secreto + "\n", 0o600);
|
|
82
|
+
if (hay.tipo === "ausente") {
|
|
83
|
+
publicado = fs.publicarSinPisar(tmp, destino);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
fs.renombrar(tmp, destino);
|
|
87
|
+
publicado = "publicado";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// El mensaje del sistema no se reenvía: podría traer la ruta del temporal o algo peor.
|
|
92
|
+
borrarTemporal(tmp);
|
|
93
|
+
return { error: "no se pudo escribir ahí (¿la carpeta existe y tenés permiso?)", codigo: 3 };
|
|
94
|
+
}
|
|
95
|
+
// Después de publicar, el temporal es un nombre de más (con `link`) o ya no existe (con `rename`): no
|
|
96
|
+
// borrarlo no cambia que la clave quedó en el destino, y convertir eso en un fallo dejaría una clave
|
|
97
|
+
// guardada sin confirmar.
|
|
98
|
+
borrarTemporal(tmp);
|
|
99
|
+
// Otro apareció entre la lectura y la publicación: lo suyo queda intacto y esta clave no se confirma.
|
|
100
|
+
if (publicado === "existe")
|
|
101
|
+
return OCUPADO;
|
|
102
|
+
// Sin caer a `rename`: eso reabre la carrera que `link` cierra. Otro destino, en otro disco, sirve.
|
|
103
|
+
if (publicado === "sin_enlaces") {
|
|
104
|
+
return { error: "el disco de esa carpeta no admite enlaces duros (¿FAT, exFAT o una carpeta de red?), y sin ellos no puedo escribir sin riesgo de pisar otra clave; elegí un destino en otro disco", codigo: 3 };
|
|
105
|
+
}
|
|
106
|
+
return "guardado";
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
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.3.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
|
+
}
|