supcoflow 0.1.0 → 0.5.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 +83 -3
- package/dist/api.js +187 -6
- package/dist/argumentos.js +63 -3
- package/dist/candado.js +57 -0
- package/dist/cli.js +66 -5
- 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 +173 -3
- package/dist/comandos/guia.js +244 -0
- package/dist/comandos/proyecto-del-repo.js +26 -0
- package/dist/comandos/proyecto.js +213 -0
- package/dist/comandos/quien-soy.js +6 -13
- package/dist/comandos/repo-conectar.js +126 -0
- package/dist/entorno.js +323 -5
- package/dist/flags-por-comando.js +33 -0
- package/dist/guia.js +11 -0
- package/dist/idempotencia.js +88 -0
- package/dist/repo-local.js +68 -0
- package/dist/secreto-a-archivo.js +109 -0
- package/package.json +1 -1
package/dist/entorno.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// Lo único que toca el mundo de verdad: el proceso, el disco, el llavero, la red y el reloj. Todo lo
|
|
2
2
|
// demás recibe estas piezas inyectadas y se prueba con dobles; acá sólo se enchufan.
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
4
|
-
import {
|
|
3
|
+
import { execFile, spawn } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { chmodSync, closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeSync, } from "node:fs";
|
|
5
6
|
import { homedir, hostname } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { canjear, pedirCodigo, quienSoy, revocarPropia, urlDeLaApi } from "./api.js";
|
|
7
|
+
import { basename, dirname, join } from "node:path";
|
|
8
|
+
import { buscarProyecto, canjear, configurar, crearConexion, crearProyecto, elegirOrigen, esClaveDeConsultaDelProyecto, leerCatalogo, leerConfiguracion, leerGuia, leerProyecto, listarClaves, pedirCodigo, quienSoy, revocarPropia, subirLogo, ultimaConexion, urlDeLaApi, publicarGuia, revisarGuia, } from "./api.js";
|
|
9
|
+
import { crearCandados } from "./candado.js";
|
|
8
10
|
import { almacenDeArchivo } from "./guardado/archivo.js";
|
|
9
11
|
import { crearGuardado } from "./guardado/index.js";
|
|
10
12
|
import { almacenDeLlavero } from "./guardado/llavero.js";
|
|
13
|
+
import { crearGuardaDeSecretos } from "./secreto-a-archivo.js";
|
|
14
|
+
import { crearRegistroDeIdempotencia } from "./idempotencia.js";
|
|
11
15
|
import { TRAMO_MAXIMO_MS } from "./sondeo.js";
|
|
12
16
|
// El `.tmp` se crea en exclusiva (`wx`: falla si quedó algo, enlace incluido, en vez de escribir a
|
|
13
17
|
// través de él) y el `fchmod` va aparte porque el modo de `open` pasa por la umask del proceso.
|
|
@@ -60,6 +64,121 @@ export const fsReal = {
|
|
|
60
64
|
}
|
|
61
65
|
},
|
|
62
66
|
};
|
|
67
|
+
// El candado de `claves crear` (src/candado.ts). `wx` es la exclusión: SÓLO `EEXIST` es «ya hay uno»;
|
|
68
|
+
// cualquier otro error (permisos, disco) se relanza y sale `no_disponible`, nunca «ocupado». La lectura no
|
|
69
|
+
// sigue enlaces: un `.lock` que alguien cambió por un enlace no se lee como si fuera el nuestro.
|
|
70
|
+
export const fsDelCandadoReal = {
|
|
71
|
+
// Se escribe completo en un temporal de la misma carpeta y se PUBLICA con `link`, que falla con EEXIST si
|
|
72
|
+
// ya hay uno: con `wx` directo, otro proceso podía leer el `.lock` recién creado y todavía vacío, y
|
|
73
|
+
// informar `candado_abandonado` de un candado vivo. Así el `.lock` nunca se ve sin contenido.
|
|
74
|
+
crearExclusivo(p, contenido, modo) {
|
|
75
|
+
const tmp = `${p}.${randomUUID()}.tmp`;
|
|
76
|
+
try {
|
|
77
|
+
const fd = openSync(tmp, "wx", modo);
|
|
78
|
+
try {
|
|
79
|
+
fchmodSync(fd, modo);
|
|
80
|
+
writeSync(fd, contenido);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
linkSync(tmp, p);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
if (e.code === "EEXIST")
|
|
90
|
+
return false;
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
rmSync(tmp, { force: true });
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
leer(p) {
|
|
98
|
+
try {
|
|
99
|
+
const fd = openSync(p, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
100
|
+
try {
|
|
101
|
+
return readFileSync(fd, "utf8");
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
closeSync(fd);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
borrar: (p) => rmSync(p, { force: true }),
|
|
112
|
+
crearDir: (p, modo) => void mkdirSync(p, { recursive: true, mode: modo }),
|
|
113
|
+
};
|
|
114
|
+
// ¿Sigue vivo el dueño de un candado? `kill(pid, 0)` no manda nada: sólo pregunta. `EPERM` es «existe pero
|
|
115
|
+
// es de otro usuario»: vivo. Sólo `ESRCH` (no existe) lo da por muerto.
|
|
116
|
+
export function procesoVivo(pid) {
|
|
117
|
+
try {
|
|
118
|
+
process.kill(pid, 0);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
return e.code !== "ESRCH";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// El destino del secreto de `claves crear` (src/secreto-a-archivo.ts): el `fsReal` de siempre más una
|
|
126
|
+
// lectura que no sigue un enlace en el último tramo (`O_NOFOLLOW` → `ELOOP`), no se cuelga en un FIFO y
|
|
127
|
+
// tiene tope. Sólo archivos comunes.
|
|
128
|
+
function leerSinSeguir(p, tope) {
|
|
129
|
+
let fd;
|
|
130
|
+
try {
|
|
131
|
+
fd = openSync(p, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
const code = e.code;
|
|
135
|
+
if (code === "ENOENT")
|
|
136
|
+
return { tipo: "ausente" };
|
|
137
|
+
// macOS y Linux dan `ELOOP` al abrir un enlace con O_NOFOLLOW; algunos BSD, `EMLINK`.
|
|
138
|
+
return code === "ELOOP" || code === "EMLINK" ? { tipo: "enlace" } : { tipo: "ilegible" };
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
if (!fstatSync(fd).isFile())
|
|
142
|
+
return { tipo: "ilegible" };
|
|
143
|
+
const buf = Buffer.alloc(tope + 1);
|
|
144
|
+
let n = 0;
|
|
145
|
+
for (;;) {
|
|
146
|
+
const leidos = readSync(fd, buf, n, buf.length - n, null);
|
|
147
|
+
if (leidos === 0)
|
|
148
|
+
break;
|
|
149
|
+
n += leidos;
|
|
150
|
+
if (n > tope)
|
|
151
|
+
return { tipo: "ilegible" };
|
|
152
|
+
}
|
|
153
|
+
return { tipo: "contenido", texto: buf.subarray(0, n).toString("utf8") };
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return { tipo: "ilegible" };
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
closeSync(fd);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// `link` es el idioma del candado (arriba): crea el nombre sólo si no existe y falla con EEXIST si sí, sin
|
|
163
|
+
// ventana entre mirar y escribir. El temporal queda con dos nombres hasta que `guardar` lo borra.
|
|
164
|
+
// Un disco sin enlaces duros (FAT, exFAT, SMB) no da EEXIST sino EPERM, ENOTSUP/EOPNOTSUPP o ENOSYS según
|
|
165
|
+
// el sistema: se informa aparte para no decirle a la persona «¿tenés permiso?» cuando el problema es el disco.
|
|
166
|
+
const SIN_ENLACES = new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EXDEV"]);
|
|
167
|
+
function publicarSinPisar(tmp, destino) {
|
|
168
|
+
try {
|
|
169
|
+
linkSync(tmp, destino);
|
|
170
|
+
return "publicado";
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
const code = e.code ?? "";
|
|
174
|
+
if (code === "EEXIST")
|
|
175
|
+
return "existe";
|
|
176
|
+
if (SIN_ENLACES.has(code))
|
|
177
|
+
return "sin_enlaces";
|
|
178
|
+
throw e;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export const fsDeSecretosReal = { ...fsReal, leerSinSeguir, publicarSinPisar };
|
|
63
182
|
// Para hablar con `security`/`secret-tool`. stderr se captura sólo como señal (¿dijo algo?: es lo
|
|
64
183
|
// que separa «no encontrado» de «sin D-Bus» en `secret-tool`), recortado, y nunca se muestra ni se
|
|
65
184
|
// registra: podría traer algo del secreto. Al techo el hijo se mata con SIGKILL (un llavero bloqueado no atiende un SIGTERM) y se
|
|
@@ -108,6 +227,150 @@ export const correrReal = (bin, args, stdin, techoMs) => new Promise((resolve) =
|
|
|
108
227
|
hijo.stdin.on("error", () => { });
|
|
109
228
|
hijo.stdin.end(stdin ?? undefined);
|
|
110
229
|
});
|
|
230
|
+
// `git` con entorno mínimo (sólo PATH): ni GIT_DIR ni GIT_CONFIG del shell desvían la lectura, y
|
|
231
|
+
// SUPCOFLOW_TOKEN no le llega. Un git ausente (ENOENT) es «sin git», no un fallo. El plazo es parámetro para no
|
|
232
|
+
// duplicar la función: lo que git imprima nunca sale a la terminal, sólo el código y el stdout.
|
|
233
|
+
const gitConPlazo = (plazoMs) => (args, cwd) => new Promise((resolve) => {
|
|
234
|
+
execFile("git", args, { cwd, timeout: plazoMs, env: { PATH: process.env.PATH ?? "" } }, (e, stdout) => {
|
|
235
|
+
if (!e)
|
|
236
|
+
return resolve({ codigo: 0, stdout: String(stdout) });
|
|
237
|
+
const codigo = e.code;
|
|
238
|
+
resolve({ codigo: typeof codigo === "number" ? codigo : null, stdout: "" });
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
export const gitReal = gitConPlazo(5_000);
|
|
242
|
+
// El commit y la rama que la terminal DECLARA al subir la guía: 3 segundos (no 5) porque esto es un extra que nunca
|
|
243
|
+
// debe hacer esperar a nadie.
|
|
244
|
+
export const gitRapido = gitConPlazo(3_000);
|
|
245
|
+
// `.supcoflow/proyecto.json` vive DENTRO del repo, y el repo puede ser un clon ajeno: su contenido lo
|
|
246
|
+
// decide quien lo escribió. Por eso ni la carpeta ni el archivo se siguen si son enlaces (uno hacia
|
|
247
|
+
// `~/.ssh/…` haría que la terminal leyera, o escribiera, lo que el repo quiera), y se lee con tope.
|
|
248
|
+
export const TOPE_DEL_ARCHIVO_DEL_REPO = 64 * 1024;
|
|
249
|
+
// Lo que devuelve `leer` cuando hay ALGO ahí que no se lee (un enlace, una carpeta, algo enorme, sin
|
|
250
|
+
// permiso). No es JSON a propósito: `proyecto crear` lo trata como un archivo distinto (sale 4, «no lo
|
|
251
|
+
// toqué») sin que nada de lo que hay ahí llegue a la salida.
|
|
252
|
+
export const ARCHIVO_QUE_NO_SE_LEE = "\u0000(no se lee)";
|
|
253
|
+
// Un archivo que la persona nombra (el logo) con las mismas defensas que los del repo: sin seguir
|
|
254
|
+
// enlaces, sin FIFOs que cuelguen, sólo archivos comunes y con tope (se lee tope + 1 por si crece).
|
|
255
|
+
export function leerArchivoChico(p, tope) {
|
|
256
|
+
let fd;
|
|
257
|
+
try {
|
|
258
|
+
fd = openSync(p, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
const code = e.code;
|
|
262
|
+
return { error: code === "ENOENT" ? "no existe" : code === "ELOOP" ? "es un enlace" : "no se pudo abrir" };
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
if (!fstatSync(fd).isFile())
|
|
266
|
+
return { error: "no es un archivo común" };
|
|
267
|
+
const buf = Buffer.alloc(tope + 1);
|
|
268
|
+
let n = 0;
|
|
269
|
+
for (;;) {
|
|
270
|
+
const leidos = readSync(fd, buf, n, buf.length - n, null);
|
|
271
|
+
if (leidos === 0)
|
|
272
|
+
break;
|
|
273
|
+
n += leidos;
|
|
274
|
+
if (n > tope)
|
|
275
|
+
return { error: "es demasiado grande" };
|
|
276
|
+
}
|
|
277
|
+
return { bytes: new Uint8Array(buf.subarray(0, n)) };
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return { error: "no se pudo leer" };
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
closeSync(fd);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
export function archivosDelRepo(avisar) {
|
|
287
|
+
// La carpeta: tiene que ser una carpeta de verdad. Un enlace o un archivo con ese nombre se rechaza;
|
|
288
|
+
// `null` sólo si no existe.
|
|
289
|
+
const carpetaPropia = (dir) => {
|
|
290
|
+
try {
|
|
291
|
+
const st = lstatSync(dir);
|
|
292
|
+
return st.isDirectory() && !st.isSymbolicLink() ? "propia" : "ajena";
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
return e.code === "ENOENT" ? "ausente" : "ajena";
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
const noSeLee = (p, porque) => {
|
|
299
|
+
avisar(`No leo ${p}: ${porque}.`);
|
|
300
|
+
return ARCHIVO_QUE_NO_SE_LEE;
|
|
301
|
+
};
|
|
302
|
+
return {
|
|
303
|
+
leer(p) {
|
|
304
|
+
const carpeta = carpetaPropia(dirname(p));
|
|
305
|
+
if (carpeta === "ausente")
|
|
306
|
+
return null;
|
|
307
|
+
if (carpeta === "ajena")
|
|
308
|
+
return noSeLee(p, `${basename(dirname(p))} no es una carpeta común (¿un enlace?)`);
|
|
309
|
+
let fd;
|
|
310
|
+
try {
|
|
311
|
+
// O_NOFOLLOW: un enlace en el último tramo falla (ELOOP) en vez de abrir su destino.
|
|
312
|
+
// O_NONBLOCK: abrir un FIFO no se cuelga esperando a quien escriba.
|
|
313
|
+
fd = openSync(p, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
const code = e.code;
|
|
317
|
+
if (code === "ENOENT")
|
|
318
|
+
return null;
|
|
319
|
+
return noSeLee(p, code === "ELOOP" ? "es un enlace" : "no se pudo abrir");
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const st = fstatSync(fd);
|
|
323
|
+
if (!st.isFile())
|
|
324
|
+
return noSeLee(p, "no es un archivo común");
|
|
325
|
+
if (st.size > TOPE_DEL_ARCHIVO_DEL_REPO)
|
|
326
|
+
return noSeLee(p, "es demasiado grande");
|
|
327
|
+
// Se lee hasta el tope + 1 y no `st.size`: el archivo puede crecer entre el fstat y la lectura.
|
|
328
|
+
const buf = Buffer.alloc(TOPE_DEL_ARCHIVO_DEL_REPO + 1);
|
|
329
|
+
let n = 0;
|
|
330
|
+
for (;;) {
|
|
331
|
+
const leidos = readSync(fd, buf, n, buf.length - n, null);
|
|
332
|
+
if (leidos === 0)
|
|
333
|
+
break;
|
|
334
|
+
n += leidos;
|
|
335
|
+
if (n > TOPE_DEL_ARCHIVO_DEL_REPO)
|
|
336
|
+
return noSeLee(p, "es demasiado grande");
|
|
337
|
+
}
|
|
338
|
+
return buf.subarray(0, n).toString("utf8");
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return noSeLee(p, "no se pudo leer");
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
closeSync(fd);
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
// Sólo se llama cuando `leer` dijo que no hay nada. El `.tmp` lleva un nombre al azar y se crea en
|
|
348
|
+
// exclusiva (`wx` no atraviesa un enlace que alguien dejara con ese nombre), y la carpeta se vuelve
|
|
349
|
+
// a mirar DESPUÉS del mkdir: `mkdir -p` sobre un enlace a una carpeta no falla, y escribir ahí
|
|
350
|
+
// sería escribir donde el repo quiera.
|
|
351
|
+
escribir(p, s) {
|
|
352
|
+
const dir = dirname(p);
|
|
353
|
+
mkdirSync(dir, { recursive: true });
|
|
354
|
+
if (carpetaPropia(dir) !== "propia")
|
|
355
|
+
throw new Error(`${dir} no es una carpeta común`);
|
|
356
|
+
const tmp = join(dir, `.${basename(p)}.${randomUUID()}.tmp`);
|
|
357
|
+
try {
|
|
358
|
+
const fd = openSync(tmp, "wx", 0o644);
|
|
359
|
+
try {
|
|
360
|
+
writeSync(fd, s);
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
closeSync(fd);
|
|
364
|
+
}
|
|
365
|
+
renameSync(tmp, p);
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
rmSync(tmp, { force: true });
|
|
369
|
+
throw e;
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
111
374
|
const LOCALES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
112
375
|
export function entornoReal(env = process.env) {
|
|
113
376
|
const api = urlDeLaApi(env);
|
|
@@ -126,6 +389,29 @@ export function entornoReal(env = process.env) {
|
|
|
126
389
|
const guardado = crearGuardado({
|
|
127
390
|
env, llavero, archivo: almacenDeArchivo(rutaDelArchivo, fsReal), rutaDelArchivo, fs: fsReal, ahora: Date.now, avisar,
|
|
128
391
|
});
|
|
392
|
+
// Al lado de las credenciales y con el mismo almacén (carpeta 0700, archivo 0600, `.tmp` en
|
|
393
|
+
// exclusiva): nunca dentro del repo donde se corre el comando, que la publicaría con un `git add -A`.
|
|
394
|
+
const rutaDeIdempotencia = join(dirname(rutaDelArchivo), "idempotencia.json");
|
|
395
|
+
const idempotencia = crearRegistroDeIdempotencia({
|
|
396
|
+
origen: api.origin,
|
|
397
|
+
almacen: almacenDeArchivo(rutaDeIdempotencia, fsReal),
|
|
398
|
+
rutaDelArchivo: rutaDeIdempotencia,
|
|
399
|
+
fs: fsReal,
|
|
400
|
+
generar: randomUUID,
|
|
401
|
+
ahora: Date.now,
|
|
402
|
+
avisar,
|
|
403
|
+
});
|
|
404
|
+
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
405
|
+
const cwd = process.cwd();
|
|
406
|
+
// Los candados de `claves crear` viven al lado de las credenciales, nunca en un repo.
|
|
407
|
+
const candados = crearCandados(fsDelCandadoReal, join(dirname(rutaDelArchivo), "candados"), process.pid, procesoVivo);
|
|
408
|
+
const secretos = crearGuardaDeSecretos(fsDeSecretosReal);
|
|
409
|
+
const configurarEnElMotor = {
|
|
410
|
+
api, guardado, git: gitReal, cwd, avisar,
|
|
411
|
+
buscarProyecto: (a, clave, repo) => buscarProyecto(a, clave, repo, fetch),
|
|
412
|
+
leerConfiguracion: (a, clave, id) => leerConfiguracion(a, clave, id, fetch),
|
|
413
|
+
configurar: (a, clave, ruta, cuerpo, extra) => configurar(a, clave, ruta, cuerpo, fetch, extra),
|
|
414
|
+
};
|
|
129
415
|
return {
|
|
130
416
|
env,
|
|
131
417
|
api,
|
|
@@ -140,7 +426,7 @@ export function entornoReal(env = process.env) {
|
|
|
140
426
|
guardado,
|
|
141
427
|
maquina: hostname(),
|
|
142
428
|
ahora: Date.now,
|
|
143
|
-
dormir
|
|
429
|
+
dormir,
|
|
144
430
|
pedirCodigo: (a, maquina) => pedirCodigo(a, maquina, fetch),
|
|
145
431
|
canjear: (a, deviceCode, plazoMs) => canjear(a, deviceCode, fetch, plazoMs),
|
|
146
432
|
revocarPropia: (a, clave, plazoMs) => revocarPropia(a, clave, fetch, plazoMs),
|
|
@@ -148,5 +434,37 @@ export function entornoReal(env = process.env) {
|
|
|
148
434
|
tramoMs,
|
|
149
435
|
},
|
|
150
436
|
quienSoy: (a, clave) => quienSoy(a, clave, fetch),
|
|
437
|
+
git: gitReal,
|
|
438
|
+
cwd,
|
|
439
|
+
idempotencia,
|
|
440
|
+
conexion: {
|
|
441
|
+
api, guardado, git: gitReal, cwd, ahora: Date.now, dormir, avisar, tramoMs,
|
|
442
|
+
crearConexion: (a, clave, repo) => crearConexion(a, clave, repo, fetch),
|
|
443
|
+
ultimaConexion: (a, clave, repo, plazoMs) => ultimaConexion(a, clave, repo, fetch, plazoMs),
|
|
444
|
+
},
|
|
445
|
+
proyecto: {
|
|
446
|
+
api, guardado, git: gitReal, cwd, idempotencia, archivos: archivosDelRepo(avisar), avisar,
|
|
447
|
+
buscarProyecto: (a, clave, repo) => buscarProyecto(a, clave, repo, fetch),
|
|
448
|
+
ultimaConexion: (a, clave, repo, plazoMs) => ultimaConexion(a, clave, repo, fetch, plazoMs),
|
|
449
|
+
crearProyecto: (a, clave, conexionId, cuerpo, key) => crearProyecto(a, clave, conexionId, cuerpo, key, fetch),
|
|
450
|
+
leerProyecto: (a, clave, id) => leerProyecto(a, clave, id, fetch),
|
|
451
|
+
},
|
|
452
|
+
configurar: {
|
|
453
|
+
...configurarEnElMotor,
|
|
454
|
+
subirLogo: (a, clave, id, archivo) => subirLogo(a, clave, id, archivo, fetch),
|
|
455
|
+
leerArchivoChico,
|
|
456
|
+
leerCatalogo: (a, clave) => leerCatalogo(a, clave, fetch),
|
|
457
|
+
},
|
|
458
|
+
guia: {
|
|
459
|
+
...configurarEnElMotor,
|
|
460
|
+
leerArchivoChico, fetch, gitDeclarado: gitRapido,
|
|
461
|
+
revisar: revisarGuia, publicar: publicarGuia, ver: leerGuia, elegir: elegirOrigen,
|
|
462
|
+
},
|
|
463
|
+
claves: {
|
|
464
|
+
...configurarEnElMotor,
|
|
465
|
+
listarClaves: (a, clave, id) => listarClaves(a, clave, id, fetch),
|
|
466
|
+
esClaveDeConsultaDelProyecto: (a, clave, id) => esClaveDeConsultaDelProyecto(a, clave, id, fetch),
|
|
467
|
+
idempotencia, candados, secretos,
|
|
468
|
+
},
|
|
151
469
|
};
|
|
152
470
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Qué comandos aceptan cada flag que no es global. Un flag que no aplica sale 3 en vez de ignorarse:
|
|
2
|
+
// un asistente que pasa `--repo` a `quien-soy` creería que preguntó por ese repo. Vive aparte de
|
|
3
|
+
// `cli.ts` (que corre al importarse) para poder probarse sin lanzar el binario.
|
|
4
|
+
const DE_CONFIGURAR_CON_REPO = [
|
|
5
|
+
"configuracion", "chat prender", "chat apagar", "chat marca", "chat logo", "sitios agregar", "sitios quitar",
|
|
6
|
+
"datos fijar", "politica fijar", "claves crear", "claves listar", "claves revocar",
|
|
7
|
+
"guia revisar", "guia publicar", "guia ver", "guia usar",
|
|
8
|
+
];
|
|
9
|
+
export const FLAGS_POR_COMANDO = [
|
|
10
|
+
{ flags: ["start", "wait"], comandos: ["login", "repo conectar"], texto: "--start y --wait son de `supcoflow login` y `supcoflow repo conectar`." },
|
|
11
|
+
{
|
|
12
|
+
flags: ["repo"], comandos: ["repo conectar", "proyecto crear", "proyecto ver", ...DE_CONFIGURAR_CON_REPO],
|
|
13
|
+
texto: "--repo es de `supcoflow repo conectar`, `proyecto crear|ver` y los comandos que configuran un proyecto (no de `catalogo`).",
|
|
14
|
+
},
|
|
15
|
+
{ flags: ["install", "test"], comandos: ["proyecto crear"], texto: "--install y --test son de `supcoflow proyecto crear`." },
|
|
16
|
+
{ flags: ["sitio"], comandos: ["sitios agregar", "sitios quitar"], texto: "--sitio es de `supcoflow sitios agregar|quitar`." },
|
|
17
|
+
{ flags: ["titulo", "saludo", "tema"], comandos: ["chat marca"], texto: "--titulo, --saludo y --tema son de `supcoflow chat marca`." },
|
|
18
|
+
{ flags: ["archivo"], comandos: ["chat logo"], texto: "--archivo es de `supcoflow chat logo`." },
|
|
19
|
+
{ flags: ["permitir"], comandos: ["datos fijar"], texto: "--permitir es de `supcoflow datos fijar`." },
|
|
20
|
+
{ flags: ["modo"], comandos: ["politica fijar"], texto: "--modo es de `supcoflow politica fijar`." },
|
|
21
|
+
{ flags: ["nombre", "guardarEn"], comandos: ["claves crear"], texto: "--nombre y --guardar-en son de `supcoflow claves crear`." },
|
|
22
|
+
{ flags: ["origen"], comandos: ["guia usar"], texto: "--origen es de `supcoflow guia usar`." },
|
|
23
|
+
{ flags: ["desdeGithub"], comandos: ["guia publicar"], texto: "--desde-github es de `supcoflow guia publicar`." },
|
|
24
|
+
{ flags: ["id"], comandos: ["claves revocar"], texto: "--id es de `supcoflow claves revocar`." },
|
|
25
|
+
];
|
|
26
|
+
/** El mensaje del primer flag presente que el comando no acepta, o null si todo aplica. */
|
|
27
|
+
export function flagQueNoAplica(comando, presentes) {
|
|
28
|
+
for (const regla of FLAGS_POR_COMANDO) {
|
|
29
|
+
if (regla.flags.some((f) => presentes.includes(f)) && !regla.comandos.includes(comando))
|
|
30
|
+
return regla.texto;
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
package/dist/guia.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// La guía del producto que vive en el repo del cliente. Sin dependencias: sólo `node:crypto`.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
export const RUTA_DE_LA_GUIA = ".supcoflow/guia.md";
|
|
4
|
+
export const MAX_BYTES_DE_LA_GUIA = 64 * 1024;
|
|
5
|
+
// La misma regla que el motor (`src/guia/formato.ts`), copiada a propósito y clavada con los vectores
|
|
6
|
+
// de `test/vectores/huella.json` (idénticos en los dos repos). Se normaliza antes de hashear porque un
|
|
7
|
+
// editor de Windows pone BOM o \r\n, y si las dos puntas hashearan distinto, `guia_distinta` sería eterno.
|
|
8
|
+
export function huellaDeGuia(texto) {
|
|
9
|
+
const normalizado = texto.replace(/^/, "").replace(/\r\n?/g, "\n");
|
|
10
|
+
return createHash("sha256").update(normalizado, "utf8").digest("hex");
|
|
11
|
+
}
|
|
@@ -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
|
+
}
|