supcoflow 0.8.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 +57 -9
- package/dist/api.js +1 -1
- package/dist/comandos/contexto.js +102 -0
- package/dist/comandos/describir.js +13 -13
- package/dist/comandos/guia.js +3 -2
- package/dist/comandos/instalacion.js +3 -2
- package/dist/comandos/probar.js +40 -6
- package/dist/comandos/proyecto-del-repo.js +11 -1
- package/dist/comandos/proyecto.js +34 -20
- package/dist/diagnostico.js +3 -0
- package/dist/entorno.js +38 -8
- package/dist/guardado/lugar.js +63 -0
- package/dist/guardado/protegido.js +38 -0
- package/dist/guardado/repo.js +98 -0
- package/dist/idempotencia.js +15 -27
- package/dist/pruebas-guardadas.js +28 -16
- package/dist/repo-local.js +90 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,21 +29,56 @@ the English ones.
|
|
|
29
29
|
| `supcoflow whoami` | Which key the terminal runs with, on whose behalf and in which team. |
|
|
30
30
|
| `supcoflow repo connect` | Connects this folder's repo with the GitHub App: it gives you a link and you confirm it in the console. Without a terminal, `--start` and then `--wait`, like `login`. |
|
|
31
31
|
| `supcoflow project create` | Creates (or reuses) the project of the connected repo and records it in `.supcoflow/proyecto.json`. If the engine can't work out the commands, `--install "…" --test "…"`. |
|
|
32
|
-
| `supcoflow project show` | The repo's project: branch, commands and the state of the GitHub App
|
|
32
|
+
| `supcoflow project show` | The repo's project: branch, commands and the state of the GitHub App, starting with who you are: «You're @login in team T; repo O/R; project P (existing)». |
|
|
33
33
|
| `supcoflow guide check` | Checks `.supcoflow/guide.md` against the engine without publishing anything. |
|
|
34
34
|
| `supcoflow guide publish` | Uploads `.supcoflow/guide.md` directly (with the commit and branch your clone declares, not verified in GitHub). `--from-github` verifies against the main branch instead of uploading the file. |
|
|
35
35
|
| `supcoflow guide show` | Shows the active guide, where it comes from, which file it was read from and whether the branch is still up to date. |
|
|
36
36
|
| `supcoflow guide use --source repo\|agent` | Chooses which guide is active. |
|
|
37
37
|
| `supcoflow guide versions` | The guide that governs now and the repo's publications, newest first; at most one says «live». |
|
|
38
38
|
| `supcoflow widget` | What to paste into the app to get the chat (see «Put the chat in the app»). |
|
|
39
|
-
| `supcoflow install record --skill 0.3.
|
|
40
|
-
| `supcoflow diagnose` | Free: how far the installation is (N of 6) and whether a test would go in. |
|
|
39
|
+
| `supcoflow install record --skill 0.3.1 --file app/_supcoflow/support-chat.tsx` | Records in `.supcoflow/instalacion.json` which versions the chat was installed with and which file it lives in. |
|
|
40
|
+
| `supcoflow diagnose` | Free: how far the installation is (N of 6) and whether a test would go in (including whether this machine can save the test request). |
|
|
41
41
|
| `supcoflow test` | The end-to-end test (see «Test the installation»). |
|
|
42
42
|
| `supcoflow describe --json` | The whole contract (commands, flags, key capabilities, codes) in one line of JSON. |
|
|
43
43
|
|
|
44
44
|
`supcoflow --help` (or `supcoflow <command> --help`) shows the help with examples. The repo and project commands
|
|
45
45
|
take the repo from the folder's `origin`; `--repo owner/repo` picks it by hand.
|
|
46
46
|
|
|
47
|
+
### When the `origin` isn't a GitHub address
|
|
48
|
+
|
|
49
|
+
The terminal only recognizes `origin` when its host is exactly `github.com` (`https://github.com/o/r`,
|
|
50
|
+
`git@github.com:o/r.git`, `ssh://git@github.com/o/r`). If yours is an SSH alias (`git@github-work:o/r.git`) or
|
|
51
|
+
another server, it never runs `ssh` or reads your SSH configuration (it could run programs of yours). You tell it
|
|
52
|
+
once, in your own clone, which GitHub repo this is:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git remote add supcoflow https://github.com/owner/repo.git # it's never fetched
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A remote called `supcoflow` with those same strict forms ties the clone to that repo for every command. If `origin`
|
|
59
|
+
is a GitHub address, `origin` wins. The error says the exact command to run. With `--repo owner/repo`, the commands
|
|
60
|
+
that read or write a file of the clone (`guide check`, `guide publish`, `install record`) only use this clone if it is
|
|
61
|
+
that repo; otherwise they exit 3 saying what the clone is, without asking the engine. (`project create --repo` keeps
|
|
62
|
+
creating or reusing the project; when this clone is not that repo it does so without reading or writing this clone's
|
|
63
|
+
`.supcoflow/proyecto.json`, and when it is that repo it reads and records the file as usual.)
|
|
64
|
+
|
|
65
|
+
### Is this repo the one for this account?
|
|
66
|
+
|
|
67
|
+
Every command that acts on the repo's project first checks what it can see, and exits 4 `contexto_incoherente` (with a
|
|
68
|
+
`motivo`) without touching anything when it doesn't add up:
|
|
69
|
+
|
|
70
|
+
- `proyecto_del_clon_distinto`: `.supcoflow/proyecto.json` notes a project your key doesn't see for this repo. Check
|
|
71
|
+
which account you signed in with (`supcoflow whoami`), or review that old file.
|
|
72
|
+
- `archivo_del_clon_ilegible`: that file exists but can't be read or understood (its content is never printed).
|
|
73
|
+
- `dueno_distinto_del_login` (only when this repo has no project yet, in `project show` and `project create`; `show` stops the walk-through before `repo connect`): the repo's owner on GitHub isn't the GitHub login of your
|
|
74
|
+
key. It is a *possible* mismatch (the engine compares GitHub's number, and a login can be renamed): sign in to the
|
|
75
|
+
console with GitHub again, or as the owner. If your key comes from `SUPCOFLOW_TOKEN`, that variable wins over a new
|
|
76
|
+
login, and the terminal doesn't touch it.
|
|
77
|
+
- `sin_github` (only when this repo has no project yet, in `project show` and `project create`): the account was signed in by email; connecting a repo needs GitHub.
|
|
78
|
+
|
|
79
|
+
A project that already exists is never blocked for belonging to another person of the team. This doesn't see a project
|
|
80
|
+
of the same repo in another team when the clone doesn't note it.
|
|
81
|
+
|
|
47
82
|
### From zero to a project
|
|
48
83
|
|
|
49
84
|
1. `supcoflow login`: you approve the code in the console.
|
|
@@ -112,9 +147,9 @@ badly formed, that is an error: it never falls back to the old one.
|
|
|
112
147
|
`supcoflow guide show` shows the active guide, the file it was read from and whether the branch is still up to date
|
|
113
148
|
(`al_dia` in the JSON); `supcoflow guide versions` lists the guide that governs now and the repo's publications;
|
|
114
149
|
`supcoflow guide use --source repo|agent` chooses which one is active when there is one from the repo and one from
|
|
115
|
-
our agent. All of them take the repo from the `origin
|
|
116
|
-
|
|
117
|
-
accept `--dry-run`; `--from-github` is only for `publish`.
|
|
150
|
+
our agent. All of them take the repo from the `origin` (or a `supcoflow` remote, see above), or `--repo owner/repo`
|
|
151
|
+
(with `check` and `publish`, if this clone isn't that repo it exits 3 `falta_guia` saying what the clone is: the guide
|
|
152
|
+
is read from the file); `check`, `publish` and `use` accept `--dry-run`; `--from-github` is only for `publish`.
|
|
118
153
|
|
|
119
154
|
The format of `.supcoflow/guide.md`:
|
|
120
155
|
|
|
@@ -172,7 +207,7 @@ the repo from the `origin`, or `--repo owner/repo`.
|
|
|
172
207
|
| Command | What it does |
|
|
173
208
|
|---|---|
|
|
174
209
|
| `supcoflow diagnose` | Free and writes nothing. It prints «Checked sign-in, repo, GitHub App, allowed sites, chat and guide: N of 6, at no cost.» and, apart, «Ready for a test: yes / no, because …» with the policy, the month's balance and open tests. Exits 0 if everything is ready, 4 if something needs fixing (with each point). `--json` gives `instalacion: { ok, total: 6, puntos }` and `prueba: { listo, puntos }`. |
|
|
175
|
-
| `supcoflow test` | Without `--authorize-spend` it sends nothing: it exits 4 with the test's ceiling, the balance, the cap and what is committed this month, and the exact command to launch it. |
|
|
210
|
+
| `supcoflow test` | Without `--authorize-spend` it sends nothing: it exits 4 with the test's ceiling, the balance, the cap and what is committed this month, and the exact command to launch it. It also says, before you authorize anything, whether this machine can save the test request (`maquina: { ok, impedimentos }`) and any request still waiting for confirmation (`intento_pendiente: { techo_usd, politica }`). |
|
|
176
211
|
| `supcoflow test --authorize-spend --ceiling 2 --expect-policy investigate` | Requests the test: the problem comes in through the same door as the chat and runs an investigation that **spends** up to the ceiling you authorize (with the `save` policy it goes as far as the saved case and doesn't spend). Exits 5 with the id. `--message "…"` uses your own text. |
|
|
177
212
|
| `supcoflow test --wait` | Follows the last test up to 90 s: 5 while it runs (call it again), 0 with the issue, 4 if it needs a look in the console (it brings the link). The charge is shown as it is: `pendiente` right at the close, never 0. `--test-id prb_…` follows another one. |
|
|
178
213
|
| `supcoflow test --dry-run --authorize-spend --ceiling 2 --expect-policy investigate` | What it would do (project, ceiling, balance, policy), without requesting anything. |
|
|
@@ -190,6 +225,17 @@ requesting another. While a request is unconfirmed, another `--message`, `--ceil
|
|
|
190
225
|
open test per project: another one exits 4 `prueba_en_curso` with the id of the open one. A test's issue stays open and
|
|
191
226
|
is marked as a test.
|
|
192
227
|
|
|
228
|
+
**If your home folder is a git repo.** The request is saved in `~/.config/supcoflow/pruebas.json` (it carries the
|
|
229
|
+
ceiling, the policy and, if you passed one, your `--message`). That is allowed inside a repo only when git itself says
|
|
230
|
+
it ignores the file and doesn't track it: the first time, the terminal creates a `.gitignore` containing `*` in that
|
|
231
|
+
folder (only if there isn't one; it never edits yours), and then asks git (`git check-ignore`) about the file and its
|
|
232
|
+
`.tmp`, for every repo that contains the written or the real path. A file that is already tracked or added to the index
|
|
233
|
+
is refused with the exact `git rm --cached …` to run (the terminal never runs it or deletes anything of yours), and so
|
|
234
|
+
is a missing or failing git. The same goes for every write or delete of `idempotencia.json`. `diagnose` and `test`
|
|
235
|
+
check all this (and permissions, broken links and unreadable files) **before** you authorize, without writing
|
|
236
|
+
anything: «no obstacle found» is a check, not a promise that the disk won't fill up later. The credentials don't use
|
|
237
|
+
this exception: inside a repo they are still refused.
|
|
238
|
+
|
|
193
239
|
## With an assistant
|
|
194
240
|
|
|
195
241
|
If you use a code assistant such as Claude Code, the Supcoflow skill does the whole path for you: session, project,
|
|
@@ -231,11 +277,13 @@ Made to be read by a program, not only by a person:
|
|
|
231
277
|
- If there is no keychain, in `~/.config/supcoflow/credenciales.json` (or `$XDG_CONFIG_HOME/supcoflow/…`) with `0600`
|
|
232
278
|
permissions, and the terminal warns you.
|
|
233
279
|
- **Never inside a git repo**: if that file would land inside one (a `HOME` that is a dotfiles repo, for example), it
|
|
234
|
-
saves nothing and asks you to use the keychain or `SUPCOFLOW_TOKEN`.
|
|
280
|
+
saves nothing and asks you to use the keychain or `SUPCOFLOW_TOKEN`. (The test request and the idempotency key are
|
|
281
|
+
the only things with an exception: see «Test the installation».)
|
|
235
282
|
- **`SUPCOFLOW_TOKEN`** takes priority over what is saved: it is for CI and the cloud.
|
|
236
283
|
- The key never appears in a log or in a `--json` output.
|
|
237
284
|
- `project create` keeps its idempotency key (it isn't a secret: it only avoids creating twice if the answer is lost)
|
|
238
|
-
in `idempotencia.json`, next to the credentials and with the same permissions.
|
|
285
|
+
in `idempotencia.json`, next to the credentials and with the same permissions. Inside a repo it is saved only if git
|
|
286
|
+
ignores it and doesn't track it; otherwise the terminal says so and goes on without saving it.
|
|
239
287
|
|
|
240
288
|
When you sign in again, the previous key is revoked only after the new one is saved; if it couldn't be revoked, it is
|
|
241
289
|
noted down and retried on the next `login` or `logout`.
|
package/dist/api.js
CHANGED
|
@@ -23,7 +23,7 @@ export function urlDeLaApi(env) {
|
|
|
23
23
|
}
|
|
24
24
|
// A mano a propósito: tiene que coincidir con package.json (lo pinza test/describir.test.ts); sólo
|
|
25
25
|
// identifica a la terminal en los logs del motor, no decide nada.
|
|
26
|
-
const VERSION = "0.8.
|
|
26
|
+
const VERSION = "0.8.1";
|
|
27
27
|
const PLAZO_POR_DEFECTO_MS = 15_000;
|
|
28
28
|
// Los dos POST que crean algo: detrás, el motor acuña con Vercel Connect y le pregunta a GitHub varias
|
|
29
29
|
// veces (instalación, repos, comandos, rama). Con 15 s una respuesta lenta pero buena se cortaba y
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// ¿Este repo va con la cuenta en la que estoy? (1A.1, fricción 6). La terminal junta lo que ya sabe y frena, ANTES de
|
|
2
|
+
// conectar, crear o publicar nada, ante lo que no cierra y se puede VER:
|
|
3
|
+
// - el `.supcoflow/proyecto.json` del clon contra el proyecto que la clave ve para ese repo;
|
|
4
|
+
// - ese archivo cuando no se entiende;
|
|
5
|
+
// - al crear un proyecto: el dueño del repo contra el login de GitHub de la clave, y la falta de GitHub.
|
|
6
|
+
// Qué NO cubre: un proyecto del mismo repo en OTRO equipo cuando el clon no tiene su archivo (el motor tendría que mirar
|
|
7
|
+
// en todos los equipos y eso filtra información entre clientes), ni prueba una identidad: el motor compara el número de
|
|
8
|
+
// GitHub y el login es una etiqueta que se puede cambiar. Por eso `dueno_distinto_del_login` es una posible incoherencia.
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { SALIDA } from "../contrato.js";
|
|
11
|
+
// Lo único que se lee de un `.supcoflow/proyecto.json` ajeno es su id, y sólo si tiene esta forma:
|
|
12
|
+
// así lo que salga a la terminal no puede ser otra cosa que un id (Review Focus 11).
|
|
13
|
+
const FORMA_DEL_ID = /^proj_[a-z0-9-]{1,80}$/;
|
|
14
|
+
export const RUTA_DEL_ARCHIVO_DEL_CLON = path.join(".supcoflow", "proyecto.json");
|
|
15
|
+
export function idDelArchivo(crudo) {
|
|
16
|
+
try {
|
|
17
|
+
const v = JSON.parse(crudo);
|
|
18
|
+
if (typeof v !== "object" || v === null)
|
|
19
|
+
return null;
|
|
20
|
+
const id = v.id;
|
|
21
|
+
return typeof id === "string" && FORMA_DEL_ID.test(id) ? id : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Los tres estados del archivo del clon. Nunca se imprime su contenido; sólo el id, si tiene forma de id. */
|
|
28
|
+
export function leerElArchivoDelClon(archivos, raiz) {
|
|
29
|
+
// Sin clon de este repo (un `--repo` ajeno a la carpeta) no hay archivo que le pertenezca.
|
|
30
|
+
if (archivos === undefined || raiz === null)
|
|
31
|
+
return { estado: "ausente" };
|
|
32
|
+
let crudo;
|
|
33
|
+
try {
|
|
34
|
+
crudo = archivos.leer(path.join(raiz, RUTA_DEL_ARCHIVO_DEL_CLON));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { estado: "ilegible" };
|
|
38
|
+
}
|
|
39
|
+
if (crudo === null)
|
|
40
|
+
return { estado: "ausente" };
|
|
41
|
+
const id = idDelArchivo(crudo);
|
|
42
|
+
return id === null ? { estado: "ilegible" } : { estado: "valido", id };
|
|
43
|
+
}
|
|
44
|
+
export function contextoIncoherente(motivo, message, extra = {}) {
|
|
45
|
+
return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "contexto_incoherente", motivo, message, ...extra }, texto: message };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* El clon contra lo que la clave ve (lo que comparten TODOS los comandos que actúan sobre el proyecto del repo).
|
|
49
|
+
* `null` = coherente. `proyecto` es el que la clave ve para este repo, o null si no ve ninguno.
|
|
50
|
+
*/
|
|
51
|
+
export function coherenciaDelClon(archivo, proyecto, repo) {
|
|
52
|
+
if (archivo.estado === "ilegible") {
|
|
53
|
+
return contextoIncoherente("archivo_del_clon_ilegible", "I can't read `.supcoflow/proyecto.json` in this clone; I won't act on this repo until it's readable. Look at it before going on.", { repo, en_el_clon: { estado: "ilegible", id: null } });
|
|
54
|
+
}
|
|
55
|
+
if (archivo.estado === "valido" && archivo.id !== proyecto?.id) {
|
|
56
|
+
return contextoIncoherente("proyecto_del_clon_distinto", `The project noted in this clone (${archivo.id}) isn't the one your account sees for this repo (${proyecto?.id ?? "none"}). `
|
|
57
|
+
+ "Check which account you signed in with (`supcoflow whoami`), or, if that file is old, review it.", { repo, en_el_clon: { estado: "valido", id: archivo.id }, proyecto: proyecto?.id ?? null });
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
export function armarElContexto(d) {
|
|
62
|
+
const { yo } = d;
|
|
63
|
+
const soloLectura = yo.via === "clave_de_consulta";
|
|
64
|
+
const login = yo.usuario?.login ?? null;
|
|
65
|
+
const tipo = soloLectura ? "clave_de_consulta" : login !== null ? "github" : "email";
|
|
66
|
+
return {
|
|
67
|
+
equipo: yo.equipo,
|
|
68
|
+
identidad: tipo === "github" ? login : tipo,
|
|
69
|
+
identidad_tipo: tipo,
|
|
70
|
+
fuente: d.fuente,
|
|
71
|
+
repo: d.repo,
|
|
72
|
+
proyecto: d.proyecto?.id ?? null,
|
|
73
|
+
en_el_clon: { estado: d.archivo.estado, id: d.archivo.estado === "valido" ? d.archivo.id : null },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** La línea que la persona lee: quién es, en qué equipo, qué repo y qué proyecto. */
|
|
77
|
+
export function lineaDelContexto(c, readOnlyDe = null) {
|
|
78
|
+
const quien = c.identidad_tipo === "github" ? `@${c.identidad}` : c.identidad_tipo === "email" ? "signed in with email (no GitHub)" : `using a read-only key${readOnlyDe ? ` for ${readOnlyDe}` : ""}`;
|
|
79
|
+
const cuenta = `You're ${quien} in team ${c.equipo}`;
|
|
80
|
+
const proyecto = c.proyecto !== null ? `project ${c.proyecto} (existing)` : `no project yet (it will be created in team ${c.equipo})`;
|
|
81
|
+
return `${cuenta}; repo ${c.repo}; ${proyecto}.`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Sólo cuando la clave NO ve proyecto para el repo y se va a crear uno: lo que se ve de la cuenta contra el repo.
|
|
85
|
+
* Una clave de consulta no crea nada, así que no se compara su login con nadie.
|
|
86
|
+
*/
|
|
87
|
+
export function coherenciaAlCrear(d) {
|
|
88
|
+
if (d.yo.via === "clave_de_consulta")
|
|
89
|
+
return null;
|
|
90
|
+
const login = d.yo.usuario?.login ?? null;
|
|
91
|
+
if (login === null) {
|
|
92
|
+
return contextoIncoherente("sin_github", "Connecting a repo needs a Supcoflow account signed in with GitHub.", { repo: d.repo });
|
|
93
|
+
}
|
|
94
|
+
const dueno = d.repo.split("/")[0];
|
|
95
|
+
if (dueno.toLowerCase() === login.toLowerCase())
|
|
96
|
+
return null;
|
|
97
|
+
const deLaVariable = d.fuente === "env"
|
|
98
|
+
? " Your key comes from SUPCOFLOW_TOKEN, which wins over a new login: unset it or replace it first; I don't touch it."
|
|
99
|
+
: "";
|
|
100
|
+
return contextoIncoherente("dueno_distinto_del_login", `This repo's owner on GitHub (@${dueno}) isn't the GitHub account your key shows (@${login}). `
|
|
101
|
+
+ `If you renamed your GitHub account, sign in to the Supcoflow console with GitHub again (that updates it) and run this again; otherwise sign in as @${dueno}.${deLaVariable}`, { repo: d.repo, dueno, login, ...(d.fuente === "env" ? { fuente: "env" } : {}) });
|
|
102
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ayuda pensada para personas. La ayuda se arma desde acá: una sola fuente, así no se desfasan.
|
|
4
4
|
import { SALIDA } from "../contrato.js";
|
|
5
5
|
// A mano a propósito, como en api.ts: tiene que coincidir con package.json (lo pinza un test).
|
|
6
|
-
const VERSION = "0.8.
|
|
6
|
+
const VERSION = "0.8.1";
|
|
7
7
|
export const DESCRIPCION = {
|
|
8
8
|
version: VERSION,
|
|
9
9
|
comandos: [
|
|
@@ -55,11 +55,11 @@ export const DESCRIPCION = {
|
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
nombre: "project create",
|
|
58
|
-
resumen: "Creates (or reuses) the already connected repo's project and records it in .supcoflow/proyecto.json; a different existing file is never overwritten (exits 4).",
|
|
58
|
+
resumen: "Creates (or reuses) the already connected repo's project and records it in .supcoflow/proyecto.json; a different existing file is never overwritten (exits 4). Before connecting or creating anything it checks what it can see and exits 4 contexto_incoherente (motivo proyecto_del_clon_distinto: the clone notes a project your account doesn't see for this repo; archivo_del_clon_ilegible: .supcoflow/proyecto.json can't be read; dueno_distinto_del_login: a possible mismatch, the repo's owner on GitHub isn't the GitHub login of your key; sin_github: the account isn't signed in with GitHub) without touching anything.",
|
|
59
59
|
flags: [
|
|
60
60
|
"--install \"…\": the install command, when the engine can't work it out",
|
|
61
61
|
"--test \"…\": the test command, same",
|
|
62
|
-
"--repo owner/repo: the repo, if it isn't this folder's «origin» (without writing
|
|
62
|
+
"--repo owner/repo: the repo, if it isn't this folder's «origin» (without reading or writing this clone's file)",
|
|
63
63
|
"--dry-run: says what it would create without creating anything",
|
|
64
64
|
"--json",
|
|
65
65
|
],
|
|
@@ -68,10 +68,10 @@ export const DESCRIPCION = {
|
|
|
68
68
|
},
|
|
69
69
|
{
|
|
70
70
|
nombre: "project show",
|
|
71
|
-
resumen: "Shows the repo's project: branch, commands and the GitHub App's state; null if it has none yet.",
|
|
71
|
+
resumen: "Shows the repo's project: branch, commands and the GitHub App's state; null if it has none yet. It starts with who you are: `contexto` { equipo, identidad (the GitHub login, or «email», or «clave_de_consulta»), identidad_tipo, fuente, repo, proyecto (id or null), en_el_clon { estado: ausente|valido|ilegible, id } }, said as «You're @login in team T; repo O/R; project P (existing)». Exits 4 contexto_incoherente if this clone's .supcoflow/proyecto.json doesn't match what your key sees (proyecto_del_clon_distinto / archivo_del_clon_ilegible) or, when the repo has no project yet, if the account can't create one for it (dueno_distinto_del_login, a possible mismatch / sin_github): it stops BEFORE `repo connect`. The repo comes from the «origin» of this clone; if origin is an SSH alias or not GitHub, from a remote called `supcoflow` that points at https://github.com/owner/repo.git (the first time, `git remote add supcoflow https://github.com/owner/repo.git`: it is never fetched and ssh is never run).",
|
|
72
72
|
flags: ["--repo owner/repo: the repo, if it isn't this folder's «origin»", "--json"],
|
|
73
73
|
ejemplos: ["supcoflow project show --json"],
|
|
74
|
-
codigos: [0, 1, 2, 3],
|
|
74
|
+
codigos: [0, 1, 2, 3, 4],
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
nombre: "catalog",
|
|
@@ -82,7 +82,7 @@ export const DESCRIPCION = {
|
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
nombre: "config",
|
|
85
|
-
resumen: "Shows the repo's project configuration: chat, sites, brand, problem policy and data agreement. The agreement limits what is saved and what the investigating AI reads; it doesn't cover what the chat's AI sees while it talks.",
|
|
85
|
+
resumen: "Shows the repo's project configuration: chat, sites, brand, problem policy and data agreement. The agreement limits what is saved and what the investigating AI reads; it doesn't cover what the chat's AI sees while it talks. Like every command that acts on the repo's project, it exits 4 contexto_incoherente without doing anything if this clone's .supcoflow/proyecto.json notes another project than the one your key sees (proyecto_del_clon_distinto) or can't be read (archivo_del_clon_ilegible).",
|
|
86
86
|
flags: ["--repo owner/repo: the repo, if it isn't this folder's «origin»", "--json"],
|
|
87
87
|
ejemplos: ["supcoflow config --json"],
|
|
88
88
|
codigos: [0, 1, 2, 3, 4],
|
|
@@ -185,14 +185,14 @@ export const DESCRIPCION = {
|
|
|
185
185
|
{
|
|
186
186
|
nombre: "guide check",
|
|
187
187
|
resumen: "Checks .supcoflow/guide.md (this clone's root) against the engine, without publishing anything: says whether it's well formed and how many sections it has. It's the step before the commit. If guide.md doesn't exist it reads the old name .supcoflow/guia.md and says so.",
|
|
188
|
-
flags: ["--repo owner/repo: the repo, if it isn't this folder's «origin» (
|
|
188
|
+
flags: ["--repo owner/repo: the repo, if it isn't this folder's «origin» (the guide is read from this clone only if it is that repo: its origin, or a `supcoflow` remote at https://github.com/owner/repo.git; otherwise it exits 3 falta_guia saying what this clone is and the exact `git remote add supcoflow …`, without asking the engine)", "--dry-run: says what it would send without sending it", "--json"],
|
|
189
189
|
ejemplos: ["supcoflow guide check --json"],
|
|
190
190
|
codigos: [0, 1, 2, 3, 4],
|
|
191
191
|
},
|
|
192
192
|
{
|
|
193
193
|
nombre: "guide publish",
|
|
194
194
|
resumen: "Uploads .supcoflow/guide.md directly (or the old name guia.md, only if guide.md doesn't exist): sends the content, the file it read and the commit and branch this clone declares (if there's git and commits; otherwise, no commit). The engine checks it and makes it the active guide; the commit is kept as «declared by the terminal, not verified in GitHub». With --from-github it keeps the earlier route: it sends the fingerprint and the engine compares it with the main branch's; if it isn't pushed to that branch yet (or it's another one), it exits 5.",
|
|
195
|
-
flags: ["--from-github: doesn't upload the file; verifies against GitHub's main branch (exits 5 guia_no_subida / guia_distinta if it doesn't match)", "--repo owner/repo: the repo, if it isn't this folder's «origin» (
|
|
195
|
+
flags: ["--from-github: doesn't upload the file; verifies against GitHub's main branch (exits 5 guia_no_subida / guia_distinta if it doesn't match)", "--repo owner/repo: the repo, if it isn't this folder's «origin» (the guide is read from this clone only if it is that repo: its origin, or a `supcoflow` remote at https://github.com/owner/repo.git; otherwise it exits 3 falta_guia saying what this clone is and the exact `git remote add supcoflow …`, without asking the engine)", "--dry-run: says what it would send (or the fingerprint with --from-github), without requesting anything", "--json"],
|
|
196
196
|
ejemplos: ["supcoflow guide publish --json"],
|
|
197
197
|
codigos: [0, 1, 2, 3, 4, 5],
|
|
198
198
|
},
|
|
@@ -231,23 +231,23 @@ export const DESCRIPCION = {
|
|
|
231
231
|
"--skill version: the metadata.version of the skill that installs it (semver, required; without it exits 3 falta_skill without requesting anything)",
|
|
232
232
|
"--file path: the chat file, relative to the repo root (no `..`, not absolute, not a symlink or through one; otherwise exits 3 archivo_invalido and writes nothing)",
|
|
233
233
|
"--app folder: the app's folder, relative to the repo root (the root by default; outside the repo it exits 3 app_fuera_del_repo)",
|
|
234
|
-
"--repo owner/repo: the repo, if it isn't this folder's «origin» (
|
|
234
|
+
"--repo owner/repo: the repo, if it isn't this folder's «origin» (the file goes in this clone only if it is that repo: its origin, or a `supcoflow` remote at https://github.com/owner/repo.git; otherwise it exits 3 sin_clon saying what this clone is and the exact `git remote add supcoflow …`, without asking the engine)",
|
|
235
235
|
"--dry-run: shows the file and the difference without writing anything",
|
|
236
236
|
"--json",
|
|
237
237
|
],
|
|
238
|
-
ejemplos: ["supcoflow install record --skill 0.3.
|
|
238
|
+
ejemplos: ["supcoflow install record --skill 0.3.1 --app web --file web/app/_supcoflow/support-chat.tsx --json"],
|
|
239
239
|
codigos: [0, 1, 2, 3, 4],
|
|
240
240
|
},
|
|
241
241
|
{
|
|
242
242
|
nombre: "diagnose",
|
|
243
|
-
resumen: "Says, free of charge and writing nothing, how far the installation is (sign-in, repo, GitHub App, allowed sites, chat and guide: N of 6) and, apart, whether a test of the project would go in and how far it would get: policy, monthly balance against the test's ceiling, open tests and whatever else the engine adds. Exits 0 if everything is ready and 4 if something needs fixing (no_listo), with each point.",
|
|
244
|
-
flags: ["--repo owner/repo: the repo, if it isn't this folder's «origin»", "--json: { instalacion: { ok, total: 6, puntos }, prueba: { listo, puntos }, … }"],
|
|
243
|
+
resumen: "Says, free of charge and writing nothing, how far the installation is (sign-in, repo, GitHub App, allowed sites, chat and guide: N of 6) and, apart, whether a test of the project would go in and how far it would get: policy, monthly balance against the test's ceiling, open tests, whether THIS machine can save the test request (the local point `maquina`, checked without writing anything) and whatever else the engine adds. Exits 0 if everything is ready and 4 if something needs fixing (no_listo), with each point; if `maquina` isn't ok, `listo` and `prueba.listo` are false and the N of 6 doesn't change.",
|
|
244
|
+
flags: ["--repo owner/repo: the repo, if it isn't this folder's «origin»", "--json: { instalacion: { ok, total: 6, puntos }, prueba: { listo, puntos }, maquina: { ok, impedimentos: [{ codigo, detalle }] }, intento_pendiente: { techo_usd, politica } | null, … }"],
|
|
245
245
|
ejemplos: ["supcoflow diagnose --json"],
|
|
246
246
|
codigos: [0, 1, 2, 3, 4],
|
|
247
247
|
},
|
|
248
248
|
{
|
|
249
249
|
nombre: "test",
|
|
250
|
-
resumen: "Tests the installation end to end: a problem comes in through the same door as the chat and ends up as an issue. It spends one investigation (up to the ceiling you authorize; with the save policy it goes as far as the saved case and doesn't spend). Without --authorize-spend it sends nothing: it exits 4 falta_autorizacion with techo_usd, saldo_usd, tope_usd and comprometido_usd. With --authorize-spend, --ceiling and --expect-policy it requests the test with exactly those values (it doesn't re-read anything to choose them) and exits 5 with prueba_id; then --wait follows it. If the ceiling or the policy changed in the meantime it exits 4 techo_distinto / politica_distinta with the new values and doesn't retry: ask again. Others: 4 prueba_en_curso (with its id), chat_deshabilitado, requiere_api_key, intento_pendiente; 6 tope_mensual; 1 with intento_pendiente: true if the answer didn't arrive (repeating resumes the same test).",
|
|
250
|
+
resumen: "Tests the installation end to end: a problem comes in through the same door as the chat and ends up as an issue. It spends one investigation (up to the ceiling you authorize; with the save policy it goes as far as the saved case and doesn't spend). Without --authorize-spend it sends nothing: it exits 4 falta_autorizacion with techo_usd, saldo_usd, tope_usd and comprometido_usd, and, checked before you say yes and without writing anything, `maquina` { ok, impedimentos: [{ codigo, detalle }] } (whether this machine can save the test request: if it isn't ok the message says why first) and `intento_pendiente` { techo_usd, politica } | null (a request waiting for confirmation: only its ceiling and policy, never its message or key). With --authorize-spend, --ceiling and --expect-policy it requests the test with exactly those values (it doesn't re-read anything to choose them) and exits 5 with prueba_id; then --wait follows it. If the ceiling or the policy changed in the meantime it exits 4 techo_distinto / politica_distinta with the new values and doesn't retry: ask again. Others: 4 prueba_en_curso (with its id), chat_deshabilitado, requiere_api_key, intento_pendiente; 6 tope_mensual; 1 with intento_pendiente: true if the answer didn't arrive (repeating resumes the same test).",
|
|
251
251
|
flags: [
|
|
252
252
|
"--authorize-spend: authorizes the spend of ONE test; without it nothing that writes is sent",
|
|
253
253
|
"--ceiling usd: the ceiling you authorize, the number you were shown (required with --authorize-spend)",
|
package/dist/comandos/guia.js
CHANGED
|
@@ -33,14 +33,15 @@ const AVISOS_DEL_NOMBRE = {
|
|
|
33
33
|
// error: nunca motivo para leer el viejo). Si están los dos manda `guide.md` y se avisa que el otro se ignora.
|
|
34
34
|
async function leerLaGuia(d, flags) {
|
|
35
35
|
let raiz;
|
|
36
|
+
let sinClon;
|
|
36
37
|
try {
|
|
37
|
-
raiz =
|
|
38
|
+
({ raiz, sinClon } = await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd }));
|
|
38
39
|
}
|
|
39
40
|
catch (e) {
|
|
40
41
|
return { ok: false, resultado: resultadoDeFallo(e) };
|
|
41
42
|
}
|
|
42
43
|
if (raiz === null) {
|
|
43
|
-
return { ok: false, resultado: fallo(SALIDA.entradaInvalida, "falta_guia",
|
|
44
|
+
return { ok: false, resultado: fallo(SALIDA.entradaInvalida, "falta_guia", `${sinClon ?? "I don't have that repo's clone here."} The guide is read from ${RUTA_DE_LA_GUIA} in the repo's clone: stand in it.`) };
|
|
44
45
|
}
|
|
45
46
|
let ruta = RUTA_DE_LA_GUIA;
|
|
46
47
|
let aviso = null;
|
|
@@ -139,14 +139,15 @@ export async function instalacionAnotar(d, flags) {
|
|
|
139
139
|
}
|
|
140
140
|
// ─── La raíz y la app, antes de cualquier pedido ───
|
|
141
141
|
let raizGit;
|
|
142
|
+
let sinClon;
|
|
142
143
|
try {
|
|
143
|
-
raizGit =
|
|
144
|
+
({ raiz: raizGit, sinClon } = await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd }));
|
|
144
145
|
}
|
|
145
146
|
catch (e) {
|
|
146
147
|
return resultadoDeFallo(e);
|
|
147
148
|
}
|
|
148
149
|
if (raizGit === null)
|
|
149
|
-
return fallo(SALIDA.entradaInvalida, "sin_clon",
|
|
150
|
+
return fallo(SALIDA.entradaInvalida, "sin_clon", `${sinClon ?? "I don't have that repo's clone here."} ${RUTA_DE_LA_INSTALACION} goes in the repo's clone: stand in it.`);
|
|
150
151
|
const raiz = d.rutaReal(raizGit);
|
|
151
152
|
if (raiz === null)
|
|
152
153
|
return fallo(SALIDA.inesperado, "inesperado", "I couldn't resolve the repo's root.");
|
package/dist/comandos/probar.js
CHANGED
|
@@ -44,6 +44,26 @@ function datosDelDiagnostico(g) {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
const lineasDePuntos = (puntos, nombre = (p) => p) => puntos.map((p) => ` [${p.ok ? "ok" : "missing"}] ${nombre(p.punto)}: ${p.mensaje}`);
|
|
47
|
+
// Lo que esta máquina puede o no hacer con el pedido de la prueba, visto ANTES del OK de gasto y sin escribir nada. Un
|
|
48
|
+
// prechequeo que falla es «no ok», nunca un error: avisar tarde es justo lo que esto evita.
|
|
49
|
+
async function revisarLaMaquina(d, proyectoId) {
|
|
50
|
+
try {
|
|
51
|
+
return await d.pruebas.revisar(proyectoId);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return { maquina: { ok: false, impedimentos: [{ codigo: "no_se_pudo_revisar", detalle: "I couldn't check whether this machine can save the test request." }] }, intentoPendiente: null };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const datosDeLaMaquina = (r) => ({ maquina: r.maquina });
|
|
58
|
+
const datosDelIntentoPendiente = (r) => ({
|
|
59
|
+
intento_pendiente: r.intentoPendiente === null ? null : { techo_usd: r.intentoPendiente.techoUsd, politica: r.intentoPendiente.politica },
|
|
60
|
+
});
|
|
61
|
+
const detalles = (r) => r.maquina.impedimentos.map((i) => i.detalle).join(" ");
|
|
62
|
+
function puntoDeLaMaquina(r) {
|
|
63
|
+
return r.maquina.ok
|
|
64
|
+
? { punto: "maquina", ok: true, mensaje: "No obstacle found to saving the test request on this machine.", grupo: "prueba" }
|
|
65
|
+
: { punto: "maquina", ok: false, mensaje: `This machine can't save the test request yet: ${detalles(r)}`, grupo: "prueba" };
|
|
66
|
+
}
|
|
47
67
|
export async function diagnosticar(d, flags) {
|
|
48
68
|
const p = await proyectoDelRepo(d, flags);
|
|
49
69
|
if (!p.ok)
|
|
@@ -64,16 +84,23 @@ export async function diagnosticar(d, flags) {
|
|
|
64
84
|
catch {
|
|
65
85
|
local = { repo: null, raiz: null, motivo: "sin_git" };
|
|
66
86
|
}
|
|
67
|
-
const
|
|
87
|
+
const agrupados = agruparPuntos(g, [puntoDeSesion(), puntoDelRepo(local, p.proyecto.repo)]);
|
|
88
|
+
const instalacion = agrupados.instalacion;
|
|
89
|
+
// El punto local de la prueba (1A.1): ¿esta máquina puede guardar el pedido? Si no, la prueba no entra, y se dice acá y
|
|
90
|
+
// no después del OK de gasto. No cuenta en el «N of 6» de la instalación.
|
|
91
|
+
const revision = await revisarLaMaquina(d, p.proyecto.id);
|
|
92
|
+
const prueba = [puntoDeLaMaquina(revision), ...agrupados.prueba];
|
|
68
93
|
const buenos = contarBuenos(instalacion);
|
|
69
|
-
const listo = g.listo && instalacion.every((x) => x.ok || !["sign-in", "repo"].includes(x.punto));
|
|
94
|
+
const listo = g.listo && revision.maquina.ok && instalacion.every((x) => x.ok || !["sign-in", "repo"].includes(x.punto));
|
|
70
95
|
const datos = {
|
|
71
96
|
proyecto: { id: p.proyecto.id, repo: p.proyecto.repo },
|
|
72
97
|
...datosDelDiagnostico(g),
|
|
73
98
|
listo,
|
|
74
99
|
puntos: [...instalacion, ...prueba],
|
|
75
100
|
instalacion: { ok: buenos, total: TOTAL_DE_INSTALACION, puntos: instalacion },
|
|
76
|
-
prueba: { listo: g.listo, puntos: prueba },
|
|
101
|
+
prueba: { listo: g.listo && revision.maquina.ok, puntos: prueba },
|
|
102
|
+
...datosDeLaMaquina(revision),
|
|
103
|
+
...datosDelIntentoPendiente(revision),
|
|
77
104
|
};
|
|
78
105
|
const resumen = `Monthly balance ${usdONull(g.saldoUsd)} of ${usdONull(g.topeUsd)}; a test authorizes up to ${usd(g.techoUsd)}. Policy: ${enInglesLaPolitica(g.politica)}.`;
|
|
79
106
|
const motivos = [
|
|
@@ -155,7 +182,9 @@ export async function probar(d, flags) {
|
|
|
155
182
|
catch (e) {
|
|
156
183
|
return resultadoDeFallo(e);
|
|
157
184
|
}
|
|
158
|
-
|
|
185
|
+
// Lo que esta máquina puede hacer con el pedido, visto ANTES del sí de gasto (fricción 3).
|
|
186
|
+
const revision = await revisarLaMaquina(d, proyecto.id);
|
|
187
|
+
const datos = { proyecto: { id: proyecto.id, repo: proyecto.repo }, ...datosDelDiagnostico(g), ...datosDeLaMaquina(revision) };
|
|
159
188
|
const gasto = g.politica === "guardar"
|
|
160
189
|
? `With the \`save\` policy the test goes as far as the saved case and spends nothing; the ceiling it authorizes is still ${usd(g.techoUsd)}.`
|
|
161
190
|
: `A test runs an investigation that spends up to ${usd(g.techoUsd)} (monthly balance ${usdONull(g.saldoUsd)} of ${usdONull(g.topeUsd)}).`;
|
|
@@ -180,8 +209,13 @@ export async function probar(d, flags) {
|
|
|
180
209
|
};
|
|
181
210
|
}
|
|
182
211
|
const lanzar = `supcoflow test --authorize-spend --ceiling ${g.techoUsd} --expect-policy ${enInglesLaPolitica(g.politica)}`;
|
|
183
|
-
|
|
184
|
-
|
|
212
|
+
// Primero lo que impide, si algo: es lo que la persona tiene que saber antes de decir que sí.
|
|
213
|
+
const impide = revision.maquina.ok ? "" : `This machine can't save the test request yet: ${detalles(revision)} Fix that before authorizing. `;
|
|
214
|
+
const previo = revision.intentoPendiente;
|
|
215
|
+
const pendiente = previo === null ? ""
|
|
216
|
+
: ` There's a test request waiting for confirmation (ceiling ${usd(previo.techoUsd)}, policy ${enInglesLaPolitica(previo.politica)}): to recover that same request, repeat \`${comandoDeReintento({ clave: "", mensaje: null, techoUsd: previo.techoUsd, politicaEsperada: previo.politica })}\`.`;
|
|
217
|
+
const message = `${impide}${gasto} To launch it: \`${lanzar}\`. \`supcoflow diagnose\` first says whether everything is ready.${pendiente}`;
|
|
218
|
+
return { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "falta_autorizacion", message, ...datos, ...datosDelIntentoPendiente(revision) }, texto: message };
|
|
185
219
|
}
|
|
186
220
|
// ── Con --authorize-spend: el ciclo de la clave.
|
|
187
221
|
let intento;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { resultadoDeFallo, SALIDA } from "../contrato.js";
|
|
2
2
|
import { repoPedido } from "../repo-local.js";
|
|
3
3
|
import { claveVigente } from "./clave-vigente.js";
|
|
4
|
+
import { coherenciaDelClon, leerElArchivoDelClon } from "./contexto.js";
|
|
4
5
|
export async function proyectoDelRepo(d, flags) {
|
|
5
6
|
let repo;
|
|
7
|
+
let raiz;
|
|
6
8
|
try {
|
|
7
|
-
repo =
|
|
9
|
+
({ repo, raiz } = await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd }));
|
|
8
10
|
}
|
|
9
11
|
catch (e) {
|
|
10
12
|
return { ok: false, resultado: resultadoDeFallo(e) };
|
|
@@ -12,8 +14,16 @@ export async function proyectoDelRepo(d, flags) {
|
|
|
12
14
|
const leida = await claveVigente(d.guardado, d.api.origin);
|
|
13
15
|
if (!leida.ok)
|
|
14
16
|
return { ok: false, resultado: leida.resultado };
|
|
17
|
+
// El archivo del clon se lee ANTES de actuar: un `proyecto.json` que no se entiende frena sin tocar nada, y uno que anota
|
|
18
|
+
// otro proyecto que el que la clave ve también (ningún pedido parcial actúa sobre un proyecto distinto del que anota el clon).
|
|
19
|
+
const archivo = leerElArchivoDelClon(d.archivos, raiz);
|
|
20
|
+
if (archivo.estado === "ilegible")
|
|
21
|
+
return { ok: false, resultado: coherenciaDelClon(archivo, null, repo) };
|
|
15
22
|
try {
|
|
16
23
|
const proyecto = await d.buscarProyecto(d.api, leida.vigente.clave, repo);
|
|
24
|
+
const incoherente = coherenciaDelClon(archivo, proyecto, repo);
|
|
25
|
+
if (incoherente !== null)
|
|
26
|
+
return { ok: false, resultado: incoherente };
|
|
17
27
|
if (proyecto === null) {
|
|
18
28
|
const message = `The repo ${repo} doesn't have a project yet. Create the project: \`supcoflow project create\`.`;
|
|
19
29
|
return { ok: false, resultado: { codigo: SALIDA.haceFaltaUnaPersona, json: { ok: false, error: "sin_proyecto", message, repo }, texto: message } };
|
|
@@ -5,11 +5,9 @@ import path from "node:path";
|
|
|
5
5
|
import { resultadoDeFallo, SALIDA } from "../contrato.js";
|
|
6
6
|
import { repoPedido } from "../repo-local.js";
|
|
7
7
|
import { claveVigente } from "./clave-vigente.js";
|
|
8
|
+
import { armarElContexto, coherenciaAlCrear, coherenciaDelClon, idDelArchivo, leerElArchivoDelClon, lineaDelContexto } from "./contexto.js";
|
|
8
9
|
const PLAZO_DE_LECTURA_MS = 15_000;
|
|
9
10
|
const OPERACION = "proyecto_crear";
|
|
10
|
-
// Lo único que se lee de un `.supcoflow/proyecto.json` ajeno es su id, y sólo si tiene esta forma:
|
|
11
|
-
// así lo que salga a la terminal no puede ser otra cosa que un id (Review Focus 11).
|
|
12
|
-
const FORMA_DEL_ID = /^proj_[a-z0-9-]{1,80}$/;
|
|
13
11
|
// Los 422/409 del alta que el plan no listaba (alta-desde-conexion.ts del motor).
|
|
14
12
|
const RECHAZOS_DE_UNA_PERSONA = new Set(["instalacion_no_verificada", "repo_fuera_de_la_instalacion"]);
|
|
15
13
|
const RECHAZOS_TRANSITORIOS = new Set(["slug_ocupado", "repo_ya_conectado"]);
|
|
@@ -23,18 +21,6 @@ const comillas = (s) => `"${s.replace(/["\\]/g, "\\$&")}"`;
|
|
|
23
21
|
function contenidoDelArchivo(p) {
|
|
24
22
|
return JSON.stringify({ id: p.id, slug: p.slug, repo: p.repo }, null, 2) + "\n";
|
|
25
23
|
}
|
|
26
|
-
function idDelArchivo(crudo) {
|
|
27
|
-
try {
|
|
28
|
-
const v = JSON.parse(crudo);
|
|
29
|
-
if (typeof v !== "object" || v === null)
|
|
30
|
-
return null;
|
|
31
|
-
const id = v.id;
|
|
32
|
-
return typeof id === "string" && FORMA_DEL_ID.test(id) ? id : null;
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
24
|
// Mismo contenido aunque el formato cambie (otra indentación, otro orden): no es «otro proyecto».
|
|
39
25
|
function mismoArchivo(crudo, p) {
|
|
40
26
|
if (crudo === contenidoDelArchivo(p))
|
|
@@ -96,12 +82,24 @@ export async function proyectoCrear(d, flags) {
|
|
|
96
82
|
if (!leida.ok)
|
|
97
83
|
return leida.resultado;
|
|
98
84
|
const clave = leida.vigente.clave;
|
|
85
|
+
// Antes de conectar o crear nada (fricción 6): el archivo del clon contra lo que la clave ve, y, si va a haber un alta, el
|
|
86
|
+
// dueño del repo contra la cuenta. Sale 4 `contexto_incoherente` sin tocar nada.
|
|
87
|
+
const archivo = leerElArchivoDelClon(d.archivos, raiz);
|
|
88
|
+
if (archivo.estado === "ilegible")
|
|
89
|
+
return coherenciaDelClon(archivo, null, repo);
|
|
99
90
|
let existente;
|
|
100
91
|
let conexion = null;
|
|
101
92
|
try {
|
|
102
93
|
existente = await d.buscarProyecto(d.api, clave, repo);
|
|
103
|
-
|
|
94
|
+
const incoherente = coherenciaDelClon(archivo, existente, repo);
|
|
95
|
+
if (incoherente !== null)
|
|
96
|
+
return incoherente;
|
|
97
|
+
if (existente === null) {
|
|
98
|
+
const alCrear = coherenciaAlCrear({ yo: await d.quienSoy(d.api, clave), fuente: leida.vigente.fuente, repo });
|
|
99
|
+
if (alCrear !== null)
|
|
100
|
+
return alCrear;
|
|
104
101
|
conexion = await d.ultimaConexion(d.api, clave, repo, PLAZO_DE_LECTURA_MS);
|
|
102
|
+
}
|
|
105
103
|
}
|
|
106
104
|
catch (e) {
|
|
107
105
|
return resultadoDeFallo(e);
|
|
@@ -184,8 +182,9 @@ export async function proyectoCrear(d, flags) {
|
|
|
184
182
|
}
|
|
185
183
|
export async function proyectoVer(d, flags) {
|
|
186
184
|
let repo;
|
|
185
|
+
let raiz;
|
|
187
186
|
try {
|
|
188
|
-
repo =
|
|
187
|
+
({ repo, raiz } = await repoPedido({ flagRepo: flags.repo, git: d.git, cwd: d.cwd }));
|
|
189
188
|
}
|
|
190
189
|
catch (e) {
|
|
191
190
|
return resultadoDeFallo(e);
|
|
@@ -194,17 +193,32 @@ export async function proyectoVer(d, flags) {
|
|
|
194
193
|
if (!leida.ok)
|
|
195
194
|
return leida.resultado;
|
|
196
195
|
const clave = leida.vigente.clave;
|
|
196
|
+
const archivo = leerElArchivoDelClon(d.archivos, raiz);
|
|
197
|
+
if (archivo.estado === "ilegible")
|
|
198
|
+
return coherenciaDelClon(archivo, null, repo);
|
|
197
199
|
try {
|
|
198
200
|
const encontrado = await d.buscarProyecto(d.api, clave, repo);
|
|
201
|
+
const incoherente = coherenciaDelClon(archivo, encontrado, repo);
|
|
202
|
+
if (incoherente !== null)
|
|
203
|
+
return incoherente;
|
|
204
|
+
// Quién soy, en qué equipo, qué repo y qué proyecto: la línea que la persona lee al empezar, también cuando todo cierra.
|
|
205
|
+
const yo = await d.quienSoy(d.api, clave);
|
|
206
|
+
const contexto = armarElContexto({ yo, fuente: leida.vigente.fuente, repo, proyecto: encontrado, archivo });
|
|
207
|
+
const linea = lineaDelContexto(contexto, yo.proyecto);
|
|
199
208
|
if (encontrado === null) {
|
|
200
|
-
|
|
209
|
+
// Sin proyecto, el recorrido sigue con `repo connect`: el freno por cuenta (dueño distinto, sin GitHub) tiene que llegar ANTES,
|
|
210
|
+
// no al crear. Con proyecto existente no se compara nada, y con una clave de consulta tampoco.
|
|
211
|
+
const alCrear = coherenciaAlCrear({ yo, fuente: leida.vigente.fuente, repo });
|
|
212
|
+
if (alCrear !== null)
|
|
213
|
+
return alCrear;
|
|
214
|
+
return { codigo: SALIDA.ok, json: { ok: true, repo, proyecto: null, contexto }, texto: `${linea} This repo doesn't have a project yet: \`supcoflow project create\`.` };
|
|
201
215
|
}
|
|
202
216
|
const { proyecto: p, github } = await d.leerProyecto(d.api, clave, encontrado.id);
|
|
203
217
|
const proyecto = { id: p.id, slug: p.slug, repo: p.repo, rama: p.rama, install_command: p.installCommand, test_command: p.testCommand };
|
|
204
218
|
return {
|
|
205
219
|
codigo: SALIDA.ok,
|
|
206
|
-
json: { ok: true, proyecto, github: { estado: github.estado, verificado_en: github.verificadoEn } },
|
|
207
|
-
texto: `${p.id} (${p.repo}, branch ${p.rama}): installs with \`${p.installCommand}\`, tests with \`${p.testCommand}\`. GitHub: ${github.estado ?? "not verified"}.`,
|
|
220
|
+
json: { ok: true, proyecto, github: { estado: github.estado, verificado_en: github.verificadoEn }, contexto },
|
|
221
|
+
texto: `${linea} ${p.id} (${p.repo}, branch ${p.rama}): installs with \`${p.installCommand}\`, tests with \`${p.testCommand}\`. GitHub: ${github.estado ?? "not verified"}.`,
|
|
208
222
|
};
|
|
209
223
|
}
|
|
210
224
|
catch (e) {
|
package/dist/diagnostico.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { frasePorQueNoAtaClon } from "./repo-local.js";
|
|
1
2
|
export const TOTAL_DE_INSTALACION = 6;
|
|
2
3
|
// El orden y los nombres con que se dicen: los ids de los puntos del motor son datos suyos y no cambian.
|
|
3
4
|
const DE_LA_INSTALACION = [
|
|
@@ -21,6 +22,8 @@ export function puntoDelRepo(local, repoDelProyecto) {
|
|
|
21
22
|
return mal("This folder isn't a git repo, so I can't tell which repo it is.");
|
|
22
23
|
if (local.motivo === "sin_origin")
|
|
23
24
|
return mal("This repo has no «origin» remote, so I can't tell which repo it is.");
|
|
25
|
+
if (local.motivo === "origin_alias_ssh")
|
|
26
|
+
return mal(`The «origin» of this repo isn't a GitHub repo. ${frasePorQueNoAtaClon(local, null)}`);
|
|
24
27
|
return mal("The «origin» of this repo isn't a GitHub repo.");
|
|
25
28
|
}
|
|
26
29
|
if (local.repo.toLowerCase() !== repoDelProyecto.toLowerCase()) {
|
package/dist/entorno.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// demás recibe estas piezas inyectadas y se prueba con dobles; acá sólo se enchufan.
|
|
3
3
|
import { execFile, spawn } from "node:child_process";
|
|
4
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
|
+
import { accessSync, chmodSync, closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, mkdirSync, statSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, writeSync, } from "node:fs";
|
|
6
6
|
import { NO_LEIDO } from "./lectura.js";
|
|
7
7
|
import { homedir, hostname } from "node:os";
|
|
8
8
|
import { basename, dirname, join } from "node:path";
|
|
@@ -12,6 +12,8 @@ import { esCarpetaReal, esEnlaceReal, rutaRealOEnNada } from "./comandos/instala
|
|
|
12
12
|
import { DESCRIPCION } from "./comandos/describir.js";
|
|
13
13
|
import { almacenDeArchivo } from "./guardado/archivo.js";
|
|
14
14
|
import { crearGuardado } from "./guardado/index.js";
|
|
15
|
+
import { almacenProtegido } from "./guardado/protegido.js";
|
|
16
|
+
import { revisarElLugar } from "./guardado/lugar.js";
|
|
15
17
|
import { almacenDeLlavero } from "./guardado/llavero.js";
|
|
16
18
|
import { crearGuardaDeSecretos } from "./secreto-a-archivo.js";
|
|
17
19
|
import { crearRegistroDeIdempotencia } from "./idempotencia.js";
|
|
@@ -68,6 +70,29 @@ export const fsReal = {
|
|
|
68
70
|
}
|
|
69
71
|
},
|
|
70
72
|
};
|
|
73
|
+
// Lo que el prechequeo de la máquina (src/guardado/lugar.ts) ve del disco, sin escribir. `estado` sigue los enlaces (`stat`): un
|
|
74
|
+
// enlace roto es «no existe» para él, y `existe` (que es `lstat`) lo distingue.
|
|
75
|
+
export const fsDelLugarReal = {
|
|
76
|
+
...fsReal,
|
|
77
|
+
estado(p) {
|
|
78
|
+
try {
|
|
79
|
+
const st = statSync(p);
|
|
80
|
+
return { existe: true, tipo: st.isDirectory() ? "carpeta" : st.isFile() ? "archivo" : "otro", propio: typeof process.getuid !== "function" || st.uid === process.getuid() };
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return { existe: false };
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
puede(p, que) {
|
|
87
|
+
try {
|
|
88
|
+
accessSync(p, que === "leer" ? constants.R_OK : constants.W_OK | constants.X_OK);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
71
96
|
// El candado de `claves crear` (src/candado.ts). `wx` es la exclusión: SÓLO `EEXIST` es «ya hay uno»;
|
|
72
97
|
// cualquier otro error (permisos, disco) se relanza y sale `no_disponible`, nunca «ocupado». La lectura no
|
|
73
98
|
// sigue enlaces: un `.lock` que alguien cambió por un enlace no se lee como si fuera el nuestro.
|
|
@@ -396,27 +421,31 @@ export function entornoReal(env = process.env) {
|
|
|
396
421
|
// Al lado de las credenciales y con el mismo almacén (carpeta 0700, archivo 0600, `.tmp` en
|
|
397
422
|
// exclusiva): nunca dentro del repo donde se corre el comando, que la publicaría con un `git add -A`.
|
|
398
423
|
const rutaDeIdempotencia = join(dirname(rutaDelArchivo), "idempotencia.json");
|
|
424
|
+
// Estos dos registros (y sólo estos) pueden vivir bajo una carpeta personal que es un repo de git: el almacén
|
|
425
|
+
// protegido comprueba con git, antes de cada escritura y cada borrado, que los deja afuera del repo.
|
|
426
|
+
const protegido = (ruta) => almacenProtegido(almacenDeArchivo(ruta, fsReal), { ruta, fs: fsReal, git: gitReal });
|
|
399
427
|
const idempotencia = crearRegistroDeIdempotencia({
|
|
400
428
|
origen: api.origin,
|
|
401
|
-
almacen:
|
|
429
|
+
almacen: protegido(rutaDeIdempotencia),
|
|
402
430
|
rutaDelArchivo: rutaDeIdempotencia,
|
|
403
|
-
fs: fsReal,
|
|
404
431
|
generar: randomUUID,
|
|
405
432
|
ahora: Date.now,
|
|
406
433
|
avisar,
|
|
407
434
|
});
|
|
408
|
-
// El pedido de `probar` (intento + última prueba), con el mismo almacén
|
|
435
|
+
// El pedido de `probar` (intento + última prueba), con el mismo almacén protegido.
|
|
409
436
|
const rutaDePruebas = join(dirname(rutaDelArchivo), "pruebas.json");
|
|
410
437
|
const pruebas = crearRegistroDePruebas({
|
|
411
|
-
origen: api.origin, almacen:
|
|
438
|
+
origen: api.origin, almacen: protegido(rutaDePruebas), rutaDelArchivo: rutaDePruebas, ahora: Date.now, avisar,
|
|
439
|
+
revisarElLugar: () => revisarElLugar({ ruta: rutaDePruebas, fs: fsDelLugarReal, git: gitReal }),
|
|
412
440
|
});
|
|
413
441
|
const dormir = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
414
442
|
const cwd = process.cwd();
|
|
415
443
|
// Los candados de `claves crear` viven al lado de las credenciales, nunca en un repo.
|
|
416
444
|
const candados = crearCandados(fsDelCandadoReal, join(dirname(rutaDelArchivo), "candados"), process.pid, procesoVivo);
|
|
417
445
|
const secretos = crearGuardaDeSecretos(fsDeSecretosReal);
|
|
446
|
+
// `archivos`: `proyectoDelRepo` lee `.supcoflow/proyecto.json` del clon con él (fricción 6).
|
|
418
447
|
const configurarEnElMotor = {
|
|
419
|
-
api, guardado, git: gitReal, cwd, avisar,
|
|
448
|
+
api, guardado, git: gitReal, cwd, avisar, archivos: archivosDelRepo(avisar),
|
|
420
449
|
buscarProyecto: (a, clave, repo) => buscarProyecto(a, clave, repo, fetch),
|
|
421
450
|
leerConfiguracion: (a, clave, id) => leerConfiguracion(a, clave, id, fetch),
|
|
422
451
|
configurar: (a, clave, ruta, cuerpo, extra) => configurar(a, clave, ruta, cuerpo, fetch, extra),
|
|
@@ -454,6 +483,7 @@ export function entornoReal(env = process.env) {
|
|
|
454
483
|
proyecto: {
|
|
455
484
|
api, guardado, git: gitReal, cwd, idempotencia, archivos: archivosDelRepo(avisar), avisar,
|
|
456
485
|
buscarProyecto: (a, clave, repo) => buscarProyecto(a, clave, repo, fetch),
|
|
486
|
+
quienSoy: (a, clave) => quienSoy(a, clave, fetch),
|
|
457
487
|
ultimaConexion: (a, clave, repo, plazoMs) => ultimaConexion(a, clave, repo, fetch, plazoMs),
|
|
458
488
|
crearProyecto: (a, clave, conexionId, cuerpo, key) => crearProyecto(a, clave, conexionId, cuerpo, key, fetch),
|
|
459
489
|
leerProyecto: (a, clave, id) => leerProyecto(a, clave, id, fetch),
|
|
@@ -476,7 +506,7 @@ export function entornoReal(env = process.env) {
|
|
|
476
506
|
idempotencia, candados, secretos,
|
|
477
507
|
},
|
|
478
508
|
widget: {
|
|
479
|
-
api, guardado, git: gitReal, cwd,
|
|
509
|
+
api, guardado, git: gitReal, cwd, archivos: archivosDelRepo(avisar),
|
|
480
510
|
buscarProyecto: configurarEnElMotor.buscarProyecto,
|
|
481
511
|
leerWidget: (a, clave, id) => leerWidget(a, clave, id, fetch),
|
|
482
512
|
},
|
|
@@ -489,7 +519,7 @@ export function entornoReal(env = process.env) {
|
|
|
489
519
|
terminal: DESCRIPCION.version,
|
|
490
520
|
},
|
|
491
521
|
prueba: {
|
|
492
|
-
api, guardado, git: gitReal, cwd, avisar, pruebas, generar: randomUUID, ahora: Date.now, dormir, tramoMs,
|
|
522
|
+
api, guardado, git: gitReal, cwd, avisar, archivos: archivosDelRepo(avisar), pruebas, generar: randomUUID, ahora: Date.now, dormir, tramoMs,
|
|
493
523
|
buscarProyecto: configurarEnElMotor.buscarProyecto,
|
|
494
524
|
leerDiagnostico: (a, clave, id) => leerDiagnostico(a, clave, id, fetch),
|
|
495
525
|
pedirPrueba: (a, clave, id, intento) => pedirPrueba(a, clave, id, intento, fetch),
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// El prechequeo SIN ESCRITURAS de lo que guardar un registro necesita (1A.1, fricción 3): se llama antes del OK de gasto
|
|
2
|
+
// para que la persona sepa ya si esta máquina va a poder guardar el pedido de la prueba, y no después. Mira lo mismo que
|
|
3
|
+
// `persistir` (src/guardado/archivo.ts) va a pedirle al disco, y lo de git de la fricción 2c.
|
|
4
|
+
//
|
|
5
|
+
// Qué promete y qué no: «no se detectaron impedimentos» no es «se va a poder escribir». Un disco que se llena después, o
|
|
6
|
+
// un cambio al mismo tiempo, no se pueden garantizar sin escribir. El guardado estricto, justo antes del POST, se queda.
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { mirarProteccionDeGit, rutaReal } from "./repo.js";
|
|
9
|
+
export async function revisarElLugar(d) {
|
|
10
|
+
const impedimentos = [];
|
|
11
|
+
const dir = path.dirname(d.ruta);
|
|
12
|
+
const nombre = path.basename(d.ruta);
|
|
13
|
+
const tmp = d.ruta + ".tmp";
|
|
14
|
+
let real;
|
|
15
|
+
try {
|
|
16
|
+
real = rutaReal(path.resolve(dir), d.fs);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// Un enlace roto en el camino: no se sabe dónde se escribiría, y `persistir` fallaría.
|
|
20
|
+
return [{ codigo: "ruta_irresoluble", detalle: `I can't tell where ${dir} really is (a broken symlink?).` }];
|
|
21
|
+
}
|
|
22
|
+
const carpeta = d.fs.estado(real);
|
|
23
|
+
if (carpeta.existe) {
|
|
24
|
+
if (carpeta.tipo !== "carpeta") {
|
|
25
|
+
impedimentos.push({ codigo: "carpeta_no_es_carpeta", detalle: `${dir} exists but isn't a folder.` });
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
if (!carpeta.propio)
|
|
29
|
+
impedimentos.push({ codigo: "carpeta_ajena", detalle: `${dir} isn't yours, so I can't set its permissions to 0700.` });
|
|
30
|
+
if (!d.fs.puede(real, "escribir"))
|
|
31
|
+
impedimentos.push({ codigo: "carpeta_sin_permiso", detalle: `I can't write in ${dir}.` });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
// Todavía no existe: el ancestro más cercano que existe tiene que ser una carpeta donde se pueda crear.
|
|
36
|
+
let ancestro = path.dirname(real);
|
|
37
|
+
while (!d.fs.estado(ancestro).existe && path.dirname(ancestro) !== ancestro)
|
|
38
|
+
ancestro = path.dirname(ancestro);
|
|
39
|
+
const a = d.fs.estado(ancestro);
|
|
40
|
+
if (!a.existe || a.tipo !== "carpeta" || !d.fs.puede(ancestro, "escribir")) {
|
|
41
|
+
impedimentos.push({ codigo: "padre_sin_permiso", detalle: `${dir} doesn't exist and I can't create it: ${ancestro} isn't a folder I can write in.` });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const archivo = d.fs.estado(path.join(real, nombre));
|
|
45
|
+
if (archivo.existe) {
|
|
46
|
+
if (archivo.tipo !== "archivo")
|
|
47
|
+
impedimentos.push({ codigo: "archivo_no_comun", detalle: `${d.ruta} isn't a regular file.` });
|
|
48
|
+
else if (!d.fs.puede(path.join(real, nombre), "leer"))
|
|
49
|
+
impedimentos.push({ codigo: "archivo_ilegible", detalle: `I can't read ${d.ruta}.` });
|
|
50
|
+
}
|
|
51
|
+
else if (d.fs.existe(path.join(real, nombre))) {
|
|
52
|
+
impedimentos.push({ codigo: "archivo_no_comun", detalle: `${d.ruta} is a broken symlink.` });
|
|
53
|
+
}
|
|
54
|
+
// El `.tmp` se borra antes de escribir (`borrarSiExiste`, sin recursión): si es una carpeta, no se puede.
|
|
55
|
+
const temporal = d.fs.estado(path.join(real, nombre + ".tmp"));
|
|
56
|
+
if (temporal.existe && temporal.tipo === "carpeta") {
|
|
57
|
+
impedimentos.push({ codigo: "tmp_es_carpeta", detalle: `${tmp} is a folder: I remove that name before saving, and I can't remove a folder.` });
|
|
58
|
+
}
|
|
59
|
+
const git = await mirarProteccionDeGit({ dir, archivos: [nombre, nombre + ".tmp"], fs: d.fs, git: d.git });
|
|
60
|
+
if (!git.protegido)
|
|
61
|
+
impedimentos.push(git.impedimento);
|
|
62
|
+
return impedimentos;
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// El almacén de archivo de los dos registros de la terminal que PUEDEN vivir bajo una carpeta personal que es un repo
|
|
2
|
+
// (`pruebas.json` e `idempotencia.json`; 1A.1, fricción 2c). Es un envoltorio fino: cada `escribir` y cada `borrar`
|
|
3
|
+
// pasan por `asegurarProteccionDeGit` ANTES de tocar el disco, porque borrar una entrada reescribe el archivo entero
|
|
4
|
+
// por su `.tmp`. Las credenciales no lo usan: dentro de un repo se siguen negando.
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { asegurarProteccionDeGit } from "./repo.js";
|
|
7
|
+
/** Git no garantiza que este archivo quede fuera del repo: no se escribió (ni se borró) nada. */
|
|
8
|
+
export class SinProteccionDeGit extends Error {
|
|
9
|
+
impedimento;
|
|
10
|
+
constructor(impedimento) {
|
|
11
|
+
super(impedimento.detalle);
|
|
12
|
+
this.impedimento = impedimento;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function almacenProtegido(base, d) {
|
|
16
|
+
const dir = path.dirname(d.ruta);
|
|
17
|
+
const archivos = [path.basename(d.ruta), path.basename(d.ruta) + ".tmp"];
|
|
18
|
+
async function proteger() {
|
|
19
|
+
const p = await asegurarProteccionDeGit({ dir, archivos, fs: d.fs, git: d.git });
|
|
20
|
+
if (!p.protegido)
|
|
21
|
+
throw new SinProteccionDeGit(p.impedimento);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
nombre: base.nombre,
|
|
25
|
+
leer: (cuenta, techoMs) => base.leer(cuenta, techoMs),
|
|
26
|
+
async escribir(cuenta, r, techoMs) {
|
|
27
|
+
await proteger();
|
|
28
|
+
await base.escribir(cuenta, r, techoMs);
|
|
29
|
+
},
|
|
30
|
+
async borrar(cuenta, techoMs) {
|
|
31
|
+
// Si no hay nada que borrar, `borrar` no reescribe nada: no hay nada que proteger.
|
|
32
|
+
if ((await base.leer(cuenta, techoMs)) === null)
|
|
33
|
+
return;
|
|
34
|
+
await proteger();
|
|
35
|
+
await base.borrar(cuenta, techoMs);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
package/dist/guardado/repo.js
CHANGED
|
@@ -33,3 +33,101 @@ export function dentroDeUnWorkingTree(dir, fs) {
|
|
|
33
33
|
}
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
36
|
+
const CONTENIDO_DEL_GITIGNORE = "# Written by supcoflow: nothing in this folder belongs in git.\n*\n";
|
|
37
|
+
// La raíz del repo (si hay) de cada una de las dos rutas: la escrita y la real. Un tramo enlazado (`stow`)
|
|
38
|
+
// puede hacer que sean dos repos distintos, y cualquiera de los dos publicaría el archivo.
|
|
39
|
+
function raicesQueContienen(dir, fs) {
|
|
40
|
+
const escrita = path.resolve(dir);
|
|
41
|
+
const rutas = new Set([escrita, rutaReal(escrita, fs)]);
|
|
42
|
+
const raices = new Map();
|
|
43
|
+
for (const ruta of rutas) {
|
|
44
|
+
let actual = ruta;
|
|
45
|
+
for (;;) {
|
|
46
|
+
if (fs.existe(path.join(actual, ".git"))) {
|
|
47
|
+
raices.set(actual, [...(raices.get(actual) ?? []), ruta]);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
const padre = path.dirname(actual);
|
|
51
|
+
if (padre === actual)
|
|
52
|
+
break;
|
|
53
|
+
actual = padre;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return raices;
|
|
57
|
+
}
|
|
58
|
+
const sinProteger = (codigo, detalle) => ({ protegido: false, impedimento: { codigo, detalle } });
|
|
59
|
+
function queHacer(raiz, archivo) {
|
|
60
|
+
const rel = path.relative(raiz, archivo);
|
|
61
|
+
return `Take it out of the repo with \`git -C ${raiz} rm --cached -- ${rel}\` or have git ignore it; I won't touch your repo myself.`;
|
|
62
|
+
}
|
|
63
|
+
async function mirar(d, conGitignore) {
|
|
64
|
+
let raices;
|
|
65
|
+
try {
|
|
66
|
+
raices = raicesQueContienen(d.dir, d.fs);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return sinProteger("ruta_irresoluble", `I can't tell where ${d.dir} really is (a broken symlink?).`);
|
|
70
|
+
}
|
|
71
|
+
for (const [raiz, carpetas] of raices) {
|
|
72
|
+
for (const carpeta of carpetas) {
|
|
73
|
+
const hayGitignore = conGitignore(carpeta);
|
|
74
|
+
for (const nombre of d.archivos) {
|
|
75
|
+
const archivo = path.join(carpeta, nombre);
|
|
76
|
+
if (hayGitignore) {
|
|
77
|
+
// 0 = ignorado y no seguido; 1 = no lo es (o está seguido o agregado). Cualquier otra cosa (git ausente,
|
|
78
|
+
// `fatal`, tiempo) es «no sé», y no saber no es «protegido».
|
|
79
|
+
const r = await d.git(["check-ignore", "-q", "--", archivo], raiz);
|
|
80
|
+
if (r.codigo === 1) {
|
|
81
|
+
return sinProteger("git_no_lo_ignora", `Git doesn't ignore ${archivo} in the repo ${raiz} (it is tracked or staged, or a .gitignore doesn't cover it). ${queHacer(raiz, archivo)}`);
|
|
82
|
+
}
|
|
83
|
+
if (r.codigo !== 0)
|
|
84
|
+
return sinProteger("git_no_contesta", `I couldn't ask git whether it ignores ${archivo} in the repo ${raiz}.`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// Sin `.gitignore` todavía: se crearía uno con `*`, que ignora todo lo que no esté seguido ni en el índice.
|
|
88
|
+
const r = await d.git(["ls-files", "--", archivo], raiz);
|
|
89
|
+
if (r.codigo !== 0)
|
|
90
|
+
return sinProteger("git_no_contesta", `I couldn't ask git whether it tracks ${archivo} in the repo ${raiz}.`);
|
|
91
|
+
if (r.stdout.trim() !== "") {
|
|
92
|
+
return sinProteger("git_lo_sigue", `Git tracks ${archivo} in the repo ${raiz}. ${queHacer(raiz, archivo)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { protegido: true };
|
|
99
|
+
}
|
|
100
|
+
/** Sin escribir nada: lo mismo que `asegurarProteccionDeGit` va a comprobar, para avisar antes. */
|
|
101
|
+
export function mirarProteccionDeGit(d) {
|
|
102
|
+
return mirar(d, (carpeta) => d.fs.existe(path.join(carpeta, ".gitignore")));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Antes de CADA escritura o borrado de un registro. Si la carpeta cae en un repo y no tiene `.gitignore`, crea
|
|
106
|
+
* uno con `*` (en exclusiva: si aparece otro al mismo tiempo no se pisa; y nunca se edita uno que ya estaba),
|
|
107
|
+
* y después le pregunta a git por cada archivo y su `.tmp`. Falla cerrada.
|
|
108
|
+
*/
|
|
109
|
+
export async function asegurarProteccionDeGit(d) {
|
|
110
|
+
let raices;
|
|
111
|
+
try {
|
|
112
|
+
raices = raicesQueContienen(d.dir, d.fs);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return sinProteger("ruta_irresoluble", `I can't tell where ${d.dir} really is (a broken symlink?).`);
|
|
116
|
+
}
|
|
117
|
+
if (raices.size === 0)
|
|
118
|
+
return { protegido: true };
|
|
119
|
+
const gitignore = path.join(path.resolve(d.dir), ".gitignore");
|
|
120
|
+
if (!d.fs.existe(gitignore)) {
|
|
121
|
+
try {
|
|
122
|
+
d.fs.crearDir(path.resolve(d.dir), 0o700);
|
|
123
|
+
d.fs.escribirExclusivo(gitignore, CONTENIDO_DEL_GITIGNORE, 0o600);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
// `EEXIST`: otro proceso lo creó entre medio; sirve igual, y no se toca.
|
|
127
|
+
if (e.code !== "EEXIST") {
|
|
128
|
+
return sinProteger("no_se_pudo_crear_gitignore", `I couldn't create ${gitignore} to keep this folder out of git.`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return mirar(d, () => true);
|
|
133
|
+
}
|
package/dist/idempotencia.js
CHANGED
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
// reintento tiene que mandar la MISMA clave para que el motor devuelva lo que ya hizo en vez de
|
|
4
4
|
// hacerlo dos veces. Por eso se escribe ANTES de devolverla, y sólo se olvida cuando el motor ya dio
|
|
5
5
|
// un desenlace que no hace falta repetir.
|
|
6
|
-
import path from "node:path";
|
|
7
6
|
import { FalloDeLaTerminal, SALIDA } from "./contrato.js";
|
|
8
|
-
import {
|
|
7
|
+
import { SinProteccionDeGit } from "./guardado/protegido.js";
|
|
9
8
|
export function crearRegistroDeIdempotencia(d) {
|
|
10
9
|
// Por origen, como la clave (un motor local y producción no comparten claves), y el alcance en
|
|
11
10
|
// minúsculas: `D/r` y `d/r` son el mismo repo para GitHub y para el motor.
|
|
@@ -15,7 +14,7 @@ export function crearRegistroDeIdempotencia(d) {
|
|
|
15
14
|
// repo) y el motor lo reusa. Una clave de consulta no tiene clave natural: sin la de idempotencia
|
|
16
15
|
// guardada, el reintento tras una respuesta perdida crea OTRA fila. Mejor no mandar nada y decir qué
|
|
17
16
|
// arreglar. Por eso, en estricto, cada «aviso y sigo» de abajo lanza.
|
|
18
|
-
const noGuardada = () => new FalloDeLaTerminal(SALIDA.inesperado, "idempotencia_no_guardada", `I couldn't save the retry key in ${d.rutaDelArchivo}; without it, a retry would create another key. Check that file
|
|
17
|
+
const noGuardada = (causa) => new FalloDeLaTerminal(SALIDA.inesperado, "idempotencia_no_guardada", `I couldn't save the retry key in ${d.rutaDelArchivo}; without it, a retry would create another key. Check that file and try again.${causa instanceof SinProteccionDeGit ? ` ${causa.message}` : ""}`);
|
|
19
18
|
return {
|
|
20
19
|
async claveDe(operacion, alcance, opciones = {}) {
|
|
21
20
|
const c = cuenta(operacion, alcance);
|
|
@@ -34,33 +33,20 @@ export function crearRegistroDeIdempotencia(d) {
|
|
|
34
33
|
}
|
|
35
34
|
const nueva = d.generar();
|
|
36
35
|
// Perder la clave no duplica nada: el motor reusa el proyecto por su repo. Por eso un destino
|
|
37
|
-
// dentro de un repo
|
|
36
|
+
// dentro de un repo que git no garantiza que ignore (el almacén protegido, fricción 2c) o un disco
|
|
38
37
|
// 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(`I couldn't resolve the path of ${d.rutaDelArchivo}: I'm going on with an idempotency key that isn't saved.`);
|
|
49
|
-
return nueva;
|
|
50
|
-
}
|
|
51
|
-
if (raiz !== null) {
|
|
52
|
-
if (opciones.estricta)
|
|
53
|
-
throw noGuardada();
|
|
54
|
-
d.avisar(`I'm not saving the idempotency key inside a git repo (${raiz}): a retry will send a different one.`);
|
|
55
|
-
return nueva;
|
|
56
|
-
}
|
|
57
38
|
try {
|
|
58
39
|
await d.almacen.escribir(c, { valor: nueva, escritoEn: d.ahora() });
|
|
59
40
|
}
|
|
60
|
-
catch {
|
|
41
|
+
catch (e) {
|
|
61
42
|
if (opciones.estricta)
|
|
62
|
-
throw noGuardada();
|
|
63
|
-
|
|
43
|
+
throw noGuardada(e);
|
|
44
|
+
if (e instanceof SinProteccionDeGit) {
|
|
45
|
+
d.avisar(`I'm not saving the idempotency key in ${d.rutaDelArchivo}: a retry will send a different one. ${e.message}`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
d.avisar(`I couldn't save the idempotency key in ${d.rutaDelArchivo}: a retry will send a different one.`);
|
|
49
|
+
}
|
|
64
50
|
}
|
|
65
51
|
return nueva;
|
|
66
52
|
},
|
|
@@ -80,8 +66,10 @@ export function crearRegistroDeIdempotencia(d) {
|
|
|
80
66
|
try {
|
|
81
67
|
await d.almacen.borrar(cuenta(operacion, alcance));
|
|
82
68
|
}
|
|
83
|
-
catch {
|
|
84
|
-
d.avisar(
|
|
69
|
+
catch (e) {
|
|
70
|
+
d.avisar(e instanceof SinProteccionDeGit
|
|
71
|
+
? `I didn't delete the idempotency key from ${d.rutaDelArchivo}, so as not to rewrite a file git doesn't ignore. ${e.message}`
|
|
72
|
+
: `I couldn't delete the idempotency key from ${d.rutaDelArchivo}.`);
|
|
85
73
|
}
|
|
86
74
|
},
|
|
87
75
|
};
|
|
@@ -14,9 +14,8 @@
|
|
|
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;
|
|
@@ -28,7 +27,7 @@ export function crearRegistroDePruebas(d) {
|
|
|
28
27
|
// Por origen, como la clave y el registro de idempotencia: un motor local (o el proxy de P4) y
|
|
29
28
|
// producción no comparten intentos. Cambiar de origen entre dos comandos es otra clave.
|
|
30
29
|
const cuenta = (proyectoId) => `${d.origen}#prueba#${proyectoId}`;
|
|
31
|
-
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
|
|
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.`);
|
|
32
31
|
async function leer(proyectoId) {
|
|
33
32
|
let crudo;
|
|
34
33
|
try {
|
|
@@ -58,18 +57,6 @@ export function crearRegistroDePruebas(d) {
|
|
|
58
57
|
};
|
|
59
58
|
}
|
|
60
59
|
async function escribir(proyectoId, g) {
|
|
61
|
-
// Misma regla que las credenciales: un destino dentro de un repo se publicaría con un `git add -A`.
|
|
62
|
-
// No saber dónde cae (un enlace roto) es otro «no se puede guardar».
|
|
63
|
-
let raiz;
|
|
64
|
-
try {
|
|
65
|
-
raiz = dentroDeUnWorkingTree(path.dirname(d.rutaDelArchivo), d.fs);
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
throw noGuardada("resolve the path of");
|
|
69
|
-
}
|
|
70
|
-
if (raiz !== null) {
|
|
71
|
-
throw new FalloDeLaTerminal(SALIDA.inesperado, "prueba_no_guardada", `I'm not saving the test request inside a git repo (${raiz}); without saving it I won't send it. Point XDG_CONFIG_HOME outside a repo.`);
|
|
72
|
-
}
|
|
73
60
|
const valor = JSON.stringify({
|
|
74
61
|
intento: g.intento === null
|
|
75
62
|
? null
|
|
@@ -79,12 +66,37 @@ export function crearRegistroDePruebas(d) {
|
|
|
79
66
|
try {
|
|
80
67
|
await d.almacen.escribir(cuenta(proyectoId), { valor, escritoEn: d.ahora() });
|
|
81
68
|
}
|
|
82
|
-
catch {
|
|
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
|
+
}
|
|
83
75
|
throw noGuardada("save to");
|
|
84
76
|
}
|
|
85
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
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
impedimentos.push({ codigo: "archivo_ilegible", detalle: `I can't read or understand ${d.rutaDelArchivo}.` });
|
|
94
|
+
}
|
|
95
|
+
return { maquina: { ok: impedimentos.length === 0, impedimentos }, intentoPendiente };
|
|
96
|
+
}
|
|
86
97
|
return {
|
|
87
98
|
leer,
|
|
99
|
+
revisar,
|
|
88
100
|
async abrir(proyectoId, intento) {
|
|
89
101
|
const antes = await leer(proyectoId);
|
|
90
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
109
|
sin_git: "this folder isn't a git repo",
|
|
49
110
|
sin_origin: "the repo has no remote called \"origin\"",
|
|
50
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
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
145
|
const porque = local.motivo ? MOTIVOS[local.motivo] : "the folder couldn't be read";
|
|
65
|
-
|
|
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
|
}
|
package/package.json
CHANGED