komplian 0.7.0 → 0.7.2

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.
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Komplian setup — un solo comando: onboard → postman → mcp-tools → db:all:dev → localhost.
4
- * Por defecto abre el navegador en localhost para datos sensibles (Postman API key, URLs Neon dev)
5
- * con texto de ayuda; alternativa: --terminal-only (sin navegador).
3
+ * Komplian setup — one flow: onboard → postman → mcp-tools → db:all:dev → localhost.
4
+ * Local browser UI (127.0.0.1 only) for secrets; optional Neon OAuth when env is configured.
5
+ * Postman’s HTTP API uses API keys only (no OAuth); the UI links to key creation.
6
6
  */
7
7
 
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { createServer } from "node:http";
10
- import { randomBytes } from "node:crypto";
10
+ import { createHash, randomBytes } from "node:crypto";
11
11
  import { existsSync, mkdirSync } from "node:fs";
12
12
  import { dirname, join, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
@@ -41,12 +41,123 @@ const c = {
41
41
  yellow: "\x1b[33m",
42
42
  };
43
43
 
44
- function log(s = "") {
44
+ function out(s = "") {
45
45
  console.log(s);
46
46
  }
47
47
 
48
+ function errLine(s = "") {
49
+ console.error(s);
50
+ }
51
+
48
52
  const PLACEHOLDER = "komplian_localhost_placeholder";
49
53
 
54
+ function b64url(buf) {
55
+ return Buffer.from(buf)
56
+ .toString("base64")
57
+ .replace(/\+/g, "-")
58
+ .replace(/\//g, "_")
59
+ .replace(/=+$/g, "");
60
+ }
61
+
62
+ function newPkce() {
63
+ const verifier = b64url(randomBytes(32));
64
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
65
+ return { verifier, challenge };
66
+ }
67
+
68
+ function neonOAuthEnvReady() {
69
+ const id = process.env.KOMPLIAN_NEON_OAUTH_CLIENT_ID?.trim();
70
+ const sec = process.env.KOMPLIAN_NEON_OAUTH_CLIENT_SECRET?.trim();
71
+ const redir = process.env.KOMPLIAN_NEON_OAUTH_REDIRECT_URI?.trim();
72
+ const a = process.env.KOMPLIAN_NEON_OAUTH_PROJECT_ID_APP?.trim();
73
+ const b = process.env.KOMPLIAN_NEON_OAUTH_PROJECT_ID_ADMIN?.trim();
74
+ const w = process.env.KOMPLIAN_NEON_OAUTH_PROJECT_ID_WEB?.trim();
75
+ return !!(id && sec && redir && a && b && w);
76
+ }
77
+
78
+ function parseListenFromRedirectUri() {
79
+ const u = process.env.KOMPLIAN_NEON_OAUTH_REDIRECT_URI?.trim();
80
+ if (!u) return null;
81
+ try {
82
+ const x = new URL(u);
83
+ if (x.hostname !== "127.0.0.1" && x.hostname !== "localhost") return null;
84
+ const port = Number(x.port || (x.protocol === "https:" ? 443 : 80));
85
+ if (!Number.isFinite(port) || port <= 0) return null;
86
+ return { host: "127.0.0.1", port, pathname: x.pathname || "/" };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ async function neonExchangeCode(clientId, clientSecret, redirectUri, code, verifier) {
93
+ const body = new URLSearchParams({
94
+ client_id: clientId,
95
+ client_secret: clientSecret,
96
+ grant_type: "authorization_code",
97
+ code,
98
+ redirect_uri: redirectUri,
99
+ code_verifier: verifier,
100
+ });
101
+ const r = await fetch("https://oauth2.neon.tech/oauth2/token", {
102
+ method: "POST",
103
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
104
+ body,
105
+ });
106
+ const j = await r.json().catch(() => ({}));
107
+ if (!r.ok) {
108
+ return {
109
+ ok: false,
110
+ error: typeof j.error_description === "string" ? j.error_description : "Neon token exchange failed.",
111
+ };
112
+ }
113
+ if (!j.access_token) return { ok: false, error: "No access_token from Neon." };
114
+ return { ok: true, access_token: j.access_token };
115
+ }
116
+
117
+ async function neonFetchConnectionUri(accessToken, projectId) {
118
+ const h = {
119
+ Authorization: `Bearer ${accessToken}`,
120
+ Accept: "application/json",
121
+ };
122
+ const br = await fetch(
123
+ `https://console.neon.tech/api/v2/projects/${encodeURIComponent(projectId)}/branches`,
124
+ { headers: h }
125
+ );
126
+ const bj = await br.json().catch(() => ({}));
127
+ const branches = Array.isArray(bj.branches)
128
+ ? bj.branches
129
+ : Array.isArray(bj.data?.branches)
130
+ ? bj.data.branches
131
+ : [];
132
+ const branch = branches.find((x) => x.default === true) || branches[0];
133
+ if (!branch?.id) return { ok: false, error: "No default branch for project." };
134
+ const cr = await fetch(
135
+ `https://console.neon.tech/api/v2/projects/${encodeURIComponent(projectId)}/connection_uri?branch_id=${encodeURIComponent(branch.id)}&database_name=neondb`,
136
+ { headers: h }
137
+ );
138
+ const cj = await cr.json().catch(() => ({}));
139
+ const uri = cj.uri || cj.connection_uri;
140
+ if (!cr.ok || typeof uri !== "string" || !uri.startsWith("postgres")) {
141
+ return { ok: false, error: "Could not read connection URI from Neon API." };
142
+ }
143
+ return { ok: true, uri };
144
+ }
145
+
146
+ async function neonBuildTriplet(accessToken) {
147
+ const appId = process.env.KOMPLIAN_NEON_OAUTH_PROJECT_ID_APP?.trim();
148
+ const adminId = process.env.KOMPLIAN_NEON_OAUTH_PROJECT_ID_ADMIN?.trim();
149
+ const webId = process.env.KOMPLIAN_NEON_OAUTH_PROJECT_ID_WEB?.trim();
150
+ const [ra, rb, rw] = await Promise.all([
151
+ neonFetchConnectionUri(accessToken, appId),
152
+ neonFetchConnectionUri(accessToken, adminId),
153
+ neonFetchConnectionUri(accessToken, webId),
154
+ ]);
155
+ if (!ra.ok) return { ok: false, error: ra.error };
156
+ if (!rb.ok) return { ok: false, error: rb.error };
157
+ if (!rw.ok) return { ok: false, error: rw.error };
158
+ return { ok: true, db: { app: ra.uri, admin: rb.uri, web: rw.uri } };
159
+ }
160
+
50
161
  function isValidPostgresUrl(s) {
51
162
  const t = (s || "").trim();
52
163
  if (!t.startsWith("postgresql://") && !t.startsWith("postgres://")) return false;
@@ -85,126 +196,211 @@ function openBrowserSync(url) {
85
196
  spawnSync("xdg-open", [url], { stdio: "ignore" });
86
197
  }
87
198
  } catch {
88
- log(`${c.yellow}○${c.reset} No se pudo abrir el navegador. Abre manualmente: ${c.bold}${url}${c.reset}`);
199
+ errLine(`${c.yellow}○${c.reset} Open this URL in your browser: ${url}`);
89
200
  }
90
201
  }
91
202
 
92
- function buildSetupPageHtml(token, { needsPostmanKey, needsDbUrls, emailDomain }) {
93
- const esc = (s) =>
94
- String(s)
95
- .replace(/&/g, "&amp;")
96
- .replace(/</g, "&lt;")
97
- .replace(/"/g, "&quot;");
98
- const postmanBlock = needsPostmanKey
99
- ? `
100
- <section class="card">
101
- <h2>1. Postman API key</h2>
102
- <p class="help">Crea una clave en Postman → <strong>Settings</strong> → <strong>API keys</strong> → Generate. Cuenta con email <strong>@${esc(emailDomain)}</strong>. Se guarda en <code>~/.komplian/postman-api-key</code>. Si la dejas vacía, la terminal te la pedirá en el paso Postman.</p>
103
- <label for="postman_api_key">API key (opcional aquí)</label>
104
- <input type="password" id="postman_api_key" name="postman_api_key" autocomplete="off" placeholder="PMAK-…" />
203
+ const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="132" height="28" viewBox="0 0 132 28" fill="none" aria-hidden="true"><text x="0" y="20" fill="#fafafa" font-family="system-ui,-apple-system,sans-serif" font-size="18" font-weight="600">Komplian</text><circle cx="124" cy="14" r="4" fill="#22c55e"/></svg>`;
204
+
205
+ function buildSetupPageHtml(token, opts) {
206
+ const { needsPostmanKey, needsDbUrls, emailDomain, neonOAuth } = opts;
207
+ const postmanSection = needsPostmanKey
208
+ ? `<section class="card">
209
+ <p class="fine">Postman does not provide OAuth for API keys. Open your account, create a key, paste it once.</p>
210
+ <a class="btn secondary" href="https://go.postman.co/settings/me/api-keys" target="_blank" rel="noopener">Open Postman</a>
211
+ <label>API key · @${emailDomain.replace(/"/g, "")}</label>
212
+ <input type="password" id="postman_api_key" autocomplete="off" placeholder="••••••••" />
105
213
  </section>`
106
214
  : "";
107
- const dbBlock = needsDbUrls
108
- ? `
109
- <section class="card">
110
- <h2>${needsPostmanKey ? "2" : "1"}. Bases de datos (solo desarrollo)</h2>
111
- <p class="help">Pega las connection strings <strong>postgresql://…</strong> de Neon (rama <em>development</em> u homólogo). Tres bases: <strong>app</strong> (también usa la API), <strong>admin</strong> y <strong>web</strong> (pilot). Se escriben en <code>~/.komplian/dev-databases.json</code> y en cada <code>.env.local</code> del monorepo.</p>
112
- <label for="db_app">URL APP (app + API)</label>
113
- <textarea id="db_app" name="db_app" rows="2" placeholder="postgresql://…"></textarea>
114
- <label for="db_admin">URL ADMIN</label>
115
- <textarea id="db_admin" name="db_admin" rows="2" placeholder="postgresql://…"></textarea>
116
- <label for="db_web">URL WEB (pilot)</label>
117
- <textarea id="db_web" name="db_web" rows="2" placeholder="postgresql://…"></textarea>
118
- </section>`
215
+
216
+ const neonBtn = neonOAuth && needsDbUrls
217
+ ? `<button type="button" class="btn secondary" id="neon_oauth">Connect Neon</button>
218
+ <p id="neon_status" class="fine hidden">Linked.</p>`
119
219
  : "";
220
+
221
+ const dbFields = `<label>APP + API</label><textarea id="db_app" rows="2" autocomplete="off"></textarea>
222
+ <label>ADMIN</label><textarea id="db_admin" rows="2" autocomplete="off"></textarea>
223
+ <label>WEB</label><textarea id="db_web" rows="2" autocomplete="off"></textarea>`;
224
+
225
+ const dbManual =
226
+ needsDbUrls && !neonOAuth
227
+ ? `<section class="card">${dbFields}</section>`
228
+ : needsDbUrls && neonOAuth
229
+ ? `<section class="card"><details class="fine" style="margin:0"><summary style="cursor:pointer;outline:none">Paste URLs</summary>${dbFields}</details></section>`
230
+ : "";
231
+
120
232
  return `<!DOCTYPE html>
121
- <html lang="es">
233
+ <html lang="en">
122
234
  <head>
123
235
  <meta charset="utf-8" />
124
236
  <meta name="viewport" content="width=device-width, initial-scale=1" />
125
- <title>Komplian setup</title>
237
+ <title>Komplian</title>
126
238
  <style>
127
- :root { font-family: system-ui, sans-serif; background: #0a0a0a; color: #e5e5e5; }
128
- body { max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
129
- h1 { font-size: 1.25rem; font-weight: 600; }
130
- .card { background: #171717; border: 1px solid #262626; border-radius: 10px; padding: 1.25rem; margin-bottom: 1.25rem; }
131
- .help { color: #a3a3a3; font-size: 0.9rem; line-height: 1.5; margin: 0 0 1rem; }
132
- label { display: block; font-size: 0.8rem; color: #737373; margin: 0.75rem 0 0.35rem; }
133
- input, textarea { width: 100%; box-sizing: border-box; padding: 0.6rem 0.75rem; border-radius: 8px; border: 1px solid #404040; background: #0a0a0a; color: #fafafa; font-family: ui-monospace, monospace; font-size: 0.85rem; }
134
- button { margin-top: 1.25rem; width: 100%; padding: 0.85rem; border: none; border-radius: 8px; background: #fafafa; color: #0a0a0a; font-weight: 600; cursor: pointer; font-size: 1rem; }
135
- button:disabled { opacity: 0.5; cursor: not-allowed; }
136
- .err { color: #f87171; font-size: 0.9rem; margin-top: 0.75rem; white-space: pre-wrap; }
137
- .ok { color: #4ade80; margin-top: 1rem; }
138
- code { font-size: 0.8em; background: #262626; padding: 0.1em 0.35em; border-radius: 4px; }
239
+ :root { --bg:#0a0a0a; --fg:#fafafa; --muted:#737373; --line:#262626; --card:#111; --accent:#22c55e; }
240
+ * { box-sizing: border-box; }
241
+ body { margin:0; min-height:100vh; font-family:system-ui,-apple-system,sans-serif; background:var(--bg); color:var(--fg);
242
+ display:flex; align-items:center; justify-content:center; padding:1.5rem; }
243
+ .wrap { width:100%; max-width:380px; }
244
+ .logo { margin-bottom:1.75rem; }
245
+ .card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:1.25rem; margin-bottom:1rem; }
246
+ label { display:block; font-size:0.7rem; letter-spacing:0.06em; text-transform:uppercase; color:var(--muted); margin:1rem 0 0.4rem; }
247
+ input, textarea { width:100%; padding:0.65rem 0.75rem; border-radius:8px; border:1px solid var(--line); background:#0a0a0a; color:var(--fg); font-family:ui-monospace,monospace; font-size:0.8rem; }
248
+ .btn { display:block; width:100%; margin-top:0.75rem; padding:0.75rem; border:none; border-radius:8px; font-weight:600; font-size:0.9rem; cursor:pointer; text-align:center; text-decoration:none; }
249
+ .btn.primary { background:var(--fg); color:var(--bg); }
250
+ .btn.secondary { background:transparent; color:var(--fg); border:1px solid var(--line); }
251
+ .btn:disabled { opacity:0.45; cursor:not-allowed; }
252
+ .fine { font-size:0.75rem; color:var(--muted); line-height:1.45; margin:0 0 0.75rem; }
253
+ .err { color:#f87171; font-size:0.8rem; margin-top:0.75rem; display:none; }
254
+ .hidden { display:none !important; }
139
255
  </style>
140
256
  </head>
141
257
  <body>
142
- <h1>Komplian — asistente de setup</h1>
143
- <p class="help">Esta ventana es local (solo tu ordenador). Rellena lo que pida el asistente y pulsa <strong>Continuar</strong>. Puedes cerrar la pestaña después del mensaje de éxito.</p>
144
- ${postmanBlock}
145
- ${dbBlock}
146
- <div id="err" class="err" style="display:none"></div>
147
- <div id="ok" class="ok" style="display:none"></div>
148
- <button type="button" id="go">Continuar</button>
258
+ <div class="wrap">
259
+ <div class="logo">${LOGO_SVG}</div>
260
+ ${postmanSection}
261
+ ${needsDbUrls ? `<section class="card">${neonBtn}</section>` : ""}
262
+ ${dbManual}
263
+ <div id="err" class="err"></div>
264
+ <button type="button" class="btn primary" id="go">Continue</button>
265
+ </div>
149
266
  <script>
150
267
  const TOKEN = ${JSON.stringify(token)};
151
268
  const needsPostman = ${JSON.stringify(needsPostmanKey)};
152
269
  const needsDb = ${JSON.stringify(needsDbUrls)};
270
+ const neonOAuth = ${JSON.stringify(!!neonOAuth)};
271
+ function showErr(t) {
272
+ const e = document.getElementById("err");
273
+ e.textContent = t;
274
+ e.style.display = "block";
275
+ }
276
+ async function submitBody(body) {
277
+ const r = await fetch("/submit", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify(body) });
278
+ const j = await r.json().catch(() => ({}));
279
+ if (!r.ok || !j.ok) { showErr(j.error || "Try again."); return false; }
280
+ return true;
281
+ }
282
+ let pollTimer;
283
+ if (neonOAuth && needsDb) {
284
+ pollTimer = setInterval(async () => {
285
+ try {
286
+ const r = await fetch("/neon/status");
287
+ const j = await r.json();
288
+ if (j.ready) {
289
+ clearInterval(pollTimer);
290
+ document.getElementById("neon_status")?.classList.remove("hidden");
291
+ }
292
+ } catch {}
293
+ }, 900);
294
+ document.getElementById("neon_oauth")?.addEventListener("click", () => { location.href = "/neon/start"; });
295
+ }
153
296
  document.getElementById("go").onclick = async () => {
154
- const err = document.getElementById("err");
155
- const ok = document.getElementById("ok");
156
- err.style.display = "none";
157
- ok.style.display = "none";
297
+ document.getElementById("err").style.display = "none";
158
298
  const body = { token: TOKEN };
159
- if (needsPostman) body.postman_api_key = (document.getElementById("postman_api_key") || {}).value || "";
299
+ if (needsPostman) body.postman_api_key = (document.getElementById("postman_api_key")||{}).value || "";
160
300
  if (needsDb) {
161
- body.db_app = (document.getElementById("db_app") || {}).value || "";
162
- body.db_admin = (document.getElementById("db_admin") || {}).value || "";
163
- body.db_web = (document.getElementById("db_web") || {}).value || "";
301
+ body.db_app = (document.getElementById("db_app")||{}).value || "";
302
+ body.db_admin = (document.getElementById("db_admin")||{}).value || "";
303
+ body.db_web = (document.getElementById("db_web")||{}).value || "";
164
304
  }
165
305
  const btn = document.getElementById("go");
166
306
  btn.disabled = true;
167
- try {
168
- const r = await fetch("/submit", {
169
- method: "POST",
170
- headers: { "Content-Type": "application/json" },
171
- body: JSON.stringify(body),
172
- });
173
- const j = await r.json().catch(() => ({}));
174
- if (!r.ok || !j.ok) {
175
- err.textContent = j.error || "Error al validar. Revisa los datos.";
176
- err.style.display = "block";
177
- btn.disabled = false;
178
- return;
179
- }
180
- ok.textContent = "Listo. Vuelve a la terminal; puedes cerrar esta pestaña.";
181
- ok.style.display = "block";
182
- } catch (e) {
183
- err.textContent = "No se pudo enviar. ¿Sigues en la misma red local?";
184
- err.style.display = "block";
185
- btn.disabled = false;
186
- }
307
+ if (await submitBody(body)) {
308
+ setTimeout(() => { try { window.close(); } catch(_){} window.location.href = "about:blank"; }, 400);
309
+ } else btn.disabled = false;
187
310
  };
188
311
  </script>
189
312
  </body>
190
313
  </html>`;
191
314
  }
192
315
 
193
- /**
194
- * Servidor solo en 127.0.0.1. Devuelve { postmanKey?, db } según lo pedido.
195
- */
196
316
  function runSetupBrowserForm(opts) {
197
317
  const {
198
318
  needsPostmanKey,
199
319
  needsDbUrls,
200
320
  emailDomain,
321
+ neonOAuth,
322
+ listenHost,
323
+ listenPort,
201
324
  timeoutMs = 600_000,
202
325
  } = opts;
203
326
 
204
327
  return new Promise((resolvePromise, rejectPromise) => {
205
328
  const token = randomBytes(24).toString("hex");
329
+ const ctx = {
330
+ token,
331
+ neonPkce: null,
332
+ neonTriplet: null,
333
+ needsPostmanKey,
334
+ needsDbUrls,
335
+ emailDomain,
336
+ };
337
+
206
338
  const server = createServer(async (req, res) => {
207
- const url = new URL(req.url || "/", `http://127.0.0.1`);
339
+ const url = new URL(req.url || "/", "http://127.0.0.1");
340
+
341
+ if (req.method === "GET" && url.pathname === "/neon/status") {
342
+ res.writeHead(200, { "Content-Type": "application/json" });
343
+ res.end(JSON.stringify({ ready: !!ctx.neonTriplet && isValidTriplet(ctx.neonTriplet) }));
344
+ return;
345
+ }
346
+
347
+ if (req.method === "GET" && url.pathname === "/neon/start" && neonOAuth) {
348
+ const { verifier, challenge } = newPkce();
349
+ const state = b64url(randomBytes(16));
350
+ ctx.neonPkce = { verifier, state };
351
+ const clientId = process.env.KOMPLIAN_NEON_OAUTH_CLIENT_ID.trim();
352
+ const redirectUri = process.env.KOMPLIAN_NEON_OAUTH_REDIRECT_URI.trim();
353
+ const scope = [
354
+ "openid",
355
+ "offline",
356
+ "offline_access",
357
+ "urn:neoncloud:projects:read",
358
+ ].join(" ");
359
+ const auth = new URL("https://oauth2.neon.tech/oauth2/auth");
360
+ auth.searchParams.set("client_id", clientId);
361
+ auth.searchParams.set("redirect_uri", redirectUri);
362
+ auth.searchParams.set("response_type", "code");
363
+ auth.searchParams.set("scope", scope);
364
+ auth.searchParams.set("state", state);
365
+ auth.searchParams.set("code_challenge", challenge);
366
+ auth.searchParams.set("code_challenge_method", "S256");
367
+ res.writeHead(302, { Location: auth.toString() });
368
+ res.end();
369
+ return;
370
+ }
371
+
372
+ if (req.method === "GET" && url.pathname === "/neon/callback" && neonOAuth) {
373
+ const code = url.searchParams.get("code");
374
+ const state = url.searchParams.get("state");
375
+ const st = ctx.neonPkce;
376
+ if (!code || !st || state !== st.state) {
377
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
378
+ res.end("<!DOCTYPE html><html><body style='background:#0a0a0a;color:#fafafa;font-family:system-ui;padding:2rem'>Invalid OAuth state. Close this tab.</body></html>");
379
+ return;
380
+ }
381
+ const clientId = process.env.KOMPLIAN_NEON_OAUTH_CLIENT_ID.trim();
382
+ const clientSecret = process.env.KOMPLIAN_NEON_OAUTH_CLIENT_SECRET.trim();
383
+ const redirectUri = process.env.KOMPLIAN_NEON_OAUTH_REDIRECT_URI.trim();
384
+ const ex = await neonExchangeCode(clientId, clientSecret, redirectUri, code, st.verifier);
385
+ if (!ex.ok) {
386
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
387
+ res.end(`<!DOCTYPE html><html><body style='background:#0a0a0a;color:#f87171;font-family:system-ui;padding:2rem'>${String(ex.error).replace(/</g, "")}</body></html>`);
388
+ return;
389
+ }
390
+ const trip = await neonBuildTriplet(ex.access_token);
391
+ if (!trip.ok) {
392
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
393
+ res.end(`<!DOCTYPE html><html><body style='background:#0a0a0a;color:#f87171;font-family:system-ui;padding:2rem'>${String(trip.error).replace(/</g, "")}</body></html>`);
394
+ return;
395
+ }
396
+ ctx.neonTriplet = trip.db;
397
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
398
+ res.end(`<!DOCTYPE html><html><body style='background:#0a0a0a;color:#fafafa;font-family:system-ui;padding:2rem;text-align:center'>
399
+ <p>Neon connected.</p>
400
+ <script>setTimeout(function(){ try{window.close();}catch(e){} }, 300);</script>
401
+ </body></html>`);
402
+ return;
403
+ }
208
404
 
209
405
  if (req.method === "GET" && url.pathname === "/") {
210
406
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
@@ -213,6 +409,7 @@ function runSetupBrowserForm(opts) {
213
409
  needsPostmanKey,
214
410
  needsDbUrls,
215
411
  emailDomain,
412
+ neonOAuth,
216
413
  })
217
414
  );
218
415
  return;
@@ -226,12 +423,12 @@ function runSetupBrowserForm(opts) {
226
423
  data = JSON.parse(raw || "{}");
227
424
  } catch {
228
425
  res.writeHead(400, { "Content-Type": "application/json" });
229
- res.end(JSON.stringify({ ok: false, error: "JSON inválido." }));
426
+ res.end(JSON.stringify({ ok: false, error: "Bad JSON." }));
230
427
  return;
231
428
  }
232
429
  if (data.token !== token) {
233
430
  res.writeHead(403, { "Content-Type": "application/json" });
234
- res.end(JSON.stringify({ ok: false, error: "Token inválido." }));
431
+ res.end(JSON.stringify({ ok: false, error: "Bad token." }));
235
432
  return;
236
433
  }
237
434
 
@@ -243,7 +440,7 @@ function runSetupBrowserForm(opts) {
243
440
  const v = await validatePostmanApiKeyForKomplian(pk, emailDomain);
244
441
  if (!v.ok) {
245
442
  res.writeHead(400, { "Content-Type": "application/json" });
246
- res.end(JSON.stringify({ ok: false, error: v.error || "Postman inválido." }));
443
+ res.end(JSON.stringify({ ok: false, error: v.error || "Invalid Postman key." }));
247
444
  return;
248
445
  }
249
446
  out.postmanKey = pk;
@@ -251,23 +448,28 @@ function runSetupBrowserForm(opts) {
251
448
  }
252
449
 
253
450
  if (needsDbUrls) {
254
- const triplet = {
255
- app: String(data.db_app || "").trim(),
256
- admin: String(data.db_admin || "").trim(),
257
- web: String(data.db_web || "").trim(),
258
- };
259
- if (!isValidTriplet(triplet)) {
260
- res.writeHead(400, { "Content-Type": "application/json" });
261
- res.end(
262
- JSON.stringify({
263
- ok: false,
264
- error:
265
- "Las tres URLs deben ser postgresql:// o postgres:// válidas (sin placeholder).",
266
- })
267
- );
268
- return;
451
+ if (ctx.neonTriplet && isValidTriplet(ctx.neonTriplet)) {
452
+ out.db = ctx.neonTriplet;
453
+ } else {
454
+ const triplet = {
455
+ app: String(data.db_app || "").trim(),
456
+ admin: String(data.db_admin || "").trim(),
457
+ web: String(data.db_web || "").trim(),
458
+ };
459
+ if (!isValidTriplet(triplet)) {
460
+ res.writeHead(400, { "Content-Type": "application/json" });
461
+ res.end(
462
+ JSON.stringify({
463
+ ok: false,
464
+ error: neonOAuth
465
+ ? "Connect Neon or paste three postgres URLs."
466
+ : "Three valid postgres:// URLs required.",
467
+ })
468
+ );
469
+ return;
470
+ }
471
+ out.db = triplet;
269
472
  }
270
- out.db = triplet;
271
473
  }
272
474
 
273
475
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -286,27 +488,21 @@ function runSetupBrowserForm(opts) {
286
488
 
287
489
  const timer = setTimeout(() => {
288
490
  server.close();
289
- rejectPromise(new Error("Tiempo agotado esperando el formulario en el navegador."));
491
+ rejectPromise(new Error("Browser setup timed out."));
290
492
  }, timeoutMs);
291
493
 
292
- server.listen(0, "127.0.0.1", () => {
494
+ const onListen = () => {
293
495
  const addr = server.address();
294
- const port = typeof addr === "object" && addr ? addr.port : 0;
295
- const openUrl = `http://127.0.0.1:${port}/`;
296
- log("");
297
- log(
298
- `${c.cyan}━━ Navegador (formulario local) ━━${c.reset} ${c.bold}${openUrl}${c.reset}`
299
- );
300
- log(
301
- `${c.dim}Solo escucha en tu máquina (127.0.0.1). Cierra la pestaña tras el mensaje de éxito.${c.reset}`
302
- );
303
- openBrowserSync(openUrl);
304
- });
496
+ const port = typeof addr === "object" && addr ? addr.port : listenPort;
497
+ openBrowserSync(`http://127.0.0.1:${port}/`);
498
+ };
305
499
 
306
500
  server.on("error", (e) => {
307
501
  clearTimeout(timer);
308
502
  rejectPromise(e);
309
503
  });
504
+
505
+ server.listen(listenPort, listenHost, onListen);
310
506
  });
311
507
  }
312
508
 
@@ -322,8 +518,7 @@ function parseArgs(argv) {
322
518
  };
323
519
  for (let i = 0; i < argv.length; i++) {
324
520
  const a = argv[i];
325
- if (a === "--terminal-only" || a === "--no-browser")
326
- out.terminalOnly = true;
521
+ if (a === "--terminal-only" || a === "--no-browser") out.terminalOnly = true;
327
522
  else if (a === "-w" || a === "--workspace") out.workspace = argv[++i] || "";
328
523
  else if (a === "-t" || a === "--team") out.team = argv[++i] || "";
329
524
  else if (a === "--ssh") out.ssh = true;
@@ -331,7 +526,7 @@ function parseArgs(argv) {
331
526
  else if (a === "--no-install") out.noInstall = true;
332
527
  else if (a === "-h" || a === "--help") out.help = true;
333
528
  else if (a.startsWith("-")) {
334
- log(`${c.red}✗${c.reset} Opción desconocida: ${a}`);
529
+ errLine(`${c.red}✗${c.reset} Unknown option: ${a}`);
335
530
  process.exit(1);
336
531
  } else if (!out.workspace) out.workspace = a;
337
532
  }
@@ -339,22 +534,21 @@ function parseArgs(argv) {
339
534
  }
340
535
 
341
536
  function usage() {
342
- log(`Uso: npx komplian setup [opciones] [carpeta-workspace]`);
343
- log(``);
344
- log(
345
- ` Orden: ${c.bold}onboard${c.reset} ${c.bold}postman${c.reset} ${c.bold}mcp-tools${c.reset} ${c.bold}db:all:dev${c.reset} ${c.bold}localhost${c.reset}`
346
- );
347
- log(
348
- ` Por defecto abre el ${c.bold}navegador${c.reset} para Postman API key (si falta) y las 3 URLs Neon dev (si faltan).`
349
- );
350
- log(``);
351
- log(` --terminal-only Sin formulario web; postman/db usan terminal o env`);
352
- log(` -w, --workspace Raíz del monorepo (destino del clone)`);
353
- log(` -t, --team Equipo (komplian-team-repos.json)`);
354
- log(` --all-repos Clonar todos los repos del JSON`);
355
- log(` --ssh Clonar por SSH`);
356
- log(` --no-install No npm install tras clone`);
357
- log(` -h, --help`);
537
+ out(`Usage: npx komplian setup [options] [workspace-dir]`);
538
+ out(``);
539
+ out(` Runs: onboard → postman → mcp-tools → db:all:dev → localhost`);
540
+ out(` Secrets: local browser on 127.0.0.1 (no URLs or DB strings printed).`);
541
+ out(` Neon OAuth (optional): set KOMPLIAN_NEON_OAUTH_CLIENT_ID, _CLIENT_SECRET,`);
542
+ out(` _REDIRECT_URI (http://127.0.0.1:<port>/neon/callback), and project IDs:`);
543
+ out(` KOMPLIAN_NEON_OAUTH_PROJECT_ID_APP, _ADMIN, _WEB. Partner OAuth required from Neon.`);
544
+ out(``);
545
+ out(` --terminal-only No browser`);
546
+ out(` -w, --workspace Clone / monorepo root`);
547
+ out(` -t, --team Team slug (komplian-team-repos.json)`);
548
+ out(` --all-repos Clone all repos from JSON`);
549
+ out(` --ssh git@ clone`);
550
+ out(` --no-install Skip npm install`);
551
+ out(` -h, --help`);
358
552
  }
359
553
 
360
554
  function runOnboardChild(args) {
@@ -363,6 +557,7 @@ function runOnboardChild(args) {
363
557
  const r = spawnSync(process.execPath, [script, ...argv], {
364
558
  stdio: "inherit",
365
559
  windowsHide: true,
560
+ env: { ...process.env, KOMPLIAN_CLI_QUIET: process.env.KOMPLIAN_CLI_QUIET || "1" },
366
561
  });
367
562
  return r.status === 0;
368
563
  }
@@ -374,24 +569,21 @@ export async function runSetup(argv) {
374
569
  return;
375
570
  }
376
571
 
572
+ process.env.KOMPLIAN_CLI_QUIET = "1";
573
+
377
574
  const nodeMajor = Number(process.versions.node.split(".")[0], 10);
378
575
  if (nodeMajor < 18) {
379
- log(`${c.red}✗${c.reset} Hace falta Node 18+.`);
576
+ errLine(`${c.red}✗${c.reset} Node 18+ required.`);
380
577
  process.exit(1);
381
578
  }
382
579
 
383
580
  let workspaceArg = (opts.workspace || "").trim();
384
581
  if (!workspaceArg) workspaceArg = process.cwd();
385
- const workspaceAbs = resolve(
386
- workspaceArg.replace(/^~(?=$|[/\\])/, homedir())
387
- );
582
+ const workspaceAbs = resolve(workspaceArg.replace(/^~(?=$|[/\\])/, homedir()));
388
583
 
389
584
  mkdirSync(workspaceAbs, { recursive: true });
390
585
 
391
- log(`${c.cyan}${c.bold}━━ Komplian setup ━━${c.reset}`);
392
- log(`${c.dim}Workspace:${c.reset} ${workspaceAbs}`);
393
- log("");
394
-
586
+ out(`${c.cyan}1/5${c.reset} repos`);
395
587
  const onboardArgs = ["--yes"];
396
588
  if (opts.team) onboardArgs.push("-t", opts.team);
397
589
  if (opts.allRepos) onboardArgs.push("--all-repos");
@@ -399,9 +591,8 @@ export async function runSetup(argv) {
399
591
  if (opts.noInstall) onboardArgs.push("--no-install");
400
592
  onboardArgs.push(workspaceAbs);
401
593
 
402
- log(`${c.cyan}━━ 1/5 Onboard ━━${c.reset}`);
403
594
  if (!runOnboardChild(onboardArgs)) {
404
- log(`${c.red}✗${c.reset} Onboard falló. Revisa GitHub CLI (gh) y permisos.`);
595
+ errLine(`${c.red}✗${c.reset} onboard failed`);
405
596
  process.exit(1);
406
597
  }
407
598
 
@@ -410,9 +601,7 @@ export async function runSetup(argv) {
410
601
  monorepoRoot = findWorkspaceRoot(workspaceAbs);
411
602
  }
412
603
  if (!existsSync(join(monorepoRoot, "api", "package.json"))) {
413
- log(
414
- `${c.red}✗${c.reset} No se encontró monorepo con api/package.json bajo ${workspaceAbs}.`
415
- );
604
+ errLine(`${c.red}✗${c.reset} monorepo not found`);
416
605
  process.exit(1);
417
606
  }
418
607
 
@@ -429,49 +618,51 @@ export async function runSetup(argv) {
429
618
  const { key: existingPmKey } = resolveApiKey();
430
619
  const needsPostmanKey = !existingPmKey;
431
620
  const needsDbUrls = !envDbOk && !savedDbOk;
621
+ const neonOAuth = neonOAuthEnvReady();
622
+
623
+ if (neonOAuth && needsDbUrls) {
624
+ const lp = parseListenFromRedirectUri();
625
+ if (!lp || !/\/neon\/callback\/?$/.test(lp.pathname || "")) {
626
+ errLine(`${c.red}✗${c.reset} KOMPLIAN_NEON_OAUTH_REDIRECT_URI must be http://127.0.0.1:<port>/neon/callback`);
627
+ process.exit(1);
628
+ }
629
+ }
432
630
 
433
631
  if (useBrowser && (needsPostmanKey || needsDbUrls)) {
434
- log(`${c.cyan}━━ Formulario en el navegador ━━${c.reset}`);
632
+ out(`${c.cyan}browser${c.reset} local form`);
435
633
  try {
634
+ const lp = neonOAuth && needsDbUrls ? parseListenFromRedirectUri() : null;
436
635
  const form = await runSetupBrowserForm({
437
636
  needsPostmanKey,
438
637
  needsDbUrls,
439
638
  emailDomain,
639
+ neonOAuth: !!(neonOAuth && needsDbUrls),
640
+ listenHost: lp?.host || "127.0.0.1",
641
+ listenPort: lp?.port ?? 0,
440
642
  });
441
- if (form.postmanKey) {
442
- savePostmanApiKeyToKomplianHome(form.postmanKey);
443
- log(`${c.green}✓${c.reset} Postman API key guardada en ~/.komplian/`);
444
- }
643
+ if (form.postmanKey) savePostmanApiKeyToKomplianHome(form.postmanKey);
445
644
  if (form.db) {
446
645
  process.env.KOMPLIAN_DEV_APP_DATABASE_URL = form.db.app;
447
646
  process.env.KOMPLIAN_DEV_ADMIN_DATABASE_URL = form.db.admin;
448
647
  process.env.KOMPLIAN_DEV_WEB_DATABASE_URL = form.db.web;
449
- log(`${c.green}✓${c.reset} URLs de desarrollo recibidas desde el formulario.`);
450
648
  }
451
649
  } catch (e) {
452
- log(
453
- `${c.red}✗${c.reset} Formulario web: ${e?.message || e}. Prueba ${c.bold}--terminal-only${c.reset} o define variables de entorno.`
454
- );
650
+ errLine(`${c.red}✗${c.reset} browser setup: ${e?.message || e}`);
455
651
  process.exit(1);
456
652
  }
457
653
  }
458
654
 
459
- log(`${c.cyan}━━ 2/5 Postman ━━${c.reset}`);
655
+ out(`${c.cyan}2/5${c.reset} postman`);
460
656
  await runPostman(["--yes"]);
461
657
 
462
- log(`${c.cyan}━━ 3/5 MCP tools ━━${c.reset}`);
658
+ out(`${c.cyan}3/5${c.reset} mcp`);
463
659
  await runMcpTools(["--yes"]);
464
660
 
465
- log(`${c.cyan}━━ 4/5 Bases de datos (development) ━━${c.reset}`);
466
- const dbArgs = ["--yes", "-w", monorepoRoot];
467
- await runDbAllDev(dbArgs);
661
+ out(`${c.cyan}4/5${c.reset} databases`);
662
+ await runDbAllDev(["--yes", "-w", monorepoRoot]);
468
663
 
469
- log(`${c.cyan}━━ 5/5 Localhost ━━${c.reset}`);
664
+ out(`${c.cyan}5/5${c.reset} dev`);
470
665
  await runLocalhost(["--yes"]);
471
666
 
472
- log("");
473
- log(`${c.green}✓${c.reset} ${c.bold}Setup completado.${c.reset}`);
474
- log(
475
- `${c.dim}Monorepo:${c.reset} ${monorepoRoot} ${c.dim}· Cursor: File → Open Folder${c.reset}`
476
- );
667
+ out(`${c.green}✓${c.reset} ready`);
477
668
  }