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.
- package/README.md +42 -6
- package/bin/account.js +5 -0
- package/bin/cli.js +3 -2
- package/bin/connect/guide.js +106 -24
- package/bin/connect.js +8 -0
- package/bin/desktop.js +47 -14
- package/bin/detect.js +59 -3
- package/bin/hook.js +12 -6
- package/bin/integrations.js +306 -57
- package/bin/link.js +207 -0
- package/bin/repo.js +193 -0
- package/bin/shared.js +76 -13
- package/bin/status.js +35 -0
- package/bin/verify.js +125 -8
- package/dist/server.js +192 -3
- package/package.json +2 -2
package/bin/integrations.js
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
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
|
-
`
|
|
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
|
|
300
|
-
` -
|
|
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
|
-
/**
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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 === ""
|
|
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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(
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
430
|
-
|
|
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
|
|
455
|
-
|
|
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
|
|
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
|
+
}
|