komplian 0.7.3 → 0.7.4

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.
@@ -460,16 +460,143 @@ function npmInstallOneRepo(dir, name, opts = {}) {
460
460
  return { ok: r.status === 0, skipped: false };
461
461
  }
462
462
 
463
- /** Silent installs for batch UX (single spinner + one summary line). */
464
- function npmInstallBatch(workspace) {
465
- const results = [];
463
+ function npmInstallSpawnAsync(cmd, args, cwd, stdio) {
464
+ return new Promise((resolve) => {
465
+ const child = spawn(cmd, args, spawnWin({ cwd, stdio }));
466
+ child.on("close", (code) => resolve(code === 0));
467
+ child.on("error", () => resolve(false));
468
+ });
469
+ }
470
+
471
+ /** Same behavior as npmInstallOneRepo, non-blocking (parallel batch). */
472
+ async function npmInstallOneRepoAsync(dir, name, opts = {}) {
473
+ const silent = opts.silent === true;
474
+ const pkg = join(dir, "package.json");
475
+ if (!existsSync(pkg)) return { ok: true, skipped: true };
476
+
477
+ const q = process.env.KOMPLIAN_CLI_QUIET === "1";
478
+ const stdio = silent || q ? "ignore" : "inherit";
479
+
480
+ const yarnLock = join(dir, "yarn.lock");
481
+ const pnpmLock = join(dir, "pnpm-lock.yaml");
482
+ const npmLock = join(dir, "package-lock.json");
483
+
484
+ if (existsSync(yarnLock)) {
485
+ if (!canRun("yarn", ["--version"])) {
486
+ if (!silent) {
487
+ log(
488
+ `${c.yellow}○${c.reset} ${name} ${c.dim}(yarn.lock; install yarn or run yarn install manually)${c.reset}`
489
+ );
490
+ }
491
+ return { ok: true, skipped: true };
492
+ }
493
+ if (!silent) {
494
+ if (q) {
495
+ ux(
496
+ ` ${c.blue}☁${c.reset} ${c.cyan}…${c.reset} ${c.bold}${name}${c.reset} ${c.dim}yarn…${c.reset}`
497
+ );
498
+ } else {
499
+ log(`${c.dim}→${c.reset} ${name} ${c.dim}(yarn)${c.reset}`);
500
+ }
501
+ }
502
+ const ok = await npmInstallSpawnAsync(
503
+ "yarn",
504
+ ["install", "--frozen-lockfile"],
505
+ dir,
506
+ stdio
507
+ );
508
+ return { ok, skipped: false };
509
+ }
510
+
511
+ if (existsSync(pnpmLock)) {
512
+ if (!canRun("pnpm", ["--version"])) {
513
+ if (!silent) {
514
+ log(
515
+ `${c.yellow}○${c.reset} ${name} ${c.dim}(pnpm-lock; install pnpm or run pnpm install manually)${c.reset}`
516
+ );
517
+ }
518
+ return { ok: true, skipped: true };
519
+ }
520
+ if (!silent) {
521
+ if (q) {
522
+ ux(
523
+ ` ${c.blue}☁${c.reset} ${c.cyan}…${c.reset} ${c.bold}${name}${c.reset} ${c.dim}pnpm…${c.reset}`
524
+ );
525
+ } else {
526
+ log(`${c.dim}→${c.reset} ${name} ${c.dim}(pnpm)${c.reset}`);
527
+ }
528
+ }
529
+ const ok = await npmInstallSpawnAsync(
530
+ "pnpm",
531
+ ["install", "--frozen-lockfile"],
532
+ dir,
533
+ stdio
534
+ );
535
+ return { ok, skipped: false };
536
+ }
537
+
538
+ if (!canRun("npm", ["--version"])) {
539
+ if (!silent) {
540
+ log(`${c.yellow}○${c.reset} npm not in PATH — skipping ${name}`);
541
+ }
542
+ return { ok: true, skipped: true };
543
+ }
544
+
545
+ const quiet = npmQuietFlags();
546
+
547
+ if (existsSync(npmLock)) {
548
+ if (!silent) {
549
+ if (q) {
550
+ ux(
551
+ ` ${c.blue}☁${c.reset} ${c.cyan}…${c.reset} ${c.bold}${name}${c.reset} ${c.dim}npm ci…${c.reset}`
552
+ );
553
+ } else {
554
+ log(`${c.dim}→${c.reset} ${name} ${c.dim}(npm ci)${c.reset}`);
555
+ }
556
+ }
557
+ const ok = await npmInstallSpawnAsync("npm", ["ci", ...quiet], dir, stdio);
558
+ if (ok) return { ok: true, skipped: false };
559
+ if (!silent) {
560
+ log(
561
+ `${c.yellow}○${c.reset} ${name}: npm ci failed (lock out of sync?). ${c.dim}Try npm install in that repo.${c.reset}`
562
+ );
563
+ }
564
+ return { ok: false, skipped: false };
565
+ }
566
+
567
+ if (!silent) {
568
+ if (q) {
569
+ ux(
570
+ ` ${c.blue}☁${c.reset} ${c.cyan}…${c.reset} ${c.bold}${name}${c.reset} ${c.dim}npm install…${c.reset}`
571
+ );
572
+ } else {
573
+ log(`${c.dim}→${c.reset} ${name} ${c.dim}(npm install — no new lockfile)${c.reset}`);
574
+ }
575
+ }
576
+ const ok = await npmInstallSpawnAsync(
577
+ "npm",
578
+ ["install", ...quiet, "--no-package-lock"],
579
+ dir,
580
+ stdio
581
+ );
582
+ return { ok, skipped: false };
583
+ }
584
+
585
+ /** Silent installs in parallel (faster than sequential). */
586
+ async function npmInstallBatchAsync(workspace) {
587
+ const jobs = [];
466
588
  for (const ent of readdirSync(workspace)) {
467
589
  const d = join(workspace, ent);
468
590
  if (!statSync(d).isDirectory()) continue;
469
- const { ok, skipped } = npmInstallOneRepo(d, ent, { silent: true });
470
- results.push({ name: ent, ok, skipped });
591
+ jobs.push(
592
+ npmInstallOneRepoAsync(d, ent, { silent: true }).then(({ ok, skipped }) => ({
593
+ name: ent,
594
+ ok,
595
+ skipped,
596
+ }))
597
+ );
471
598
  }
472
- return results;
599
+ return Promise.all(jobs);
473
600
  }
474
601
 
475
602
  function usage() {
@@ -748,7 +875,7 @@ async function main() {
748
875
  }
749
876
  let depResults = [];
750
877
  try {
751
- depResults = npmInstallBatch(abs);
878
+ depResults = await npmInstallBatchAsync(abs);
752
879
  } finally {
753
880
  if (spin) spin.stop();
754
881
  }
@@ -8,7 +8,7 @@
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { createServer } from "node:http";
10
10
  import { createHash, randomBytes } from "node:crypto";
11
- import { existsSync, mkdirSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
12
12
  import { dirname, join, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { homedir } from "node:os";
@@ -75,6 +75,41 @@ function neonOAuthEnvReady() {
75
75
  return !!(id && sec && redir && a && b && w);
76
76
  }
77
77
 
78
+ /** Project IDs for Neon Management API (API key or OAuth token). */
79
+ function neonProjectIdsTriplet() {
80
+ const app =
81
+ process.env.KOMPLIAN_NEON_PROJECT_ID_APP?.trim() ||
82
+ process.env.KOMPLIAN_NEON_OAUTH_PROJECT_ID_APP?.trim();
83
+ const admin =
84
+ process.env.KOMPLIAN_NEON_PROJECT_ID_ADMIN?.trim() ||
85
+ process.env.KOMPLIAN_NEON_OAUTH_PROJECT_ID_ADMIN?.trim();
86
+ const web =
87
+ process.env.KOMPLIAN_NEON_PROJECT_ID_WEB?.trim() ||
88
+ process.env.KOMPLIAN_NEON_OAUTH_PROJECT_ID_WEB?.trim();
89
+ return { app, admin, web };
90
+ }
91
+
92
+ function neonApiKeyResolveReady() {
93
+ const { app, admin, web } = neonProjectIdsTriplet();
94
+ return !!(app && admin && web);
95
+ }
96
+
97
+ async function neonBuildTripletFromApiKey(apiKey) {
98
+ const { app, admin, web } = neonProjectIdsTriplet();
99
+ if (!app || !admin || !web) {
100
+ return { ok: false, error: "Neon project IDs are not configured in the environment." };
101
+ }
102
+ const [ra, rb, rw] = await Promise.all([
103
+ neonFetchConnectionUri(apiKey, app),
104
+ neonFetchConnectionUri(apiKey, admin),
105
+ neonFetchConnectionUri(apiKey, web),
106
+ ]);
107
+ if (!ra.ok) return { ok: false, error: ra.error };
108
+ if (!rb.ok) return { ok: false, error: rb.error };
109
+ if (!rw.ok) return { ok: false, error: rw.error };
110
+ return { ok: true, db: { app: ra.uri, admin: rb.uri, web: rw.uri } };
111
+ }
112
+
78
113
  function parseListenFromRedirectUri() {
79
114
  const u = process.env.KOMPLIAN_NEON_OAUTH_REDIRECT_URI?.trim();
80
115
  if (!u) return null;
@@ -200,74 +235,225 @@ function openBrowserSync(url) {
200
235
  }
201
236
  }
202
237
 
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
238
  function buildSetupPageHtml(token, opts) {
206
- const { needsPostmanKey, needsDbUrls, emailDomain, neonOAuth } = opts;
207
- const postmanSection = needsPostmanKey
239
+ const {
240
+ needsPostmanKey,
241
+ needsDbUrls,
242
+ emailDomain,
243
+ neonOAuth,
244
+ neonApiResolve,
245
+ showPostman,
246
+ logoHtml = "",
247
+ } = opts;
248
+ const safeDomain = String(emailDomain || "komplian.com").replace(/[<>&"]/g, "");
249
+
250
+ const postmanSection = showPostman
208
251
  ? `<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="••••••••" />
252
+ <h2 class="card-title">Postman</h2>
253
+ <p class="fine">${needsPostmanKey ? `Add your API key once (account must match <strong>@${safeDomain}</strong>). Postman has no OAuth for the API — open their site and paste the key.` : `A key is already saved on this machine. Paste below only if you want to replace it.`}</p>
254
+ <a class="btn secondary" href="https://go.postman.co/settings/me/api-keys" target="_blank" rel="noopener">Open Postman · API keys</a>
255
+ <label for="postman_api_key">API key${needsPostmanKey ? " · required" : " · optional"}</label>
256
+ <input type="password" id="postman_api_key" autocomplete="off" placeholder="PMAK-…" ${needsPostmanKey ? "required" : ""} />
213
257
  </section>`
214
258
  : "";
215
259
 
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>`
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
- : "";
260
+ let neonSection = "";
261
+ if (needsDbUrls) {
262
+ const oauthBlock =
263
+ neonOAuth &&
264
+ `<div class="stack">
265
+ <p class="fine">Sign in with Neon (OAuth). A browser tab will open; return here when it says linked.</p>
266
+ <button type="button" class="btn secondary" id="neon_oauth">Connect Neon</button>
267
+ <p id="neon_status" class="ok hidden">Neon linked — URLs loaded.</p>
268
+ </div>`;
269
+ const apiKeyBlock =
270
+ !neonOAuth &&
271
+ neonApiResolve &&
272
+ `<div class="stack">
273
+ <p class="fine">Paste your <a href="https://console.neon.tech/app/settings/api-keys" target="_blank" rel="noopener">Neon API key</a>. Project IDs are read from the CLI environment (KOMPLIAN_NEON_PROJECT_ID_* or OAUTH_* IDs).</p>
274
+ <label for="neon_api_key">Neon API key</label>
275
+ <input type="password" id="neon_api_key" autocomplete="off" placeholder="napi_…" />
276
+ <button type="button" class="btn secondary" id="neon_load">Load connection strings</button>
277
+ <p id="neon_key_status" class="ok hidden">URLs loaded from Neon.</p>
278
+ </div>`;
279
+ const fallbackBlock =
280
+ !neonOAuth &&
281
+ !neonApiResolve &&
282
+ `<p class="fine">Set <code>KOMPLIAN_NEON_PROJECT_ID_APP</code> (and ADMIN, WEB) in the environment to enable one-click fetch, or paste Postgres URLs below. <a href="https://console.neon.tech" target="_blank" rel="noopener">Neon Console</a></p>`;
283
+
284
+ const dbFields = `<label for="db_app">APP + API</label><textarea id="db_app" rows="2" spellcheck="false" autocomplete="off" placeholder="postgresql://…"></textarea>
285
+ <label for="db_admin">ADMIN</label><textarea id="db_admin" rows="2" spellcheck="false" autocomplete="off" placeholder="postgresql://…"></textarea>
286
+ <label for="db_web">WEB</label><textarea id="db_web" rows="2" spellcheck="false" autocomplete="off" placeholder="postgresql://…"></textarea>`;
287
+
288
+ neonSection = `<section class="card">
289
+ <h2 class="card-title">Neon · databases</h2>
290
+ ${oauthBlock || ""}
291
+ ${apiKeyBlock || ""}
292
+ ${fallbackBlock || ""}
293
+ <details class="manual" open>
294
+ <summary>Or paste connection strings</summary>
295
+ <div class="details-body">${dbFields}</div>
296
+ </details>
297
+ </section>`;
298
+ }
231
299
 
232
300
  return `<!DOCTYPE html>
233
301
  <html lang="en">
234
302
  <head>
235
303
  <meta charset="utf-8" />
236
304
  <meta name="viewport" content="width=device-width, initial-scale=1" />
237
- <title>Komplian</title>
305
+ <title>Komplian — local setup</title>
306
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
307
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
308
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
238
309
  <style>
239
- :root { --bg:#0a0a0a; --fg:#fafafa; --muted:#737373; --line:#262626; --card:#111; --accent:#22c55e; }
310
+ :root {
311
+ --bg: #0a0a0a;
312
+ --fg: #fafafa;
313
+ --muted: #a3a3a3;
314
+ --line: #262626;
315
+ --card: #111111;
316
+ --accent: #22c55e;
317
+ --accent-dim: rgba(34, 197, 94, 0.15);
318
+ }
240
319
  * { 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; }
320
+ body {
321
+ margin: 0; min-height: 100vh;
322
+ font-family: Inter, system-ui, -apple-system, sans-serif;
323
+ background: var(--bg); color: var(--fg);
324
+ display: flex; align-items: center; justify-content: center;
325
+ padding: 1.5rem;
326
+ -webkit-font-smoothing: antialiased;
327
+ }
328
+ .wrap { width: 100%; max-width: 420px; position: relative; }
329
+ .brand {
330
+ display: flex; align-items: center; gap: 10px;
331
+ margin-bottom: 1.75rem; min-height: 32px;
332
+ }
333
+ .brand-logo { display: flex; align-items: center; max-width: 152px; }
334
+ .brand-logo :is(svg) { width: 100%; height: auto; display: block; }
335
+ .brand-word {
336
+ font-size: 1.5rem; font-weight: 600; letter-spacing: -0.04em; color: var(--fg);
337
+ }
338
+ .brand-dot {
339
+ width: 9px; height: 9px; border-radius: 50%;
340
+ background: var(--accent);
341
+ box-shadow: 0 0 14px rgba(34, 197, 94, 0.5);
342
+ }
343
+ .card {
344
+ background: var(--card);
345
+ border: 1px solid var(--line);
346
+ border-radius: 12px;
347
+ padding: 1.25rem 1.35rem;
348
+ margin-bottom: 1rem;
349
+ }
350
+ .card-title {
351
+ margin: 0 0 0.75rem;
352
+ font-size: 0.8rem; font-weight: 600;
353
+ letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted);
354
+ }
355
+ .stack { margin-bottom: 1rem; }
356
+ .stack:last-child { margin-bottom: 0; }
357
+ label {
358
+ display: block;
359
+ font-size: 0.7rem; font-weight: 500;
360
+ letter-spacing: 0.06em; text-transform: uppercase;
361
+ color: var(--muted); margin: 1rem 0 0.45rem;
362
+ }
363
+ input, textarea {
364
+ width: 100%;
365
+ padding: 0.65rem 0.8rem;
366
+ border-radius: 8px;
367
+ border: 1px solid var(--line);
368
+ background: var(--bg);
369
+ color: var(--fg);
370
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
371
+ font-size: 0.8125rem;
372
+ }
373
+ input:focus, textarea:focus {
374
+ outline: none;
375
+ border-color: #404040;
376
+ box-shadow: 0 0 0 3px var(--accent-dim);
377
+ }
378
+ .btn {
379
+ display: block; width: 100%;
380
+ margin-top: 0.65rem;
381
+ padding: 0.78rem 1rem;
382
+ border: none; border-radius: 8px;
383
+ font-family: inherit; font-weight: 600; font-size: 0.9rem;
384
+ cursor: pointer; text-align: center; text-decoration: none;
385
+ transition: opacity 0.15s, transform 0.1s;
386
+ }
387
+ .btn:active { transform: scale(0.99); }
388
+ .btn.primary { background: var(--fg); color: var(--bg); }
389
+ .btn.primary:hover { opacity: 0.92; }
390
+ .btn.secondary {
391
+ background: transparent; color: var(--fg);
392
+ border: 1px solid var(--line);
393
+ }
394
+ .btn.secondary:hover { border-color: #404040; background: rgba(255,255,255,0.03); }
395
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
396
+ .fine { font-size: 0.8125rem; color: var(--muted); line-height: 1.5; margin: 0 0 0.75rem; }
397
+ .fine a { color: var(--fg); text-decoration: underline; text-underline-offset: 3px; }
398
+ .fine code { font-size: 0.75rem; background: var(--bg); padding: 0.1rem 0.35rem; border-radius: 4px; }
399
+ .ok { color: var(--accent); font-size: 0.8125rem; margin: 0.5rem 0 0; font-weight: 500; }
400
+ .manual { margin-top: 1rem; border-top: 1px solid var(--line); padding-top: 1rem; }
401
+ .manual summary {
402
+ cursor: pointer; font-size: 0.8rem; font-weight: 500; color: var(--muted);
403
+ list-style: none;
404
+ }
405
+ .manual summary::-webkit-details-marker { display: none; }
406
+ .details-body { margin-top: 0.75rem; }
407
+ .err { color: #f87171; font-size: 0.8125rem; margin: 0.75rem 0 0; display: none; line-height: 1.4; }
408
+ .hidden { display: none !important; }
409
+ #overlay {
410
+ position: fixed; inset: 0; background: rgba(0,0,0,0.65);
411
+ display: none; align-items: center; justify-content: center;
412
+ z-index: 50; backdrop-filter: blur(4px);
413
+ }
414
+ #overlay.show { display: flex; }
415
+ .loader {
416
+ display: flex; flex-direction: column; align-items: center; gap: 1rem;
417
+ padding: 2rem; border-radius: 12px; background: var(--card); border: 1px solid var(--line);
418
+ }
419
+ .spinner {
420
+ width: 36px; height: 36px;
421
+ border: 3px solid var(--line);
422
+ border-top-color: var(--accent);
423
+ border-radius: 50%;
424
+ animation: spin 0.7s linear infinite;
425
+ }
426
+ @keyframes spin { to { transform: rotate(360deg); } }
427
+ .loader p { margin: 0; font-size: 0.875rem; color: var(--muted); }
255
428
  </style>
256
429
  </head>
257
430
  <body>
431
+ <div id="overlay" aria-live="polite"><div class="loader"><div class="spinner"></div><p>Securing your session…</p></div></div>
258
432
  <div class="wrap">
259
- <div class="logo">${LOGO_SVG}</div>
433
+ <header class="brand" aria-label="Komplian">
434
+ ${
435
+ logoHtml
436
+ ? `<div class="brand-logo">${logoHtml}</div>`
437
+ : `<span class="brand-word">Komplian</span><span class="brand-dot" aria-hidden="true"></span>`
438
+ }
439
+ </header>
260
440
  ${postmanSection}
261
- ${needsDbUrls ? `<section class="card">${neonBtn}</section>` : ""}
262
- ${dbManual}
263
- <div id="err" class="err"></div>
441
+ ${neonSection}
442
+ <div id="err" class="err" role="alert"></div>
264
443
  <button type="button" class="btn primary" id="go">Continue</button>
265
444
  </div>
266
445
  <script>
267
446
  const TOKEN = ${JSON.stringify(token)};
268
447
  const needsPostman = ${JSON.stringify(needsPostmanKey)};
448
+ const showPostman = ${JSON.stringify(!!showPostman)};
269
449
  const needsDb = ${JSON.stringify(needsDbUrls)};
270
450
  const neonOAuth = ${JSON.stringify(!!neonOAuth)};
451
+ function setLoading(on, msg) {
452
+ const o = document.getElementById("overlay");
453
+ o.classList.toggle("show", on);
454
+ const p = o.querySelector(".loader p");
455
+ if (p && msg) p.textContent = msg;
456
+ }
271
457
  function showErr(t) {
272
458
  const e = document.getElementById("err");
273
459
  e.textContent = t;
@@ -293,20 +479,57 @@ function buildSetupPageHtml(token, opts) {
293
479
  }, 900);
294
480
  document.getElementById("neon_oauth")?.addEventListener("click", () => { location.href = "/neon/start"; });
295
481
  }
482
+ document.getElementById("neon_load")?.addEventListener("click", async () => {
483
+ document.getElementById("err").style.display = "none";
484
+ const k = (document.getElementById("neon_api_key")||{}).value?.trim() || "";
485
+ if (!k) { showErr("Paste your Neon API key."); return; }
486
+ setLoading(true, "Fetching Neon connection strings…");
487
+ try {
488
+ const r = await fetch("/neon/resolve-key", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ token: TOKEN, neon_api_key: k }) });
489
+ const j = await r.json().catch(() => ({}));
490
+ if (!r.ok || !j.ok) { showErr(j.error || "Neon API error."); return; }
491
+ document.getElementById("neon_key_status")?.classList.remove("hidden");
492
+ if (j.db) {
493
+ const a = document.getElementById("db_app");
494
+ const b = document.getElementById("db_admin");
495
+ const w = document.getElementById("db_web");
496
+ if (a) a.value = j.db.app || "";
497
+ if (b) b.value = j.db.admin || "";
498
+ if (w) w.value = j.db.web || "";
499
+ }
500
+ } finally { setLoading(false, "Securing your session…"); }
501
+ });
296
502
  document.getElementById("go").onclick = async () => {
297
503
  document.getElementById("err").style.display = "none";
298
504
  const body = { token: TOKEN };
299
- if (needsPostman) body.postman_api_key = (document.getElementById("postman_api_key")||{}).value || "";
505
+ const pmEl = document.getElementById("postman_api_key");
506
+ if (showPostman) body.postman_api_key = (pmEl && pmEl.value) ? pmEl.value.trim() : "";
300
507
  if (needsDb) {
301
508
  body.db_app = (document.getElementById("db_app")||{}).value || "";
302
509
  body.db_admin = (document.getElementById("db_admin")||{}).value || "";
303
510
  body.db_web = (document.getElementById("db_web")||{}).value || "";
511
+ const nk = (document.getElementById("neon_api_key")||{}).value?.trim() || "";
512
+ if (nk) body.neon_api_key = nk;
304
513
  }
305
514
  const btn = document.getElementById("go");
515
+ if (needsPostman && !body.postman_api_key) {
516
+ showErr("Postman API key is required.");
517
+ return;
518
+ }
306
519
  btn.disabled = true;
307
- if (await submitBody(body)) {
308
- setTimeout(() => { try { window.close(); } catch(_){} window.location.href = "about:blank"; }, 400);
309
- } else btn.disabled = false;
520
+ setLoading(true, "Validating and saving…");
521
+ try {
522
+ if (await submitBody(body)) {
523
+ setLoading(true, "Done — closing…");
524
+ setTimeout(() => { try { window.close(); } catch(_){} window.location.href = "about:blank"; }, 500);
525
+ } else {
526
+ btn.disabled = false;
527
+ setLoading(false);
528
+ }
529
+ } catch (_) {
530
+ btn.disabled = false;
531
+ setLoading(false);
532
+ }
310
533
  };
311
534
  </script>
312
535
  </body>
@@ -319,6 +542,9 @@ function runSetupBrowserForm(opts) {
319
542
  needsDbUrls,
320
543
  emailDomain,
321
544
  neonOAuth,
545
+ neonApiResolve,
546
+ showPostman,
547
+ logoHtml = "",
322
548
  listenHost,
323
549
  listenPort,
324
550
  timeoutMs = 600_000,
@@ -344,6 +570,50 @@ function runSetupBrowserForm(opts) {
344
570
  return;
345
571
  }
346
572
 
573
+ if (req.method === "POST" && url.pathname === "/neon/resolve-key") {
574
+ let raw = "";
575
+ for await (const ch of req) raw += ch;
576
+ let data;
577
+ try {
578
+ data = JSON.parse(raw || "{}");
579
+ } catch {
580
+ res.writeHead(400, { "Content-Type": "application/json" });
581
+ res.end(JSON.stringify({ ok: false, error: "Bad JSON." }));
582
+ return;
583
+ }
584
+ if (data.token !== token) {
585
+ res.writeHead(403, { "Content-Type": "application/json" });
586
+ res.end(JSON.stringify({ ok: false, error: "Bad token." }));
587
+ return;
588
+ }
589
+ if (!neonApiKeyResolveReady()) {
590
+ res.writeHead(400, { "Content-Type": "application/json" });
591
+ res.end(
592
+ JSON.stringify({
593
+ ok: false,
594
+ error: "Set KOMPLIAN_NEON_PROJECT_ID_APP, _ADMIN, and _WEB (or OAuth project ID env vars).",
595
+ })
596
+ );
597
+ return;
598
+ }
599
+ const apiKey = String(data.neon_api_key || "").trim();
600
+ if (!apiKey) {
601
+ res.writeHead(400, { "Content-Type": "application/json" });
602
+ res.end(JSON.stringify({ ok: false, error: "Neon API key required." }));
603
+ return;
604
+ }
605
+ const trip = await neonBuildTripletFromApiKey(apiKey);
606
+ if (!trip.ok) {
607
+ res.writeHead(400, { "Content-Type": "application/json" });
608
+ res.end(JSON.stringify({ ok: false, error: trip.error || "Neon API error." }));
609
+ return;
610
+ }
611
+ ctx.neonTriplet = trip.db;
612
+ res.writeHead(200, { "Content-Type": "application/json" });
613
+ res.end(JSON.stringify({ ok: true, db: trip.db }));
614
+ return;
615
+ }
616
+
347
617
  if (req.method === "GET" && url.pathname === "/neon/start" && neonOAuth) {
348
618
  const { verifier, challenge } = newPkce();
349
619
  const state = b64url(randomBytes(16));
@@ -410,6 +680,9 @@ function runSetupBrowserForm(opts) {
410
680
  needsDbUrls,
411
681
  emailDomain,
412
682
  neonOAuth,
683
+ neonApiResolve: !!neonApiResolve,
684
+ showPostman: !!showPostman,
685
+ logoHtml,
413
686
  })
414
687
  );
415
688
  return;
@@ -436,40 +709,72 @@ function runSetupBrowserForm(opts) {
436
709
 
437
710
  if (needsPostmanKey) {
438
711
  const pk = String(data.postman_api_key || "").trim();
439
- if (pk) {
440
- const v = await validatePostmanApiKeyForKomplian(pk, emailDomain);
712
+ if (!pk) {
713
+ res.writeHead(400, { "Content-Type": "application/json" });
714
+ res.end(JSON.stringify({ ok: false, error: "Postman API key required." }));
715
+ return;
716
+ }
717
+ const v = await validatePostmanApiKeyForKomplian(pk, emailDomain);
718
+ if (!v.ok) {
719
+ res.writeHead(400, { "Content-Type": "application/json" });
720
+ res.end(JSON.stringify({ ok: false, error: v.error || "Invalid Postman key." }));
721
+ return;
722
+ }
723
+ out.postmanKey = pk;
724
+ } else {
725
+ const pkOpt = String(data.postman_api_key || "").trim();
726
+ if (pkOpt) {
727
+ const v = await validatePostmanApiKeyForKomplian(pkOpt, emailDomain);
441
728
  if (!v.ok) {
442
729
  res.writeHead(400, { "Content-Type": "application/json" });
443
730
  res.end(JSON.stringify({ ok: false, error: v.error || "Invalid Postman key." }));
444
731
  return;
445
732
  }
446
- out.postmanKey = pk;
733
+ out.postmanKey = pkOpt;
447
734
  }
448
735
  }
449
736
 
450
737
  if (needsDbUrls) {
738
+ let dbOut = null;
451
739
  if (ctx.neonTriplet && isValidTriplet(ctx.neonTriplet)) {
452
- out.db = ctx.neonTriplet;
740
+ dbOut = ctx.neonTriplet;
453
741
  } else {
454
742
  const triplet = {
455
743
  app: String(data.db_app || "").trim(),
456
744
  admin: String(data.db_admin || "").trim(),
457
745
  web: String(data.db_web || "").trim(),
458
746
  };
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;
747
+ if (isValidTriplet(triplet)) {
748
+ dbOut = triplet;
749
+ } else {
750
+ const nk = String(data.neon_api_key || "").trim();
751
+ if (nk && neonApiKeyResolveReady()) {
752
+ const trip = await neonBuildTripletFromApiKey(nk);
753
+ if (!trip.ok) {
754
+ res.writeHead(400, { "Content-Type": "application/json" });
755
+ res.end(JSON.stringify({ ok: false, error: trip.error || "Neon API error." }));
756
+ return;
757
+ }
758
+ dbOut = trip.db;
759
+ ctx.neonTriplet = trip.db;
760
+ }
470
761
  }
471
- out.db = triplet;
472
762
  }
763
+ if (!dbOut || !isValidTriplet(dbOut)) {
764
+ res.writeHead(400, { "Content-Type": "application/json" });
765
+ res.end(
766
+ JSON.stringify({
767
+ ok: false,
768
+ error: neonOAuth
769
+ ? "Connect Neon, load URLs with API key, or paste three postgres URLs."
770
+ : neonApiKeyResolveReady()
771
+ ? "Paste three postgres URLs, or use “Load connection strings” / Neon API key on submit."
772
+ : "Three valid postgres:// URLs required (or configure Neon project IDs + API key).",
773
+ })
774
+ );
775
+ return;
776
+ }
777
+ out.db = dbOut;
473
778
  }
474
779
 
475
780
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -538,9 +843,9 @@ function usage() {
538
843
  out(``);
539
844
  out(` Runs: onboard → postman → mcp-tools → db:all:dev → localhost`);
540
845
  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.`);
846
+ out(` Neon OAuth (optional): KOMPLIAN_NEON_OAUTH_CLIENT_ID, _CLIENT_SECRET,`);
847
+ out(` _REDIRECT_URI (http://127.0.0.1:<port>/neon/callback), + KOMPLIAN_NEON_OAUTH_PROJECT_ID_*`);
848
+ out(` Neon API key (optional): set KOMPLIAN_NEON_PROJECT_ID_APP, _ADMIN, _WEB then paste key in setup UI.`);
544
849
  out(``);
545
850
  out(` --terminal-only No browser`);
546
851
  out(` -w, --workspace Clone / monorepo root`);
@@ -618,6 +923,18 @@ export async function runSetup(argv) {
618
923
  const needsPostmanKey = !existingPmKey;
619
924
  const needsDbUrls = !envDbOk && !savedDbOk;
620
925
  const neonOAuth = neonOAuthEnvReady();
926
+ const neonApiResolve = needsDbUrls && neonApiKeyResolveReady();
927
+ const showPostman = needsPostmanKey || needsDbUrls;
928
+
929
+ let logoHtml = "";
930
+ const logoPath = join(monorepoRoot, "web/public/images/logo-white.svg");
931
+ if (existsSync(logoPath)) {
932
+ try {
933
+ logoHtml = readFileSync(logoPath, "utf8").replace(/fill="#000000"/g, 'fill="#fafafa"');
934
+ } catch {
935
+ /* ignore */
936
+ }
937
+ }
621
938
 
622
939
  if (neonOAuth && needsDbUrls) {
623
940
  const lp = parseListenFromRedirectUri();
@@ -635,6 +952,9 @@ export async function runSetup(argv) {
635
952
  needsDbUrls,
636
953
  emailDomain,
637
954
  neonOAuth: !!(neonOAuth && needsDbUrls),
955
+ neonApiResolve,
956
+ showPostman,
957
+ logoHtml,
638
958
  listenHost: lp?.host || "127.0.0.1",
639
959
  listenPort: lp?.port ?? 0,
640
960
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "komplian",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Komplian CLI: setup (all-in-one), onboard, Postman, localhost, mcp-tools, db (psql). Node 18+.",
5
5
  "type": "module",
6
6
  "engines": {