premanmcp 0.13.0 → 0.15.1

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.
@@ -14,8 +14,11 @@
14
14
  * hand, and no third-party credential ever touches the terminal.
15
15
  */
16
16
 
17
+ import { describeCheckout, explainNoCheckout, resolveCheckout } from "./repo.js";
17
18
  import {
19
+ DEFAULT_BACKEND,
18
20
  assertOk,
21
+ backendUrl,
19
22
  callBackendJson,
20
23
  cliInvocation,
21
24
  frontendUrl,
@@ -58,10 +61,41 @@ export function connected(message) {
58
61
  process.stdout.write(`${MARK.ok()} ${message}\n`);
59
62
  }
60
63
 
64
+ const OPENING_FRAMES = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
65
+
66
+ function showOpeningDesktop() {
67
+ const label = "Opening PreMan\u2026";
68
+ if (!process.stdout.isTTY) {
69
+ process.stdout.write(`${label}\n`);
70
+ return () => {};
71
+ }
72
+
73
+ let frame = 0;
74
+ const render = () => {
75
+ process.stdout.write(`\r\u001b[2K${OPENING_FRAMES[frame % OPENING_FRAMES.length]} ${label}`);
76
+ frame += 1;
77
+ };
78
+ render();
79
+ const timer = setInterval(render, 80);
80
+ if (typeof timer.unref === "function") timer.unref();
81
+
82
+ let stopped = false;
83
+ return () => {
84
+ if (stopped) return;
85
+ stopped = true;
86
+ clearInterval(timer);
87
+ process.stdout.write("\r\u001b[2K");
88
+ };
89
+ }
90
+
91
+ const INSTALLATIONS_URL = "https://github.com/settings/installations";
92
+
61
93
  function present(url, what) {
62
94
  process.stdout.write(`\n ${url}\n\n`);
63
- if (openUrl(url)) process.stdout.write(`Opened ${what} in your browser.\n`);
64
- else process.stdout.write(`Open the link above to ${what}.\n`);
95
+ // A tab appearing is its own confirmation; saying so as well spends a line
96
+ // on what the customer is already looking at. It earns its place only when
97
+ // no tab opened and the link is the way through.
98
+ if (!openUrl(url)) process.stdout.write(`Open the link above to ${what}.\n`);
65
99
  }
66
100
 
67
101
  /**
@@ -265,9 +299,7 @@ export async function githubCommand(args) {
265
299
  {
266
300
  timeoutMs: Number(process.env.PREMAN_GITHUB_POLL_MS) || GITHUB_POLL_TIMEOUT_MS,
267
301
  hintAfterMs: 20000,
268
- hint:
269
- "Still nothing from GitHub. Picking at least one repository and confirming\n" +
270
- "is what sends you back here.",
302
+ hint: "Still nothing from GitHub — confirm a repository to come back here.",
271
303
  }
272
304
  );
273
305
 
@@ -291,15 +323,12 @@ function githubHandOff(args, refresh) {
291
323
  if (installed) {
292
324
  return (
293
325
  `The App is installed, but no repositories are shared with it.\n` +
294
- ` - Add some: https://github.com/settings/installations\n` +
295
- ` - Then re-run '${cliInvocation()} github'.\n`
326
+ ` Add some at ${INSTALLATIONS_URL}, then re-run '${cliInvocation()} github'.\n`
296
327
  );
297
328
  }
298
329
  return (
299
- `Nothing from GitHub yet — no need to wait here.\n` +
300
- ` - Finish the install in the browser; it records itself when you confirm.\n` +
301
- ` - Check it: ${frontendUrl(args)} or https://github.com/settings/installations\n` +
302
- ` - Then re-run '${cliInvocation()} github'.\n`
330
+ `Nothing from GitHub yet. Finish the install in the browser — it records itself.\n` +
331
+ ` Then re-run '${cliInvocation()} github'.\n`
303
332
  );
304
333
  }
305
334
 
@@ -337,14 +366,25 @@ export async function slackCommand(args) {
337
366
  // The guided run
338
367
  // ---------------------------------------------------------------------------
339
368
 
340
- /** "yes" | "no" | "back" -- back only offered once there is somewhere to go. */
341
- async function askStep(question, { assumeYes, canGoBack }) {
342
- if (assumeYes) return "yes";
343
- const hint = canGoBack ? "[Y/n/b]" : "[Y/n]";
369
+ /**
370
+ * "yes" | "no" | "back" -- back only offered once there is somewhere to go.
371
+ *
372
+ * `--yes` takes each step's own default rather than answering yes to all of
373
+ * them. One step needs that today and it is the expensive one: the desktop app
374
+ * defaults to no because it downloads a hundred-odd megabytes and writes to
375
+ * /Applications, and `onboard --yes` used to do exactly that, unattended, to
376
+ * someone who only meant "stop asking me questions". They have
377
+ * `preman install-desktop` when they want it.
378
+ */
379
+ async function askStep(question, { assumeYes, canGoBack, defaultYes = true }) {
380
+ const fallback = defaultYes ? "yes" : "no";
381
+ if (assumeYes) return fallback;
382
+ const shown = defaultYes ? "Y/n" : "y/N";
383
+ const hint = canGoBack ? `[${shown}/b]` : `[${shown}]`;
344
384
  const answer = (await promptText(`${question} ${hint}: `)).trim().toLowerCase();
345
385
  if (canGoBack && (answer === "b" || answer === "back")) return "back";
346
- if (answer === "" || answer === "y" || answer === "yes") return "yes";
347
- return "no";
386
+ if (answer === "") return fallback;
387
+ return answer === "y" || answer === "yes" ? "yes" : "no";
348
388
  }
349
389
 
350
390
  /**
@@ -354,50 +394,238 @@ async function askStep(question, { assumeYes, canGoBack }) {
354
394
  * finish Slack today should still leave with AWS streaming, so a step that
355
395
  * throws is reported and the run continues rather than unwinding the ones that
356
396
  * already worked.
397
+ *
398
+ * The order is the argument. Setup used to open with a hundred-megabyte
399
+ * download and close with the repository, so the first thing PreMan did was ask
400
+ * for disk space and the last was ask about the code — and someone who quit
401
+ * halfway had a desktop app pointed at an empty account. Endpoints come first
402
+ * now because they are the only step that shows what PreMan is for, and every
403
+ * ask after it can name what it just found. The app goes last because it is the
404
+ * one thing here nobody needs.
405
+ *
406
+ * The steps themselves live in connect/guide.js, which is also what
407
+ * `preman connect --guide` runs. Two walks that asked the same questions in
408
+ * different words with different defaults was the thing worth deleting.
357
409
  */
358
410
  export async function onboardCommand(
359
411
  commandArgs,
360
- { makeArgs, authenticateTerminal, installDesktop, openDesktopSignedIn }
412
+ { makeArgs, authenticateTerminal, installDesktop, openDesktopSignedIn, installPushHook }
361
413
  ) {
362
414
  const args = makeArgs(commandArgs);
363
415
  const assumeYes = args.has("--yes");
364
416
 
417
+ // Imported at call time, not at the top: guide.js imports this module for MARK
418
+ // and the three integration commands, so a static edge back would close the
419
+ // cycle. By the time anyone runs onboard, this module is fully evaluated.
420
+ const { desktopPrecheck, desktopQuestion, desktopSkipped, discoverEndpoints, runDesktopInstall } =
421
+ await import("./connect/guide.js");
422
+ const { resolveAgentToDrive } = await import("./connect/agents.js");
423
+
365
424
  process.stdout.write("PreMan setup\n\n");
366
425
 
426
+ // Which PreMan this is talking to is invisible until the very last line,
427
+ // and a stored login from a local run silently redirects the whole walk:
428
+ // the account is created in a database nobody else can see, and the GitHub
429
+ // App redirects to the real callback, so the install never arrives and the
430
+ // wait looks like GitHub being slow. Production says nothing, because there
431
+ // is nothing to warn about; anything else is worth one line up front.
432
+ const target = backendUrl(args);
433
+ if (target !== DEFAULT_BACKEND) {
434
+ process.stdout.write(`${MARK.skip()} Using ${target}, not ${DEFAULT_BACKEND}.\n\n`);
435
+ }
436
+
367
437
  const creds = await authenticateTerminal(args);
368
438
  connected(`Signed in as ${creds.user_email || "your account"}.`);
369
439
 
440
+ // Asked once, here, because the repository step and the notice that stands in
441
+ // for it both need the answer and it costs a round trip. It is reported later,
442
+ // in the repository's own slot in the walk, so everything about this checkout
443
+ // is said in one place rather than before PreMan has looked at the code.
444
+ const checkout = await resolveCheckout(args, resolveApiKey(args));
445
+
370
446
  const steps = [
371
447
  {
372
- name: "PreMan app",
373
- question: "Install the PreMan app and open it signed in?",
448
+ // No question. Everything below is a decision about code PreMan has not
449
+ // read yet, and asking permission to look was how people ended up with an
450
+ // account, an app and nothing in either. It skips a repo that is already
451
+ // mapped, prints a brief when no agent can be driven, and never throws.
452
+ name: "Endpoints",
453
+ run: async () => {
454
+ // `interactive: false` on purpose. Getting started does not ask which
455
+ // coding agent you use -- that question belongs to `preman connect`,
456
+ // and putting a three-way picker in front of someone ninety seconds
457
+ // into their first run is how this walk got long in the first place.
458
+ // An unambiguous machine still gets its agent driven; anything less
459
+ // clear prints the brief and moves on.
460
+ const agent = await resolveAgentToDrive({
461
+ agent: args.value("--agent", ""),
462
+ interactive: false,
463
+ });
464
+ const counts = await discoverEndpoints(args, agent, args.value("--name", "preman"), {
465
+ handOffBrief: false,
466
+ });
467
+ return { state: counts.registered ? "done" : "skipped", counts };
468
+ },
469
+ },
470
+ ];
471
+
472
+ // Asking "Connect GitHub?" hands someone a chore with no visible end; asking
473
+ // "Connect acme/api?" is the entire decision, and this is the repository they
474
+ // ran the command in. It sits after discovery so it can say what is at stake
475
+ // in this repo rather than in the abstract, and because this is the step that
476
+ // decides whether a fix from this code can ever open a pull request — leaving
477
+ // it for later is leaving them to find the gap in a fix that produced nothing.
478
+ if (checkout?.supported && !checkout.match) {
479
+ steps.push({
480
+ name: "This repository",
481
+ question: (previous) => {
482
+ const found = previous.get("Endpoints")?.counts?.registered || 0;
483
+ if (!found) return `Connect ${checkout.slug} so fixes here can open pull requests?`;
484
+ return `Connect ${checkout.slug} so PreMan can open pull requests for the ${found} endpoint${found === 1 ? "" : "s"} it just mapped?`;
485
+ },
374
486
  run: async () => {
375
- const installed = await installDesktop([...commandArgs]);
376
- if (installed?.state === "unsupported") {
377
- // Not a failure: the download link has already been printed, and the
378
- // account this step exists to create is finished either way.
379
- process.stdout.write("Sign in there with the account you just used.\n");
487
+ await githubCommand(args);
488
+ // Finishing the App install is not the same as this repository being
489
+ // connected: the install shares whichever repositories the customer
490
+ // picked, which may not include this one. Re-asking is the difference
491
+ // between "GitHub is connected" and the claim actually made above.
492
+ const after = await resolveCheckout(args, resolveApiKey(args), {
493
+ slug: checkout.slug,
494
+ });
495
+ if (after?.match) {
496
+ process.stdout.write(describeCheckout(after));
380
497
  return;
381
498
  }
382
- process.stdout.write("Opening PreMan\u2026\n");
383
- const opened = await openDesktopSignedIn(creds);
384
- if (opened.state === "opened-signed-in") {
385
- process.stdout.write("Opened PreMan, signed in as this account.\n");
386
- } else if (opened.state === "not-installed") {
499
+ process.stdout.write(
500
+ `${checkout.slug} is still not shared. Add it at ${INSTALLATIONS_URL}.\n`
501
+ );
502
+ },
503
+ });
504
+ } else {
505
+ // Already connected, or there is no repository here to connect. Neither is a
506
+ // question, but both are the fact that decides whether a fix from this code
507
+ // can ever open a pull request, so the walk says it out loud in the slot
508
+ // where the question would have been. It used to say nothing at all when
509
+ // there was no checkout, which left people to discover the gap much later,
510
+ // from a fix task that finished and produced no pull request.
511
+ const already = describeCheckout(checkout);
512
+ steps.push({
513
+ name: "This repository",
514
+ run: () => {
515
+ if (already) {
516
+ process.stdout.write(already);
517
+ return { state: checkout?.match ? "done" : "skipped" };
518
+ }
519
+ process.stdout.write(`${MARK.skip()} ${explainNoCheckout()}\n`);
520
+ process.stdout.write(
521
+ ` Run '${cliInvocation()} github' from your API repo to connect it.\n`
522
+ );
523
+ return { state: "skipped" };
524
+ },
525
+ });
526
+ }
527
+
528
+ steps.push({
529
+ name: "Push checks",
530
+ question: "Check your endpoints on every git push in this repo?",
531
+ run: async () => {
532
+ let result;
533
+ try {
534
+ result = installPushHook(args);
535
+ } catch (err) {
536
+ // Onboarding from a home directory or a plain folder is ordinary, and
537
+ // it costs the customer nothing: the account is already done, and the
538
+ // hook is one command away inside the repo they meant.
539
+ if (/not inside a git repository/i.test(err.message)) {
387
540
  process.stdout.write(
388
- "PreMan is not in /Applications yet \u2014 open it once installed and sign in.\n"
541
+ "Not a git repository, so there is nothing to hook here.\n" +
542
+ `Run '${cliInvocation()} hook install' inside your API repo.\n`
389
543
  );
390
- } else {
391
- // Either the session could not be handed over or this app is too old to
392
- // take it up. Say so rather than let the customer wonder why they are
393
- // looking at a login screen.
394
- process.stdout.write("Opened PreMan \u2014 sign in with the account you just used.\n");
544
+ return;
395
545
  }
396
- },
546
+ throw err;
547
+ }
548
+
549
+ // A hook we refused to write is not a failure of setup, but saying
550
+ // nothing would leave the customer believing their pushes are checked.
551
+ if (result.action === "conflict" || result.action === "unproven") {
552
+ process.stdout.write(`Not installed: ${result.detail}\n ${result.path}\n`);
553
+ return;
554
+ }
555
+
556
+ // One line, like every other step. The pinned invocation and the skip
557
+ // variable are real information and both are a `hook status` away;
558
+ // spelling them out here buried the one fact that changes a decision.
559
+ connected(`Pre-push hook ${result.action} — every push is checked, never blocked.`);
560
+ if (result.detail) process.stdout.write(` ${result.detail}\n`);
397
561
  },
398
- // GitHub, AWS and Slack are deliberately not here. Starting out is an account
399
- // and the app; each integration is its own command for whenever it is wanted.
400
- ];
562
+ });
563
+
564
+ // Last, defaulted to no, and only where there is something to install. The
565
+ // guards run out here rather than inside the step so a Linux user is not
566
+ // asked a macOS question and someone who already has the app is not asked to
567
+ // download it again — both of which happened when the check came after.
568
+ const desktop = desktopPrecheck(args);
569
+ if (desktop?.state === "installed") {
570
+ steps.push({
571
+ name: "Desktop app",
572
+ run: () => {
573
+ process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
574
+ },
575
+ });
576
+ } else if (!desktop) {
577
+ steps.push({
578
+ name: "Desktop app",
579
+ question: desktopQuestion(args),
580
+ // `--desktop` is how an unattended run opts in, since `--yes` gives every
581
+ // step its own default and this step's default is no.
582
+ defaultYes: args.has("--desktop"),
583
+ onSkip: desktopSkipped,
584
+ run: () =>
585
+ runDesktopInstall(args, {
586
+ // Onboarding installs *and* hands the fresh session over, so the app
587
+ // opens already signed in rather than on a login screen for the
588
+ // account created ninety seconds ago.
589
+ install: async () => {
590
+ let stopOpening = null;
591
+ try {
592
+ const installed = await installDesktop([...commandArgs], {
593
+ onInstalled: () => {
594
+ stopOpening ??= showOpeningDesktop();
595
+ },
596
+ });
597
+ if (installed?.state === "unsupported") {
598
+ // Not a failure: the download link has already been printed, and
599
+ // the account this step exists to create is finished either way.
600
+ process.stdout.write("Sign in there with the account you just used.\n");
601
+ return installed;
602
+ }
603
+ stopOpening ??= showOpeningDesktop();
604
+ // The same --dest the install honoured, or the launch would look
605
+ // for the app somewhere it was never copied to.
606
+ const destination = args.value("--dest", "/Applications");
607
+ const opened = await openDesktopSignedIn(creds, { destination });
608
+ stopOpening();
609
+ if (opened.state === "opened-signed-in") {
610
+ process.stdout.write("Opened PreMan, signed in as this account.\n");
611
+ } else if (opened.state === "not-installed") {
612
+ process.stdout.write(
613
+ `PreMan is not in ${destination} yet \u2014 open it once installed and sign in.\n`
614
+ );
615
+ } else {
616
+ // Either the session could not be handed over or this app is too
617
+ // old to take it up. Say so rather than let the customer wonder
618
+ // why they are looking at a login screen.
619
+ process.stdout.write("Opened PreMan \u2014 sign in with the account you just used.\n");
620
+ }
621
+ return installed;
622
+ } finally {
623
+ stopOpening?.();
624
+ }
625
+ },
626
+ }),
627
+ });
628
+ }
401
629
 
402
630
  // Outcome per step rather than three lists, so revisiting a step replaces its
403
631
  // result instead of recording it twice.
@@ -411,23 +639,39 @@ export async function onboardCommand(
411
639
  const step = steps[i];
412
640
  process.stdout.write(`\n── ${step.name} ──\n`);
413
641
 
414
- const choice = await askStep(step.question, { assumeYes, canGoBack: i > 0 });
415
- if (choice === "back") {
416
- // Drop the result we are about to redo, or the summary would report the
417
- // stale outcome of a step the customer chose to revisit.
418
- outcome.delete(steps[i - 1].name);
419
- i -= 1;
420
- continue;
421
- }
422
- if (choice === "no") {
423
- outcome.set(step.name, { state: "skipped" });
424
- i += 1;
425
- continue;
642
+ if (step.question) {
643
+ // "back" goes to the last step that asked something, not simply to the
644
+ // previous one: landing on a step with no question would re-run it and
645
+ // return straight here, which is not what typing b asked for.
646
+ const earlier = steps.slice(0, i).reduce((last, s, idx) => (s.question ? idx : last), -1);
647
+ // Late-bound so a question can name what an earlier step found. "Connect
648
+ // this repo?" is a different question once PreMan can say what is in it.
649
+ const question = typeof step.question === "function" ? step.question(outcome) : step.question;
650
+ const choice = await askStep(question, {
651
+ assumeYes,
652
+ canGoBack: earlier >= 0,
653
+ defaultYes: step.defaultYes ?? true,
654
+ });
655
+ if (choice === "back") {
656
+ // Drop the result we are about to redo, or the summary would report the
657
+ // stale outcome of a step the customer chose to revisit.
658
+ outcome.delete(steps[earlier].name);
659
+ i = earlier;
660
+ continue;
661
+ }
662
+ if (choice === "no") {
663
+ step.onSkip?.();
664
+ outcome.set(step.name, { state: "skipped" });
665
+ i += 1;
666
+ continue;
667
+ }
426
668
  }
427
669
 
428
670
  try {
429
- await step.run();
430
- outcome.set(step.name, { state: "done" });
671
+ // A step that caught its own failure and already explained it says so, and
672
+ // is not summarised as done; anything else that returns is taken as done.
673
+ const reported = await step.run();
674
+ outcome.set(step.name, { state: "done", ...(reported || {}) });
431
675
  } catch (err) {
432
676
  // Report and carry on: a failed Slack install must not cost the customer
433
677
  // the AWS connection they just finished.
@@ -451,13 +695,18 @@ export async function onboardCommand(
451
695
 
452
696
  export const INTEGRATIONS_HELP = `
453
697
  Setup options:
454
- preman onboard Create or sign in to an account, then install the
455
- PreMan app and open it signed in
698
+ preman onboard Sign in, map this repo's endpoints, connect it for
699
+ pull requests, and check the endpoints you touch on
700
+ every git push. Offers the desktop app at the end
456
701
  preman aws Connect an AWS account and stream a log group
457
702
  preman github Install the PreMan GitHub App
458
703
  preman slack Add PreMan to a Slack workspace
459
704
 
460
- --yes Accept every step without prompting (onboard)
705
+ --yes Take each step's default without prompting. The
706
+ desktop app defaults to no, so --yes never
707
+ downloads it (onboard)
708
+ --desktop Install the desktop app without asking (onboard)
709
+ --no-desktop Do not offer the desktop app at all (onboard)
461
710
  b at any onboard prompt Go back to the previous step
462
711
  --account <id> AWS account id, skips the prompt
463
712
  --region <region> AWS region for log groups. Defaults to us-east-1
package/bin/link.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The clickable PreMan line a push leaves behind.
3
+ *
4
+ * A terminal hyperlink is an OSC 8 escape wrapping a label, so the whole
5
+ * decision about *where a click lands* has to be made here, before anything is
6
+ * printed. There is no callback: the hook writes bytes and exits, and seconds
7
+ * later the terminal hands whatever URL it was given to the OS. That is why
8
+ * this module chooses a scheme rather than opening anything — an https link
9
+ * reaches the browser and a `preman://` link reaches the desktop app, and the
10
+ * only chance to pick between them is at print time.
11
+ *
12
+ * Deliberately not `openPlayground()` from desktop.js, which answers the same
13
+ * "desktop or web" question by spawning `open -a PreMan`. Stealing focus during
14
+ * a `git push` is exactly the behaviour that gets a tool uninstalled.
15
+ */
16
+
17
+ import { readFileSync } from "node:fs";
18
+ import path from "node:path";
19
+
20
+ import { desktopAppInstalled, installedAppPath } from "./desktop.js";
21
+ import { DEFAULT_FRONTEND, frontendUrl } from "./shared.js";
22
+
23
+ const DIM = "\u001b[2m";
24
+ const RESET = "\u001b[0m";
25
+
26
+ /**
27
+ * The brand palette, spelled once per colour depth.
28
+ *
29
+ * Basic ANSI green is whatever the reader's theme decided green means, which on
30
+ * a solarized or gruvbox scheme is not our green at all. The 24-bit values are
31
+ * the dashboard's own (`rgba(180, 215, 94)` for a pass, `rgba(227, 111, 102)`
32
+ * for a failure); the 256-colour indices are the nearest cube entries, 149 being
33
+ * rgb(175,215,95) and 167 rgb(215,95,95).
34
+ */
35
+ export const PASS = { rgb: [180, 215, 94], x256: 149, basic: 32 };
36
+ export const FAIL = { rgb: [227, 111, 102], x256: 167, basic: 31 };
37
+
38
+ /**
39
+ * The strongest colour escape this stream will actually render.
40
+ *
41
+ * `getColorDepth()` is Node's own answer and already reads COLORTERM, TERM and
42
+ * FORCE_COLOR, so there is nothing to sniff by hand. It only exists on a real
43
+ * tty.WriteStream, and a truecolor sequence sent somewhere that cannot decode it
44
+ * prints as literal text -- hence the conservative default for anything else.
45
+ */
46
+ export function paintTone(tone, stream) {
47
+ const depth = typeof stream.getColorDepth === "function" ? stream.getColorDepth() : 4;
48
+ if (depth >= 24) return `\u001b[38;2;${tone.rgb.join(";")}m`;
49
+ if (depth >= 8) return `\u001b[38;5;${tone.x256}m`;
50
+ return `\u001b[${tone.basic}m`;
51
+ }
52
+
53
+ /** The dashboard view a push link points at. */
54
+ export const RUN_ROUTE = "/observability/endpoints";
55
+
56
+ /**
57
+ * The first desktop build that reads `route` out of a `preman://open` URL.
58
+ *
59
+ * The contract PreMan-Desktop is expected to honour from this version on:
60
+ *
61
+ * preman://open?source=push&route=%2Fobservability%2Fendpoints%3Fbatch%3D<uuid>
62
+ *
63
+ * `route` is one URL-encoded value holding a path and its query, to be navigated
64
+ * to after the window is raised.
65
+ *
66
+ * Older builds treat any `preman://open?…` as "bring the window forward" and
67
+ * ignore the query, so without this gate a link would raise a window on some
68
+ * unrelated view and look broken. Falling back to https costs a browser tab and
69
+ * always works, which is why an unreadable bundle resolves the same way.
70
+ */
71
+ export const MIN_ROUTE_VERSION = "0.4.0";
72
+
73
+ /** Numeric compare of dotted versions. Negative when `a` is older than `b`. */
74
+ export function compareVersions(a, b) {
75
+ const parse = (value) => String(value || "").split(".").map((part) => Number.parseInt(part, 10) || 0);
76
+ const left = parse(a);
77
+ const right = parse(b);
78
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
79
+ const diff = (left[i] || 0) - (right[i] || 0);
80
+ if (diff !== 0) return diff;
81
+ }
82
+ return 0;
83
+ }
84
+
85
+ /** CFBundleShortVersionString of the installed app, or "". */
86
+ export function installedDesktopVersion(destination = "/Applications") {
87
+ try {
88
+ const plist = path.join(installedAppPath(destination), "Contents", "Info.plist");
89
+ const match = /<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/.exec(
90
+ readFileSync(plist, "utf8")
91
+ );
92
+ return match ? match[1].trim() : "";
93
+ } catch {
94
+ // Not installed, or a bundle we cannot read. Either way: not routable.
95
+ return "";
96
+ }
97
+ }
98
+
99
+ export function desktopSupportsRouting(destination = "/Applications") {
100
+ const version = installedDesktopVersion(destination);
101
+ return Boolean(version) && compareVersions(version, MIN_ROUTE_VERSION) >= 0;
102
+ }
103
+
104
+ /**
105
+ * Which surface a click should reach, and the URL that gets it there.
106
+ *
107
+ * `webUrl` is returned alongside `href` in every branch because it is always
108
+ * printed as the plain-text fallback -- macOS Terminal.app does not support OSC
109
+ * 8, and a `preman://` string is useless to anyone reading a captured log.
110
+ *
111
+ * A non-default frontend always resolves to the browser. A local Vite dev
112
+ * server is a browser thing, and the desktop app ships its own bundled UI, so
113
+ * handing it a localhost route would open the wrong build.
114
+ */
115
+ export function resolveRunSurface(args, { batchId = "", destination = "/Applications" } = {}) {
116
+ const base = frontendUrl(args);
117
+ const route = batchId ? `${RUN_ROUTE}?batch=${encodeURIComponent(batchId)}` : RUN_ROUTE;
118
+ const webUrl = `${base}${route}`;
119
+
120
+ if (base !== DEFAULT_FRONTEND) return { href: webUrl, webUrl, surface: "web-local" };
121
+ if (desktopAppInstalled(destination) && desktopSupportsRouting(destination)) {
122
+ return {
123
+ href: `preman://open?source=push&route=${encodeURIComponent(route)}`,
124
+ webUrl,
125
+ surface: "desktop",
126
+ };
127
+ }
128
+ return { href: webUrl, webUrl, surface: "web" };
129
+ }
130
+
131
+ /** Wrap `label` so supporting terminals make it clickable. */
132
+ export function terminalHyperlink(url, label) {
133
+ return `\u001b]8;;${url}\u001b\\${label}\u001b]8;;\u001b\\`;
134
+ }
135
+
136
+ /**
137
+ * A clickable headline over its own plain-text URL.
138
+ *
139
+ * Escapes are dropped entirely when stdout is not a TTY: hook output is
140
+ * routinely piped, captured by a GUI git client, or read back in CI, and a
141
+ * literal `\u001b]8;;` in a log file is worse than a plain URL. `NO_COLOR`
142
+ * suppresses only the colour, per the convention -- the hyperlink is not
143
+ * decoration.
144
+ *
145
+ * A `tone` of null dims the label instead of tinting it. The palette carries a
146
+ * verdict, so spending it on a line that merely points somewhere would make
147
+ * "here is your dashboard" look like "your push passed".
148
+ */
149
+ function renderLinkBlock({ href, webUrl, label, tone = null, stream }) {
150
+ const rich = Boolean(stream.isTTY);
151
+ const coloured = rich && !process.env.NO_COLOR;
152
+
153
+ const tinted = coloured
154
+ ? `${tone ? paintTone(tone, stream) : DIM}${label}${RESET}`
155
+ : label;
156
+ const headline = rich ? terminalHyperlink(href, tinted) : label;
157
+ const fallback = coloured ? `${DIM} ${webUrl}${RESET}` : ` ${webUrl}`;
158
+
159
+ return `${headline}\n${fallback}`;
160
+ }
161
+
162
+ /**
163
+ * The two-line block printed after a push, or "" when there is nothing to link
164
+ * to.
165
+ */
166
+ export function formatPushLink({
167
+ batchId = "",
168
+ state = "passed",
169
+ args,
170
+ stream = process.stdout,
171
+ destination = "/Applications",
172
+ } = {}) {
173
+ if (!batchId) return "";
174
+
175
+ const { href, webUrl } = resolveRunSurface(args, { batchId, destination });
176
+ const failed = state === "failed";
177
+ const label = `\u25c6 PreMan \u00b7 check #${String(batchId).slice(0, 6)} ${
178
+ failed ? "found problems" : "passed"
179
+ } \u2014 view results`;
180
+
181
+ return renderLinkBlock({
182
+ href,
183
+ webUrl,
184
+ label,
185
+ tone: failed ? FAIL : PASS,
186
+ stream,
187
+ });
188
+ }
189
+
190
+ /**
191
+ * The same block for a run that does not exist: no batch, no verdict, just the
192
+ * endpoints view.
193
+ *
194
+ * This is what a push prints when there was nothing to verify. A skip that ends
195
+ * in prose leaves the reader with a state they cannot see and no way to look --
196
+ * and the reason is nearly always something the dashboard shows plainly, like an
197
+ * inventory that is still being discovered.
198
+ */
199
+ export function formatDashboardLink({
200
+ args,
201
+ label = "\u25c6 PreMan \u00b7 view endpoints",
202
+ stream = process.stdout,
203
+ destination = "/Applications",
204
+ } = {}) {
205
+ const { href, webUrl } = resolveRunSurface(args, { destination });
206
+ return renderLinkBlock({ href, webUrl, label, stream });
207
+ }