komplian 0.7.2 → 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.
@@ -519,12 +519,15 @@ export async function runLocalhost(argv) {
519
519
  if (!cliQuiet()) {
520
520
  log(`${c.cyan}━━ .env.local ━━${c.reset}`);
521
521
  }
522
- for (const w of written) {
523
- const st = w.skipped ? `${c.dim}skip${c.reset}` : `${c.green}ok${c.reset}`;
524
- const name = w.label || "?";
525
- if (cliQuiet()) {
526
- logAlways(` ${st} ${name}`);
527
- } else {
522
+ if (cliQuiet()) {
523
+ const labels = written.map((w) => w.label || "?").join(`${c.dim}·${c.reset} `);
524
+ logAlways(
525
+ ` ${c.green}✓${c.reset} ${c.dim}.env.local${c.reset} ${labels}`
526
+ );
527
+ } else {
528
+ for (const w of written) {
529
+ const st = w.skipped ? `${c.dim}skip${c.reset}` : `${c.green}ok${c.reset}`;
530
+ const name = w.label || "?";
528
531
  log(` ${st} ${name}`);
529
532
  }
530
533
  }
@@ -565,7 +568,7 @@ export async function runLocalhost(argv) {
565
568
  });
566
569
 
567
570
  if (cliQuiet()) {
568
- logAlways(`${c.cyan}━━ dev servers ━━${c.reset}`);
571
+ logAlways(`${c.dim}dev servers · Ctrl+C stop${c.reset}`);
569
572
  } else {
570
573
  log(`${c.cyan}━━ dev servers (${services.length}) ━━${c.reset} ${c.dim}Ctrl+C stop${c.reset}`);
571
574
  log("");
@@ -573,9 +576,13 @@ export async function runLocalhost(argv) {
573
576
 
574
577
  const npx = process.platform === "win32" ? "npx.cmd" : "npx";
575
578
  const useShell = process.platform === "win32";
579
+ /** --raw avoids concurrently prefix logger bugs (e.g. prev.replace on Windows / npx). */
580
+ const concArgs = cliQuiet()
581
+ ? ["--yes", "concurrently@9", "--raw", ...scripts]
582
+ : ["--yes", "concurrently@9", "-c", colors, "-n", names, ...scripts];
576
583
  const child = spawn(
577
584
  npx,
578
- ["--yes", "concurrently@9", "-c", colors, "-n", names, ...scripts],
585
+ concArgs,
579
586
  {
580
587
  cwd: workspaceRoot,
581
588
  stdio: "inherit",
@@ -65,6 +65,15 @@ const KOMPLIAN_MCP_PRESET = {
65
65
  args: ["-y", "chrome-devtools-mcp@latest"],
66
66
  env: {},
67
67
  },
68
+ /** Remote MCP — OAuth in Cursor (“Needs login”). */
69
+ "KOMPLIAN-vercel": {
70
+ url: "https://mcp.vercel.com",
71
+ },
72
+ /** Remote MCP — OAuth via mcp-remote (Neon docs). */
73
+ "KOMPLIAN-neon": {
74
+ command: "npx",
75
+ args: ["-y", "mcp-remote", "https://mcp.neon.tech/mcp"],
76
+ },
68
77
  },
69
78
  };
70
79
 
@@ -83,10 +92,12 @@ Enable each row in **Cursor → Settings → MCP**. Fill \`env\` per the table.
83
92
  | **KOMPLIAN-sentry** | \`@sentry/mcp-server\` | Sentry | First time: browser login. **Komplian:** org \`komplian\`, \`regionUrl\` \`https://de.sentry.io\`, projects \`komplian-api\` / \`komplian-app\`. |
84
93
  | **KOMPLIAN-stripe** | \`@stripe/mcp\` | Stripe API | \`STRIPE_SECRET_KEY\` (restricted / test in dev). [Stripe MCP docs](https://docs.stripe.com/mcp). |
85
94
  | **KOMPLIAN-chrome-devtools** | \`chrome-devtools-mcp\` | Chrome (network, console, screenshots) | Needs **Node 20.19+** and stable Chrome. [Chrome DevTools MCP](https://developer.chrome.com/blog/chrome-devtools-mcp). |
95
+ | **KOMPLIAN-vercel** | \`url\` → \`https://mcp.vercel.com\` | Vercel projects & deploys | **OAuth** in Cursor (see [Vercel MCP](https://mcp.vercel.com/)). No token in JSON. |
96
+ | **KOMPLIAN-neon** | \`npx -y mcp-remote https://mcp.neon.tech/mcp\` | Neon Postgres / API | **OAuth** when the client starts (see [Neon MCP](https://neon.tech/docs/ai/neon-mcp-server)). Alt: local \`@neondatabase/mcp-server-neon\` + API key. |
86
97
 
87
98
  ## 2. Optional: Cursor native connectors
88
99
 
89
- For Cursor OAuth/UI connectors: **Settings → MCP** → **Atlassian**, **Sentry**, **Stripe**, **Chrome DevTools**. They can coexist with **KOMPLIAN-*** entries; avoid duplicating the same capability twice.
100
+ For Cursor OAuth/UI connectors: **Settings → MCP** → **Atlassian**, **Sentry**, **Stripe**, **Chrome DevTools**, **Vercel**, **Neon**. They can coexist with **KOMPLIAN-*** entries; avoid duplicating the same capability twice.
90
101
 
91
102
  ## 3. Restart
92
103
 
@@ -46,31 +46,38 @@ function ux(s = "") {
46
46
  console.log(s);
47
47
  }
48
48
 
49
+ /** Block-letter logo + box frame (OpenClaw-style table). */
49
50
  function banner() {
50
- ux(
51
- [
52
- `${c.cyan}${c.bold}`,
53
- "██╗ ██╗ ██████╗ ███╗ ███╗ ██████╗ ██╗ ██╗ █████╗ ███╗ ██╗",
54
- "██║ ██╔╝ ██╔═══██╗ ████╗ ████║ ██╔══██╗ ██║ ██║ ██╔══██╗ ████╗ ██║",
55
- "█████╔╝ ██║ ██║ ██╔████╔██║ ██████╔╝ ██║ ██║ ███████║ ██╔██╗ ██║",
56
- "██╔═██╗ ██║ ██║ ██║╚██╔╝██║ ██╔═══╝ ██║ ██║ ██╔══██║ ██║╚██╗██║",
57
- "██║ ██╗ ╚██████╔╝ ██║ ╚═╝ ██║ ██║ ███████╗ ██║ ██║ ██║ ██║ ╚████║",
58
- "╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝",
59
- `${c.reset}`,
60
- `${c.dim} Secure setup · GitHub CLI · git clone${c.reset}`,
61
- "",
62
- ].join("\n")
63
- );
51
+ const art = [
52
+ "██╗ ██╗ ██████╗ ███╗ ███╗ ██████╗ ██╗ ██╗ █████╗ ███╗ ██╗",
53
+ "██║ ██╔╝ ██╔═══██╗ ████╗ ████║ ██╔══██╗ ██║ ██║ ██╔══██╗ ████╗ ██║",
54
+ "█████╔╝ ██║ ██║ ██╔████╔██║ ██████╔╝ ██║ ██║ ███████║ ██╔██╗ ██║",
55
+ "██╔═██╗ ██║ ██║ ██║╚██╔╝██║ ██╔═══╝ ██║ ██║ ██╔══██║ ██║╚██╗██║",
56
+ "██║ ██╗ ╚██████╔╝ ██║ ╚═╝ ██║ ██║ ███████╗ ██║ ██║ ██║ ██║ ╚████║",
57
+ "╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝",
58
+ ];
59
+ const w = Math.max(...art.map((line) => [...line].length));
60
+ const pad = (line) => line + " ".repeat(w - [...line].length);
61
+ const horiz = "─".repeat(w + 2);
62
+ const b = `${c.cyan}${c.bold}`;
63
+ const x = c.reset;
64
+ ux(`${b}┌${horiz}┐${x}`);
65
+ for (const line of art) {
66
+ ux(`${b}│ ${pad(line)} │${x}`);
67
+ }
68
+ ux(`${b}└${horiz}┘${x}`);
69
+ ux(`${c.dim} Secure setup · GitHub CLI · Monorepo${c.reset}`);
70
+ ux("");
64
71
  }
65
72
 
66
- function startRepoSpinner(repoName) {
73
+ function startBatchSpinner(message) {
67
74
  const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
68
75
  const cloud = `${c.blue}☁${c.reset}`;
69
76
  let i = 0;
70
77
  const id = setInterval(() => {
71
78
  const fr = frames[i++ % frames.length];
72
79
  process.stdout.write(
73
- `\r ${cloud} ${c.cyan}${fr}${c.reset} ${c.bold}${repoName}${c.reset} ${c.dim}…${c.reset} `
80
+ `\r ${cloud} ${c.cyan}${fr}${c.reset} ${c.dim}${message}${c.reset} `
74
81
  );
75
82
  }, 90);
76
83
  return {
@@ -280,55 +287,27 @@ function isSafeTargetDir(abs) {
280
287
  return !bad.includes(n);
281
288
  }
282
289
 
283
- function cloudLine(org, name, status) {
284
- const cloud = `${c.blue}☁${c.reset}`;
285
- if (status === "ok") {
286
- return ` ${cloud} ${c.green}✓${c.reset} ${c.bold}${org}/${name}${c.reset} ${c.dim}cloned${c.reset}`;
287
- }
288
- if (status === "skip") {
289
- return ` ${cloud} ${c.yellow}○${c.reset} ${org}/${name} ${c.dim}(already present)${c.reset}`;
290
- }
291
- if (status === "fail") {
292
- return ` ${cloud} ${c.red}✗${c.reset} ${org}/${name} ${c.dim}failed${c.reset}`;
293
- }
294
- return ` ${cloud} ${c.cyan}…${c.reset} ${org}/${name} ${c.dim}cloning…${c.reset}`;
290
+ function summarizeCloneLine(repos, outcomes) {
291
+ const parts = repos.map((name) => {
292
+ const st = outcomes.get(name) || "fail";
293
+ if (st === "fail") return `${c.red}✗${c.reset} ${name}`;
294
+ if (st === "skip") return `${c.green}✓${c.reset} ${c.dim}${name}${c.reset}`;
295
+ return `${c.green}✓${c.reset} ${name}`;
296
+ });
297
+ return ` ${c.blue}☁${c.reset} ${parts.join(` `)}`;
295
298
  }
296
299
 
297
- async function cloneOneAsync(org, name, workspace, useSsh) {
298
- const q = process.env.KOMPLIAN_CLI_QUIET === "1";
299
- const gitDir = join(workspace, name, ".git");
300
- if (existsSync(gitDir)) {
301
- ux(cloudLine(org, name, "skip"));
302
- return true;
303
- }
304
-
305
- const args = useSsh
306
- ? ["clone", `git@github.com:${org}/${name}.git`, name]
307
- : ["repo", "clone", `${org}/${name}`, name];
308
- const cmd = useSsh ? "git" : "gh";
309
-
310
- const tty = process.stdout.isTTY;
311
- const spin = tty ? startRepoSpinner(name) : null;
312
- if (!tty) {
313
- ux(
314
- `${c.blue}☁${c.reset} ${c.cyan}…${c.reset} ${c.bold}${name}${c.reset} ${c.dim}cloning…${c.reset}`
315
- );
316
- }
317
-
318
- const r = await runSpawn(cmd, args, workspace);
319
- if (spin) spin.stop();
320
-
321
- if (r.status === 0) {
322
- ux(cloudLine(org, name, "ok"));
323
- return true;
324
- }
325
- ux(cloudLine(org, name, "fail"));
326
- const err = (r.stderr || "").trim();
327
- if (err) {
328
- if (q) console.error(`${c.dim}${err.slice(0, 500)}${c.reset}`);
329
- else log(`${c.dim}${err}${c.reset}`);
300
+ function summarizeDepsLine(results) {
301
+ const ran = results.filter((r) => !r.skipped);
302
+ if (ran.length === 0) {
303
+ return ` ${c.blue}☁${c.reset} ${c.dim}dependencies — nothing to install${c.reset}`;
330
304
  }
331
- return false;
305
+ const parts = ran.map((r) =>
306
+ r.ok
307
+ ? `${c.green}✓${c.reset} ${r.name}`
308
+ : `${c.yellow}○${c.reset} ${r.name}`
309
+ );
310
+ return ` ${c.blue}☁${c.reset} ${parts.join(` `)}`;
332
311
  }
333
312
 
334
313
  function copyCursorPack(workspace, cursorRepoUrl) {
@@ -371,12 +350,13 @@ function npmQuietFlags() {
371
350
  return audit ? [] : ["--no-audit", "--no-fund"];
372
351
  }
373
352
 
374
- function npmInstallOneRepo(dir, name) {
353
+ function npmInstallOneRepo(dir, name, opts = {}) {
354
+ const silent = opts.silent === true;
375
355
  const pkg = join(dir, "package.json");
376
356
  if (!existsSync(pkg)) return { ok: true, skipped: true };
377
357
 
378
358
  const q = process.env.KOMPLIAN_CLI_QUIET === "1";
379
- const stdio = q ? "ignore" : "inherit";
359
+ const stdio = silent || q ? "ignore" : "inherit";
380
360
 
381
361
  const yarnLock = join(dir, "yarn.lock");
382
362
  const pnpmLock = join(dir, "pnpm-lock.yaml");
@@ -384,17 +364,21 @@ function npmInstallOneRepo(dir, name) {
384
364
 
385
365
  if (existsSync(yarnLock)) {
386
366
  if (!canRun("yarn", ["--version"])) {
387
- log(
388
- `${c.yellow}○${c.reset} ${name} ${c.dim}(yarn.lock; install yarn or run yarn install manually)${c.reset}`
389
- );
367
+ if (!silent) {
368
+ log(
369
+ `${c.yellow}○${c.reset} ${name} ${c.dim}(yarn.lock; install yarn or run yarn install manually)${c.reset}`
370
+ );
371
+ }
390
372
  return { ok: true, skipped: true };
391
373
  }
392
- if (q) {
393
- ux(
394
- ` ${c.blue}☁${c.reset} ${c.cyan}…${c.reset} ${c.bold}${name}${c.reset} ${c.dim}yarn install…${c.reset}`
395
- );
396
- } else {
397
- log(`${c.dim}→${c.reset} ${name} ${c.dim}(yarn)${c.reset}`);
374
+ if (!silent) {
375
+ if (q) {
376
+ ux(
377
+ ` ${c.blue}☁${c.reset} ${c.cyan}…${c.reset} ${c.bold}${name}${c.reset} ${c.dim}yarn…${c.reset}`
378
+ );
379
+ } else {
380
+ log(`${c.dim}→${c.reset} ${name} ${c.dim}(yarn)${c.reset}`);
381
+ }
398
382
  }
399
383
  const r = spawnSync(
400
384
  "yarn",
@@ -406,17 +390,21 @@ function npmInstallOneRepo(dir, name) {
406
390
 
407
391
  if (existsSync(pnpmLock)) {
408
392
  if (!canRun("pnpm", ["--version"])) {
409
- log(
410
- `${c.yellow}○${c.reset} ${name} ${c.dim}(pnpm-lock; install pnpm or run pnpm install manually)${c.reset}`
411
- );
393
+ if (!silent) {
394
+ log(
395
+ `${c.yellow}○${c.reset} ${name} ${c.dim}(pnpm-lock; install pnpm or run pnpm install manually)${c.reset}`
396
+ );
397
+ }
412
398
  return { ok: true, skipped: true };
413
399
  }
414
- if (q) {
415
- ux(
416
- ` ${c.blue}☁${c.reset} ${c.cyan}…${c.reset} ${c.bold}${name}${c.reset} ${c.dim}pnpm install…${c.reset}`
417
- );
418
- } else {
419
- log(`${c.dim}→${c.reset} ${name} ${c.dim}(pnpm)${c.reset}`);
400
+ if (!silent) {
401
+ if (q) {
402
+ ux(
403
+ ` ${c.blue}☁${c.reset} ${c.cyan}…${c.reset} ${c.bold}${name}${c.reset} ${c.dim}pnpm…${c.reset}`
404
+ );
405
+ } else {
406
+ log(`${c.dim}→${c.reset} ${name} ${c.dim}(pnpm)${c.reset}`);
407
+ }
420
408
  }
421
409
  const r = spawnSync(
422
410
  "pnpm",
@@ -427,34 +415,42 @@ function npmInstallOneRepo(dir, name) {
427
415
  }
428
416
 
429
417
  if (!canRun("npm", ["--version"])) {
430
- log(`${c.yellow}○${c.reset} npm not in PATH — skipping ${name}`);
418
+ if (!silent) {
419
+ log(`${c.yellow}○${c.reset} npm not in PATH — skipping ${name}`);
420
+ }
431
421
  return { ok: true, skipped: true };
432
422
  }
433
423
 
434
424
  const quiet = npmQuietFlags();
435
425
 
436
426
  if (existsSync(npmLock)) {
437
- if (q) {
438
- ux(
439
- ` ${c.blue}☁${c.reset} ${c.cyan}…${c.reset} ${c.bold}${name}${c.reset} ${c.dim}npm ci…${c.reset}`
440
- );
441
- } else {
442
- log(`${c.dim}→${c.reset} ${name} ${c.dim}(npm ci)${c.reset}`);
427
+ if (!silent) {
428
+ if (q) {
429
+ ux(
430
+ ` ${c.blue}☁${c.reset} ${c.cyan}…${c.reset} ${c.bold}${name}${c.reset} ${c.dim}npm ci…${c.reset}`
431
+ );
432
+ } else {
433
+ log(`${c.dim}→${c.reset} ${name} ${c.dim}(npm ci)${c.reset}`);
434
+ }
443
435
  }
444
436
  const r = spawnSync("npm", ["ci", ...quiet], spawnWin({ cwd: dir, stdio }));
445
437
  if (r.status === 0) return { ok: true, skipped: false };
446
- log(
447
- `${c.yellow}○${c.reset} ${name}: npm ci failed (lock out of sync?). ${c.dim}Try npm install in that repo.${c.reset}`
448
- );
438
+ if (!silent) {
439
+ log(
440
+ `${c.yellow}○${c.reset} ${name}: npm ci failed (lock out of sync?). ${c.dim}Try npm install in that repo.${c.reset}`
441
+ );
442
+ }
449
443
  return { ok: false, skipped: false };
450
444
  }
451
445
 
452
- if (q) {
453
- ux(
454
- ` ${c.blue}☁${c.reset} ${c.cyan}…${c.reset} ${c.bold}${name}${c.reset} ${c.dim}npm install…${c.reset}`
455
- );
456
- } else {
457
- log(`${c.dim}→${c.reset} ${name} ${c.dim}(npm install — no new lockfile)${c.reset}`);
446
+ if (!silent) {
447
+ if (q) {
448
+ ux(
449
+ ` ${c.blue}☁${c.reset} ${c.cyan}…${c.reset} ${c.bold}${name}${c.reset} ${c.dim}npm install…${c.reset}`
450
+ );
451
+ } else {
452
+ log(`${c.dim}→${c.reset} ${name} ${c.dim}(npm install — no new lockfile)${c.reset}`);
453
+ }
458
454
  }
459
455
  const r = spawnSync(
460
456
  "npm",
@@ -464,33 +460,143 @@ function npmInstallOneRepo(dir, name) {
464
460
  return { ok: r.status === 0, skipped: false };
465
461
  }
466
462
 
467
- function npmInstallEach(workspace) {
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
+
468
477
  const q = process.env.KOMPLIAN_CLI_QUIET === "1";
469
- if (!q) {
470
- log("");
471
- log(`${c.cyan}━━ dependencies ━━${c.reset}`);
472
- } else {
473
- ux("");
474
- ux(`${c.cyan}Dependencies${c.reset}`);
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 };
475
509
  }
476
- for (const ent of readdirSync(workspace)) {
477
- const d = join(workspace, ent);
478
- if (!statSync(d).isDirectory()) continue;
479
- const { ok, skipped } = npmInstallOneRepo(d, ent);
480
- if (skipped) continue;
481
- if (q) {
482
- const cloud = `${c.blue}☁${c.reset}`;
483
- if (ok) {
484
- ux(` ${cloud} ${c.green}✓${c.reset} ${c.bold}${ent}${c.reset} ${c.dim}deps${c.reset}`);
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
+ );
485
553
  } else {
486
- ux(` ${cloud} ${c.yellow}○${c.reset} ${c.bold}${ent}${c.reset} ${c.dim}deps${c.reset}`);
554
+ log(`${c.dim}→${c.reset} ${name} ${c.dim}(npm ci)${c.reset}`);
487
555
  }
488
- } else if (ok) {
489
- log(`${c.green}✓${c.reset} ${ent}`);
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
+ );
490
572
  } else {
491
- log(`${c.yellow}○${c.reset} ${ent}`);
573
+ log(`${c.dim}→${c.reset} ${name} ${c.dim}(npm install — no new lockfile)${c.reset}`);
492
574
  }
493
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 = [];
588
+ for (const ent of readdirSync(workspace)) {
589
+ const d = join(workspace, ent);
590
+ if (!statSync(d).isDirectory()) continue;
591
+ jobs.push(
592
+ npmInstallOneRepoAsync(d, ent, { silent: true }).then(({ ok, skipped }) => ({
593
+ name: ent,
594
+ ok,
595
+ skipped,
596
+ }))
597
+ );
598
+ }
599
+ return Promise.all(jobs);
494
600
  }
495
601
 
496
602
  function usage() {
@@ -721,36 +827,68 @@ async function main() {
721
827
  mkdirSync(abs, { recursive: true });
722
828
  const q = process.env.KOMPLIAN_CLI_QUIET === "1";
723
829
  ux("");
724
- if (!q) {
725
- log(`${c.cyan}━━ Workspace ━━${c.reset} ${c.bold}${abs}${c.reset}`);
726
- log(`${c.cyan}━━ Org ━━${c.reset} ${c.bold}${org}${c.reset}`);
727
- if (team) log(`${c.cyan}━━ Team ━━${c.reset} ${c.bold}${team}${c.reset}`);
728
- log(`${c.cyan}━━ Repos (${repos.length}) ━━${c.reset}`);
729
- } else {
730
- ux(`${c.dim}Workspace:${c.reset} ${abs}`);
731
- if (team) ux(`${c.dim}Team:${c.reset} ${team}`);
732
- ux(`${c.cyan}Repositories${c.reset} ${c.dim}(${repos.length})${c.reset}`);
733
- }
830
+ ux(`${c.dim}${abs}${c.reset}`);
831
+ if (team) ux(`${c.dim}team · ${team}${c.reset}`);
832
+ ux(`${c.dim}${repos.join(" · ")}${c.reset}`);
734
833
  ux("");
735
834
 
835
+ const outcomes = new Map();
836
+ let spin = process.stdout.isTTY ? startBatchSpinner("Cloning repositories…") : null;
837
+ if (!spin) {
838
+ ux(` ${c.blue}☁${c.reset} ${c.dim}Cloning repositories…${c.reset}`);
839
+ }
840
+
736
841
  let failed = 0;
737
- for (const name of repos) {
738
- const ok = await cloneOneAsync(org, name, abs, args.ssh);
739
- if (!ok) failed += 1;
842
+ try {
843
+ for (const name of repos) {
844
+ const gitDir = join(abs, name, ".git");
845
+ if (existsSync(gitDir)) {
846
+ outcomes.set(name, "skip");
847
+ continue;
848
+ }
849
+ const cloneArgs = args.ssh
850
+ ? ["clone", `git@github.com:${org}/${name}.git`, name]
851
+ : ["repo", "clone", `${org}/${name}`, name];
852
+ const cmd = args.ssh ? "git" : "gh";
853
+ const r = await runSpawn(cmd, cloneArgs, abs);
854
+ if (r.status === 0) {
855
+ outcomes.set(name, "ok");
856
+ } else {
857
+ outcomes.set(name, "fail");
858
+ failed += 1;
859
+ const err = (r.stderr || "").trim();
860
+ if (err) console.error(`${c.dim}${err.slice(0, 500)}${c.reset}`);
861
+ }
862
+ }
863
+ } finally {
864
+ if (spin) spin.stop();
740
865
  }
741
866
 
867
+ ux(summarizeCloneLine(repos, outcomes));
868
+
742
869
  copyCursorPack(abs, process.env.KOMPLIAN_CURSOR_REPO);
743
870
 
744
871
  if (args.install) {
745
- npmInstallEach(abs);
872
+ spin = process.stdout.isTTY ? startBatchSpinner("Installing dependencies…") : null;
873
+ if (!spin) {
874
+ ux(` ${c.blue}☁${c.reset} ${c.dim}Installing dependencies…${c.reset}`);
875
+ }
876
+ let depResults = [];
877
+ try {
878
+ depResults = await npmInstallBatchAsync(abs);
879
+ } finally {
880
+ if (spin) spin.stop();
881
+ }
882
+ ux(summarizeDepsLine(depResults));
746
883
  }
747
884
 
748
885
  ux("");
749
- ux(`${c.cyan}━━ Done ━━${c.reset}`);
750
886
  if (failed > 0) {
751
887
  ux(
752
888
  `${c.yellow}○${c.reset} ${failed} repo(s) failed — check access and retry.`
753
889
  );
890
+ } else {
891
+ ux(`${c.green}✓${c.reset} ${c.dim}Repositories ready${c.reset}`);
754
892
  }
755
893
  ux(`${c.green}✓${c.reset} Open in Cursor: ${c.bold}File → Open Folder → ${abs}${c.reset}`);
756
894
  if (!q) {
@@ -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`);
@@ -583,7 +888,6 @@ export async function runSetup(argv) {
583
888
 
584
889
  mkdirSync(workspaceAbs, { recursive: true });
585
890
 
586
- out(`${c.cyan}1/5${c.reset} repos`);
587
891
  const onboardArgs = ["--yes"];
588
892
  if (opts.team) onboardArgs.push("-t", opts.team);
589
893
  if (opts.allRepos) onboardArgs.push("--all-repos");
@@ -619,6 +923,18 @@ export async function runSetup(argv) {
619
923
  const needsPostmanKey = !existingPmKey;
620
924
  const needsDbUrls = !envDbOk && !savedDbOk;
621
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
+ }
622
938
 
623
939
  if (neonOAuth && needsDbUrls) {
624
940
  const lp = parseListenFromRedirectUri();
@@ -629,7 +945,6 @@ export async function runSetup(argv) {
629
945
  }
630
946
 
631
947
  if (useBrowser && (needsPostmanKey || needsDbUrls)) {
632
- out(`${c.cyan}browser${c.reset} local form`);
633
948
  try {
634
949
  const lp = neonOAuth && needsDbUrls ? parseListenFromRedirectUri() : null;
635
950
  const form = await runSetupBrowserForm({
@@ -637,6 +952,9 @@ export async function runSetup(argv) {
637
952
  needsDbUrls,
638
953
  emailDomain,
639
954
  neonOAuth: !!(neonOAuth && needsDbUrls),
955
+ neonApiResolve,
956
+ showPostman,
957
+ logoHtml,
640
958
  listenHost: lp?.host || "127.0.0.1",
641
959
  listenPort: lp?.port ?? 0,
642
960
  });
@@ -652,16 +970,12 @@ export async function runSetup(argv) {
652
970
  }
653
971
  }
654
972
 
655
- out(`${c.cyan}2/5${c.reset} postman`);
656
973
  await runPostman(["--yes"]);
657
974
 
658
- out(`${c.cyan}3/5${c.reset} mcp`);
659
975
  await runMcpTools(["--yes"]);
660
976
 
661
- out(`${c.cyan}4/5${c.reset} databases`);
662
977
  await runDbAllDev(["--yes", "-w", monorepoRoot]);
663
978
 
664
- out(`${c.cyan}5/5${c.reset} dev`);
665
979
  await runLocalhost(["--yes"]);
666
980
 
667
981
  out(`${c.green}✓${c.reset} ready`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "komplian",
3
- "version": "0.7.2",
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": {