premanmcp 0.13.0 → 0.15.0

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,6 +14,7 @@
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 {
18
19
  assertOk,
19
20
  callBackendJson,
@@ -58,6 +59,33 @@ export function connected(message) {
58
59
  process.stdout.write(`${MARK.ok()} ${message}\n`);
59
60
  }
60
61
 
62
+ const OPENING_FRAMES = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
63
+
64
+ function showOpeningDesktop() {
65
+ const label = "Opening PreMan\u2026";
66
+ if (!process.stdout.isTTY) {
67
+ process.stdout.write(`${label}\n`);
68
+ return () => {};
69
+ }
70
+
71
+ let frame = 0;
72
+ const render = () => {
73
+ process.stdout.write(`\r\u001b[2K${OPENING_FRAMES[frame % OPENING_FRAMES.length]} ${label}`);
74
+ frame += 1;
75
+ };
76
+ render();
77
+ const timer = setInterval(render, 80);
78
+ if (typeof timer.unref === "function") timer.unref();
79
+
80
+ let stopped = false;
81
+ return () => {
82
+ if (stopped) return;
83
+ stopped = true;
84
+ clearInterval(timer);
85
+ process.stdout.write("\r\u001b[2K");
86
+ };
87
+ }
88
+
61
89
  function present(url, what) {
62
90
  process.stdout.write(`\n ${url}\n\n`);
63
91
  if (openUrl(url)) process.stdout.write(`Opened ${what} in your browser.\n`);
@@ -337,14 +365,25 @@ export async function slackCommand(args) {
337
365
  // The guided run
338
366
  // ---------------------------------------------------------------------------
339
367
 
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]";
368
+ /**
369
+ * "yes" | "no" | "back" -- back only offered once there is somewhere to go.
370
+ *
371
+ * `--yes` takes each step's own default rather than answering yes to all of
372
+ * them. One step needs that today and it is the expensive one: the desktop app
373
+ * defaults to no because it downloads a hundred-odd megabytes and writes to
374
+ * /Applications, and `onboard --yes` used to do exactly that, unattended, to
375
+ * someone who only meant "stop asking me questions". They have
376
+ * `preman install-desktop` when they want it.
377
+ */
378
+ async function askStep(question, { assumeYes, canGoBack, defaultYes = true }) {
379
+ const fallback = defaultYes ? "yes" : "no";
380
+ if (assumeYes) return fallback;
381
+ const shown = defaultYes ? "Y/n" : "y/N";
382
+ const hint = canGoBack ? `[${shown}/b]` : `[${shown}]`;
344
383
  const answer = (await promptText(`${question} ${hint}: `)).trim().toLowerCase();
345
384
  if (canGoBack && (answer === "b" || answer === "back")) return "back";
346
- if (answer === "" || answer === "y" || answer === "yes") return "yes";
347
- return "no";
385
+ if (answer === "") return fallback;
386
+ return answer === "y" || answer === "yes" ? "yes" : "no";
348
387
  }
349
388
 
350
389
  /**
@@ -354,50 +393,227 @@ async function askStep(question, { assumeYes, canGoBack }) {
354
393
  * finish Slack today should still leave with AWS streaming, so a step that
355
394
  * throws is reported and the run continues rather than unwinding the ones that
356
395
  * already worked.
396
+ *
397
+ * The order is the argument. Setup used to open with a hundred-megabyte
398
+ * download and close with the repository, so the first thing PreMan did was ask
399
+ * for disk space and the last was ask about the code — and someone who quit
400
+ * halfway had a desktop app pointed at an empty account. Endpoints come first
401
+ * now because they are the only step that shows what PreMan is for, and every
402
+ * ask after it can name what it just found. The app goes last because it is the
403
+ * one thing here nobody needs.
404
+ *
405
+ * The steps themselves live in connect/guide.js, which is also what
406
+ * `preman connect --guide` runs. Two walks that asked the same questions in
407
+ * different words with different defaults was the thing worth deleting.
357
408
  */
358
409
  export async function onboardCommand(
359
410
  commandArgs,
360
- { makeArgs, authenticateTerminal, installDesktop, openDesktopSignedIn }
411
+ { makeArgs, authenticateTerminal, installDesktop, openDesktopSignedIn, installPushHook }
361
412
  ) {
362
413
  const args = makeArgs(commandArgs);
363
414
  const assumeYes = args.has("--yes");
364
415
 
416
+ // Imported at call time, not at the top: guide.js imports this module for MARK
417
+ // and the three integration commands, so a static edge back would close the
418
+ // cycle. By the time anyone runs onboard, this module is fully evaluated.
419
+ const { desktopPrecheck, desktopQuestion, desktopSkipped, discoverEndpoints, runDesktopInstall } =
420
+ await import("./connect/guide.js");
421
+ const { resolveAgentToDrive } = await import("./connect/agents.js");
422
+
365
423
  process.stdout.write("PreMan setup\n\n");
366
424
 
367
425
  const creds = await authenticateTerminal(args);
368
426
  connected(`Signed in as ${creds.user_email || "your account"}.`);
369
427
 
428
+ // Asked once, here, because the repository step and the notice that stands in
429
+ // for it both need the answer and it costs a round trip. It is reported later,
430
+ // in the repository's own slot in the walk, so everything about this checkout
431
+ // is said in one place rather than before PreMan has looked at the code.
432
+ const checkout = await resolveCheckout(args, resolveApiKey(args));
433
+
370
434
  const steps = [
371
435
  {
372
- name: "PreMan app",
373
- question: "Install the PreMan app and open it signed in?",
436
+ // No question. Everything below is a decision about code PreMan has not
437
+ // read yet, and asking permission to look was how people ended up with an
438
+ // account, an app and nothing in either. It skips a repo that is already
439
+ // mapped, prints a brief when no agent can be driven, and never throws.
440
+ name: "Endpoints",
374
441
  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");
442
+ // `interactive: false` on purpose. Getting started does not ask which
443
+ // coding agent you use -- that question belongs to `preman connect`,
444
+ // and putting a three-way picker in front of someone ninety seconds
445
+ // into their first run is how this walk got long in the first place.
446
+ // An unambiguous machine still gets its agent driven; anything less
447
+ // clear prints the brief and moves on.
448
+ const agent = await resolveAgentToDrive({
449
+ agent: args.value("--agent", ""),
450
+ interactive: false,
451
+ });
452
+ const counts = await discoverEndpoints(args, agent, args.value("--name", "preman"));
453
+ return { state: counts.registered ? "done" : "skipped", counts };
454
+ },
455
+ },
456
+ ];
457
+
458
+ // Asking "Connect GitHub?" hands someone a chore with no visible end; asking
459
+ // "Connect acme/api?" is the entire decision, and this is the repository they
460
+ // ran the command in. It sits after discovery so it can say what is at stake
461
+ // in this repo rather than in the abstract, and because this is the step that
462
+ // decides whether a fix from this code can ever open a pull request — leaving
463
+ // it for later is leaving them to find the gap in a fix that produced nothing.
464
+ if (checkout?.supported && !checkout.match) {
465
+ steps.push({
466
+ name: "This repository",
467
+ question: (previous) => {
468
+ const found = previous.get("Endpoints")?.counts?.registered || 0;
469
+ if (!found) return `Connect ${checkout.slug} so fixes here can open pull requests?`;
470
+ return `Connect ${checkout.slug} so PreMan can open pull requests for the ${found} endpoint${found === 1 ? "" : "s"} it just mapped?`;
471
+ },
472
+ run: async () => {
473
+ await githubCommand(args);
474
+ // Finishing the App install is not the same as this repository being
475
+ // connected: the install shares whichever repositories the customer
476
+ // picked, which may not include this one. Re-asking is the difference
477
+ // between "GitHub is connected" and the claim actually made above.
478
+ const after = await resolveCheckout(args, resolveApiKey(args), {
479
+ slug: checkout.slug,
480
+ });
481
+ if (after?.match) {
482
+ process.stdout.write(describeCheckout(after));
380
483
  return;
381
484
  }
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") {
485
+ process.stdout.write(
486
+ `${checkout.slug} is still not shared with PreMan.\n` +
487
+ ` Add it at https://github.com/settings/installations, then re-run ` +
488
+ `'${cliInvocation()} github'.\n`
489
+ );
490
+ },
491
+ });
492
+ } else {
493
+ // Already connected, or there is no repository here to connect. Neither is a
494
+ // question, but both are the fact that decides whether a fix from this code
495
+ // can ever open a pull request, so the walk says it out loud in the slot
496
+ // where the question would have been. It used to say nothing at all when
497
+ // there was no checkout, which left people to discover the gap much later,
498
+ // from a fix task that finished and produced no pull request.
499
+ const already = describeCheckout(checkout);
500
+ steps.push({
501
+ name: "This repository",
502
+ run: () => {
503
+ if (already) {
504
+ process.stdout.write(already);
505
+ return { state: checkout?.match ? "done" : "skipped" };
506
+ }
507
+ process.stdout.write(`${MARK.skip()} ${explainNoCheckout()}\n`);
508
+ process.stdout.write(
509
+ ` Run '${cliInvocation()} github' from your API repo to connect it.\n`
510
+ );
511
+ return { state: "skipped" };
512
+ },
513
+ });
514
+ }
515
+
516
+ steps.push({
517
+ name: "Push checks",
518
+ question: "Check your endpoints on every git push in this repo?",
519
+ run: async () => {
520
+ let result;
521
+ try {
522
+ result = installPushHook(args);
523
+ } catch (err) {
524
+ // Onboarding from a home directory or a plain folder is ordinary, and
525
+ // it costs the customer nothing: the account is already done, and the
526
+ // hook is one command away inside the repo they meant.
527
+ if (/not inside a git repository/i.test(err.message)) {
387
528
  process.stdout.write(
388
- "PreMan is not in /Applications yet \u2014 open it once installed and sign in.\n"
529
+ "Not a git repository, so there is nothing to hook here.\n" +
530
+ `Run '${cliInvocation()} hook install' inside your API repo.\n`
389
531
  );
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");
532
+ return;
395
533
  }
396
- },
534
+ throw err;
535
+ }
536
+
537
+ // A hook we refused to write is not a failure of setup, but saying
538
+ // nothing would leave the customer believing their pushes are checked.
539
+ if (result.action === "conflict" || result.action === "unproven") {
540
+ process.stdout.write(`Not installed: ${result.detail}\n ${result.path}\n`);
541
+ return;
542
+ }
543
+
544
+ // One line, like every other step. The pinned invocation and the skip
545
+ // variable are real information and both are a `hook status` away;
546
+ // spelling them out here buried the one fact that changes a decision.
547
+ connected(`Pre-push hook ${result.action} — every push is checked, never blocked.`);
548
+ if (result.detail) process.stdout.write(` ${result.detail}\n`);
397
549
  },
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
- ];
550
+ });
551
+
552
+ // Last, defaulted to no, and only where there is something to install. The
553
+ // guards run out here rather than inside the step so a Linux user is not
554
+ // asked a macOS question and someone who already has the app is not asked to
555
+ // download it again — both of which happened when the check came after.
556
+ const desktop = desktopPrecheck(args);
557
+ if (desktop?.state === "installed") {
558
+ steps.push({
559
+ name: "Desktop app",
560
+ run: () => {
561
+ process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
562
+ },
563
+ });
564
+ } else if (!desktop) {
565
+ steps.push({
566
+ name: "Desktop app",
567
+ question: desktopQuestion(args),
568
+ // `--desktop` is how an unattended run opts in, since `--yes` gives every
569
+ // step its own default and this step's default is no.
570
+ defaultYes: args.has("--desktop"),
571
+ onSkip: desktopSkipped,
572
+ run: () =>
573
+ runDesktopInstall(args, {
574
+ // Onboarding installs *and* hands the fresh session over, so the app
575
+ // opens already signed in rather than on a login screen for the
576
+ // account created ninety seconds ago.
577
+ install: async () => {
578
+ let stopOpening = null;
579
+ try {
580
+ const installed = await installDesktop([...commandArgs], {
581
+ onInstalled: () => {
582
+ stopOpening ??= showOpeningDesktop();
583
+ },
584
+ });
585
+ if (installed?.state === "unsupported") {
586
+ // Not a failure: the download link has already been printed, and
587
+ // the account this step exists to create is finished either way.
588
+ process.stdout.write("Sign in there with the account you just used.\n");
589
+ return installed;
590
+ }
591
+ stopOpening ??= showOpeningDesktop();
592
+ // The same --dest the install honoured, or the launch would look
593
+ // for the app somewhere it was never copied to.
594
+ const destination = args.value("--dest", "/Applications");
595
+ const opened = await openDesktopSignedIn(creds, { destination });
596
+ stopOpening();
597
+ if (opened.state === "opened-signed-in") {
598
+ process.stdout.write("Opened PreMan, signed in as this account.\n");
599
+ } else if (opened.state === "not-installed") {
600
+ process.stdout.write(
601
+ `PreMan is not in ${destination} yet \u2014 open it once installed and sign in.\n`
602
+ );
603
+ } else {
604
+ // Either the session could not be handed over or this app is too
605
+ // old to take it up. Say so rather than let the customer wonder
606
+ // why they are looking at a login screen.
607
+ process.stdout.write("Opened PreMan \u2014 sign in with the account you just used.\n");
608
+ }
609
+ return installed;
610
+ } finally {
611
+ stopOpening?.();
612
+ }
613
+ },
614
+ }),
615
+ });
616
+ }
401
617
 
402
618
  // Outcome per step rather than three lists, so revisiting a step replaces its
403
619
  // result instead of recording it twice.
@@ -411,23 +627,39 @@ export async function onboardCommand(
411
627
  const step = steps[i];
412
628
  process.stdout.write(`\n── ${step.name} ──\n`);
413
629
 
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;
630
+ if (step.question) {
631
+ // "back" goes to the last step that asked something, not simply to the
632
+ // previous one: landing on a step with no question would re-run it and
633
+ // return straight here, which is not what typing b asked for.
634
+ const earlier = steps.slice(0, i).reduce((last, s, idx) => (s.question ? idx : last), -1);
635
+ // Late-bound so a question can name what an earlier step found. "Connect
636
+ // this repo?" is a different question once PreMan can say what is in it.
637
+ const question = typeof step.question === "function" ? step.question(outcome) : step.question;
638
+ const choice = await askStep(question, {
639
+ assumeYes,
640
+ canGoBack: earlier >= 0,
641
+ defaultYes: step.defaultYes ?? true,
642
+ });
643
+ if (choice === "back") {
644
+ // Drop the result we are about to redo, or the summary would report the
645
+ // stale outcome of a step the customer chose to revisit.
646
+ outcome.delete(steps[earlier].name);
647
+ i = earlier;
648
+ continue;
649
+ }
650
+ if (choice === "no") {
651
+ step.onSkip?.();
652
+ outcome.set(step.name, { state: "skipped" });
653
+ i += 1;
654
+ continue;
655
+ }
426
656
  }
427
657
 
428
658
  try {
429
- await step.run();
430
- outcome.set(step.name, { state: "done" });
659
+ // A step that caught its own failure and already explained it says so, and
660
+ // is not summarised as done; anything else that returns is taken as done.
661
+ const reported = await step.run();
662
+ outcome.set(step.name, { state: "done", ...(reported || {}) });
431
663
  } catch (err) {
432
664
  // Report and carry on: a failed Slack install must not cost the customer
433
665
  // the AWS connection they just finished.
@@ -451,13 +683,18 @@ export async function onboardCommand(
451
683
 
452
684
  export const INTEGRATIONS_HELP = `
453
685
  Setup options:
454
- preman onboard Create or sign in to an account, then install the
455
- PreMan app and open it signed in
686
+ preman onboard Sign in, map this repo's endpoints, connect it for
687
+ pull requests, and check the endpoints you touch on
688
+ every git push. Offers the desktop app at the end
456
689
  preman aws Connect an AWS account and stream a log group
457
690
  preman github Install the PreMan GitHub App
458
691
  preman slack Add PreMan to a Slack workspace
459
692
 
460
- --yes Accept every step without prompting (onboard)
693
+ --yes Take each step's default without prompting. The
694
+ desktop app defaults to no, so --yes never
695
+ downloads it (onboard)
696
+ --desktop Install the desktop app without asking (onboard)
697
+ --no-desktop Do not offer the desktop app at all (onboard)
461
698
  b at any onboard prompt Go back to the previous step
462
699
  --account <id> AWS account id, skips the prompt
463
700
  --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
+ }