supcoflow 0.7.0 → 0.8.1
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 +253 -176
- package/dist/alias.js +60 -0
- package/dist/api.js +41 -19
- package/dist/argumentos.js +92 -85
- package/dist/cli.js +40 -35
- package/dist/comandos/clave-vigente.js +2 -2
- package/dist/comandos/claves.js +25 -25
- package/dist/comandos/configurar.js +40 -40
- package/dist/comandos/contexto.js +102 -0
- package/dist/comandos/datos-y-politica.js +35 -23
- package/dist/comandos/describir.js +162 -152
- package/dist/comandos/guia.js +150 -62
- package/dist/comandos/instalacion.js +73 -41
- package/dist/comandos/login.js +25 -24
- package/dist/comandos/logout.js +17 -16
- package/dist/comandos/probar.js +177 -64
- package/dist/comandos/proyecto-del-repo.js +12 -2
- package/dist/comandos/proyecto.js +48 -34
- package/dist/comandos/quien-soy.js +8 -7
- package/dist/comandos/repo-conectar.js +9 -9
- package/dist/comandos/widget.js +9 -5
- package/dist/diagnostico.js +51 -0
- package/dist/entorno.js +57 -26
- package/dist/flags-por-comando.js +25 -21
- package/dist/guardado/archivo.js +2 -2
- package/dist/guardado/index.js +12 -8
- package/dist/guardado/llavero.js +5 -5
- package/dist/guardado/lugar.js +63 -0
- package/dist/guardado/protegido.js +38 -0
- package/dist/guardado/repo.js +98 -0
- package/dist/guia.js +3 -1
- package/dist/idempotencia.js +16 -28
- package/dist/instalacion.js +50 -11
- package/dist/lectura.js +10 -0
- package/dist/pruebas-guardadas.js +40 -22
- package/dist/repo-local.js +95 -13
- package/dist/secreto-a-archivo.js +6 -6
- package/package.json +1 -1
|
@@ -14,27 +14,27 @@
|
|
|
14
14
|
//
|
|
15
15
|
// Estricto en todo, como `claves crear` en el registro de idempotencia: una prueba no tiene clave natural
|
|
16
16
|
// del lado de la terminal, así que mandar sin el intento guardado arriesga una segunda prueba (que gasta).
|
|
17
|
-
import path from "node:path";
|
|
18
17
|
import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
|
|
19
|
-
import {
|
|
18
|
+
import { SinProteccionDeGit } from "./guardado/protegido.js";
|
|
20
19
|
function esIntento(v) {
|
|
21
20
|
if (typeof v !== "object" || v === null)
|
|
22
21
|
return false;
|
|
23
22
|
const i = v;
|
|
24
|
-
|
|
23
|
+
const politica = i.politica_esperada === undefined || i.politica_esperada === null || i.politica_esperada === "relevar" || i.politica_esperada === "guardar";
|
|
24
|
+
return typeof i.clave === "string" && i.clave.length > 0 && (i.mensaje === null || typeof i.mensaje === "string") && typeof i.techo_usd === "number" && politica;
|
|
25
25
|
}
|
|
26
26
|
export function crearRegistroDePruebas(d) {
|
|
27
27
|
// Por origen, como la clave y el registro de idempotencia: un motor local (o el proxy de P4) y
|
|
28
28
|
// producción no comparten intentos. Cambiar de origen entre dos comandos es otra clave.
|
|
29
29
|
const cuenta = (proyectoId) => `${d.origen}#prueba#${proyectoId}`;
|
|
30
|
-
const noGuardada = (que) => new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `
|
|
30
|
+
const noGuardada = (que) => new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `I couldn't ${que} ${d.rutaDelArchivo}; without that, a retry could ask for another test (and spend twice). Check that file and try again.`);
|
|
31
31
|
async function leer(proyectoId) {
|
|
32
32
|
let crudo;
|
|
33
33
|
try {
|
|
34
34
|
crudo = (await d.almacen.leer(cuenta(proyectoId)))?.valor ?? null;
|
|
35
35
|
}
|
|
36
36
|
catch {
|
|
37
|
-
throw noGuardada("
|
|
37
|
+
throw noGuardada("read");
|
|
38
38
|
}
|
|
39
39
|
if (crudo === null)
|
|
40
40
|
return { intento: null, ultima: null };
|
|
@@ -43,42 +43,60 @@ export function crearRegistroDePruebas(d) {
|
|
|
43
43
|
v = JSON.parse(crudo);
|
|
44
44
|
}
|
|
45
45
|
catch {
|
|
46
|
-
throw noGuardada("
|
|
46
|
+
throw noGuardada("understand");
|
|
47
47
|
}
|
|
48
48
|
const o = (typeof v === "object" && v !== null ? v : {});
|
|
49
49
|
const intentoOk = o.intento === null || esIntento(o.intento);
|
|
50
50
|
const ultimaOk = o.ultima === null || typeof o.ultima === "string";
|
|
51
51
|
if (!intentoOk || !ultimaOk)
|
|
52
|
-
throw noGuardada("
|
|
52
|
+
throw noGuardada("understand");
|
|
53
53
|
const i = o.intento;
|
|
54
|
-
return {
|
|
54
|
+
return {
|
|
55
|
+
intento: i === null ? null : { clave: i.clave, mensaje: i.mensaje, techoUsd: i.techo_usd, politicaEsperada: i.politica_esperada ?? null },
|
|
56
|
+
ultima: o.ultima,
|
|
57
|
+
};
|
|
55
58
|
}
|
|
56
59
|
async function escribir(proyectoId, g) {
|
|
57
|
-
// Misma regla que las credenciales: un destino dentro de un repo se publicaría con un `git add -A`.
|
|
58
|
-
// No saber dónde cae (un enlace roto) es otro «no se puede guardar».
|
|
59
|
-
let raiz;
|
|
60
|
-
try {
|
|
61
|
-
raiz = dentroDeUnWorkingTree(path.dirname(d.rutaDelArchivo), d.fs);
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
throw noGuardada("resolver la ruta de");
|
|
65
|
-
}
|
|
66
|
-
if (raiz !== null) {
|
|
67
|
-
throw new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `No guardo el pedido de la prueba dentro de un repo de git (${raiz}); sin guardarlo no lo mando. Apuntá XDG_CONFIG_HOME fuera de un repo.`);
|
|
68
|
-
}
|
|
69
60
|
const valor = JSON.stringify({
|
|
70
|
-
intento: g.intento === null
|
|
61
|
+
intento: g.intento === null
|
|
62
|
+
? null
|
|
63
|
+
: { clave: g.intento.clave, mensaje: g.intento.mensaje, techo_usd: g.intento.techoUsd, politica_esperada: g.intento.politicaEsperada },
|
|
71
64
|
ultima: g.ultima,
|
|
72
65
|
});
|
|
73
66
|
try {
|
|
74
67
|
await d.almacen.escribir(cuenta(proyectoId), { valor, escritoEn: d.ahora() });
|
|
75
68
|
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
// El almacén protegido no escribe dentro de un repo si git no garantiza que lo deja afuera (fricción 2c):
|
|
71
|
+
// el mensaje dice qué archivo, qué repo y qué hacer.
|
|
72
|
+
if (e instanceof SinProteccionDeGit) {
|
|
73
|
+
throw new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `I'm not saving the test request in ${d.rutaDelArchivo}; without saving it I won't send it. ${e.message}`);
|
|
74
|
+
}
|
|
75
|
+
throw noGuardada("save to");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function revisar(proyectoId) {
|
|
79
|
+
const impedimentos = [];
|
|
80
|
+
try {
|
|
81
|
+
impedimentos.push(...(await d.revisarElLugar?.() ?? []));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
impedimentos.push({ codigo: "no_se_pudo_revisar", detalle: `I couldn't check whether ${d.rutaDelArchivo} can be saved.` });
|
|
85
|
+
}
|
|
86
|
+
let intentoPendiente = null;
|
|
87
|
+
try {
|
|
88
|
+
const { intento } = await leer(proyectoId);
|
|
89
|
+
if (intento !== null)
|
|
90
|
+
intentoPendiente = { techoUsd: intento.techoUsd, politica: intento.politicaEsperada };
|
|
91
|
+
}
|
|
76
92
|
catch {
|
|
77
|
-
|
|
93
|
+
impedimentos.push({ codigo: "archivo_ilegible", detalle: `I can't read or understand ${d.rutaDelArchivo}.` });
|
|
78
94
|
}
|
|
95
|
+
return { maquina: { ok: impedimentos.length === 0, impedimentos }, intentoPendiente };
|
|
79
96
|
}
|
|
80
97
|
return {
|
|
81
98
|
leer,
|
|
99
|
+
revisar,
|
|
82
100
|
async abrir(proyectoId, intento) {
|
|
83
101
|
const antes = await leer(proyectoId);
|
|
84
102
|
await escribir(proyectoId, { intento, ultima: antes.ultima });
|
package/dist/repo-local.js
CHANGED
|
@@ -30,6 +30,52 @@ export function repoDelOrigin(url) {
|
|
|
30
30
|
}
|
|
31
31
|
return null;
|
|
32
32
|
}
|
|
33
|
+
// El `origin` con forma de alias de SSH (`git@github-gf0:dueño/repo`, `ssh://git@github-gf0/dueño/repo`): el host no
|
|
34
|
+
// es github.com, pero el dueño/repo sí tiene la forma de uno de GitHub. NO se resuelve (no se corre `ssh` ni se lee su
|
|
35
|
+
// configuración: `Match exec` ejecutaría programas de la persona): sólo se le dice qué asociar a mano, una vez.
|
|
36
|
+
const FORMAS_DE_ALIAS = [
|
|
37
|
+
/^[A-Za-z0-9._-]+@([A-Za-z0-9._-]+):([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
38
|
+
/^ssh:\/\/[A-Za-z0-9._-]+@([A-Za-z0-9._-]+)(?::\d+)?\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/,
|
|
39
|
+
];
|
|
40
|
+
export function aliasDelOrigin(url) {
|
|
41
|
+
const limpia = url.trim();
|
|
42
|
+
for (const forma of FORMAS_DE_ALIAS) {
|
|
43
|
+
const m = forma.exec(limpia);
|
|
44
|
+
if (!m)
|
|
45
|
+
continue;
|
|
46
|
+
const repo = `${m[2]}/${m[3]}`;
|
|
47
|
+
if (m[1].toLowerCase() === "github.com" || !esRepoValido(repo))
|
|
48
|
+
return null;
|
|
49
|
+
return { host: m[1], repo };
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
// Lo que se muestra de un `origin`: nunca el usuario:clave@ de una URL (podría ser un token). Las credenciales se quitan de la
|
|
54
|
+
// URL ENTERA y recién después se limita el tamaño (recortar antes dejaría pasar un `@` lejano con su clave a medias). Lo que no se
|
|
55
|
+
// puede interpretar con seguridad no se muestra crudo: sale un marcador neutro.
|
|
56
|
+
const TOPE_DEL_ORIGEN_MOSTRADO = 200;
|
|
57
|
+
export function origenParaMostrar(url) {
|
|
58
|
+
const limpia = url.trim();
|
|
59
|
+
const url_ = /^([A-Za-z][A-Za-z0-9+.-]*:\/\/)([^/\s]*)(.*)$/s.exec(limpia);
|
|
60
|
+
let mostrable = null;
|
|
61
|
+
if (url_) {
|
|
62
|
+
// La autoridad llega hasta la primera `/`; el usuario:clave es todo lo anterior al ÚLTIMO `@` de la autoridad.
|
|
63
|
+
const arroba = url_[2].lastIndexOf("@");
|
|
64
|
+
const usuario = arroba === -1 ? "" : url_[2].slice(0, arroba).split(":")[0];
|
|
65
|
+
const host = arroba === -1 ? url_[2] : url_[2].slice(arroba + 1);
|
|
66
|
+
mostrable = `${url_[1]}${usuario === "git" ? "git@" : ""}${host}${url_[3]}`;
|
|
67
|
+
}
|
|
68
|
+
else if (/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:/.test(limpia)) {
|
|
69
|
+
mostrable = limpia; // `git@host:o/r`: el usuario de un scp no lleva clave
|
|
70
|
+
}
|
|
71
|
+
if (mostrable === null || /\s/.test(mostrable))
|
|
72
|
+
return "(an address I don't recognize)";
|
|
73
|
+
return mostrable.length > TOPE_DEL_ORIGEN_MOSTRADO ? mostrable.slice(0, TOPE_DEL_ORIGEN_MOSTRADO) + "…" : mostrable;
|
|
74
|
+
}
|
|
75
|
+
// El remoto de asociación explícita: la persona le dice una sola vez a qué repo de GitHub corresponde este clon cuando su
|
|
76
|
+
// `origin` no es una dirección de GitHub (alias de SSH, otro servidor). Con las mismas formas estrictas de `origin`;
|
|
77
|
+
// nunca se usa para traer nada.
|
|
78
|
+
export const REMOTO_DE_ASOCIACION = "supcoflow";
|
|
33
79
|
export async function leerRepoLocal(git, cwd) {
|
|
34
80
|
const top = await git(["rev-parse", "--show-toplevel"], cwd);
|
|
35
81
|
const raiz = top.codigo === 0 ? top.stdout.trim() : "";
|
|
@@ -37,32 +83,68 @@ export async function leerRepoLocal(git, cwd) {
|
|
|
37
83
|
return { repo: null, raiz: null, motivo: "sin_git" };
|
|
38
84
|
const origin = await git(["remote", "get-url", "origin"], cwd);
|
|
39
85
|
const url = origin.codigo === 0 ? origin.stdout.trim() : "";
|
|
86
|
+
const repoDelOrigen = url ? repoDelOrigin(url) : null;
|
|
87
|
+
// Si `origin` es de GitHub, gana `origin`, como siempre.
|
|
88
|
+
if (repoDelOrigen)
|
|
89
|
+
return { repo: repoDelOrigen, raiz };
|
|
90
|
+
const asociado = await git(["remote", "get-url", REMOTO_DE_ASOCIACION], cwd);
|
|
91
|
+
const urlAsociada = asociado.codigo === 0 ? asociado.stdout.trim() : "";
|
|
92
|
+
const repoAsociado = urlAsociada ? repoDelOrigin(urlAsociada) : null;
|
|
93
|
+
if (repoAsociado)
|
|
94
|
+
return { repo: repoAsociado, raiz };
|
|
95
|
+
const alias = url ? aliasDelOrigin(url) : null;
|
|
96
|
+
const comun = { repo: null, raiz, ...(url ? { origen: origenParaMostrar(url) } : {}), ...(urlAsociada ? { hayRemotoSupcoflow: true } : {}) };
|
|
40
97
|
if (!url)
|
|
41
|
-
return {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
98
|
+
return { ...comun, motivo: "sin_origin" };
|
|
99
|
+
if (alias)
|
|
100
|
+
return { ...comun, motivo: "origin_alias_ssh", alias };
|
|
101
|
+
return { ...comun, motivo: "origin_no_es_github" };
|
|
102
|
+
}
|
|
103
|
+
// El comando exacto que asocia este clon con un repo de GitHub. `add` si todavía no hay remoto `supcoflow`; `set-url` si
|
|
104
|
+
// hay uno que no es una dirección de GitHub.
|
|
105
|
+
export function comandoDeAsociacion(repo, hayRemoto) {
|
|
106
|
+
return `git remote ${hayRemoto ? "set-url" : "add"} ${REMOTO_DE_ASOCIACION} https://github.com/${repo}.git`;
|
|
46
107
|
}
|
|
47
108
|
const MOTIVOS = {
|
|
48
|
-
sin_git: "
|
|
49
|
-
sin_origin: "
|
|
50
|
-
origin_no_es_github: "
|
|
109
|
+
sin_git: "this folder isn't a git repo",
|
|
110
|
+
sin_origin: "the repo has no remote called \"origin\"",
|
|
111
|
+
origin_no_es_github: "the \"origin\" remote isn't a GitHub repo",
|
|
112
|
+
origin_alias_ssh: "the \"origin\" remote isn't a GitHub repo",
|
|
51
113
|
};
|
|
114
|
+
// La frase que dice la verdad sobre un clon que no se puede atar a `repo` (owner/repo de GitHub) y cómo atarlo.
|
|
115
|
+
export function frasePorQueNoAtaClon(local, repo) {
|
|
116
|
+
if (local.motivo === "sin_git")
|
|
117
|
+
return "This folder isn't a git clone.";
|
|
118
|
+
const sugerido = repo ?? local.alias?.repo ?? null;
|
|
119
|
+
const como = sugerido === null ? ""
|
|
120
|
+
: ` If this is ${sugerido} on GitHub, tell me once: \`${comandoDeAsociacion(sugerido, local.hayRemotoSupcoflow === true)}\` (it's never fetched).`;
|
|
121
|
+
if (local.motivo === "origin_alias_ssh" && repo === null && local.alias) {
|
|
122
|
+
return `Your origin uses the SSH alias \`${local.alias.host}\`, which I don't resolve.${como}`;
|
|
123
|
+
}
|
|
124
|
+
const clon = local.origen ? `This clone is \`${local.origen}\`, which I can't tie to github.com${repo ? `/${repo}` : ""}.` : "This clone has no \"origin\" remote I can tie to GitHub.";
|
|
125
|
+
return `${clon}${como}`;
|
|
126
|
+
}
|
|
52
127
|
export async function repoPedido(d) {
|
|
53
128
|
const local = await leerRepoLocal(d.git, d.cwd);
|
|
54
129
|
if (d.flagRepo !== null) {
|
|
55
130
|
if (!esRepoValido(d.flagRepo)) {
|
|
56
|
-
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "repo_invalido", "
|
|
131
|
+
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "repo_invalido", "The value of --repo must be owner/repo (for example owner/my-app), without the URL.");
|
|
57
132
|
}
|
|
58
|
-
// La raíz sólo se da si el
|
|
133
|
+
// La raíz sólo se da si el repo local reconocido es ESE repo: con otro, lo que se escriba en esta
|
|
59
134
|
// carpeta (el archivo del proyecto) no le pertenece.
|
|
60
135
|
const mismo = local.repo !== null && local.repo.toLowerCase() === d.flagRepo.toLowerCase();
|
|
61
|
-
|
|
136
|
+
if (mismo)
|
|
137
|
+
return { repo: d.flagRepo, raiz: local.raiz };
|
|
138
|
+
// El mensaje de «no es tu clon» dice por qué, y deja el comando exacto para atarlo si la persona SABE que lo es.
|
|
139
|
+
const sinClon = local.repo !== null
|
|
140
|
+
? `This clone is github.com/${local.repo}, not ${d.flagRepo}.`
|
|
141
|
+
: frasePorQueNoAtaClon(local, d.flagRepo);
|
|
142
|
+
return { repo: d.flagRepo, raiz: null, sinClon };
|
|
62
143
|
}
|
|
63
144
|
if (local.repo === null) {
|
|
64
|
-
const porque = local.motivo ? MOTIVOS[local.motivo] : "
|
|
65
|
-
|
|
145
|
+
const porque = local.motivo ? MOTIVOS[local.motivo] : "the folder couldn't be read";
|
|
146
|
+
const detalle = local.motivo === "origin_alias_ssh" ? ` ${frasePorQueNoAtaClon(local, null)}` : "";
|
|
147
|
+
throw new FalloDeLaTerminal(SALIDA.entradaInvalida, "sin_repo", `I couldn't find a GitHub repo here (${porque}).${detalle} Pass --repo owner/repo.`);
|
|
66
148
|
}
|
|
67
149
|
return { repo: local.repo, raiz: local.raiz };
|
|
68
150
|
}
|
|
@@ -22,7 +22,7 @@ import { dentroDeUnWorkingTree } from "./guardado/repo.js";
|
|
|
22
22
|
// Una clave ocupa 85 caracteres: 4 KB es de sobra, y algo más grande ya no es una clave.
|
|
23
23
|
const TOPE = 4 * 1024;
|
|
24
24
|
const CLAVE = /^scf_([0-9a-f]{16})_[0-9a-f]{64}$/;
|
|
25
|
-
const OCUPADO = { error: "
|
|
25
|
+
const OCUPADO = { error: "the file already has something else; delete it or pick another destination", codigo: 4 };
|
|
26
26
|
export function crearGuardaDeSecretos(fs) {
|
|
27
27
|
function borrarTemporal(tmp) {
|
|
28
28
|
try {
|
|
@@ -50,10 +50,10 @@ export function crearGuardaDeSecretos(fs) {
|
|
|
50
50
|
raiz = dentroDeUnWorkingTree(path.dirname(destino), fs);
|
|
51
51
|
}
|
|
52
52
|
catch {
|
|
53
|
-
return { error: "
|
|
53
|
+
return { error: "I couldn't resolve the folder's real path (a broken link?)", codigo: 3 };
|
|
54
54
|
}
|
|
55
55
|
if (raiz !== null)
|
|
56
|
-
return { error: `
|
|
56
|
+
return { error: `it falls inside the git repo ${raiz}, and a key in a repo ends up committed; pick a destination outside it`, codigo: 3 };
|
|
57
57
|
return null;
|
|
58
58
|
}
|
|
59
59
|
return {
|
|
@@ -67,7 +67,7 @@ export function crearGuardaDeSecretos(fs) {
|
|
|
67
67
|
return invalido;
|
|
68
68
|
const hay = fs.leerSinSeguir(destino, TOPE);
|
|
69
69
|
if (hay.tipo === "enlace")
|
|
70
|
-
return { error: "
|
|
70
|
+
return { error: "the destination is a symlink; pick a regular file", codigo: 3 };
|
|
71
71
|
if (hay.tipo !== "ausente") {
|
|
72
72
|
const actual = leer(destino);
|
|
73
73
|
if (actual.estado !== "clave" || actual.claveId !== claveId)
|
|
@@ -90,7 +90,7 @@ export function crearGuardaDeSecretos(fs) {
|
|
|
90
90
|
catch {
|
|
91
91
|
// El mensaje del sistema no se reenvía: podría traer la ruta del temporal o algo peor.
|
|
92
92
|
borrarTemporal(tmp);
|
|
93
|
-
return { error: "
|
|
93
|
+
return { error: "it couldn't be written there (does the folder exist, and do you have permission?)", codigo: 3 };
|
|
94
94
|
}
|
|
95
95
|
// Después de publicar, el temporal es un nombre de más (con `link`) o ya no existe (con `rename`): no
|
|
96
96
|
// borrarlo no cambia que la clave quedó en el destino, y convertir eso en un fallo dejaría una clave
|
|
@@ -101,7 +101,7 @@ export function crearGuardaDeSecretos(fs) {
|
|
|
101
101
|
return OCUPADO;
|
|
102
102
|
// Sin caer a `rename`: eso reabre la carrera que `link` cierra. Otro destino, en otro disco, sirve.
|
|
103
103
|
if (publicado === "sin_enlaces") {
|
|
104
|
-
return { error: "
|
|
104
|
+
return { error: "that folder's disk doesn't support hard links (FAT, exFAT or a network folder?), and without them I can't write without risking overwriting another key; pick a destination on another disk", codigo: 3 };
|
|
105
105
|
}
|
|
106
106
|
return "guardado";
|
|
107
107
|
},
|
package/package.json
CHANGED