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.
- package/README.md +1 -1
- package/komplian-db-all-dev.mjs +32 -29
- package/komplian-localhost.mjs +73 -48
- package/komplian-mcp-tools.mjs +46 -41
- package/komplian-onboard.mjs +237 -116
- package/komplian-postman.mjs +35 -27
- package/komplian-setup.mjs +359 -168
- package/package.json +2 -2
package/komplian-setup.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Komplian setup —
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
-
|
|
199
|
+
errLine(`${c.yellow}○${c.reset} Open this URL in your browser: ${url}`);
|
|
89
200
|
}
|
|
90
201
|
}
|
|
91
202
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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="
|
|
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
|
|
237
|
+
<title>Komplian</title>
|
|
126
238
|
<style>
|
|
127
|
-
:root {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
.
|
|
137
|
-
.
|
|
138
|
-
|
|
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
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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")
|
|
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")
|
|
162
|
-
body.db_admin = (document.getElementById("db_admin")
|
|
163
|
-
body.db_web = (document.getElementById("db_web")
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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 || "/",
|
|
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
|
|
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: "
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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("
|
|
491
|
+
rejectPromise(new Error("Browser setup timed out."));
|
|
290
492
|
}, timeoutMs);
|
|
291
493
|
|
|
292
|
-
|
|
494
|
+
const onListen = () => {
|
|
293
495
|
const addr = server.address();
|
|
294
|
-
const port = typeof addr === "object" && addr ? addr.port :
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
);
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
655
|
+
out(`${c.cyan}2/5${c.reset} postman`);
|
|
460
656
|
await runPostman(["--yes"]);
|
|
461
657
|
|
|
462
|
-
|
|
658
|
+
out(`${c.cyan}3/5${c.reset} mcp`);
|
|
463
659
|
await runMcpTools(["--yes"]);
|
|
464
660
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
await runDbAllDev(dbArgs);
|
|
661
|
+
out(`${c.cyan}4/5${c.reset} databases`);
|
|
662
|
+
await runDbAllDev(["--yes", "-w", monorepoRoot]);
|
|
468
663
|
|
|
469
|
-
|
|
664
|
+
out(`${c.cyan}5/5${c.reset} dev`);
|
|
470
665
|
await runLocalhost(["--yes"]);
|
|
471
666
|
|
|
472
|
-
|
|
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
|
}
|