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.
- package/komplian-onboard.mjs +134 -7
- package/komplian-setup.mjs +387 -67
- package/package.json +1 -1
package/komplian-onboard.mjs
CHANGED
|
@@ -460,16 +460,143 @@ function npmInstallOneRepo(dir, name, opts = {}) {
|
|
|
460
460
|
return { ok: r.status === 0, skipped: false };
|
|
461
461
|
}
|
|
462
462
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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
|
|
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 =
|
|
878
|
+
depResults = await npmInstallBatchAsync(abs);
|
|
752
879
|
} finally {
|
|
753
880
|
if (spin) spin.stop();
|
|
754
881
|
}
|
package/komplian-setup.mjs
CHANGED
|
@@ -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 {
|
|
207
|
-
|
|
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
|
-
<
|
|
210
|
-
<
|
|
211
|
-
<
|
|
212
|
-
<
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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 {
|
|
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 {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
.
|
|
250
|
-
.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
.
|
|
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
|
-
<
|
|
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
|
-
${
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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):
|
|
542
|
-
out(` _REDIRECT_URI (http://127.0.0.1:<port>/neon/callback),
|
|
543
|
-
out(`
|
|
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
|
});
|