@jpkme/diff-review 0.1.16 → 0.1.17

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.
Files changed (154) hide show
  1. package/README.md +22 -14
  2. package/bin/diff-review.mjs +192 -21
  3. package/lib/acp.mjs +86 -10
  4. package/lib/agent-login.mjs +848 -36
  5. package/lib/agent-login.test.mjs +538 -0
  6. package/lib/agents.mjs +0 -39
  7. package/lib/agents.test.mjs +17 -24
  8. package/lib/extract-cache.test.mjs +162 -162
  9. package/lib/git.mjs +25 -9
  10. package/lib/model-label.mjs +20 -4
  11. package/lib/model-label.test.mjs +15 -2
  12. package/lib/prompt.mjs +12 -10
  13. package/lib/prompt.test.mjs +12 -0
  14. package/lib/review-meta.mjs +11 -3
  15. package/lib/review-meta.test.mjs +19 -2
  16. package/lib/review-prep.mjs +63 -63
  17. package/lib/validate.mjs +1 -1
  18. package/lib/wait-output.test.mjs +1 -1
  19. package/package.json +2 -2
  20. package/web/build/client/_app/immutable/assets/0.B04dzd8P.css +1 -0
  21. package/web/build/client/_app/immutable/assets/0.B04dzd8P.css.br +0 -0
  22. package/web/build/client/_app/immutable/assets/0.B04dzd8P.css.gz +0 -0
  23. package/web/build/client/_app/immutable/assets/2.CQmxVXnC.css +1 -0
  24. package/web/build/client/_app/immutable/assets/2.CQmxVXnC.css.br +0 -0
  25. package/web/build/client/_app/immutable/assets/2.CQmxVXnC.css.gz +0 -0
  26. package/web/build/client/_app/immutable/assets/3.BVJgADrM.css +1 -0
  27. package/web/build/client/_app/immutable/assets/3.BVJgADrM.css.br +0 -0
  28. package/web/build/client/_app/immutable/assets/3.BVJgADrM.css.gz +0 -0
  29. package/web/build/client/_app/immutable/assets/storage.Ztmr3s84.css +1 -0
  30. package/web/build/client/_app/immutable/assets/storage.Ztmr3s84.css.br +0 -0
  31. package/web/build/client/_app/immutable/assets/storage.Ztmr3s84.css.gz +0 -0
  32. package/web/build/client/_app/immutable/chunks/B-2kfLeA.js +1 -0
  33. package/web/build/client/_app/immutable/chunks/B-2kfLeA.js.br +0 -0
  34. package/web/build/client/_app/immutable/chunks/B-2kfLeA.js.gz +0 -0
  35. package/web/build/client/_app/immutable/chunks/{DHO7NTh1.js → B4SiKXUO.js} +2 -2
  36. package/web/build/client/_app/immutable/chunks/B4SiKXUO.js.br +0 -0
  37. package/web/build/client/_app/immutable/chunks/B4SiKXUO.js.gz +0 -0
  38. package/web/build/client/_app/immutable/chunks/{CXhIC34H.js → DA0YnpGR.js} +3 -3
  39. package/web/build/client/_app/immutable/chunks/DA0YnpGR.js.br +0 -0
  40. package/web/build/client/_app/immutable/chunks/DA0YnpGR.js.gz +0 -0
  41. package/web/build/client/_app/immutable/chunks/{cYiFfRa3.js → Dv85Hz5p.js} +1 -1
  42. package/web/build/client/_app/immutable/chunks/Dv85Hz5p.js.br +2 -0
  43. package/web/build/client/_app/immutable/chunks/Dv85Hz5p.js.gz +0 -0
  44. package/web/build/client/_app/immutable/chunks/b-BaDYIc.js +215 -0
  45. package/web/build/client/_app/immutable/chunks/b-BaDYIc.js.br +0 -0
  46. package/web/build/client/_app/immutable/chunks/b-BaDYIc.js.gz +0 -0
  47. package/web/build/client/_app/immutable/entry/app.DPUME9Z_.js +2 -0
  48. package/web/build/client/_app/immutable/entry/app.DPUME9Z_.js.br +0 -0
  49. package/web/build/client/_app/immutable/entry/app.DPUME9Z_.js.gz +0 -0
  50. package/web/build/client/_app/immutable/entry/start.hH87UZsD.js +1 -0
  51. package/web/build/client/_app/immutable/entry/start.hH87UZsD.js.br +0 -0
  52. package/web/build/client/_app/immutable/entry/start.hH87UZsD.js.gz +0 -0
  53. package/web/build/client/_app/immutable/nodes/0.BhHA-Ohy.js +1 -0
  54. package/web/build/client/_app/immutable/nodes/0.BhHA-Ohy.js.br +0 -0
  55. package/web/build/client/_app/immutable/nodes/0.BhHA-Ohy.js.gz +0 -0
  56. package/web/build/client/_app/immutable/nodes/1.jjvGFUxu.js +1 -0
  57. package/web/build/client/_app/immutable/nodes/1.jjvGFUxu.js.br +2 -0
  58. package/web/build/client/_app/immutable/nodes/1.jjvGFUxu.js.gz +0 -0
  59. package/web/build/client/_app/immutable/nodes/2.CJTh6CGA.js +12 -0
  60. package/web/build/client/_app/immutable/nodes/2.CJTh6CGA.js.br +0 -0
  61. package/web/build/client/_app/immutable/nodes/2.CJTh6CGA.js.gz +0 -0
  62. package/web/build/client/_app/immutable/nodes/3.DYAPRc_2.js +340 -0
  63. package/web/build/client/_app/immutable/nodes/3.DYAPRc_2.js.br +0 -0
  64. package/web/build/client/_app/immutable/nodes/3.DYAPRc_2.js.gz +0 -0
  65. package/web/build/client/_app/version.json +1 -1
  66. package/web/build/client/_app/version.json.br +0 -0
  67. package/web/build/client/_app/version.json.gz +0 -0
  68. package/web/build/handler.js +3 -3
  69. package/web/build/index.js +3 -3
  70. package/web/build/server/chunks/0-8NUeB9cB.js +11 -0
  71. package/web/build/server/chunks/{0-D7NSxn1-.js.map → 0-8NUeB9cB.js.map} +1 -1
  72. package/web/build/server/chunks/1-BApN-ILJ.js +7 -0
  73. package/web/build/server/chunks/{1-DboYz8Fw.js.map → 1-BApN-ILJ.js.map} +1 -1
  74. package/web/build/server/chunks/2-Bq7iMeQx.js +7 -0
  75. package/web/build/server/chunks/2-Bq7iMeQx.js.map +1 -0
  76. package/web/build/server/chunks/3-W0cqofyE.js +7 -0
  77. package/web/build/server/chunks/{3-DgJ_LmTL.js.map → 3-W0cqofyE.js.map} +1 -1
  78. package/web/build/server/chunks/_server.ts-Bu1fdGSB.js +123 -0
  79. package/web/build/server/chunks/_server.ts-Bu1fdGSB.js.map +1 -0
  80. package/web/build/server/chunks/{_server.ts-CP5cRLhW.js → _server.ts-BuUNechu.js} +5 -4
  81. package/web/build/server/chunks/_server.ts-BuUNechu.js.map +1 -0
  82. package/web/build/server/chunks/{agent-sessions-2XGojSJ_.js → agent-sessions-BArYLTW6.js} +1023 -135
  83. package/web/build/server/chunks/agent-sessions-BArYLTW6.js.map +1 -0
  84. package/web/build/server/chunks/{git-review-jZ_JJYfv.js → git-review-BJ_JrFNP.js} +54 -21
  85. package/web/build/server/chunks/{git-review-jZ_JJYfv.js.map → git-review-BJ_JrFNP.js.map} +1 -1
  86. package/web/build/server/chunks/{handler-B_8yUXji.js → handler-Df_bJiCq.js} +3 -3
  87. package/web/build/server/chunks/{handler-B_8yUXji.js.map → handler-Df_bJiCq.js.map} +1 -1
  88. package/web/build/server/chunks/{hooks.server-rF_eBCff.js → hooks.server-BEl7rVad.js} +4 -3
  89. package/web/build/server/chunks/{hooks.server-rF_eBCff.js.map → hooks.server-BEl7rVad.js.map} +1 -1
  90. package/web/build/server/chunks/{index.js-CGw8_Q7V.js → index.js-Bb0CZLZ_.js} +4 -4
  91. package/web/build/server/chunks/index.js-Bb0CZLZ_.js.map +1 -0
  92. package/web/build/server/chunks/manifest.js-j442duM7.js +65 -0
  93. package/web/build/server/chunks/manifest.js-j442duM7.js.map +1 -0
  94. package/web/build/server/chunks/{remote-1g4ayag-BMpjk8UD.js → remote-1g4ayag-BxKnZQPn.js} +6 -6
  95. package/web/build/server/chunks/remote-1g4ayag-BxKnZQPn.js.map +1 -0
  96. package/web/build/server/chunks/{remote-wh6t6j-dYLCg8ks.js → remote-wh6t6j-B-AHrGaN.js} +44 -10
  97. package/web/build/server/chunks/remote-wh6t6j-B-AHrGaN.js.map +1 -0
  98. package/web/build/server/chunks/{validate-Da6e71V3.js → validate-CXjTRGnR.js} +34 -17
  99. package/web/build/server/chunks/validate-CXjTRGnR.js.map +1 -0
  100. package/web/build/client/_app/immutable/assets/0.BKRG1f36.css +0 -1
  101. package/web/build/client/_app/immutable/assets/0.BKRG1f36.css.br +0 -0
  102. package/web/build/client/_app/immutable/assets/0.BKRG1f36.css.gz +0 -0
  103. package/web/build/client/_app/immutable/assets/2.C4whE4xz.css +0 -1
  104. package/web/build/client/_app/immutable/assets/2.C4whE4xz.css.br +0 -0
  105. package/web/build/client/_app/immutable/assets/2.C4whE4xz.css.gz +0 -0
  106. package/web/build/client/_app/immutable/assets/3.DB-bJJq2.css +0 -1
  107. package/web/build/client/_app/immutable/assets/3.DB-bJJq2.css.br +0 -0
  108. package/web/build/client/_app/immutable/assets/3.DB-bJJq2.css.gz +0 -0
  109. package/web/build/client/_app/immutable/assets/storage.BxR6930R.css +0 -1
  110. package/web/build/client/_app/immutable/assets/storage.BxR6930R.css.br +0 -0
  111. package/web/build/client/_app/immutable/assets/storage.BxR6930R.css.gz +0 -0
  112. package/web/build/client/_app/immutable/chunks/BLMWhW3J.js +0 -1
  113. package/web/build/client/_app/immutable/chunks/BLMWhW3J.js.br +0 -1
  114. package/web/build/client/_app/immutable/chunks/BLMWhW3J.js.gz +0 -0
  115. package/web/build/client/_app/immutable/chunks/CXhIC34H.js.br +0 -0
  116. package/web/build/client/_app/immutable/chunks/CXhIC34H.js.gz +0 -0
  117. package/web/build/client/_app/immutable/chunks/DHO7NTh1.js.br +0 -0
  118. package/web/build/client/_app/immutable/chunks/DHO7NTh1.js.gz +0 -0
  119. package/web/build/client/_app/immutable/chunks/DpxY75GB.js +0 -215
  120. package/web/build/client/_app/immutable/chunks/DpxY75GB.js.br +0 -0
  121. package/web/build/client/_app/immutable/chunks/DpxY75GB.js.gz +0 -0
  122. package/web/build/client/_app/immutable/chunks/cYiFfRa3.js.br +0 -1
  123. package/web/build/client/_app/immutable/chunks/cYiFfRa3.js.gz +0 -0
  124. package/web/build/client/_app/immutable/entry/app.CCv57mvp.js +0 -2
  125. package/web/build/client/_app/immutable/entry/app.CCv57mvp.js.br +0 -0
  126. package/web/build/client/_app/immutable/entry/app.CCv57mvp.js.gz +0 -0
  127. package/web/build/client/_app/immutable/entry/start.B012TSbW.js +0 -1
  128. package/web/build/client/_app/immutable/entry/start.B012TSbW.js.br +0 -1
  129. package/web/build/client/_app/immutable/entry/start.B012TSbW.js.gz +0 -0
  130. package/web/build/client/_app/immutable/nodes/0.slrnBioC.js +0 -1
  131. package/web/build/client/_app/immutable/nodes/0.slrnBioC.js.br +0 -0
  132. package/web/build/client/_app/immutable/nodes/0.slrnBioC.js.gz +0 -0
  133. package/web/build/client/_app/immutable/nodes/1.Bs5JdO4l.js +0 -1
  134. package/web/build/client/_app/immutable/nodes/1.Bs5JdO4l.js.br +0 -0
  135. package/web/build/client/_app/immutable/nodes/1.Bs5JdO4l.js.gz +0 -0
  136. package/web/build/client/_app/immutable/nodes/2.D5ULH3EX.js +0 -10
  137. package/web/build/client/_app/immutable/nodes/2.D5ULH3EX.js.br +0 -0
  138. package/web/build/client/_app/immutable/nodes/2.D5ULH3EX.js.gz +0 -0
  139. package/web/build/client/_app/immutable/nodes/3.hjzQQgyj.js +0 -173
  140. package/web/build/client/_app/immutable/nodes/3.hjzQQgyj.js.br +0 -0
  141. package/web/build/client/_app/immutable/nodes/3.hjzQQgyj.js.gz +0 -0
  142. package/web/build/server/chunks/0-D7NSxn1-.js +0 -11
  143. package/web/build/server/chunks/1-DboYz8Fw.js +0 -7
  144. package/web/build/server/chunks/2-B7LX817Q.js +0 -7
  145. package/web/build/server/chunks/2-B7LX817Q.js.map +0 -1
  146. package/web/build/server/chunks/3-DgJ_LmTL.js +0 -7
  147. package/web/build/server/chunks/_server.ts-CP5cRLhW.js.map +0 -1
  148. package/web/build/server/chunks/agent-sessions-2XGojSJ_.js.map +0 -1
  149. package/web/build/server/chunks/index.js-CGw8_Q7V.js.map +0 -1
  150. package/web/build/server/chunks/manifest.js-CImZFS6G.js +0 -58
  151. package/web/build/server/chunks/manifest.js-CImZFS6G.js.map +0 -1
  152. package/web/build/server/chunks/remote-1g4ayag-BMpjk8UD.js.map +0 -1
  153. package/web/build/server/chunks/remote-wh6t6j-dYLCg8ks.js.map +0 -1
  154. package/web/build/server/chunks/validate-Da6e71V3.js.map +0 -1
@@ -1,42 +1,155 @@
1
1
  /**
2
- * Login de agentes ACP: mensajes claros + abrir una terminal interactiva cuando se pueda.
2
+ * Login de agentes ACP: clasificación de authMethods, mensajes claros y lanzamiento del CLI de auth.
3
+ *
4
+ * Preferencia de launch:
5
+ * 1. Misma TTY (stdio inherit) si stdin/stdout son TTY — CLI interactiva, exit code real.
6
+ * 2. Proceso hijo oculto (sin ventana de terminal) si no hay TTY — p. ej. web; OAuth abre el browser.
7
+ * 3. Terminal externa solo con `external: true` (raro: login que exige TTY interactiva).
8
+ *
9
+ * En Windows, `cmd /c start /wait` **no** propaga el exit code del proceso iniciado (siempre 0
10
+ * hacia Node). El fallback externo usa un archivo sentinel, igual que macOS/Linux.
3
11
  */
4
- import { spawn } from "node:child_process";
12
+ import { spawn, spawnSync } from "node:child_process";
13
+ import { randomBytes } from "node:crypto";
14
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { spawnCommand } from "./agents.mjs";
5
18
 
6
19
  /**
7
- * @typedef {{ bin: string, args: string[], display: string }} LoginCommand
20
+ * @typedef {{ bin: string, args: string[], display: string, env?: Record<string, string> }} LoginCommand
21
+ * @typedef {'agent' | 'terminal' | 'terminal-meta' | 'unknown'} AuthMethodKind
22
+ * @typedef {'inherit' | 'hidden' | 'external'} LoginLaunchMode
8
23
  */
9
24
 
10
25
  /**
11
- * Comando de login interactivo por agente (null = no hay CLI de login, p. ej. Cursor app).
26
+ * Hostname de Copilot (Enterprise). Vacío / `github.com` = cuenta en github.com.
27
+ * @param {string | null | undefined} host
28
+ * @returns {string}
29
+ */
30
+ export function normalizeCopilotLoginHost(host) {
31
+ const h = String(host || "")
32
+ .trim()
33
+ .replace(/^https?:\/\//i, "")
34
+ .replace(/\/.*$/, "");
35
+ if (!h || /^github\.com$/i.test(h)) return "";
36
+ return h;
37
+ }
38
+
39
+ /**
40
+ * Agrega `--host` a un comando `copilot login` cuando el usuario eligió Enterprise.
41
+ * No fuerza nada por env: el host lo elige quien llama (CLI/UI).
42
+ * @param {LoginCommand} cmd
43
+ * @param {string | null | undefined} host
44
+ * @returns {LoginCommand}
45
+ */
46
+ export function withCopilotLoginHost(cmd, host) {
47
+ if (!cmd || cmd.bin == null) return cmd;
48
+ const h = normalizeCopilotLoginHost(host);
49
+ if (!h) return cmd;
50
+ const args = Array.isArray(cmd.args) ? [...cmd.args] : [];
51
+ const hostIdx = args.findIndex((a) => a === "--host");
52
+ if (hostIdx >= 0) {
53
+ args[hostIdx + 1] = h;
54
+ } else {
55
+ args.push("--host", h);
56
+ }
57
+ const withoutHost = String(cmd.display || "")
58
+ .replace(/\s+--host\s+\S+/g, "")
59
+ .trim();
60
+ return {
61
+ ...cmd,
62
+ args,
63
+ display: `${withoutHost} --host ${h}`.trim(),
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Comando de login interactivo por agente (fallback si no hay authMethod usable).
12
69
  * @param {string} agentId
70
+ * @param {{ host?: string | null }} [opts] solo Copilot: host Enterprise opcional
13
71
  * @returns {LoginCommand | null}
14
72
  */
15
- export function loginCommand(agentId) {
73
+ export function loginCommand(agentId, opts = {}) {
16
74
  switch (agentId) {
17
75
  case "claude":
18
- return { bin: "claude", args: ["/login"], display: "claude /login" };
76
+ // `--cli` es del adaptador ACP, no del binario `claude` (falla: unknown option '--cli').
77
+ return { bin: "claude", args: ["auth", "login"], display: "claude auth login" };
19
78
  case "copilot":
20
- return { bin: "gh", args: ["auth", "login"], display: "gh auth login" };
21
- case "codex":
22
- return { bin: "codex", args: ["login"], display: "codex login" };
23
- case "gemini":
24
- return { bin: "gemini", args: [], display: "gemini" }; // el CLI guía el auth al arrancar
79
+ return withCopilotLoginHost(
80
+ { bin: "copilot", args: ["login"], display: "copilot login" },
81
+ opts.host
82
+ );
83
+ case "cursor":
84
+ return { bin: "cursor-agent", args: ["login"], display: "cursor-agent login" };
25
85
  default:
26
86
  return null;
27
87
  }
28
88
  }
29
89
 
90
+ /**
91
+ * Args de `claude auth login …` a partir de un authMethod ACP.
92
+ * El adaptador anuncia `["--cli", "auth", "login", "--claudeai"]` (o lo mismo tras el path del
93
+ * adapter en `_meta.terminal-auth.args`); `--cli` solo entiende el wrapper ACP, no `claude`.
94
+ * @param {any} [method]
95
+ * @returns {string[]}
96
+ */
97
+ export function claudeCliLoginArgs(method) {
98
+ const meta = method?._meta?.["terminal-auth"];
99
+ const raw = Array.isArray(meta?.args)
100
+ ? meta.args.map(String)
101
+ : Array.isArray(method?.args)
102
+ ? method.args.map(String)
103
+ : [];
104
+ const cliIdx = raw.indexOf("--cli");
105
+ const after = cliIdx >= 0 ? raw.slice(cliIdx + 1) : raw;
106
+ if (after[0] === "auth") return after;
107
+ const flags = after.filter((a) => a.startsWith("--"));
108
+ return ["auth", "login", ...flags];
109
+ }
110
+
111
+ /**
112
+ * Clasifica un authMethod de `initialize` según ACP v1 + extensión Copilot `_meta.terminal-auth`.
113
+ * @param {any} method
114
+ * @returns {AuthMethodKind}
115
+ */
116
+ export function classifyAuthMethod(method) {
117
+ if (!method || typeof method.id !== "string" || !method.id) return "unknown";
118
+ // Copilot / Claude-con-meta: `_meta.terminal-auth` gana aunque también traigan type.
119
+ if (method._meta != null && method._meta["terminal-auth"] != null) return "terminal-meta";
120
+ if (method.type === "terminal") return "terminal";
121
+ if (!method.type || method.type === "agent") return "agent";
122
+ return "unknown";
123
+ }
124
+
125
+ /**
126
+ * @param {any[]} authMethods
127
+ * @returns {{ agent: any[], terminal: any[], terminalMeta: any[], unknown: any[] }}
128
+ */
129
+ export function groupAuthMethods(authMethods) {
130
+ /** @type {{ agent: any[], terminal: any[], terminalMeta: any[], unknown: any[] }} */
131
+ const groups = { agent: [], terminal: [], terminalMeta: [], unknown: [] };
132
+ for (const method of authMethods || []) {
133
+ const kind = classifyAuthMethod(method);
134
+ if (kind === "agent") groups.agent.push(method);
135
+ else if (kind === "terminal") groups.terminal.push(method);
136
+ else if (kind === "terminal-meta") groups.terminalMeta.push(method);
137
+ else groups.unknown.push(method);
138
+ }
139
+ return groups;
140
+ }
141
+
30
142
  /** @param {string} text */
31
143
  export function looksLikeAuthFailure(text) {
32
144
  const s = String(text || "").toLowerCase();
33
- return /(not\s+)?auth|login|unauthor|not logged|sign[\s-]?in|credentials|token.*(invalid|expired|missing)|please\s+run/.test(
145
+ return /(not\s+)?auth|login|unauthor|not logged|sign[\s-]?in|credentials|token.*(invalid|expired|missing)|please\s+run|auth_required/.test(
34
146
  s
35
147
  );
36
148
  }
37
149
 
38
150
  /**
39
151
  * Mensaje en español si el agente murió / falló por falta de login.
152
+ * Sin comandos CLI: el CTA abre el login por el usuario.
40
153
  * @param {string} agentId
41
154
  * @param {string} agentLabel
42
155
  * @param {string} detail
@@ -44,43 +157,428 @@ export function looksLikeAuthFailure(text) {
44
157
  */
45
158
  export function authFailureMessage(agentId, agentLabel, detail) {
46
159
  if (!looksLikeAuthFailure(detail)) return null;
47
- const cmd = loginCommand(agentId);
48
- if (agentId === "cursor") {
49
- return `${agentLabel} no está autenticado. Iniciá sesión en la app de Cursor y reintentá.`;
160
+ const label = String(agentLabel || "").trim() || "el agente";
161
+ return `Necesitás iniciar sesión en ${label}.`;
162
+ }
163
+
164
+ /** Fallo del proceso de login (exit ≠ 0 o cancelado a medias). */
165
+ export function loginIncompleteMessage() {
166
+ return "No se pudo completar el inicio de sesión. Probá de nuevo.";
167
+ }
168
+
169
+ /** Estado mientras corre el login (TTY, proceso oculto o terminal externa). */
170
+ export function loginWaitingMessage() {
171
+ return "Esperando que completes el inicio de sesión…";
172
+ }
173
+
174
+ /**
175
+ * ¿El error pide login interactivo? (AcpAuthRequiredError o texto típico de auth).
176
+ * @param {unknown} err
177
+ */
178
+ export function isAuthRequiredError(err) {
179
+ if (!err) return false;
180
+ if (typeof err === "object" && /** @type {{ name?: string }} */ (err).name === "AcpAuthRequiredError") {
181
+ return true;
182
+ }
183
+ const msg = err instanceof Error ? err.message : String(err);
184
+ return looksLikeAuthFailure(msg);
185
+ }
186
+
187
+ /**
188
+ * authMethods de un AcpAuthRequiredError (si los trae).
189
+ * @param {unknown} err
190
+ * @returns {any[]}
191
+ */
192
+ export function authMethodsFromError(err) {
193
+ if (!err || typeof err !== "object") return [];
194
+ const methods = /** @type {{ methods?: unknown }} */ (err).methods;
195
+ return Array.isArray(methods) ? methods : [];
196
+ }
197
+
198
+ /**
199
+ * Método interactivo a abrir: `_meta.terminal-auth` (Copilot) o `type: terminal` (Claude).
200
+ * Si solo hay métodos `agent` (Cursor), devolvemos null → `resolveLoginCommand` usa el fallback CLI.
201
+ * @param {any[]} [methods]
202
+ * @returns {any | null}
203
+ */
204
+ export function pickLoginAuthMethod(methods) {
205
+ const grouped = groupAuthMethods(methods || []);
206
+ return grouped.terminalMeta[0] ?? grouped.terminal[0] ?? null;
207
+ }
208
+
209
+ /**
210
+ * Tras un fallo de auth: pregunta, abre el login (espera exit 0) y reintenta `op` una vez.
211
+ * Con `priorError`, saltea el primer intento de `op` (ya falló el caller).
212
+ * No duplica clasificación: reusa `pickLoginAuthMethod` / `resolveLoginCommand` / `runLoginTerminal`.
213
+ *
214
+ * @template T
215
+ * @param {() => Promise<T>} op
216
+ * @param {{
217
+ * agent: { def: { id: string, label: string, acp?: Function }, path: string },
218
+ * cwd?: string,
219
+ * priorError?: unknown,
220
+ * askToLogin: (info: { message: string, agentLabel: string, displayCmd: string }) => Promise<boolean>,
221
+ * pickCopilotHost?: () => Promise<string | null | undefined | false>,
222
+ * onStatus?: (msg: string) => void,
223
+ * runTerminal?: typeof runLoginTerminal,
224
+ * }} opts
225
+ * @returns {Promise<
226
+ * | { ok: true, value: T }
227
+ * | { ok: false, cancelled: true, error: unknown }
228
+ * | { ok: false, cancelled: false, error: unknown }
229
+ * >}
230
+ */
231
+ export async function withAgentLoginRetry(op, opts) {
232
+ /** @type {unknown} */
233
+ let err = opts.priorError;
234
+ if (err === undefined) {
235
+ try {
236
+ return { ok: true, value: await op() };
237
+ } catch (e) {
238
+ err = e;
239
+ }
50
240
  }
51
- if (cmd) {
52
- return `${agentLabel} no está autenticado. Corré \`${cmd.display}\` en una terminal y reintentá.`;
241
+
242
+ if (!isAuthRequiredError(err)) {
243
+ return { ok: false, cancelled: false, error: err };
244
+ }
245
+
246
+ const agentLabel = opts.agent?.def?.label || "El agente";
247
+ const raw = err instanceof Error ? err.message : String(err);
248
+ const fromAcp =
249
+ typeof err === "object" &&
250
+ err &&
251
+ /** @type {{ name?: string }} */ (err).name === "AcpAuthRequiredError";
252
+ const friendly =
253
+ authFailureMessage(opts.agent?.def?.id || "", agentLabel, raw) ||
254
+ (fromAcp ? `Necesitás iniciar sesión en ${agentLabel}.` : null) ||
255
+ raw;
256
+ const message =
257
+ String(friendly).split(/\r?\n/)[0].trim() || `Necesitás iniciar sesión en ${agentLabel}.`;
258
+
259
+ const method = pickLoginAuthMethod(authMethodsFromError(err));
260
+ let cmd;
261
+ try {
262
+ cmd = resolveLoginCommand(opts.agent, method);
263
+ } catch (resolveErr) {
264
+ return { ok: false, cancelled: false, error: resolveErr };
265
+ }
266
+
267
+ let want = false;
268
+ try {
269
+ want = Boolean(await opts.askToLogin({ message, agentLabel, displayCmd: cmd.display }));
270
+ } catch {
271
+ want = false;
272
+ }
273
+ if (!want) {
274
+ return { ok: false, cancelled: true, error: err };
275
+ }
276
+
277
+ if (opts.agent?.def?.id === "copilot" && typeof opts.pickCopilotHost === "function") {
278
+ let hostPick;
279
+ try {
280
+ hostPick = await opts.pickCopilotHost();
281
+ } catch {
282
+ return { ok: false, cancelled: true, error: err };
283
+ }
284
+ if (hostPick === false) {
285
+ return { ok: false, cancelled: true, error: err };
286
+ }
287
+ cmd = withCopilotLoginHost(cmd, hostPick);
288
+ }
289
+
290
+ opts.onStatus?.(loginWaitingMessage());
291
+ const run = opts.runTerminal || runLoginTerminal;
292
+ let result;
293
+ try {
294
+ result = await run(cmd, { wait: true, cwd: opts.cwd });
295
+ } catch (loginErr) {
296
+ return { ok: false, cancelled: false, error: loginErr };
297
+ }
298
+ if (result.code !== 0) {
299
+ return {
300
+ ok: false,
301
+ cancelled: false,
302
+ error: new Error(loginIncompleteMessage()),
303
+ };
304
+ }
305
+
306
+ opts.onStatus?.("Login ok. Reintentando…");
307
+ try {
308
+ return { ok: true, value: await op() };
309
+ } catch (retryErr) {
310
+ return { ok: false, cancelled: false, error: retryErr };
53
311
  }
54
- return `${agentLabel} no está autenticado. Revisá el login del agente y reintentá.`;
55
312
  }
56
313
 
57
314
  /**
58
- * Abre una terminal nueva con el login del agente (Windows / macOS / Linux).
59
- * @param {string} agentId
315
+ * ¿Podemos correr el login en la misma terminal (stdio inherit)?
316
+ * La web / procesos sin TTY usan proceso oculto (o `external: true` → ventana).
317
+ * @param {{ external?: boolean }} [opts]
318
+ */
319
+ export function canInheritLoginTty(opts = {}) {
320
+ if (opts.external) return false;
321
+ return Boolean(process.stdin?.isTTY && process.stdout?.isTTY);
322
+ }
323
+
324
+ /**
325
+ * Modo de launch: inherit (CLI con TTY) → hidden (web / OAuth en browser) → external (forzado).
326
+ * @param {{ wait?: boolean, external?: boolean }} [opts]
327
+ * @returns {LoginLaunchMode}
328
+ */
329
+ export function resolveLoginLaunchMode(opts = {}) {
330
+ if (Boolean(opts.wait) && canInheritLoginTty(opts)) return "inherit";
331
+ if (opts.external) return "external";
332
+ return "hidden";
333
+ }
334
+
335
+ /**
336
+ * Arma el comando de login a partir de un authMethod (o fallback por agentId).
337
+ * @param {{ def: { id: string, acp?: Function }, path: string }} agent
338
+ * @param {any} [method]
339
+ * @returns {LoginCommand}
340
+ */
341
+ export function resolveLoginCommand(agent, method) {
342
+ const kind = method ? classifyAuthMethod(method) : "unknown";
343
+ const meta = method?._meta?.["terminal-auth"];
344
+
345
+ // Claude: preferir el CLI real (`claude auth login …`). Lanzar npx/adapter + `--cli` falla o es
346
+ // frágil en Windows; el meta `node + adapter --cli …` funciona pero es un rodeos innecesario.
347
+ if (
348
+ agent?.def?.id === "claude" &&
349
+ method &&
350
+ (kind === "terminal" || kind === "terminal-meta")
351
+ ) {
352
+ const args = claudeCliLoginArgs(method);
353
+ const bin = typeof agent.path === "string" && agent.path.trim() ? agent.path : "claude";
354
+ return {
355
+ bin,
356
+ args,
357
+ display: `claude ${args.join(" ")}`.trim(),
358
+ ...(method?.env && typeof method.env === "object" ? { env: method.env } : {}),
359
+ };
360
+ }
361
+
362
+ if ((kind === "terminal-meta" || kind === "terminal") && meta && typeof meta === "object") {
363
+ const command = typeof meta.command === "string" ? meta.command.trim() : "";
364
+ if (command) {
365
+ const args = Array.isArray(meta.args) ? meta.args.map(String) : [];
366
+ const label = typeof meta.label === "string" && meta.label.trim() ? meta.label.trim() : "";
367
+ return {
368
+ bin: command,
369
+ args,
370
+ display: label || `${command}${args.length ? ` ${args.join(" ")}` : ""}`.trim(),
371
+ ...(method?.env && typeof method.env === "object" ? { env: method.env } : {}),
372
+ };
373
+ }
374
+ }
375
+
376
+ if (kind === "terminal" && method && typeof agent?.def?.acp === "function") {
377
+ const launch = agent.def.acp({ path: agent.path });
378
+ const extra = Array.isArray(method.args) ? method.args.map(String) : [];
379
+ const env = {
380
+ ...(launch.env && typeof launch.env === "object" ? launch.env : {}),
381
+ ...(method.env && typeof method.env === "object" ? method.env : {}),
382
+ };
383
+ const args = [...(launch.args || []), ...extra];
384
+ return {
385
+ bin: launch.file,
386
+ args,
387
+ display: `${launch.file}${args.length ? ` ${args.join(" ")}` : ""}`.trim(),
388
+ ...(Object.keys(env).length ? { env } : {}),
389
+ };
390
+ }
391
+
392
+ const fallback = loginCommand(agent?.def?.id);
393
+ if (fallback) {
394
+ if (agent?.def?.id === "claude" && typeof agent.path === "string" && agent.path.trim()) {
395
+ return { ...fallback, bin: agent.path };
396
+ }
397
+ return fallback;
398
+ }
399
+ throw new Error("Este agente no tiene un inicio de sesión conocido.");
400
+ }
401
+
402
+ /** @param {import('node:child_process').ChildProcess} child */
403
+ function waitExit(child) {
404
+ return new Promise((resolve, reject) => {
405
+ child.once("error", reject);
406
+ child.once("exit", (code, signal) => resolve(signal ? 1 : (code ?? 1)));
407
+ });
408
+ }
409
+
410
+ /**
411
+ * Lanza el login en background (proceso oculto; no espera). Compat con UI/CTA simples (web sin sesión).
412
+ * @param {string | LoginCommand} agentIdOrCmd
60
413
  * @returns {{ display: string }}
61
414
  */
62
- export function openLoginTerminal(agentId) {
63
- const cmd = loginCommand(agentId);
415
+ export function openLoginTerminal(agentIdOrCmd) {
416
+ const cmd = typeof agentIdOrCmd === "string" ? loginCommand(agentIdOrCmd) : agentIdOrCmd;
64
417
  if (!cmd) {
418
+ const agentId = typeof agentIdOrCmd === "string" ? agentIdOrCmd : "";
65
419
  throw new Error(
66
420
  agentId === "cursor"
67
- ? "Cursor se autentica desde la app de escritorio, no desde una terminal."
68
- : "Este agente no tiene un comando de login conocido."
421
+ ? "Necesitás iniciar sesión en Cursor."
422
+ : "Este agente no tiene un inicio de sesión conocido."
69
423
  );
70
424
  }
425
+ void launchLoginProcess(cmd, { wait: false, mode: "hidden" });
426
+ return { display: cmd.display };
427
+ }
428
+
429
+ /**
430
+ * Lanza el login. Con TTY y `wait: true`, corre en esta terminal (inherit); si no, proceso oculto
431
+ * (OAuth en browser). Solo con `external: true` abre una ventana de terminal del OS.
432
+ * @param {LoginCommand} cmd
433
+ * @param {{ wait?: boolean, cwd?: string, external?: boolean }} [opts]
434
+ * @returns {Promise<{ display: string, code: number | null, mode: LoginLaunchMode }>}
435
+ */
436
+ export async function runLoginTerminal(cmd, opts = {}) {
437
+ const wait = Boolean(opts.wait);
438
+ const mode = resolveLoginLaunchMode(opts);
439
+ const code = await launchLoginProcess(cmd, {
440
+ wait,
441
+ cwd: opts.cwd,
442
+ mode,
443
+ });
444
+ return {
445
+ display: cmd.display,
446
+ code: wait ? code : null,
447
+ mode,
448
+ };
449
+ }
450
+
451
+ /** @param {string} arg */
452
+ function quoteBatArg(arg) {
453
+ const s = String(arg);
454
+ if (/^[\w./:\\@=-]+$/.test(s)) return s;
455
+ return `"${s.replace(/"/g, '""')}"`;
456
+ }
457
+
458
+ /**
459
+ * Espera a que el sentinel tenga un exit code (macOS/Linux/Windows externo).
460
+ * @param {string} sentinel
461
+ * @param {string} display
462
+ * @returns {Promise<number>}
463
+ */
464
+ async function waitForExitSentinel(sentinel, display) {
465
+ const deadline = Date.now() + 30 * 60 * 1000;
466
+ while (Date.now() < deadline) {
467
+ try {
468
+ const raw = readFileSync(sentinel, "utf8").trim();
469
+ if (raw !== "") {
470
+ const code = Number.parseInt(raw, 10);
471
+ try {
472
+ unlinkSync(sentinel);
473
+ } catch {
474
+ /* ignore */
475
+ }
476
+ return Number.isFinite(code) ? code : 1;
477
+ }
478
+ } catch {
479
+ /* aún no */
480
+ }
481
+ await new Promise((r) => setTimeout(r, 400));
482
+ }
483
+ try {
484
+ unlinkSync(sentinel);
485
+ } catch {
486
+ /* ignore */
487
+ }
488
+ throw new Error(`Timeout esperando el login (${display}).`);
489
+ }
490
+
491
+ /**
492
+ * Proceso hijo sin ventana de terminal (web / OAuth). Exit code real vía waitExit.
493
+ * @param {LoginCommand} cmd
494
+ * @param {{ wait: boolean, cwd?: string, env: NodeJS.ProcessEnv }} opts
495
+ * @returns {Promise<number>}
496
+ */
497
+ async function launchHiddenLogin(cmd, { wait, cwd, env }) {
498
+ const child = spawnCommand(cmd.bin, cmd.args, {
499
+ cwd,
500
+ env,
501
+ stdio: "ignore",
502
+ windowsHide: true,
503
+ ...(wait ? {} : { detached: true }),
504
+ });
505
+ if (!wait) {
506
+ child.unref();
507
+ return 0;
508
+ }
509
+ return waitExit(child);
510
+ }
511
+
512
+ /**
513
+ * @param {LoginCommand} cmd
514
+ * @param {{ wait: boolean, cwd?: string, mode: LoginLaunchMode }} opts
515
+ * @returns {Promise<number>}
516
+ */
517
+ async function launchLoginProcess(cmd, { wait, cwd, mode }) {
518
+ const env = { ...process.env, ...(cmd.env || {}) };
519
+
520
+ if (mode === "inherit") {
521
+ const child = spawnCommand(cmd.bin, cmd.args, { cwd, env, stdio: "inherit" });
522
+ return waitExit(child);
523
+ }
71
524
 
525
+ if (mode === "hidden") {
526
+ return launchHiddenLogin(cmd, { wait, cwd, env });
527
+ }
528
+
529
+ // mode === "external": ventana visible del OS (solo si el caller pasó external: true).
72
530
  const platform = process.platform;
531
+
73
532
  if (platform === "win32") {
74
- // `start "title" cmd /k …` — el primer quoted es el título de la ventana.
75
- spawn("cmd.exe", ["/c", "start", `${cmd.bin} login`, "cmd.exe", "/k", cmd.bin, ...cmd.args], {
76
- detached: true,
77
- stdio: "ignore",
78
- windowsHide: false,
79
- }).unref();
80
- } else if (platform === "darwin") {
81
- const script = `tell application "Terminal" to do script ${JSON.stringify(`${cmd.bin} ${cmd.args.join(" ")}`.trim())}`;
82
- spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
83
- } else {
533
+ if (!wait) {
534
+ const child = spawn(
535
+ "cmd.exe",
536
+ ["/c", "start", "diff-review login", "cmd.exe", "/k", cmd.bin, ...cmd.args],
537
+ { cwd, env, detached: true, stdio: "ignore", windowsHide: false }
538
+ );
539
+ child.unref();
540
+ return 0;
541
+ }
542
+
543
+ // `start /wait` siempre reporta 0 a Node: el exit real va al sentinel vía .bat.
544
+ const id = randomBytes(8).toString("hex");
545
+ const sentinel = join(tmpdir(), `diff-review-login-${id}.exit`);
546
+ const bat = join(tmpdir(), `diff-review-login-${id}.bat`);
547
+ const line = [quoteBatArg(cmd.bin), ...cmd.args.map(quoteBatArg)].join(" ");
548
+ // No usar `echo %ERRORLEVEL%>file`: con código 0, cmd interpreta `echo 0>` y el sentinel queda vacío.
549
+ const sentPath = sentinel.replace(/"/g, '""');
550
+ writeFileSync(
551
+ bat,
552
+ [`@echo off`, `call ${line}`, `> "${sentPath}" echo %ERRORLEVEL%`, ``].join("\r\n")
553
+ );
554
+ try {
555
+ writeFileSync(sentinel, "");
556
+ } catch {
557
+ /* ok */
558
+ }
559
+ const child = spawn(
560
+ "cmd.exe",
561
+ ["/c", "start", "/wait", "diff-review login", "cmd.exe", "/c", bat],
562
+ { cwd, env, stdio: "ignore", windowsHide: false }
563
+ );
564
+ try {
565
+ await waitExit(child);
566
+ return await waitForExitSentinel(sentinel, cmd.display);
567
+ } finally {
568
+ try {
569
+ unlinkSync(bat);
570
+ } catch {
571
+ /* ignore */
572
+ }
573
+ }
574
+ }
575
+
576
+ if (!wait) {
577
+ if (platform === "darwin") {
578
+ const script = `tell application "Terminal" to do script ${JSON.stringify(`${cmd.bin} ${cmd.args.join(" ")}`.trim())}`;
579
+ spawn("osascript", ["-e", script], { detached: true, stdio: "ignore", env }).unref();
580
+ return 0;
581
+ }
84
582
  const line = `${cmd.bin} ${cmd.args.join(" ")}`.trim();
85
583
  const tried = [
86
584
  ["x-terminal-emulator", ["-e", "bash", "-lc", `${line}; exec bash`]],
@@ -88,10 +586,42 @@ export function openLoginTerminal(agentId) {
88
586
  ["konsole", ["-e", "bash", "-lc", `${line}; exec bash`]],
89
587
  ["xterm", ["-e", "bash", "-lc", `${line}; exec bash`]],
90
588
  ];
589
+ for (const [bin, args] of tried) {
590
+ try {
591
+ spawn(bin, args, { detached: true, stdio: "ignore", env, cwd }).unref();
592
+ return 0;
593
+ } catch {
594
+ /* siguiente */
595
+ }
596
+ }
597
+ throw new Error(`No pude abrir una terminal. Corré a mano: ${cmd.display}`);
598
+ }
599
+
600
+ // wait=true en macOS/Linux: terminal externa + sentinel.
601
+ const sentinel = join(tmpdir(), `diff-review-login-${randomBytes(8).toString("hex")}.exit`);
602
+ try {
603
+ writeFileSync(sentinel, "");
604
+ } catch {
605
+ /* ok */
606
+ }
607
+ const quoted = [cmd.bin, ...cmd.args].map((a) => JSON.stringify(a)).join(" ");
608
+ const line = `${quoted}; echo $? > ${JSON.stringify(sentinel)}`;
609
+
610
+ if (platform === "darwin") {
611
+ const script = `tell application "Terminal" to do script ${JSON.stringify(line)}`;
612
+ spawn("osascript", ["-e", script], { stdio: "ignore", env, cwd });
613
+ } else {
614
+ const tried = [
615
+ ["gnome-terminal", ["--wait", "--", "bash", "-lc", line]],
616
+ ["x-terminal-emulator", ["-e", "bash", "-lc", line]],
617
+ ["konsole", ["-e", "bash", "-lc", line]],
618
+ ["xterm", ["-e", "bash", "-lc", line]],
619
+ ];
91
620
  let ok = false;
92
621
  for (const [bin, args] of tried) {
93
622
  try {
94
- spawn(bin, args, { detached: true, stdio: "ignore" }).unref();
623
+ const child = spawn(bin, args, { stdio: "ignore", env, cwd });
624
+ await waitExit(child);
95
625
  ok = true;
96
626
  break;
97
627
  } catch {
@@ -101,5 +631,287 @@ export function openLoginTerminal(agentId) {
101
631
  if (!ok) throw new Error(`No pude abrir una terminal. Corré a mano: ${cmd.display}`);
102
632
  }
103
633
 
104
- return { display: cmd.display };
634
+ return waitForExitSentinel(sentinel, cmd.display);
635
+ }
636
+
637
+ /**
638
+ * Cuenta activa del agente (para el header de la web).
639
+ * @param {string} agentId
640
+ * @param {{ env?: NodeJS.ProcessEnv, run?: typeof spawnSync }} [opts]
641
+ * @returns {{
642
+ * agentId: string,
643
+ * loggedIn: boolean,
644
+ * accountLabel: string | null,
645
+ * detail: string | null,
646
+ * source: 'gh' | 'claude' | 'env' | 'unknown' | null,
647
+ * warnings: string[],
648
+ * }}
649
+ */
650
+ export function agentAccountStatus(agentId, opts = {}) {
651
+ const id = String(agentId || "").trim();
652
+ const env = opts.env || process.env;
653
+ const run = opts.run || spawnSync;
654
+ /** @type {string[]} */
655
+ const warnings = [];
656
+
657
+ if (id === "copilot") {
658
+ const envHits = copilotTokenEnvOverrides(env);
659
+ if (envHits.length) {
660
+ warnings.push(`${envHits.join(", ")} en el entorno (pisa otras cuentas).`);
661
+ return {
662
+ agentId: id,
663
+ loggedIn: true,
664
+ accountLabel: "Token de entorno",
665
+ detail: envHits.join(", "),
666
+ source: "env",
667
+ warnings,
668
+ };
669
+ }
670
+ const accounts = listGhAuthAccounts({ env, run });
671
+ const active = accounts.find((a) => a.active) || accounts[0] || null;
672
+ if (active) {
673
+ return {
674
+ agentId: id,
675
+ loggedIn: true,
676
+ accountLabel: active.user,
677
+ detail: active.host,
678
+ source: "gh",
679
+ warnings,
680
+ };
681
+ }
682
+ return {
683
+ agentId: id,
684
+ loggedIn: false,
685
+ accountLabel: null,
686
+ detail: null,
687
+ source: null,
688
+ warnings,
689
+ };
690
+ }
691
+
692
+ if (id === "claude") {
693
+ const r = run("claude", ["auth", "status", "--json"], {
694
+ encoding: "utf8",
695
+ env,
696
+ windowsHide: true,
697
+ });
698
+ if (r.error) {
699
+ return {
700
+ agentId: id,
701
+ loggedIn: false,
702
+ accountLabel: null,
703
+ detail: null,
704
+ source: null,
705
+ warnings: [r.error.message],
706
+ };
707
+ }
708
+ try {
709
+ const parsed = JSON.parse(String(r.stdout || "{}"));
710
+ const loggedIn = Boolean(parsed?.loggedIn);
711
+ const email = String(parsed?.email || "").trim() || null;
712
+ const org = String(parsed?.orgName || "").trim() || null;
713
+ return {
714
+ agentId: id,
715
+ loggedIn,
716
+ accountLabel: loggedIn ? email || "Claude" : null,
717
+ detail: loggedIn ? org || String(parsed?.authMethod || "") || null : null,
718
+ source: loggedIn ? "claude" : null,
719
+ warnings,
720
+ };
721
+ } catch {
722
+ return {
723
+ agentId: id,
724
+ loggedIn: false,
725
+ accountLabel: null,
726
+ detail: null,
727
+ source: null,
728
+ warnings: ["No pude leer el estado de Claude."],
729
+ };
730
+ }
731
+ }
732
+
733
+ if (id === "cursor") {
734
+ return {
735
+ agentId: id,
736
+ loggedIn: true,
737
+ accountLabel: "Cursor",
738
+ detail: "Sesión del CLI / app",
739
+ source: "unknown",
740
+ warnings,
741
+ };
742
+ }
743
+
744
+ return {
745
+ agentId: id,
746
+ loggedIn: false,
747
+ accountLabel: null,
748
+ detail: null,
749
+ source: null,
750
+ warnings,
751
+ };
752
+ }
753
+
754
+ /**
755
+ * Env vars que Copilot usa antes que OAuth / gh (impiden un cambio de cuenta real).
756
+ * @param {NodeJS.ProcessEnv} [env]
757
+ * @returns {string[]}
758
+ */
759
+ export function copilotTokenEnvOverrides(env = process.env) {
760
+ /** @type {string[]} */
761
+ const hit = [];
762
+ for (const key of ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]) {
763
+ if (String(env[key] || "").trim()) hit.push(key);
764
+ }
765
+ return hit;
766
+ }
767
+
768
+ /**
769
+ * Cuentas conocidas por `gh auth status --json hosts`.
770
+ * @param {{ env?: NodeJS.ProcessEnv, run?: typeof spawnSync }} [opts]
771
+ * @returns {{ host: string, user: string, active: boolean }[]}
772
+ */
773
+ export function listGhAuthAccounts(opts = {}) {
774
+ const env = opts.env || process.env;
775
+ const run = opts.run || spawnSync;
776
+ const r = run("gh", ["auth", "status", "--json", "hosts"], {
777
+ encoding: "utf8",
778
+ env,
779
+ windowsHide: true,
780
+ });
781
+ if (r.error) return [];
782
+ try {
783
+ const parsed = JSON.parse(String(r.stdout || "{}"));
784
+ const hosts = parsed?.hosts && typeof parsed.hosts === "object" ? parsed.hosts : {};
785
+ /** @type {{ host: string, user: string, active: boolean }[]} */
786
+ const out = [];
787
+ for (const [host, accounts] of Object.entries(hosts)) {
788
+ const list = Array.isArray(accounts) ? accounts : [];
789
+ for (const acc of list) {
790
+ const user = String(acc?.login || acc?.user || "").trim();
791
+ if (!user) continue;
792
+ out.push({ host: String(host), user, active: Boolean(acc?.active) });
793
+ }
794
+ }
795
+ return out;
796
+ } catch {
797
+ return [];
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Cierra sesiones de `gh` (Copilot las reusa si no hay OAuth propio).
803
+ * @param {{ env?: NodeJS.ProcessEnv, run?: typeof spawnSync, host?: string | null }} [opts]
804
+ * @returns {{ ok: boolean, loggedOut: { host: string, user: string }[], warnings: string[] }}
805
+ */
806
+ export function logoutGhAuthAccounts(opts = {}) {
807
+ const env = opts.env || process.env;
808
+ const run = opts.run || spawnSync;
809
+ const onlyHost = normalizeCopilotLoginHost(opts.host) || null;
810
+ const accounts = listGhAuthAccounts({ env, run }).filter((a) =>
811
+ onlyHost ? a.host === onlyHost || a.host === `https://${onlyHost}` : true
812
+ );
813
+ /** @type {{ host: string, user: string }[]} */
814
+ const loggedOut = [];
815
+ /** @type {string[]} */
816
+ const warnings = [];
817
+
818
+ for (const acc of accounts) {
819
+ const r = run(
820
+ "gh",
821
+ ["auth", "logout", "--hostname", acc.host, "--user", acc.user],
822
+ { encoding: "utf8", env, windowsHide: true }
823
+ );
824
+ if (r.error) {
825
+ warnings.push(`No pude cerrar ${acc.user}@${acc.host}: ${r.error.message}`);
826
+ continue;
827
+ }
828
+ if (r.status !== 0) {
829
+ const err = String(r.stderr || r.stdout || "").trim() || `exit ${r.status}`;
830
+ warnings.push(`No pude cerrar ${acc.user}@${acc.host}: ${err}`);
831
+ continue;
832
+ }
833
+ loggedOut.push({ host: acc.host, user: acc.user });
834
+ }
835
+
836
+ const envHits = copilotTokenEnvOverrides(env);
837
+ if (envHits.length) {
838
+ warnings.push(
839
+ `Hay ${envHits.join(", ")} en el entorno: Copilot seguirá usando ese token hasta que lo saques.`
840
+ );
841
+ }
842
+
843
+ return { ok: warnings.length === 0 || loggedOut.length > 0, loggedOut, warnings };
844
+ }
845
+
846
+ /**
847
+ * Comando de logout CLI por agente (sin depender de gh).
848
+ * @param {string} agentId
849
+ * @returns {LoginCommand | null}
850
+ */
851
+ export function logoutCommand(agentId) {
852
+ switch (agentId) {
853
+ case "claude":
854
+ return { bin: "claude", args: ["auth", "logout"], display: "claude auth logout" };
855
+ default:
856
+ return null;
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Cierra la sesión del agente para poder elegir otra cuenta.
862
+ * Copilot: también desloguea cuentas de `gh` (si solo hacés logout OAuth, gh sigue ganando).
863
+ *
864
+ * @param {string} agentId
865
+ * @param {{
866
+ * host?: string | null,
867
+ * env?: NodeJS.ProcessEnv,
868
+ * run?: typeof spawnSync,
869
+ * runTerminal?: typeof runLoginTerminal,
870
+ * cwd?: string,
871
+ * }} [opts]
872
+ * @returns {Promise<{ ok: boolean, steps: string[], warnings: string[] }>}
873
+ */
874
+ export async function runAgentLogout(agentId, opts = {}) {
875
+ const id = String(agentId || "").trim();
876
+ /** @type {string[]} */
877
+ const steps = [];
878
+ /** @type {string[]} */
879
+ const warnings = [];
880
+ const env = opts.env || process.env;
881
+ const run = opts.run || spawnSync;
882
+ const runTerm = opts.runTerminal || runLoginTerminal;
883
+
884
+ if (id === "copilot") {
885
+ const gh = logoutGhAuthAccounts({ env, run, host: opts.host });
886
+ for (const a of gh.loggedOut) {
887
+ steps.push(`Cerré ${a.user} en ${a.host} (GitHub CLI)`);
888
+ }
889
+ warnings.push(...gh.warnings);
890
+ if (!gh.loggedOut.length && !gh.warnings.length) {
891
+ steps.push("No había cuentas de GitHub CLI activas.");
892
+ }
893
+ return { ok: true, steps, warnings };
894
+ }
895
+
896
+ const cmd = logoutCommand(id);
897
+ if (!cmd) {
898
+ return {
899
+ ok: false,
900
+ steps,
901
+ warnings: ["Este agente no tiene cierre de sesión desde la app."],
902
+ };
903
+ }
904
+
905
+ try {
906
+ const result = await runTerm(cmd, { wait: true, cwd: opts.cwd });
907
+ if (result.code !== 0) {
908
+ warnings.push(`El logout terminó con código ${result.code ?? "?"}.`);
909
+ return { ok: false, steps, warnings };
910
+ }
911
+ steps.push(`Cerré la sesión (${cmd.display}).`);
912
+ return { ok: true, steps, warnings };
913
+ } catch (err) {
914
+ warnings.push(err instanceof Error ? err.message : String(err));
915
+ return { ok: false, steps, warnings };
916
+ }
105
917
  }