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/dist/entorno.js
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
// Lo único que toca el mundo de verdad: el proceso, el disco, el llavero, la red y el reloj. Todo lo
|
|
2
|
+
// demás recibe estas piezas inyectadas y se prueba con dobles; acá sólo se enchufan.
|
|
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";
|
|
6
|
+
import { homedir, hostname } from "node:os";
|
|
7
|
+
import { basename, dirname, join } from "node:path";
|
|
8
|
+
import { buscarProyecto, canjear, configurar, crearConexion, crearProyecto, esClaveDeConsultaDelProyecto, leerCatalogo, leerConfiguracion, leerProyecto, listarClaves, pedirCodigo, quienSoy, revocarPropia, subirLogo, ultimaConexion, urlDeLaApi, } from "./api.js";
|
|
9
|
+
import { crearCandados } from "./candado.js";
|
|
10
|
+
import { almacenDeArchivo } from "./guardado/archivo.js";
|
|
11
|
+
import { crearGuardado } from "./guardado/index.js";
|
|
12
|
+
import { almacenDeLlavero } from "./guardado/llavero.js";
|
|
13
|
+
import { crearGuardaDeSecretos } from "./secreto-a-archivo.js";
|
|
14
|
+
import { crearRegistroDeIdempotencia } from "./idempotencia.js";
|
|
15
|
+
import { TRAMO_MAXIMO_MS } from "./sondeo.js";
|
|
16
|
+
// El `.tmp` se crea en exclusiva (`wx`: falla si quedó algo, enlace incluido, en vez de escribir a
|
|
17
|
+
// través de él) y el `fchmod` va aparte porque el modo de `open` pasa por la umask del proceso.
|
|
18
|
+
// `rmSync` sobre un enlace borra el enlace, nunca su destino.
|
|
19
|
+
export const fsReal = {
|
|
20
|
+
leer(p) {
|
|
21
|
+
try {
|
|
22
|
+
return readFileSync(p, "utf8");
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e.code === "ENOENT")
|
|
26
|
+
return null;
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
crearDir: (p, modo) => void mkdirSync(p, { recursive: true, mode: modo }),
|
|
31
|
+
chmod: (p, modo) => chmodSync(p, modo),
|
|
32
|
+
borrarSiExiste: (p) => rmSync(p, { force: true }),
|
|
33
|
+
escribirExclusivo(p, s, modo) {
|
|
34
|
+
const fd = openSync(p, "wx", modo);
|
|
35
|
+
try {
|
|
36
|
+
fchmodSync(fd, modo);
|
|
37
|
+
writeSync(fd, s);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
closeSync(fd);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
renombrar: (a, b) => renameSync(a, b),
|
|
44
|
+
// Un enlace roto que no se puede resolver es un FALLO: el control del repo no puede saber adónde
|
|
45
|
+
// apunta, y escribir sin saberlo es justo lo que el control existe para impedir.
|
|
46
|
+
realpath(p) {
|
|
47
|
+
try {
|
|
48
|
+
return realpathSync(p);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new Error(`No puedo resolver la ruta real de ${p} (¿un enlace roto?); no guardo nada ahí.`);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
// `lstat` y no `existsSync`: éste sigue el enlace y ante uno roto dice «no existe», y el control
|
|
55
|
+
// del repo pasaba de largo, resolvía al padre y daba una ruta «real» que no era por donde se iba a
|
|
56
|
+
// escribir (Review Focus 6). Con `lstat` el enlace existe, `realpath` no lo resuelve y falla cerrado.
|
|
57
|
+
existe(p) {
|
|
58
|
+
try {
|
|
59
|
+
lstatSync(p);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
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 };
|
|
182
|
+
// Para hablar con `security`/`secret-tool`. stderr se captura sólo como señal (¿dijo algo?: es lo
|
|
183
|
+
// que separa «no encontrado» de «sin D-Bus» en `secret-tool`), recortado, y nunca se muestra ni se
|
|
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
|
|
185
|
+
// resuelve sin esperar a que muera: un `close` que no llega no puede colgar el comando (Review Focus 2).
|
|
186
|
+
// Un entorno mínimo para el hijo: con `process.env` entero, SUPCOFLOW_TOKEN (y cualquier otro
|
|
187
|
+
// secreto del shell) llegaría a `security`/`secret-tool`, que no lo necesitan. Las variables de D-Bus
|
|
188
|
+
// y XDG sí: sin ellas `secret-tool` no encuentra el bus del Secret Service.
|
|
189
|
+
const VARIABLES_DEL_HIJO = ["PATH", "HOME", "USER", "LANG", "DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "DISPLAY", "XDG_CONFIG_HOME"];
|
|
190
|
+
function entornoDelHijo() {
|
|
191
|
+
const r = {};
|
|
192
|
+
for (const k of VARIABLES_DEL_HIJO) {
|
|
193
|
+
const v = process.env[k];
|
|
194
|
+
if (v !== undefined)
|
|
195
|
+
r[k] = v;
|
|
196
|
+
}
|
|
197
|
+
return r;
|
|
198
|
+
}
|
|
199
|
+
const MAXIMO_DE_STDERR = 2_048;
|
|
200
|
+
export const correrReal = (bin, args, stdin, techoMs) => new Promise((resolve) => {
|
|
201
|
+
let listo = false;
|
|
202
|
+
let stdout = "";
|
|
203
|
+
let stderr = "";
|
|
204
|
+
const terminar = (r) => {
|
|
205
|
+
if (listo)
|
|
206
|
+
return;
|
|
207
|
+
listo = true;
|
|
208
|
+
clearTimeout(reloj);
|
|
209
|
+
resolve({ ...r, stdout, stderr });
|
|
210
|
+
};
|
|
211
|
+
const hijo = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"], env: entornoDelHijo() });
|
|
212
|
+
const reloj = setTimeout(() => {
|
|
213
|
+
hijo.kill("SIGKILL");
|
|
214
|
+
terminar({ codigo: null, vencido: true });
|
|
215
|
+
}, techoMs);
|
|
216
|
+
hijo.on("error", (e) => {
|
|
217
|
+
terminar({ codigo: null, vencido: false, ausente: e.code === "ENOENT" ? true : undefined });
|
|
218
|
+
});
|
|
219
|
+
hijo.stdout.on("data", (b) => (stdout += b.toString("utf8")));
|
|
220
|
+
// Se lee siempre (un pipe lleno que nadie vacía colgaría al hijo) pero se guarda poco.
|
|
221
|
+
hijo.stderr.on("data", (b) => {
|
|
222
|
+
if (stderr.length < MAXIMO_DE_STDERR)
|
|
223
|
+
stderr = (stderr + b.toString("utf8")).slice(0, MAXIMO_DE_STDERR);
|
|
224
|
+
});
|
|
225
|
+
hijo.on("close", (codigo) => terminar({ codigo, vencido: false }));
|
|
226
|
+
// Un hijo que muere antes de leer deja un EPIPE en stdin; sin este handler tumbaría el proceso.
|
|
227
|
+
hijo.stdin.on("error", () => { });
|
|
228
|
+
hijo.stdin.end(stdin ?? undefined);
|
|
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.
|
|
232
|
+
export const gitReal = (args, cwd) => new Promise((resolve) => {
|
|
233
|
+
execFile("git", args, { cwd, timeout: 5_000, env: { PATH: process.env.PATH ?? "" } }, (e, stdout) => {
|
|
234
|
+
if (!e)
|
|
235
|
+
return resolve({ codigo: 0, stdout: String(stdout) });
|
|
236
|
+
const codigo = e.code;
|
|
237
|
+
resolve({ codigo: typeof codigo === "number" ? codigo : null, stdout: "" });
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
// `.supcoflow/proyecto.json` vive DENTRO del repo, y el repo puede ser un clon ajeno: su contenido lo
|
|
241
|
+
// decide quien lo escribió. Por eso ni la carpeta ni el archivo se siguen si son enlaces (uno hacia
|
|
242
|
+
// `~/.ssh/…` haría que la terminal leyera, o escribiera, lo que el repo quiera), y se lee con tope.
|
|
243
|
+
export const TOPE_DEL_ARCHIVO_DEL_REPO = 64 * 1024;
|
|
244
|
+
// Lo que devuelve `leer` cuando hay ALGO ahí que no se lee (un enlace, una carpeta, algo enorme, sin
|
|
245
|
+
// permiso). No es JSON a propósito: `proyecto crear` lo trata como un archivo distinto (sale 4, «no lo
|
|
246
|
+
// toqué») sin que nada de lo que hay ahí llegue a la salida.
|
|
247
|
+
export const ARCHIVO_QUE_NO_SE_LEE = "\u0000(no se lee)";
|
|
248
|
+
// Un archivo que la persona nombra (el logo) con las mismas defensas que los del repo: sin seguir
|
|
249
|
+
// enlaces, sin FIFOs que cuelguen, sólo archivos comunes y con tope (se lee tope + 1 por si crece).
|
|
250
|
+
export function leerArchivoChico(p, tope) {
|
|
251
|
+
let fd;
|
|
252
|
+
try {
|
|
253
|
+
fd = openSync(p, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
const code = e.code;
|
|
257
|
+
return { error: code === "ENOENT" ? "no existe" : code === "ELOOP" ? "es un enlace" : "no se pudo abrir" };
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
if (!fstatSync(fd).isFile())
|
|
261
|
+
return { error: "no es un archivo común" };
|
|
262
|
+
const buf = Buffer.alloc(tope + 1);
|
|
263
|
+
let n = 0;
|
|
264
|
+
for (;;) {
|
|
265
|
+
const leidos = readSync(fd, buf, n, buf.length - n, null);
|
|
266
|
+
if (leidos === 0)
|
|
267
|
+
break;
|
|
268
|
+
n += leidos;
|
|
269
|
+
if (n > tope)
|
|
270
|
+
return { error: "es demasiado grande" };
|
|
271
|
+
}
|
|
272
|
+
return { bytes: new Uint8Array(buf.subarray(0, n)) };
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return { error: "no se pudo leer" };
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
closeSync(fd);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
export function archivosDelRepo(avisar) {
|
|
282
|
+
// La carpeta: tiene que ser una carpeta de verdad. Un enlace o un archivo con ese nombre se rechaza;
|
|
283
|
+
// `null` sólo si no existe.
|
|
284
|
+
const carpetaPropia = (dir) => {
|
|
285
|
+
try {
|
|
286
|
+
const st = lstatSync(dir);
|
|
287
|
+
return st.isDirectory() && !st.isSymbolicLink() ? "propia" : "ajena";
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
return e.code === "ENOENT" ? "ausente" : "ajena";
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
const noSeLee = (p, porque) => {
|
|
294
|
+
avisar(`No leo ${p}: ${porque}.`);
|
|
295
|
+
return ARCHIVO_QUE_NO_SE_LEE;
|
|
296
|
+
};
|
|
297
|
+
return {
|
|
298
|
+
leer(p) {
|
|
299
|
+
const carpeta = carpetaPropia(dirname(p));
|
|
300
|
+
if (carpeta === "ausente")
|
|
301
|
+
return null;
|
|
302
|
+
if (carpeta === "ajena")
|
|
303
|
+
return noSeLee(p, `${basename(dirname(p))} no es una carpeta común (¿un enlace?)`);
|
|
304
|
+
let fd;
|
|
305
|
+
try {
|
|
306
|
+
// O_NOFOLLOW: un enlace en el último tramo falla (ELOOP) en vez de abrir su destino.
|
|
307
|
+
// O_NONBLOCK: abrir un FIFO no se cuelga esperando a quien escriba.
|
|
308
|
+
fd = openSync(p, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
const code = e.code;
|
|
312
|
+
if (code === "ENOENT")
|
|
313
|
+
return null;
|
|
314
|
+
return noSeLee(p, code === "ELOOP" ? "es un enlace" : "no se pudo abrir");
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
const st = fstatSync(fd);
|
|
318
|
+
if (!st.isFile())
|
|
319
|
+
return noSeLee(p, "no es un archivo común");
|
|
320
|
+
if (st.size > TOPE_DEL_ARCHIVO_DEL_REPO)
|
|
321
|
+
return noSeLee(p, "es demasiado grande");
|
|
322
|
+
// Se lee hasta el tope + 1 y no `st.size`: el archivo puede crecer entre el fstat y la lectura.
|
|
323
|
+
const buf = Buffer.alloc(TOPE_DEL_ARCHIVO_DEL_REPO + 1);
|
|
324
|
+
let n = 0;
|
|
325
|
+
for (;;) {
|
|
326
|
+
const leidos = readSync(fd, buf, n, buf.length - n, null);
|
|
327
|
+
if (leidos === 0)
|
|
328
|
+
break;
|
|
329
|
+
n += leidos;
|
|
330
|
+
if (n > TOPE_DEL_ARCHIVO_DEL_REPO)
|
|
331
|
+
return noSeLee(p, "es demasiado grande");
|
|
332
|
+
}
|
|
333
|
+
return buf.subarray(0, n).toString("utf8");
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return noSeLee(p, "no se pudo leer");
|
|
337
|
+
}
|
|
338
|
+
finally {
|
|
339
|
+
closeSync(fd);
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
// Sólo se llama cuando `leer` dijo que no hay nada. El `.tmp` lleva un nombre al azar y se crea en
|
|
343
|
+
// exclusiva (`wx` no atraviesa un enlace que alguien dejara con ese nombre), y la carpeta se vuelve
|
|
344
|
+
// a mirar DESPUÉS del mkdir: `mkdir -p` sobre un enlace a una carpeta no falla, y escribir ahí
|
|
345
|
+
// sería escribir donde el repo quiera.
|
|
346
|
+
escribir(p, s) {
|
|
347
|
+
const dir = dirname(p);
|
|
348
|
+
mkdirSync(dir, { recursive: true });
|
|
349
|
+
if (carpetaPropia(dir) !== "propia")
|
|
350
|
+
throw new Error(`${dir} no es una carpeta común`);
|
|
351
|
+
const tmp = join(dir, `.${basename(p)}.${randomUUID()}.tmp`);
|
|
352
|
+
try {
|
|
353
|
+
const fd = openSync(tmp, "wx", 0o644);
|
|
354
|
+
try {
|
|
355
|
+
writeSync(fd, s);
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
closeSync(fd);
|
|
359
|
+
}
|
|
360
|
+
renameSync(tmp, p);
|
|
361
|
+
}
|
|
362
|
+
catch (e) {
|
|
363
|
+
rmSync(tmp, { force: true });
|
|
364
|
+
throw e;
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const LOCALES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
370
|
+
export function entornoReal(env = process.env) {
|
|
371
|
+
const api = urlDeLaApi(env);
|
|
372
|
+
// Perillas de PRUEBA, no del producto: sólo valen contra un motor en la propia máquina. Contra
|
|
373
|
+
// producción se ignoran, así una variable olvidada en el shell no saca la clave del llavero ni
|
|
374
|
+
// acorta los tramos de nadie.
|
|
375
|
+
const local = LOCALES.has(api.hostname);
|
|
376
|
+
const sinLlavero = local && env.SUPCOFLOW_SIN_LLAVERO === "1";
|
|
377
|
+
const tramo = Number(env.SUPCOFLOW_TRAMO_MS);
|
|
378
|
+
const tramoMs = local && Number.isInteger(tramo) && tramo > 0 && tramo <= TRAMO_MAXIMO_MS ? tramo : undefined;
|
|
379
|
+
const avisar = (s) => void process.stderr.write(s + "\n");
|
|
380
|
+
// `||` y no `??`: un XDG_CONFIG_HOME vacío daría una ruta RELATIVA, que cae donde esté parado el
|
|
381
|
+
// comando; la especificación de XDG lo trata como no definido.
|
|
382
|
+
const rutaDelArchivo = join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "supcoflow", "credenciales.json");
|
|
383
|
+
const llavero = sinLlavero ? null : almacenDeLlavero(process.platform, correrReal);
|
|
384
|
+
const guardado = crearGuardado({
|
|
385
|
+
env, llavero, archivo: almacenDeArchivo(rutaDelArchivo, fsReal), rutaDelArchivo, fs: fsReal, ahora: Date.now, avisar,
|
|
386
|
+
});
|
|
387
|
+
// Al lado de las credenciales y con el mismo almacén (carpeta 0700, archivo 0600, `.tmp` en
|
|
388
|
+
// exclusiva): nunca dentro del repo donde se corre el comando, que la publicaría con un `git add -A`.
|
|
389
|
+
const rutaDeIdempotencia = join(dirname(rutaDelArchivo), "idempotencia.json");
|
|
390
|
+
const idempotencia = crearRegistroDeIdempotencia({
|
|
391
|
+
origen: api.origin,
|
|
392
|
+
almacen: almacenDeArchivo(rutaDeIdempotencia, fsReal),
|
|
393
|
+
rutaDelArchivo: rutaDeIdempotencia,
|
|
394
|
+
fs: fsReal,
|
|
395
|
+
generar: randomUUID,
|
|
396
|
+
ahora: Date.now,
|
|
397
|
+
avisar,
|
|
398
|
+
});
|
|
399
|
+
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
400
|
+
const cwd = process.cwd();
|
|
401
|
+
// Los candados de `claves crear` viven al lado de las credenciales, nunca en un repo.
|
|
402
|
+
const candados = crearCandados(fsDelCandadoReal, join(dirname(rutaDelArchivo), "candados"), process.pid, procesoVivo);
|
|
403
|
+
const secretos = crearGuardaDeSecretos(fsDeSecretosReal);
|
|
404
|
+
const configurarEnElMotor = {
|
|
405
|
+
api, guardado, git: gitReal, cwd, avisar,
|
|
406
|
+
buscarProyecto: (a, clave, repo) => buscarProyecto(a, clave, repo, fetch),
|
|
407
|
+
leerConfiguracion: (a, clave, id) => leerConfiguracion(a, clave, id, fetch),
|
|
408
|
+
configurar: (a, clave, ruta, cuerpo, extra) => configurar(a, clave, ruta, cuerpo, fetch, extra),
|
|
409
|
+
};
|
|
410
|
+
return {
|
|
411
|
+
env,
|
|
412
|
+
api,
|
|
413
|
+
esTty: process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
414
|
+
mostrar: avisar,
|
|
415
|
+
avisar,
|
|
416
|
+
rutaDelArchivo,
|
|
417
|
+
llavero,
|
|
418
|
+
guardado,
|
|
419
|
+
login: {
|
|
420
|
+
api,
|
|
421
|
+
guardado,
|
|
422
|
+
maquina: hostname(),
|
|
423
|
+
ahora: Date.now,
|
|
424
|
+
dormir,
|
|
425
|
+
pedirCodigo: (a, maquina) => pedirCodigo(a, maquina, fetch),
|
|
426
|
+
canjear: (a, deviceCode, plazoMs) => canjear(a, deviceCode, fetch, plazoMs),
|
|
427
|
+
revocarPropia: (a, clave, plazoMs) => revocarPropia(a, clave, fetch, plazoMs),
|
|
428
|
+
avisar,
|
|
429
|
+
tramoMs,
|
|
430
|
+
},
|
|
431
|
+
quienSoy: (a, clave) => quienSoy(a, clave, fetch),
|
|
432
|
+
git: gitReal,
|
|
433
|
+
cwd,
|
|
434
|
+
idempotencia,
|
|
435
|
+
conexion: {
|
|
436
|
+
api, guardado, git: gitReal, cwd, ahora: Date.now, dormir, avisar, tramoMs,
|
|
437
|
+
crearConexion: (a, clave, repo) => crearConexion(a, clave, repo, fetch),
|
|
438
|
+
ultimaConexion: (a, clave, repo, plazoMs) => ultimaConexion(a, clave, repo, fetch, plazoMs),
|
|
439
|
+
},
|
|
440
|
+
proyecto: {
|
|
441
|
+
api, guardado, git: gitReal, cwd, idempotencia, archivos: archivosDelRepo(avisar), avisar,
|
|
442
|
+
buscarProyecto: (a, clave, repo) => buscarProyecto(a, clave, repo, fetch),
|
|
443
|
+
ultimaConexion: (a, clave, repo, plazoMs) => ultimaConexion(a, clave, repo, fetch, plazoMs),
|
|
444
|
+
crearProyecto: (a, clave, conexionId, cuerpo, key) => crearProyecto(a, clave, conexionId, cuerpo, key, fetch),
|
|
445
|
+
leerProyecto: (a, clave, id) => leerProyecto(a, clave, id, fetch),
|
|
446
|
+
},
|
|
447
|
+
configurar: {
|
|
448
|
+
...configurarEnElMotor,
|
|
449
|
+
subirLogo: (a, clave, id, archivo) => subirLogo(a, clave, id, archivo, fetch),
|
|
450
|
+
leerArchivoChico,
|
|
451
|
+
leerCatalogo: (a, clave) => leerCatalogo(a, clave, fetch),
|
|
452
|
+
},
|
|
453
|
+
claves: {
|
|
454
|
+
...configurarEnElMotor,
|
|
455
|
+
listarClaves: (a, clave, id) => listarClaves(a, clave, id, fetch),
|
|
456
|
+
esClaveDeConsultaDelProyecto: (a, clave, id) => esClaveDeConsultaDelProyecto(a, clave, id, fetch),
|
|
457
|
+
idempotencia, candados, secretos,
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
];
|
|
8
|
+
export const FLAGS_POR_COMANDO = [
|
|
9
|
+
{ flags: ["start", "wait"], comandos: ["login", "repo conectar"], texto: "--start y --wait son de `supcoflow login` y `supcoflow repo conectar`." },
|
|
10
|
+
{
|
|
11
|
+
flags: ["repo"], comandos: ["repo conectar", "proyecto crear", "proyecto ver", ...DE_CONFIGURAR_CON_REPO],
|
|
12
|
+
texto: "--repo es de `supcoflow repo conectar`, `proyecto crear|ver` y los comandos que configuran un proyecto (no de `catalogo`).",
|
|
13
|
+
},
|
|
14
|
+
{ flags: ["install", "test"], comandos: ["proyecto crear"], texto: "--install y --test son de `supcoflow proyecto crear`." },
|
|
15
|
+
{ flags: ["sitio"], comandos: ["sitios agregar", "sitios quitar"], texto: "--sitio es de `supcoflow sitios agregar|quitar`." },
|
|
16
|
+
{ flags: ["titulo", "saludo", "tema"], comandos: ["chat marca"], texto: "--titulo, --saludo y --tema son de `supcoflow chat marca`." },
|
|
17
|
+
{ flags: ["archivo"], comandos: ["chat logo"], texto: "--archivo es de `supcoflow chat logo`." },
|
|
18
|
+
{ flags: ["permitir"], comandos: ["datos fijar"], texto: "--permitir es de `supcoflow datos fijar`." },
|
|
19
|
+
{ flags: ["modo"], comandos: ["politica fijar"], texto: "--modo es de `supcoflow politica fijar`." },
|
|
20
|
+
{ flags: ["nombre", "guardarEn"], comandos: ["claves crear"], texto: "--nombre y --guardar-en son de `supcoflow claves crear`." },
|
|
21
|
+
{ flags: ["id"], comandos: ["claves revocar"], texto: "--id es de `supcoflow claves revocar`." },
|
|
22
|
+
];
|
|
23
|
+
/** El mensaje del primer flag presente que el comando no acepta, o null si todo aplica. */
|
|
24
|
+
export function flagQueNoAplica(comando, presentes) {
|
|
25
|
+
for (const regla of FLAGS_POR_COMANDO) {
|
|
26
|
+
if (regla.flags.some((f) => presentes.includes(f)) && !regla.comandos.includes(comando))
|
|
27
|
+
return regla.texto;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// El archivo de credenciales, para cuando no hay llavero: un JSON `{ "<cuenta>": Registro }`.
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function esRegistro(v) {
|
|
4
|
+
return typeof v === "object" && v !== null
|
|
5
|
+
&& typeof v.valor === "string" && typeof v.escritoEn === "number";
|
|
6
|
+
}
|
|
7
|
+
export function almacenDeArchivo(ruta, fs) {
|
|
8
|
+
const dir = path.dirname(ruta);
|
|
9
|
+
const temporal = ruta + ".tmp";
|
|
10
|
+
// Un archivo que no se entiende es un FALLO, no un vacío: tratarlo como vacío y reescribirlo
|
|
11
|
+
// perdería claves por revocar que quizás siguen ahí.
|
|
12
|
+
function todo() {
|
|
13
|
+
const crudo = fs.leer(ruta);
|
|
14
|
+
if (crudo === null)
|
|
15
|
+
return {};
|
|
16
|
+
let v;
|
|
17
|
+
try {
|
|
18
|
+
v = JSON.parse(crudo);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
v = undefined;
|
|
22
|
+
}
|
|
23
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) {
|
|
24
|
+
throw new Error(`No entiendo el archivo de credenciales ${ruta}; borralo y volvé a iniciar sesión.`);
|
|
25
|
+
}
|
|
26
|
+
return v;
|
|
27
|
+
}
|
|
28
|
+
// El `mkdir` no corrige los permisos de una carpeta que ya existía: por eso el `chmod` aparte.
|
|
29
|
+
// El `.tmp` viejo (o un enlace dejado ahí) se borra y el nuevo se crea en exclusiva, así nunca se
|
|
30
|
+
// escribe a través de un enlace ni se hereda un `0644`; el `rename` es atómico y, si `ruta` era un
|
|
31
|
+
// enlace, reemplaza el enlace y no su destino (Review Focus 6).
|
|
32
|
+
function persistir(t) {
|
|
33
|
+
fs.crearDir(dir, 0o700);
|
|
34
|
+
fs.chmod(dir, 0o700);
|
|
35
|
+
fs.borrarSiExiste(temporal);
|
|
36
|
+
fs.escribirExclusivo(temporal, JSON.stringify(t, null, 2) + "\n", 0o600);
|
|
37
|
+
fs.renombrar(temporal, ruta);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
nombre: "archivo",
|
|
41
|
+
async leer(cuenta) {
|
|
42
|
+
const t = todo();
|
|
43
|
+
if (!Object.hasOwn(t, cuenta))
|
|
44
|
+
return null;
|
|
45
|
+
const r = t[cuenta];
|
|
46
|
+
if (!esRegistro(r))
|
|
47
|
+
throw new Error(`No entiendo el archivo de credenciales ${ruta}; borralo y volvé a iniciar sesión.`);
|
|
48
|
+
return { valor: r.valor, escritoEn: r.escritoEn };
|
|
49
|
+
},
|
|
50
|
+
async escribir(cuenta, r) {
|
|
51
|
+
const t = todo();
|
|
52
|
+
t[cuenta] = { valor: r.valor, escritoEn: r.escritoEn };
|
|
53
|
+
persistir(t);
|
|
54
|
+
},
|
|
55
|
+
async borrar(cuenta) {
|
|
56
|
+
const t = todo();
|
|
57
|
+
if (!Object.hasOwn(t, cuenta))
|
|
58
|
+
return;
|
|
59
|
+
delete t[cuenta];
|
|
60
|
+
persistir(t);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|