@dforce2055/dai 0.13.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -3,6 +3,47 @@
3
3
  Formato basado en [Keep a Changelog](https://keepachangelog.com/). Versionado semver
4
4
  (ver `VERSION`).
5
5
 
6
+ ## [0.13.1] — 2026-08-24
7
+
8
+ **Un dev en Windows no podía pushear contra el GitLab de su empresa. `ssh -T` le autenticaba
9
+ perfecto, `git push` moría con `Permission denied (publickey…)`. Dos días buscando el problema
10
+ en la clave, en el token y en los permisos del server: no estaba en ninguno de los tres, y dai
11
+ tapaba la única línea que lo decía.**
12
+
13
+ Su clave tenía passphrase. En Windows conviven dos `ssh.exe` — el de OpenSSH for Windows, que
14
+ habla con el servicio `ssh-agent`, y el que trae Git for Windows, que no lo ve. `ssh -T` usaba
15
+ el primero (la firma la hacía el agente, passphrase nunca), `git push` usaba el segundo y pedía
16
+ la passphrase por stderr. Ese prompt caía en un pipe de dai: el dev veía un cuelgue sin
17
+ explicación, ssh se rendía, caía a autenticación por password y lo único legible al final era
18
+ un error que acusa a la clave. Todo lo que hacía falta para resolverlo estaba en pantalla, y no
19
+ llegaba.
20
+
21
+ ### Arreglado
22
+ - **`dai pr` se tragaba lo que git y ssh preguntan.** El push corría con `stderr` en `pipe`
23
+ para no ensuciar la salida, pero git y ssh **preguntan por stderr**: la passphrase de una
24
+ clave, la confirmación de un host nuevo, el aviso del credential manager. Con el prompt
25
+ invisible el comando no se cuelga por un bug, se cuelga esperando una respuesta que nadie
26
+ sabe que tiene que dar. Ahora `stderr` va heredado y el push pregunta a la vista. El error de
27
+ git se lee en vivo, cuando todavía sirve, en vez de aparecer resumido después del fracaso.
28
+ - **La pista al fallar el push asumía que el remoto era HTTPS.** Decía siempre *"si es la
29
+ primera vez contra este remoto, autenticá pusheando a mano una vez"*. Eso arregla HTTPS,
30
+ donde el credential manager pide la credencial la primera vez. Contra un remoto **SSH** el
31
+ push a mano falla exactamente igual — así que la pista mandaba a repetir un comando condenado
32
+ y a seguir buscando en el lugar equivocado. Ahora el consejo depende del transporte: en SSH
33
+ aclara que el token de `gh`/`glab` no interviene en el push, que lo que hay que mirar es la
34
+ clave, y deriva a `dai doctor`.
35
+
36
+ ### Agregado
37
+ - **`dai doctor` — sección `forge`.** Reporta el remoto `origin` con su forge detectado y, en
38
+ Windows con remoto SSH, **qué `ssh.exe` va a usar git**: si es el suyo (el de Git for
39
+ Windows, que no llega al `ssh-agent`), lo advierte, explica el modo de falla y da el fix
40
+ (`git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe"`). Respeta la
41
+ precedencia real de git —`GIT_SSH_COMMAND` > `GIT_SSH` > `core.sshCommand`— y avisa cuando
42
+ una variable de entorno está pisando un `core.sshCommand` que ya estaba bien: ese caso es
43
+ particularmente cruel, porque el dev arregla el config, no funciona, y mirando el
44
+ `.gitconfig` no hay nada que ver. Fuera de Windows, o con un remoto HTTPS, no opina.
45
+ Núcleo puro y testeado en `cli/lib/git-ssh.mjs` (14 tests); en `dai.mjs` queda solo el I/O.
46
+
6
47
  ## [0.13.0] — 2026-08-21
7
48
 
8
49
  **Un dev de backend en Windows siguió el tutorial al pie de la letra y el agente se puso a
@@ -696,6 +737,7 @@ ClickUp y Jira Cloud.
696
737
  - Tests de las rutas de red (jira/clickup/forge) con `fetch` mockeado. Sin links rotos;
697
738
  `files` de npm sin tests ni secretos.
698
739
 
740
+ [0.13.1]: https://github.com/dforce2055/dai/releases/tag/v0.13.1
699
741
  [0.13.0]: https://github.com/dforce2055/dai/releases/tag/v0.13.0
700
742
  [0.12.0]: https://github.com/dforce2055/dai/releases/tag/v0.12.0
701
743
  [0.11.0]: https://github.com/dforce2055/dai/releases/tag/v0.11.0
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.13.0
1
+ 0.13.1
package/cli/dai.mjs CHANGED
@@ -28,6 +28,7 @@ import { parsePrRef, getPR, postComment, postReview } from "./lib/forge-api.mjs"
28
28
  import { trackerUrl } from "./lib/tracker-url.mjs";
29
29
  import { parseFindings, diffPositions, validateFindings, filterFindings, renderFindingBody, renderReviewSummary } from "./lib/review-findings.mjs";
30
30
  import { composePrBody, prTitle, forgeTool } from "./lib/pr.mjs";
31
+ import { diagnoseGitSsh, pushFailureHint, WINDOWS_OPENSSH } from "./lib/git-ssh.mjs";
31
32
  import { dirsEqual } from "./lib/fsutil.mjs";
32
33
  import { parseFlags, parseAssistants, isAssistantToken, asList } from "./lib/args.mjs";
33
34
  import { versionDrift, planUpgrade, compareVersions } from "./lib/semver.mjs";
@@ -990,11 +991,21 @@ async function cmdPr(opts) {
990
991
  // stdin heredado + GIT_TERMINAL_PROMPT=1: la PRIMERA vez contra un remoto HTTPS
991
992
  // corporativo, git/credential-manager necesita poder pedir la credencial. Con stdin
992
993
  // ignorado (el default de git()) el login no completaba y el push fallaba en seco.
993
- git(["push", "-u", "origin", branch], { stdio: ["inherit", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
994
+ //
995
+ // stderr heredado, y esto no es cosmético: git y ssh PREGUNTAN por stderr. Con
996
+ // stderr en 'pipe' el prompt de una clave con passphrase ("Enter passphrase for
997
+ // key …") caía en el pipe, invisible: el dev veía un cuelgue sin explicación, ssh
998
+ // se rendía y terminaba cayendo a autenticación por password, y lo único que se
999
+ // llegaba a leer era un "Permission denied (publickey…)" que acusa a la clave
1000
+ // cuando la clave estaba bien. Que pregunte a la vista.
1001
+ git(["push", "-u", "origin", branch], { stdio: ["inherit", "pipe", "inherit"], env: { ...process.env, GIT_TERMINAL_PROMPT: "1" } });
994
1002
  } catch (e) {
995
- const err = String(e.stderr || e.message || "").trim();
1003
+ // git ya imprimió su error en vivo (stderr heredado); acá solo va la pista, y esa
1004
+ // pista depende del transporte: "pushea a mano una vez" arregla HTTPS y no arregla
1005
+ // nada en SSH, donde el push a mano falla igual (lib/git-ssh.mjs).
1006
+ const err = String(e.stderr || "").trim();
996
1007
  if (err) process.stderr.write(" " + err.split("\n").join("\n ") + "\n");
997
- process.stdout.write(` Si es la primera vez contra este remoto, autenticá pusheando a mano una vez:\n git push -u origin ${branch}\n y volvé a correr: dai pr\n`);
1008
+ for (const l of pushFailureHint(remote, branch)) process.stdout.write(` ${l}\n`);
998
1009
  fail(`no pude pushear la branch '${branch}'.`, 1);
999
1010
  }
1000
1011
 
@@ -1728,6 +1739,44 @@ function cmdDoctor() {
1728
1739
  : warn("DAI_CLICKUP_LIST_ID vacío — solo hace falta para `dai publish` (crear tareas)");
1729
1740
  }
1730
1741
 
1742
+ // ── forge: el CÓMO sale del repo por acá ─────────────────────────────────────
1743
+ // Si el push no sale, no hay PR; sin PR no hay link QUÉ↔CÓMO. El chequeo del cliente
1744
+ // ssh existe porque su modo de fallar miente: ver lib/git-ssh.mjs.
1745
+ const remote = gitRemote();
1746
+ info("forge:");
1747
+ if (!remote) warn("no hay remoto 'origin' — `dai pr` no tiene dónde publicar la branch");
1748
+ else {
1749
+ const p = parseRemote(remote);
1750
+ ok(`remoto origin: ${remote}${p ? ` (${detectForge(p.host)})` : ""}`);
1751
+ let sshConfig = null;
1752
+ try { sshConfig = git(["config", "--get", "core.sshCommand"]) || null; } catch { /* sin valor: git sale 1 */ }
1753
+ const d = diagnoseGitSsh({
1754
+ platform: process.platform,
1755
+ remote,
1756
+ config: sshConfig,
1757
+ env: { GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND, GIT_SSH: process.env.GIT_SSH },
1758
+ hasWindowsOpenSsh: existsSync(WINDOWS_OPENSSH),
1759
+ });
1760
+ if (d.status === "ok") ok(`git usa el OpenSSH de Windows (ve al ssh-agent)`);
1761
+ if (d.status === "ok-por-env") {
1762
+ ok(`git usa el OpenSSH de Windows (ve al ssh-agent)`);
1763
+ warn(`pero viene de ${d.source}, no del config: se pierde al cerrar la terminal.`);
1764
+ process.stdout.write(` Para que quede: git config --global core.sshCommand "${WINDOWS_OPENSSH}"\n`);
1765
+ }
1766
+ if (d.status === "bundled" || d.status === "otro-ssh") {
1767
+ warn(d.status === "bundled"
1768
+ ? "git usa su propio ssh (Git for Windows), que NO ve al ssh-agent de Windows."
1769
+ : `git usa ${d.bin} (por ${d.source}), que probablemente no vea al ssh-agent de Windows.`);
1770
+ process.stdout.write(` Si tu clave tiene passphrase, cada push te la va a pedir — y si el prompt no llega,\n`);
1771
+ process.stdout.write(` ssh cae a password y falla con "Permission denied (publickey…)", que acusa a la clave.\n`);
1772
+ if (d.fix) process.stdout.write(` → git config --global core.sshCommand "${d.fix}"\n`);
1773
+ if (d.shadowed) process.stdout.write(` Ojo: ${d.source} está seteada y pisa tu core.sshCommand. Límpiala primero.\n`);
1774
+ }
1775
+ if (d.status === "bundled-sin-openssh") {
1776
+ warn("git usa su propio ssh y no encontré el OpenSSH de Windows: si el push pide passphrase, instálalo (Configuración → Características opcionales → Cliente OpenSSH).");
1777
+ }
1778
+ }
1779
+
1731
1780
  // ── version-drift del scaffold vs el CLI (ADR-0010) ──────────────────────────
1732
1781
  if (existsSync(join(process.cwd(), ".dai", "VERSION"))) { info("versión del scaffold:"); reportDrift(); }
1733
1782
  }
@@ -0,0 +1,107 @@
1
+ // dai · qué cliente SSH usa git, y si ese cliente llega al agente (Windows).
2
+ //
3
+ // En Windows conviven dos ssh.exe: el de OpenSSH for Windows (C:\Windows\System32\
4
+ // OpenSSH), que habla con el servicio `ssh-agent`, y el que trae Git for Windows
5
+ // (MSYS), que NO lo ve. Con una clave con passphrase el síntoma es desconcertante:
6
+ // `ssh -T` autentica (la firma la hace el agente) y `git push` pide la passphrase o,
7
+ // si nadie la contesta, cae a autenticación por password y muere con
8
+ // "Permission denied (publickey…)" — un error que apunta al lugar equivocado y se
9
+ // come una mañana buscando el problema en la clave, en el token o en el server.
10
+ //
11
+ // Núcleo puro: el I/O (git config, existsSync) vive en dai.mjs.
12
+
13
+ export const WINDOWS_OPENSSH = "C:/Windows/System32/OpenSSH/ssh.exe";
14
+
15
+ // ¿Por dónde habla el remoto? Decide qué consejo tiene sentido cuando el push falla.
16
+ export function remoteTransport(remote) {
17
+ const r = String(remote ?? "").trim();
18
+ if (!r) return null;
19
+ if (/^ssh:\/\//i.test(r)) return "ssh";
20
+ if (/^https?:\/\//i.test(r)) return "https";
21
+ if (/^[\w.-]+@[^:]+:/.test(r)) return "ssh"; // scp-like: git@host:org/repo.git
22
+ return null; // git://, file://, ruta local…
23
+ }
24
+
25
+ // El binario de un core.sshCommand / GIT_SSH_COMMAND, que puede traer argumentos y
26
+ // comillas: `"C:/Program Files/Git/usr/bin/ssh.exe" -v` → la ruta sola.
27
+ export function sshBinaryOf(cmd) {
28
+ const s = String(cmd ?? "").trim();
29
+ if (!s) return null;
30
+ if (s[0] === '"' || s[0] === "'") {
31
+ const end = s.indexOf(s[0], 1);
32
+ return end === -1 ? s.slice(1) : s.slice(1, end);
33
+ }
34
+ const sp = s.search(/\s/);
35
+ return sp === -1 ? s : s.slice(0, sp);
36
+ }
37
+
38
+ // ¿Esa ruta es el OpenSSH de Windows? Normalizado: mayúsculas y `\` vs `/` varían
39
+ // según quién haya escrito el config (git acepta las dos formas).
40
+ export function isWindowsOpenSsh(bin) {
41
+ const p = String(bin ?? "").replace(/\\/g, "/").toLowerCase();
42
+ return /(^|\/)system32\/openssh\/ssh(\.exe)?$/.test(p);
43
+ }
44
+
45
+ // Diagnóstico del cliente ssh que va a usar git. Entradas ya resueltas por el
46
+ // llamador; acá no se toca el disco ni se lanza un proceso.
47
+ // platform — process.platform
48
+ // remote — url de origin (o null)
49
+ // config — `git config --get core.sshCommand` (o null si no está)
50
+ // env — { GIT_SSH_COMMAND, GIT_SSH }
51
+ // hasWindowsOpenSsh — existsSync(WINDOWS_OPENSSH)
52
+ //
53
+ // status:
54
+ // n/a → no aplica (no es Windows, o el remoto no habla SSH)
55
+ // ok → apunta al OpenSSH de Windows por core.sshCommand
56
+ // ok-por-env → apunta bien, pero desde una env var: se pierde al cerrar la terminal
57
+ // otro-ssh → apunta a otro ssh (el de MSYS, plink…)
58
+ // bundled → nadie lo configuró: git usa el suyo, que no ve el agente
59
+ // bundled-sin-openssh → igual, pero no hay OpenSSH de Windows que recomendar
60
+ export function diagnoseGitSsh({ platform, remote, config, env = {}, hasWindowsOpenSsh = false } = {}) {
61
+ if (platform !== "win32") return { status: "n/a", reason: "no-windows" };
62
+ const transport = remoteTransport(remote);
63
+ if (transport !== "ssh") return { status: "n/a", reason: transport ? "remoto-https" : "sin-remoto" };
64
+
65
+ // Precedencia real de git: GIT_SSH_COMMAND > GIT_SSH > core.sshCommand.
66
+ const [source, raw] =
67
+ env.GIT_SSH_COMMAND ? ["GIT_SSH_COMMAND", env.GIT_SSH_COMMAND]
68
+ : env.GIT_SSH ? ["GIT_SSH", env.GIT_SSH]
69
+ : config ? ["core.sshCommand", config]
70
+ : ["default", null];
71
+
72
+ // Una env var pisando un core.sshCommand que ya estaba bien: el dev "arregló" el
73
+ // config, no funciona, y no hay forma de verlo mirando el .gitconfig.
74
+ const shadowed = source !== "default" && source !== "core.sshCommand" && Boolean(config);
75
+
76
+ if (source === "default") {
77
+ return hasWindowsOpenSsh
78
+ ? { status: "bundled", source, bin: null, fix: WINDOWS_OPENSSH, shadowed }
79
+ : { status: "bundled-sin-openssh", source, bin: null, fix: null, shadowed };
80
+ }
81
+
82
+ const bin = sshBinaryOf(raw);
83
+ if (isWindowsOpenSsh(bin)) {
84
+ return { status: source === "core.sshCommand" ? "ok" : "ok-por-env", source, bin, fix: null, shadowed };
85
+ }
86
+ return { status: "otro-ssh", source, bin, fix: hasWindowsOpenSsh ? WINDOWS_OPENSSH : null, shadowed };
87
+ }
88
+
89
+ // Qué decirle al dev cuando `git push` falla. El consejo de "pusheá a mano una vez"
90
+ // solo aplica a HTTPS, donde el credential manager pide la credencial la primera vez;
91
+ // contra un remoto SSH el push a mano falla EXACTAMENTE igual, así que mandarlo por ahí
92
+ // es hacerle perder el tiempo mientras el problema real (la clave, el agente) sigue ahí.
93
+ export function pushFailureHint(remote, branch) {
94
+ if (remoteTransport(remote) === "ssh") {
95
+ return [
96
+ `El remoto habla SSH: en el push no interviene el token de gh/glab, solo tu clave.`,
97
+ `Prueba a mano — así ves lo que ssh pregunta (p. ej. la passphrase de tu clave):`,
98
+ ` git push -u origin ${branch}`,
99
+ `Si te la pide en cada push, tu clave está en un agente que git no ve: dai doctor`,
100
+ ];
101
+ }
102
+ return [
103
+ `Si es la primera vez contra este remoto, autentica pusheando a mano una vez:`,
104
+ ` git push -u origin ${branch}`,
105
+ `y vuelve a ejecutar: dai pr`,
106
+ ];
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforce2055/dai",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Metodología de desarrollo asistido por IA — CLI de acciones deterministas (trazabilidad QUÉ↔CÓMO).",
5
5
  "repository": { "type": "git", "url": "git+https://github.com/dforce2055/dai.git" },
6
6
  "homepage": "https://dforce2055.github.io/dai/",