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
package/README.md
CHANGED
|
@@ -1,3 +1,123 @@
|
|
|
1
|
-
#
|
|
1
|
+
# supcoflow
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
La terminal de [Supcoflow](https://app.supcoflow.dev): iniciar sesión y manejar el chat de soporte
|
|
4
|
+
desde la consola, o desde un asistente de código que corre comandos por vos.
|
|
5
|
+
|
|
6
|
+
Trae iniciar y cerrar sesión, ver con qué cuenta estás, conectar el repo con la App de GitHub,
|
|
7
|
+
crear su proyecto y configurar el chat.
|
|
8
|
+
|
|
9
|
+
## Instalar
|
|
10
|
+
|
|
11
|
+
Hace falta Node 24 o más nuevo.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx supcoflow login
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Los comandos
|
|
18
|
+
|
|
19
|
+
| Comando | Qué hace |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `supcoflow login` | Te muestra un enlace y un código; lo aprobás en la consola y la terminal queda con su clave. |
|
|
22
|
+
| `supcoflow login --start` | El primer paso, solo: pide el código y sale 5 con el enlace para aprobarlo. |
|
|
23
|
+
| `supcoflow login --wait` | El segundo paso: espera la aprobación hasta 90 s. Si todavía no llegó, sale 5: volvé a correrlo. |
|
|
24
|
+
| `supcoflow logout` | Revoca la clave en uso y la borra de esta máquina. |
|
|
25
|
+
| `supcoflow quien-soy` | Con qué clave anda la terminal, a nombre de quién y en qué equipo. |
|
|
26
|
+
| `supcoflow repo conectar` | Conecta el repo de esta carpeta con la App de GitHub: te da un enlace, lo confirmás en la consola. Sin terminal, `--start` y después `--wait`, como `login`. |
|
|
27
|
+
| `supcoflow proyecto crear` | Crea (o reusa) el proyecto del repo ya conectado y lo anota en `.supcoflow/proyecto.json`. Si el motor no deduce los comandos, `--install "…" --test "…"`. |
|
|
28
|
+
| `supcoflow proyecto ver` | El proyecto del repo: rama, comandos y el estado de la App de GitHub. |
|
|
29
|
+
| `supcoflow describir --json` | Todo el contrato (comandos, flags, facultades de la clave, códigos) en una línea de JSON. |
|
|
30
|
+
|
|
31
|
+
`supcoflow --help` (o `supcoflow <comando> --help`) muestra la ayuda con ejemplos. Los comandos de
|
|
32
|
+
repo y proyecto toman el repo del `origin` de la carpeta; `--repo owner/repo` lo elige a mano.
|
|
33
|
+
|
|
34
|
+
### De cero a un proyecto
|
|
35
|
+
|
|
36
|
+
1. `supcoflow login`: aprobás el código en la consola.
|
|
37
|
+
2. `supcoflow repo conectar`: instalás la App en GitHub si hace falta y tocás Confirmar.
|
|
38
|
+
3. `supcoflow proyecto crear`: queda el proyecto y `.supcoflow/proyecto.json` para commitear.
|
|
39
|
+
4. `supcoflow proyecto ver`: para revisar cómo quedó.
|
|
40
|
+
|
|
41
|
+
### Ejemplos
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Una persona frente a la terminal
|
|
45
|
+
supcoflow login
|
|
46
|
+
|
|
47
|
+
# Un asistente, sin terminal interactiva: dos pasos
|
|
48
|
+
supcoflow login --start --json # sale 5 con verification_uri_complete y user_code
|
|
49
|
+
supcoflow login --wait --json # sale 5 mientras la persona no apruebe; 0 cuando aprueba
|
|
50
|
+
|
|
51
|
+
supcoflow repo conectar --start --json # sale 5 con la url para confirmar
|
|
52
|
+
supcoflow repo conectar --wait --json # sale 5 mientras nadie confirme; 0 conectado
|
|
53
|
+
supcoflow proyecto crear --json # 4 si hay un .supcoflow/proyecto.json distinto (no lo pisa)
|
|
54
|
+
supcoflow proyecto crear --install "pnpm i" --test "pnpm test"
|
|
55
|
+
supcoflow proyecto ver --json
|
|
56
|
+
|
|
57
|
+
supcoflow quien-soy --json
|
|
58
|
+
supcoflow logout --dry-run # qué revocaría, sin revocar nada
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Configurar
|
|
62
|
+
|
|
63
|
+
Con el repo ya conectado y su proyecto creado, se configura el chat sin entrar a la consola (la clave
|
|
64
|
+
tiene que ser de sesión, no de consulta). Todos toman el repo del `origin`, o `--repo owner/repo`, y
|
|
65
|
+
los que escriben aceptan `--dry-run`.
|
|
66
|
+
|
|
67
|
+
| Comando | Qué hace |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `supcoflow catalogo` | Qué se puede configurar y con qué valores. |
|
|
70
|
+
| `supcoflow configuracion` | Chat, sitios, marca y acuerdo de datos del proyecto. |
|
|
71
|
+
| `supcoflow chat prender` / `chat apagar` | Prende o apaga el chat (sin skill aprobada, prender sale 4). |
|
|
72
|
+
| `supcoflow chat marca --titulo "Ayuda" --tema acento=#0a7` | Título, saludo y colores; lo no nombrado queda igual. |
|
|
73
|
+
| `supcoflow chat logo --archivo logo.png` | Sube el logo (.png, .jpg o .webp, hasta 200 KB). |
|
|
74
|
+
| `supcoflow sitios agregar\|quitar --sitio https://app.ejemplo.com` | Sitios desde los que carga el chat. |
|
|
75
|
+
| `supcoflow datos fijar --permitir usuario_id,atributo:plan` | Acuerdo de datos: frena lo que se guarda y lo que lee la IA que releva; no cubre lo que ve la IA del chat mientras conversa. |
|
|
76
|
+
| `supcoflow politica fijar --modo guardar` | Qué hace el motor con un problema que llega por el chat: `relevar` (gasta) o `guardar`. Guardar frena sólo lo automático del chat; una persona puede pedir el relevamiento desde la consola, y cuesta. |
|
|
77
|
+
| `supcoflow claves crear --nombre ci --guardar-en ~/ci.key` | Clave de consulta para CI: el secreto queda sólo en ese archivo (0600, fuera de un repo), nunca en la salida. |
|
|
78
|
+
| `supcoflow claves listar` / `claves revocar --id cla_…` | Ver y revocar claves de consulta. |
|
|
79
|
+
|
|
80
|
+
## El contrato
|
|
81
|
+
|
|
82
|
+
Pensado para que lo lea un programa, no sólo una persona:
|
|
83
|
+
|
|
84
|
+
- **`--json`**: el resultado sale por stdout en **una línea** de JSON; los avisos van por stderr.
|
|
85
|
+
Los errores también: `{"ok":false,"error":"…","message":"…"}`, con su código de salida.
|
|
86
|
+
- **Sin preguntas sin terminal interactiva** (o con `--json`): si falta un dato, sale 3 diciendo qué
|
|
87
|
+
flag usar. `supcoflow login` sin terminal sale 3 y te indica el camino en dos pasos.
|
|
88
|
+
- **`--dry-run`** donde se puede simular (`login`, `login --start`, `logout`, `repo conectar --start`,
|
|
89
|
+
`proyecto crear`). `login --wait
|
|
90
|
+
--dry-run` sale 3: sondear consume el código y no hay cómo simularlo.
|
|
91
|
+
|
|
92
|
+
| Código | Quiere decir |
|
|
93
|
+
|---|---|
|
|
94
|
+
| 0 | ok |
|
|
95
|
+
| 1 | inesperado |
|
|
96
|
+
| 2 | falta login |
|
|
97
|
+
| 3 | entrada inválida |
|
|
98
|
+
| 4 | hace falta una persona |
|
|
99
|
+
| 5 | esperando a una persona: volvé a llamar |
|
|
100
|
+
| 6 | tope alcanzado |
|
|
101
|
+
|
|
102
|
+
## Dónde queda la clave
|
|
103
|
+
|
|
104
|
+
- En el **llavero del sistema** (Keychain en macOS, Secret Service con `secret-tool` en Linux).
|
|
105
|
+
- Si no hay llavero, en `~/.config/supcoflow/credenciales.json` (o `$XDG_CONFIG_HOME/supcoflow/…`)
|
|
106
|
+
con permisos `0600`, y la terminal te avisa.
|
|
107
|
+
- **Nunca dentro de un repo de git**: si ese archivo cayera dentro de uno (un `HOME` que es un repo
|
|
108
|
+
de dotfiles, por ejemplo), no guarda nada y te pide usar el llavero o `SUPCOFLOW_TOKEN`.
|
|
109
|
+
- **`SUPCOFLOW_TOKEN`** tiene prioridad sobre lo guardado: sirve para CI y para la nube.
|
|
110
|
+
- La clave nunca sale en un log ni en una salida `--json`.
|
|
111
|
+
- `proyecto crear` guarda su clave de idempotencia (no es un secreto: sólo evita crear dos veces si se
|
|
112
|
+
pierde la respuesta) en `idempotencia.json`, al lado de las credenciales y con los mismos permisos.
|
|
113
|
+
|
|
114
|
+
Cuando iniciás sesión de nuevo, la clave anterior se revoca recién cuando la nueva quedó guardada; si
|
|
115
|
+
no se pudo revocar, queda anotada y se reintenta en el próximo `login` o `logout`.
|
|
116
|
+
|
|
117
|
+
## La API es fija
|
|
118
|
+
|
|
119
|
+
La terminal habla siempre con `https://app.supcoflow.dev`. Ningún archivo del repo puede cambiar
|
|
120
|
+
eso, así que clonar un repo ajeno y correr `supcoflow` no manda tu clave a otro servidor. Para
|
|
121
|
+
desarrollo, sólo la variable de entorno `SUPCOFLOW_API_URL` la mueve (`https`, o `http` contra
|
|
122
|
+
`localhost`), y lo guardado queda atado a la API que lo emitió: una clave de un motor local nunca
|
|
123
|
+
viaja a producción.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Los pedidos de la terminal al motor. Sin dependencias: `fetch` viene de Node y se inyecta.
|
|
2
|
+
import { FalloDeLaTerminal, SALIDA, taparSecretos } from "./contrato.js";
|
|
3
|
+
// La URL viene FIJA (§5.2): si un archivo del repo pudiera cambiarla, clonar un repo ajeno y correr
|
|
4
|
+
// `supcoflow` mandaría el token a un servidor ajeno. Sólo el entorno del proceso la mueve, y `http`
|
|
5
|
+
// únicamente contra la propia máquina: contra un host remoto la clave viajaría en claro.
|
|
6
|
+
export const API_DE_PRODUCCION = "https://app.supcoflow.dev";
|
|
7
|
+
const LOCALES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
8
|
+
export function urlDeLaApi(env) {
|
|
9
|
+
const crudo = env.SUPCOFLOW_API_URL;
|
|
10
|
+
if (crudo === undefined || crudo === "")
|
|
11
|
+
return new URL(API_DE_PRODUCCION);
|
|
12
|
+
const invalida = () => new FalloDeLaTerminal(SALIDA.entradaInvalida, "api_url_invalida", "SUPCOFLOW_API_URL tiene que ser https, o http contra localhost.");
|
|
13
|
+
let u;
|
|
14
|
+
try {
|
|
15
|
+
u = new URL(crudo);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw invalida();
|
|
19
|
+
}
|
|
20
|
+
if (u.protocol === "https:" || (u.protocol === "http:" && LOCALES.has(u.hostname)))
|
|
21
|
+
return new URL(u.origin);
|
|
22
|
+
throw invalida();
|
|
23
|
+
}
|
|
24
|
+
// A mano a propósito: tiene que coincidir con package.json (0.3.0); sólo identifica a la terminal
|
|
25
|
+
// en los logs del motor, no decide nada.
|
|
26
|
+
const VERSION = "0.3.0";
|
|
27
|
+
const PLAZO_POR_DEFECTO_MS = 15_000;
|
|
28
|
+
// Los dos POST que crean algo: detrás, el motor acuña con Vercel Connect y le pregunta a GitHub varias
|
|
29
|
+
// veces (instalación, repos, comandos, rama). Con 15 s una respuesta lenta pero buena se cortaba y
|
|
30
|
+
// sólo quedaba reintentar; el sondeo no los usa, así que su tope por tramo no cambia.
|
|
31
|
+
const PLAZO_DE_ALTA_MS = 30_000;
|
|
32
|
+
// Todo lo que vuelve de afuera se trata como no confiable: nunca se pega el cuerpo crudo en un
|
|
33
|
+
// mensaje (una página HTML no sirve en una línea y podría traer cualquier cosa), y el mensaje del
|
|
34
|
+
// error de red no se reenvía (podría llevar la URL o la clave).
|
|
35
|
+
async function pedir(api, ruta, init, f, plazoMs) {
|
|
36
|
+
let res;
|
|
37
|
+
let texto;
|
|
38
|
+
try {
|
|
39
|
+
res = await f(new URL(ruta, api), {
|
|
40
|
+
...init,
|
|
41
|
+
headers: { ...init.headers, "user-agent": `supcoflow-cli/${VERSION}` },
|
|
42
|
+
signal: AbortSignal.timeout(plazoMs ?? PLAZO_POR_DEFECTO_MS),
|
|
43
|
+
// Un 307/308 reenvía el cuerpo del formulario (con el device_code) adonde diga `Location`, aunque
|
|
44
|
+
// sea otro origen o http; con el Bearer pasaría lo mismo con la clave. Ninguna ruta del motor que
|
|
45
|
+
// llama la terminal redirige: una redirección es anómala y sale como `motor_inalcanzable`, sin
|
|
46
|
+
// Location ni cuerpo en el mensaje.
|
|
47
|
+
redirect: "error",
|
|
48
|
+
});
|
|
49
|
+
texto = await res.text();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new FalloDeLaTerminal(SALIDA.inesperado, "motor_inalcanzable", "No pude comunicarme con el motor (red caída o demora).");
|
|
53
|
+
}
|
|
54
|
+
let cuerpo;
|
|
55
|
+
try {
|
|
56
|
+
cuerpo = JSON.parse(texto);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
cuerpo = undefined;
|
|
60
|
+
}
|
|
61
|
+
if (typeof cuerpo !== "object" || cuerpo === null || Array.isArray(cuerpo)) {
|
|
62
|
+
throw new FalloDeLaTerminal(SALIDA.inesperado, "respuesta_inesperada", `El motor respondió ${res.status} sin JSON.`);
|
|
63
|
+
}
|
|
64
|
+
return { status: res.status, cuerpo: cuerpo };
|
|
65
|
+
}
|
|
66
|
+
function sinServicio(status) {
|
|
67
|
+
return new FalloDeLaTerminal(SALIDA.inesperado, "motor_inalcanzable", `El motor respondió ${status}; probá en unos minutos.`);
|
|
68
|
+
}
|
|
69
|
+
function inesperada(status) {
|
|
70
|
+
return new FalloDeLaTerminal(SALIDA.inesperado, "respuesta_inesperada", `El motor respondió ${status} con algo que no esperaba.`);
|
|
71
|
+
}
|
|
72
|
+
const texto = (v) => (typeof v === "string" ? v : null);
|
|
73
|
+
const CLIENT_ID = "supcoflow-cli";
|
|
74
|
+
const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
75
|
+
const FORMULARIO = { "content-type": "application/x-www-form-urlencoded" };
|
|
76
|
+
export async function pedirCodigo(api, maquina, f) {
|
|
77
|
+
const { status, cuerpo: c } = await pedir(api, "/api/dispositivo/codigo", { method: "POST", headers: FORMULARIO, body: new URLSearchParams({ client_id: CLIENT_ID, maquina }).toString() }, f);
|
|
78
|
+
if (status === 429 || status === 503) {
|
|
79
|
+
throw new FalloDeLaTerminal(SALIDA.inesperado, texto(c.error) ?? "demasiados_pedidos", "El motor no acepta más pedidos ahora; probá en unos minutos.");
|
|
80
|
+
}
|
|
81
|
+
if (status >= 500)
|
|
82
|
+
throw sinServicio(status);
|
|
83
|
+
const [deviceCode, userCode, verificationUri, verificationUriComplete] = [c.device_code, c.user_code, c.verification_uri, c.verification_uri_complete].map(texto);
|
|
84
|
+
if (status !== 200 || !deviceCode || !userCode || !verificationUri || !verificationUriComplete
|
|
85
|
+
|| typeof c.expires_in !== "number" || typeof c.interval !== "number")
|
|
86
|
+
throw inesperada(status);
|
|
87
|
+
return { deviceCode, userCode, verificationUri, verificationUriComplete, expiresIn: c.expires_in, interval: c.interval };
|
|
88
|
+
}
|
|
89
|
+
const ERRORES_DEL_CANJE = {
|
|
90
|
+
authorization_pending: "pendiente", slow_down: "slow_down", access_denied: "rechazado",
|
|
91
|
+
expired_token: "vencido", invalid_grant: "invalido",
|
|
92
|
+
};
|
|
93
|
+
// `plazoMs` es el techo de ESTE pedido: el sondeo lo achica cerca del final de su tramo para que
|
|
94
|
+
// un pedido colgado no lo pase de largo.
|
|
95
|
+
export async function canjear(api, deviceCode, f, plazoMs) {
|
|
96
|
+
const { status, cuerpo: c } = await pedir(api, "/api/dispositivo/token", { method: "POST", headers: FORMULARIO, body: new URLSearchParams({ grant_type: GRANT_TYPE, device_code: deviceCode, client_id: CLIENT_ID }).toString() }, f, plazoMs);
|
|
97
|
+
if (status >= 500)
|
|
98
|
+
throw sinServicio(status);
|
|
99
|
+
if (status === 200) {
|
|
100
|
+
const clave = texto(c.access_token);
|
|
101
|
+
const equipo = texto(c.equipo);
|
|
102
|
+
if (!clave || !equipo)
|
|
103
|
+
throw inesperada(status);
|
|
104
|
+
return { tipo: "token", clave, equipo };
|
|
105
|
+
}
|
|
106
|
+
const tipo = ERRORES_DEL_CANJE[texto(c.error) ?? ""];
|
|
107
|
+
if (status === 400 && tipo)
|
|
108
|
+
return { tipo };
|
|
109
|
+
throw inesperada(status);
|
|
110
|
+
}
|
|
111
|
+
const bearer = (clave) => ({ authorization: `Bearer ${clave}` });
|
|
112
|
+
export async function quienSoy(api, clave, f) {
|
|
113
|
+
const { status, cuerpo: c } = await pedir(api, "/api/yo", { method: "GET", headers: bearer(clave) }, f);
|
|
114
|
+
if (status === 401) {
|
|
115
|
+
throw new FalloDeLaTerminal(SALIDA.faltaLogin, "clave_invalida", texto(c.message) ?? "La clave ya no vale: iniciá sesión de nuevo.");
|
|
116
|
+
}
|
|
117
|
+
if (status >= 500)
|
|
118
|
+
throw sinServicio(status);
|
|
119
|
+
const u = c.usuario;
|
|
120
|
+
if (status !== 200 || !texto(c.via) || !texto(c.equipo))
|
|
121
|
+
throw inesperada(status);
|
|
122
|
+
return {
|
|
123
|
+
via: c.via,
|
|
124
|
+
equipo: c.equipo,
|
|
125
|
+
usuario: u ? { id: String(u.id), login: texto(u.login), email: texto(u.email) } : null,
|
|
126
|
+
proyecto: texto(c.proyecto),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export async function revocarPropia(api, clave, f, plazoMs) {
|
|
130
|
+
const { status } = await pedir(api, "/api/dispositivo/revocar", { method: "POST", headers: bearer(clave) }, f, plazoMs);
|
|
131
|
+
if (status === 200)
|
|
132
|
+
return "revocada";
|
|
133
|
+
if (status === 401)
|
|
134
|
+
return "ya_no_valia";
|
|
135
|
+
if (status >= 500)
|
|
136
|
+
throw sinServicio(status);
|
|
137
|
+
throw inesperada(status);
|
|
138
|
+
}
|
|
139
|
+
// ---- Conexión del repo y proyecto (bloque 3) ----
|
|
140
|
+
// Una sola puerta para los pedidos con clave: suma el Bearer y traduce lo que significa lo mismo en
|
|
141
|
+
// todos (clave rota, clave sin alcance, repo mal escrito, cuenta sin GitHub). Lo que es propio de
|
|
142
|
+
// cada pedido (404, 409, 422 de comandos) lo decide quien llama. Los `message` del motor son texto de
|
|
143
|
+
// afuera: pasan por taparSecretos antes de salir en una línea de la terminal.
|
|
144
|
+
async function conClave(api, clave, ruta, init, f, plazoMs) {
|
|
145
|
+
const r = await pedir(api, ruta, { ...init, headers: { ...init.headers, ...bearer(clave) } }, f, plazoMs);
|
|
146
|
+
const error = texto(r.cuerpo.error) ?? "respuesta_inesperada";
|
|
147
|
+
const message = (porDefecto) => taparSecretos(texto(r.cuerpo.message) ?? porDefecto);
|
|
148
|
+
if (r.status === 401) {
|
|
149
|
+
throw new FalloDeLaTerminal(SALIDA.faltaLogin, "clave_invalida", message("La clave ya no vale: iniciá sesión de nuevo."));
|
|
150
|
+
}
|
|
151
|
+
if (r.status === 403 && (error === "requiere_persona" || error === "clave_de_consulta")) {
|
|
152
|
+
throw new FalloDeLaTerminal(SALIDA.haceFaltaUnaPersona, error, message("Esto lo tiene que hacer una persona con sesión."));
|
|
153
|
+
}
|
|
154
|
+
if (r.status === 422 && error === "cuenta_sin_github") {
|
|
155
|
+
throw new FalloDeLaTerminal(SALIDA.haceFaltaUnaPersona, error, message("Tu cuenta no tiene GitHub asociado."));
|
|
156
|
+
}
|
|
157
|
+
if (r.status === 400 && error === "repo_invalido") {
|
|
158
|
+
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, error, message("El repo tiene que ser owner/repo."));
|
|
159
|
+
}
|
|
160
|
+
if (r.status >= 500)
|
|
161
|
+
throw sinServicio(r.status);
|
|
162
|
+
return r;
|
|
163
|
+
}
|
|
164
|
+
const ESTADOS_DE_PEDIDO = new Set(["pendiente", "conectado", "repo_fuera_de_alcance", "vencido"]);
|
|
165
|
+
function aPedido(v, status) {
|
|
166
|
+
const c = (typeof v === "object" && v !== null ? v : {});
|
|
167
|
+
const [id, repo, estado, url, venceEn] = [c.id, c.repo, c.estado, c.url, c.vence_en].map(texto);
|
|
168
|
+
if (!id || !repo || !estado || !ESTADOS_DE_PEDIDO.has(estado) || !url || !venceEn)
|
|
169
|
+
throw inesperada(status);
|
|
170
|
+
return { id, repo, estado: estado, url, venceEn, repoCanonico: texto(c.repo_canonico), fallo: texto(c.fallo) };
|
|
171
|
+
}
|
|
172
|
+
function aProyecto(v, status) {
|
|
173
|
+
const c = (typeof v === "object" && v !== null ? v : {});
|
|
174
|
+
const [id, slug, repo, rama, installCommand, testCommand] = [c.id, c.slug, c.repo, c.rama, c.install_command, c.test_command].map(texto);
|
|
175
|
+
if (!id || !slug || !repo || !rama || installCommand === null || testCommand === null)
|
|
176
|
+
throw inesperada(status);
|
|
177
|
+
return { id, slug, repo, rama, installCommand, testCommand };
|
|
178
|
+
}
|
|
179
|
+
// Un fallo propio del pedido que el mapeo común no cubrió (404, 409…): sale como entrada inválida
|
|
180
|
+
// con el motivo del motor, nunca con el cuerpo crudo.
|
|
181
|
+
function rechazada(r) {
|
|
182
|
+
const error = texto(r.cuerpo.error);
|
|
183
|
+
if (r.status >= 400 && r.status < 500 && error) {
|
|
184
|
+
return new FalloDeLaTerminal(SALIDA.entradaInvalida, error, taparSecretos(texto(r.cuerpo.message) ?? `El motor rechazó el pedido (${r.status}).`));
|
|
185
|
+
}
|
|
186
|
+
return inesperada(r.status);
|
|
187
|
+
}
|
|
188
|
+
const JSON_BODY = { "content-type": "application/json" };
|
|
189
|
+
export async function crearConexion(api, clave, repo, f) {
|
|
190
|
+
const r = await conClave(api, clave, "/api/conexiones", { method: "POST", headers: JSON_BODY, body: JSON.stringify({ repo }) }, f, PLAZO_DE_ALTA_MS);
|
|
191
|
+
if (r.status !== 200 && r.status !== 201)
|
|
192
|
+
throw rechazada(r);
|
|
193
|
+
return aPedido(r.cuerpo.pedido, r.status);
|
|
194
|
+
}
|
|
195
|
+
export async function ultimaConexion(api, clave, repo, f, plazoMs) {
|
|
196
|
+
const r = await conClave(api, clave, `/api/conexiones?${new URLSearchParams({ repo })}`, { method: "GET" }, f, plazoMs);
|
|
197
|
+
if (r.status !== 200)
|
|
198
|
+
throw rechazada(r);
|
|
199
|
+
return r.cuerpo.pedido === null ? null : aPedido(r.cuerpo.pedido, r.status);
|
|
200
|
+
}
|
|
201
|
+
export async function crearProyecto(api, clave, conexionId, cuerpo, idempotencyKey, f) {
|
|
202
|
+
const r = await conClave(api, clave, `/api/conexiones/${encodeURIComponent(conexionId)}/proyecto`, { method: "POST", headers: { ...JSON_BODY, "idempotency-key": idempotencyKey }, body: JSON.stringify(cuerpo) }, f, PLAZO_DE_ALTA_MS);
|
|
203
|
+
const error = texto(r.cuerpo.error);
|
|
204
|
+
const message = taparSecretos(texto(r.cuerpo.message) ?? "");
|
|
205
|
+
if (r.status === 200 || r.status === 201) {
|
|
206
|
+
return { tipo: "ok", proyecto: aProyecto(r.cuerpo.proyecto, r.status), reusado: r.status === 200 };
|
|
207
|
+
}
|
|
208
|
+
if (r.status === 422 && error === "comandos_faltantes") {
|
|
209
|
+
const faltantes = Array.isArray(r.cuerpo.faltantes) ? r.cuerpo.faltantes.filter((x) => typeof x === "string") : [];
|
|
210
|
+
return { tipo: "comandos_faltantes", faltantes, message };
|
|
211
|
+
}
|
|
212
|
+
if (r.status === 409 && error === "idempotency_en_curso")
|
|
213
|
+
return { tipo: "en_curso" };
|
|
214
|
+
if (r.status === 409 && error === "idempotency_key_reusada")
|
|
215
|
+
return { tipo: "clave_reusada" };
|
|
216
|
+
if (r.status === 409 && error === "conexion_no_conectada")
|
|
217
|
+
return { tipo: "no_conectada", message };
|
|
218
|
+
if (r.status >= 400 && r.status < 500 && error)
|
|
219
|
+
return { tipo: "rechazo", error, message };
|
|
220
|
+
throw inesperada(r.status);
|
|
221
|
+
}
|
|
222
|
+
export async function buscarProyecto(api, clave, repo, f) {
|
|
223
|
+
const r = await conClave(api, clave, `/api/proyectos?${new URLSearchParams({ repo })}`, { method: "GET" }, f);
|
|
224
|
+
if (r.status !== 200)
|
|
225
|
+
throw rechazada(r);
|
|
226
|
+
return r.cuerpo.proyecto === null ? null : aProyecto(r.cuerpo.proyecto, r.status);
|
|
227
|
+
}
|
|
228
|
+
export async function leerProyecto(api, clave, id, f) {
|
|
229
|
+
const r = await conClave(api, clave, `/api/proyectos/${encodeURIComponent(id)}`, { method: "GET" }, f);
|
|
230
|
+
if (r.status !== 200)
|
|
231
|
+
throw rechazada(r);
|
|
232
|
+
const g = (typeof r.cuerpo.github === "object" && r.cuerpo.github !== null ? r.cuerpo.github : {});
|
|
233
|
+
return { proyecto: aProyecto(r.cuerpo.proyecto, r.status), github: { estado: texto(g.estado), verificadoEn: texto(g.verificado_en) } };
|
|
234
|
+
}
|
|
235
|
+
// ---- Configurar (bloque 4) ----
|
|
236
|
+
const objeto = (v, status) => {
|
|
237
|
+
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
238
|
+
throw inesperada(status);
|
|
239
|
+
return v;
|
|
240
|
+
};
|
|
241
|
+
export async function leerCatalogo(api, clave, f) {
|
|
242
|
+
const r = await conClave(api, clave, "/api/catalogo", { method: "GET" }, f);
|
|
243
|
+
if (r.status !== 200 || !Array.isArray(r.cuerpo.supcoflow))
|
|
244
|
+
throw rechazada(r);
|
|
245
|
+
return r.cuerpo;
|
|
246
|
+
}
|
|
247
|
+
export async function leerConfiguracion(api, clave, proyectoId, f) {
|
|
248
|
+
const r = await conClave(api, clave, `/api/proyectos/${encodeURIComponent(proyectoId)}/configuracion`, { method: "GET" }, f);
|
|
249
|
+
if (r.status !== 200)
|
|
250
|
+
throw rechazada(r);
|
|
251
|
+
objeto(r.cuerpo.chat, r.status);
|
|
252
|
+
if (!Array.isArray(r.cuerpo.sitios))
|
|
253
|
+
throw inesperada(r.status);
|
|
254
|
+
return r.cuerpo;
|
|
255
|
+
}
|
|
256
|
+
// Las escrituras devuelven la respuesta entera: qué significa un 409 lo decide cada comando (prender sin
|
|
257
|
+
// guía es «hace falta una persona»; la lista que cambió es «probá de nuevo»).
|
|
258
|
+
export async function configurar(api, clave, ruta, cuerpo, f, extra = {}) {
|
|
259
|
+
const headers = { ...JSON_BODY };
|
|
260
|
+
if (extra.idempotencyKey !== undefined)
|
|
261
|
+
headers["idempotency-key"] = extra.idempotencyKey;
|
|
262
|
+
return conClave(api, clave, ruta, { method: "POST", headers, body: JSON.stringify(cuerpo) }, f);
|
|
263
|
+
}
|
|
264
|
+
export async function subirLogo(api, clave, proyectoId, archivo, f) {
|
|
265
|
+
const form = new FormData();
|
|
266
|
+
form.set("logo", new Blob([archivo.bytes], { type: archivo.mime }), archivo.nombre);
|
|
267
|
+
// Sin content-type a mano: `fetch` pone el multipart con su boundary.
|
|
268
|
+
return conClave(api, clave, `/api/proyectos/${encodeURIComponent(proyectoId)}/chat/logo`, { method: "POST", body: form }, f);
|
|
269
|
+
}
|
|
270
|
+
export async function listarClaves(api, clave, proyectoId, f) {
|
|
271
|
+
const r = await conClave(api, clave, `/api/proyectos/${encodeURIComponent(proyectoId)}/claves`, { method: "GET" }, f);
|
|
272
|
+
if (r.status !== 200 || !Array.isArray(r.cuerpo.claves))
|
|
273
|
+
throw rechazada(r);
|
|
274
|
+
return r.cuerpo.claves.map((x) => {
|
|
275
|
+
const o = objeto(x, r.status);
|
|
276
|
+
const [id, nombre, creadaEn] = [o.id, o.nombre, o.creada_en].map(texto);
|
|
277
|
+
if (!id || !nombre || !creadaEn)
|
|
278
|
+
throw inesperada(r.status);
|
|
279
|
+
return { id, nombre, creadaEn };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
// ¿Esta clave es una clave de CONSULTA de este proyecto y autentica? La prueba de posesión de
|
|
283
|
+
// `claves crear`: el motor es el único que sabe cuál generación es la vigente, y de qué alcance. Que lea el
|
|
284
|
+
// proyecto no alcanza: una clave personal también lo lee y puede mucho más (tercera revisión, hallazgo 2).
|
|
285
|
+
// Sin `conClave`: un 401 acá es la respuesta («no»), no «iniciá sesión».
|
|
286
|
+
export async function esClaveDeConsultaDelProyecto(api, clave, proyectoId, f) {
|
|
287
|
+
const r = await pedir(api, "/api/yo", { method: "GET", headers: bearer(clave) }, f);
|
|
288
|
+
if (r.status === 401)
|
|
289
|
+
return false;
|
|
290
|
+
if (r.status >= 500)
|
|
291
|
+
throw sinServicio(r.status);
|
|
292
|
+
if (r.status !== 200)
|
|
293
|
+
throw inesperada(r.status);
|
|
294
|
+
return r.cuerpo.via === "clave_de_consulta" && r.cuerpo.proyecto === proyectoId;
|
|
295
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
|
|
3
|
+
export const COMANDOS_COMPUESTOS = [
|
|
4
|
+
"repo conectar", "proyecto crear", "proyecto ver",
|
|
5
|
+
"chat prender", "chat apagar", "chat marca", "chat logo", "sitios agregar", "sitios quitar",
|
|
6
|
+
"datos fijar", "politica fijar", "claves crear", "claves listar", "claves revocar",
|
|
7
|
+
];
|
|
8
|
+
// Sólo estas primeras palabras admiten una segunda: cualquier otro par sigue siendo «de más».
|
|
9
|
+
const PRIMERAS_PALABRAS = new Set(COMANDOS_COMPUESTOS.map((c) => c.split(" ")[0]));
|
|
10
|
+
const FLAGS_CON_VALOR = [
|
|
11
|
+
"repo", "install", "test", "sitio", "titulo", "saludo", "archivo", "permitir", "modo", "nombre", "guardar-en", "id",
|
|
12
|
+
];
|
|
13
|
+
// strict: un flag mal escrito (`--estart`) no se ignora en silencio: un asistente creería que
|
|
14
|
+
// pidió algo que no pidió.
|
|
15
|
+
export function leerArgumentos(argv) {
|
|
16
|
+
let parsed;
|
|
17
|
+
try {
|
|
18
|
+
parsed = parseArgs({
|
|
19
|
+
args: argv,
|
|
20
|
+
strict: true,
|
|
21
|
+
allowPositionals: true,
|
|
22
|
+
options: {
|
|
23
|
+
json: { type: "boolean" },
|
|
24
|
+
"dry-run": { type: "boolean" },
|
|
25
|
+
help: { type: "boolean", short: "h" },
|
|
26
|
+
start: { type: "boolean" },
|
|
27
|
+
wait: { type: "boolean" },
|
|
28
|
+
repo: { type: "string" },
|
|
29
|
+
install: { type: "string" },
|
|
30
|
+
test: { type: "string" },
|
|
31
|
+
sitio: { type: "string" },
|
|
32
|
+
titulo: { type: "string" },
|
|
33
|
+
saludo: { type: "string" },
|
|
34
|
+
archivo: { type: "string" },
|
|
35
|
+
permitir: { type: "string" },
|
|
36
|
+
modo: { type: "string" },
|
|
37
|
+
nombre: { type: "string" },
|
|
38
|
+
"guardar-en": { type: "string" },
|
|
39
|
+
id: { type: "string" },
|
|
40
|
+
tema: { type: "string", multiple: true },
|
|
41
|
+
},
|
|
42
|
+
tokens: true,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
const code = e.code;
|
|
47
|
+
if (code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
|
|
48
|
+
const m = /'(--?[^']+)'/.exec(e.message);
|
|
49
|
+
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "flag_desconocido", `No conozco el flag ${m ? m[1] : "que pasaste"}. Probá \`supcoflow --help\`.`);
|
|
50
|
+
}
|
|
51
|
+
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "argumentos_invalidos", `${e.message} Probá \`supcoflow --help\`.`);
|
|
52
|
+
}
|
|
53
|
+
const pos = parsed.positionals;
|
|
54
|
+
const deMas = () => new FalloDeLaTerminal(SALIDA.entradaInvalida, "argumentos_de_mas", "Pasaste más de un comando. Probá `supcoflow --help`.");
|
|
55
|
+
if (pos.length > 2 || (pos.length === 2 && !PRIMERAS_PALABRAS.has(pos[0])))
|
|
56
|
+
throw deMas();
|
|
57
|
+
// `parseArgs` se queda con el último valor de un flag repetido: un asistente que repite `--repo`
|
|
58
|
+
// creería que se usó el primero. Se mira en los tokens y se rechaza.
|
|
59
|
+
for (const nombre of FLAGS_CON_VALOR) {
|
|
60
|
+
const toks = (parsed.tokens ?? []).filter((t) => t.kind === "option" && t.name === nombre);
|
|
61
|
+
if (toks.length > 1 || toks.some((t) => t.kind === "option" && t.value === "")) {
|
|
62
|
+
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "argumentos_invalidos", `--${nombre} va una sola vez y con un valor. Probá \`supcoflow --help\`.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// `--tema` sí se repite (uno por clave), pero ninguno vacío: un `--tema ""` es un error de quien arma
|
|
66
|
+
// el comando, no «sin tema».
|
|
67
|
+
if ((parsed.values.tema ?? []).some((t) => t === "")) {
|
|
68
|
+
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "argumentos_invalidos", "--tema va con un valor clave=valor. Probá `supcoflow --help`.");
|
|
69
|
+
}
|
|
70
|
+
// Un par desconocido (`repo borrar`) queda como la primera palabra sola: el despacho la rechaza
|
|
71
|
+
// como comando desconocido, con el mismo mensaje que cualquier otra.
|
|
72
|
+
const compuesto = pos.length === 2 ? `${pos[0]} ${pos[1]}` : null;
|
|
73
|
+
const comando = compuesto !== null && COMANDOS_COMPUESTOS.includes(compuesto) ? compuesto : (pos[0] ?? null);
|
|
74
|
+
const v = parsed.values;
|
|
75
|
+
return {
|
|
76
|
+
comando,
|
|
77
|
+
flags: {
|
|
78
|
+
json: v.json === true,
|
|
79
|
+
dryRun: v["dry-run"] === true,
|
|
80
|
+
help: v.help === true,
|
|
81
|
+
start: v.start === true,
|
|
82
|
+
wait: v.wait === true,
|
|
83
|
+
repo: v.repo ?? null,
|
|
84
|
+
install: v.install ?? null,
|
|
85
|
+
test: v.test ?? null,
|
|
86
|
+
sitio: v.sitio ?? null,
|
|
87
|
+
titulo: v.titulo ?? null,
|
|
88
|
+
saludo: v.saludo ?? null,
|
|
89
|
+
archivo: v.archivo ?? null,
|
|
90
|
+
permitir: v.permitir ?? null,
|
|
91
|
+
modo: v.modo ?? null,
|
|
92
|
+
nombre: v.nombre ?? null,
|
|
93
|
+
guardarEn: v["guardar-en"] ?? null,
|
|
94
|
+
id: v.id ?? null,
|
|
95
|
+
tema: v.tema ?? [],
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
package/dist/candado.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Un candado por operación en ESTA máquina (segunda revisión externa del 2026-10-16, hallazgo 1): comprobar
|
|
2
|
+
// que un archivo autentica y después escribirlo no es atómico, y dos `claves crear` del mismo proyecto y
|
|
3
|
+
// nombre pisaban uno el secreto del otro. Un archivo creado en exclusiva es el candado más simple que
|
|
4
|
+
// funciona entre procesos sin dependencias.
|
|
5
|
+
//
|
|
6
|
+
// ─── No se recupera solo (tercera revisión, hallazgo 1) ─────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// La primera versión borraba un candado «viejo» y tomaba uno nuevo. Leer la fecha y después borrar no es
|
|
9
|
+
// atómico: dos procesos que encontraban el mismo candado viejo entraban los dos, y el primero en soltar
|
|
10
|
+
// borraba el del otro. Ahora un candado de un proceso que ya no existe se informa con su ruta y lo borra una
|
|
11
|
+
// persona, que sabe que no hay nadie corriendo. Y `soltar` sólo borra si el archivo sigue siendo el suyo.
|
|
12
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
export function crearCandados(fs, carpeta, pid, procesoVivo) {
|
|
15
|
+
return {
|
|
16
|
+
tomar(nombre) {
|
|
17
|
+
// El nombre lleva el id del proyecto y el nombre de la clave que eligió la persona: el hash lo vuelve
|
|
18
|
+
// un nombre de archivo sin barras ni puntos, sea lo que sea.
|
|
19
|
+
const ruta = path.join(carpeta, `${createHash("sha256").update(nombre).digest("hex")}.lock`);
|
|
20
|
+
const token = randomUUID();
|
|
21
|
+
let creado;
|
|
22
|
+
try {
|
|
23
|
+
fs.crearDir(carpeta, 0o700);
|
|
24
|
+
creado = fs.crearExclusivo(ruta, JSON.stringify({ pid, token }), 0o600);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return { tipo: "no_disponible", ruta };
|
|
28
|
+
}
|
|
29
|
+
if (!creado) {
|
|
30
|
+
let duenio = null;
|
|
31
|
+
try {
|
|
32
|
+
const v = JSON.parse(fs.leer(ruta) ?? "null");
|
|
33
|
+
duenio = typeof v?.pid === "number" ? v.pid : null;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
duenio = null;
|
|
37
|
+
}
|
|
38
|
+
// Un candado que no se entiende tampoco se toca: «abandonado» le pide a una persona que mire.
|
|
39
|
+
return duenio !== null && procesoVivo(duenio) ? { tipo: "ocupado" } : { tipo: "abandonado", ruta };
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
tipo: "tomado",
|
|
43
|
+
soltar() {
|
|
44
|
+
// Sólo si sigue siendo MÍO: si una persona lo borró y otro tomó uno nuevo, no se toca.
|
|
45
|
+
try {
|
|
46
|
+
const v = JSON.parse(fs.leer(ruta) ?? "null");
|
|
47
|
+
if (v?.token === token)
|
|
48
|
+
fs.borrar(ruta);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* best-effort: si no se pudo, queda abandonado y se informa con su ruta */
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|