@catalyst-cloud/cli 0.15.3 → 0.15.5

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.
@@ -1,12 +1,19 @@
1
1
  import { COMPUTER_CHECKS, standalonePlan, standalonePlanNotes, pendingContinuation, } from "./onboard-standalone-copy.js";
2
2
  import { trackedPrompt } from "./setup-prompt-frame.js";
3
3
  import { createOnboardInterrupts } from "./onboard-interrupts.js";
4
- import { setupStepView, CONNECTION_REPAIR_REASONS, setupFinalScreen, setupBrowserInstruction, setupCommandWords as say, setupPartHeading, setupPartProgress, setupTrackerRows, SETUP_PART_NUMBERS, SETUP_PART_OF, SETUP_PARTS, } from "./setup-onboard-copy.js";
4
+ import { setupStepView, CONNECTION_REPAIR_REASONS, setupFinalScreen, setupBrowserInstruction, setupCommandWords as say, setupWaitsOnAdmin, TIMED_OUT_REASONS, setupPartHeading, setupPartProgress, setupTrackerRows, SETUP_PART_NUMBERS, SETUP_PART_OF, SETUP_PARTS, } from "./setup-onboard-copy.js";
5
5
  import { createOnboardProgress, } from "./onboard-progress.js";
6
6
  import { CREATE_TEAM_CHOICE, suggestTeamKey, teamKeyProblem, teamNameProblem, } from "./onboard-team-create.js";
7
7
  import { FIRST_TICKET_SAMPLE, FIRST_TICKET_SKIP, } from "./onboard-first-ticket.js";
8
- import { ONBOARD_STEPS, ONBOARD_TITLES, stepSatisfied, onboardIdentityLines, onboardNextActions, onboardReadyForWork, onboardStepAction, } from "./onboard.js";
8
+ import { ONBOARD_STEPS, ONBOARD_TITLES, stepSatisfied, onboardHeaderIdentity, onboardIdentityLines, onboardNextActions, onboardReadyForWork, onboardStepAction, } from "./onboard.js";
9
9
  import { onboardStepDetail, onboardReasonText } from "./onboard-next.js";
10
+ import { CONNECT_PAGE_PATH } from "./onboard-checklist.js";
11
+ /** What the runner question's answers mean, said before it is asked (CTC-4739). */
12
+ const RUNNER_EXPLAINED = [
13
+ "Yes makes this computer pick up tickets and run agents in Docker with your workspace's AI accounts. It uses this computer's CPU and memory while it is on, and turning it off or letting it sleep pauses that work.",
14
+ "No is right for a personal workstation: your workspace's runner hosts do the work.",
15
+ "Pass --no-runner or --runner to skip this question next time.",
16
+ ];
10
17
  export function shouldUseOnboardUi(args, stdoutTty) {
11
18
  return (stdoutTty &&
12
19
  !args.json &&
@@ -43,6 +50,11 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
43
50
  let planned = {};
44
51
  let rechecking = false;
45
52
  let shownPlan;
53
+ // CTC-4680: steps whose wait was on the Connect accounts page. That page does not expire, so a
54
+ // wait that ran out is setup giving up, and the way back is the same page.
55
+ const connectWaits = new Map();
56
+ const onConnectPage = (step) => connectWaits.has(step.id) && TIMED_OUT_REASONS.has(step.reason ?? "");
57
+ const outcome = (step, view) => onConnectPage(step) ? "setup stopped waiting" : view.outcome;
46
58
  let browserOpened = true;
47
59
  let questionOpen = false;
48
60
  let signinShown = false;
@@ -98,11 +110,15 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
98
110
  renderer.heading(setupPartHeading(next, renderer.traits.unicode));
99
111
  return;
100
112
  }
101
- const progress = setupPartProgress(journalSeen, planned.scope);
102
- const left = progress[previous].total - progress[previous].done;
103
- renderer.heading(left
104
- ? `Part ${previous} has ${left === 1 ? "1 step" : `${left} steps`} left. Setup carries on with part ${next}.`
105
- : `Part ${previous} done. ${SETUP_PARTS[previous].done}`);
113
+ const progress = setupPartProgress(journalSeen, planned.scope, "run");
114
+ const got = progress[previous];
115
+ const left = got.total - got.done;
116
+ // Mid-run nothing yet says whether an owner or admin has done their part, so this is neutral.
117
+ renderer.heading(!left
118
+ ? `Part ${previous} done. ${SETUP_PARTS[previous].done}`
119
+ : left === got.admin + got.aside
120
+ ? `The rest of part ${previous} is for an owner or admin of your Catalyst workspace.`
121
+ : `Part ${previous} has ${left === 1 ? "1 step" : `${left} steps`} left. Setup carries on with part ${next}.`);
106
122
  renderer.tracker(setupTrackerRows(progress, { next }));
107
123
  planPart(renderer, next, journalSeen);
108
124
  renderer.blank();
@@ -221,10 +237,54 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
221
237
  return result.answer;
222
238
  };
223
239
  let summaryShown = false;
224
- const summary = (journal) => {
240
+ /**
241
+ * The end screen's actions, split by who acts: the person's own, and an owner's or admin's
242
+ * (CTC-4680). A step paused with Ctrl-C is not an action: the last line says how to carry on.
243
+ */
244
+ const sortActions = (journal) => {
225
245
  const screen = setupFinalScreen(journal, deps.baseUrl?.(), abort.signal.aborted);
246
+ const step = (id) => journal.steps.find((s) => s.id === id) ?? { id, state: "pending" };
247
+ const text = (action) => {
248
+ const row = step(action.id);
249
+ if (!onConnectPage(row))
250
+ return say(action.text);
251
+ const kinds = row.evidence?.aiAccountKinds;
252
+ // #277's rule: subscription wording only for a workspace the cloud enables for it.
253
+ const what = action.id === "accounts"
254
+ ? typeof kinds === "string" && kinds.split(",").includes("subscription")
255
+ ? "add an API key or connect a subscription"
256
+ : "add an API key"
257
+ : action.id === "github.install"
258
+ ? "install Catalyst on GitHub"
259
+ : `finish "${title(action.id)}" there`;
260
+ return `Open ${connectWaits.get(action.id)} and ${what}.`;
261
+ };
262
+ const live = screen.actions.filter((action) => step(action.id).reason !== "interrupted");
263
+ const theirs = live.filter((action) => setupWaitsOnAdmin(step(action.id), journal));
264
+ const mine = live.filter((action) => !theirs.includes(action));
265
+ // Steps only an owner or admin can do, less any already listed as their task above.
266
+ const skipped = journal.steps.filter((s) => s.reason === "member_scope" &&
267
+ setupWaitsOnAdmin(s, journal) &&
268
+ SETUP_PART_NUMBERS[s.id] !== undefined &&
269
+ s.id !== "projects" &&
270
+ !theirs.some((action) => action.id === s.id));
271
+ const waiting = theirs.length > 0 || skipped.length > 0;
272
+ const own = mine.length;
273
+ const next = screen.paused ||
274
+ !/^Next: run catalyst onboard after you finish/.test(screen.next)
275
+ ? say(screen.next)
276
+ : own
277
+ ? `Next: run catalyst setup after you finish ${own === 1 ? "1" : own === 2 ? "1 and 2" : `1 to ${own}`}.`
278
+ : waiting
279
+ ? "Next: run catalyst setup once they have."
280
+ : "Next: run catalyst setup to check again.";
281
+ return { screen, mine, theirs, skipped, waiting, next, text, live };
282
+ };
283
+ const summary = (journal) => {
284
+ const sorted = sortActions(journal);
285
+ const { screen, mine, theirs, skipped } = sorted;
226
286
  if (!renderer)
227
- return screen;
287
+ return sorted;
228
288
  renderer.suspendLive();
229
289
  active = false;
230
290
  renderer.heading(screen.heading);
@@ -233,12 +293,34 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
233
293
  renderer.tracker(setupTrackerRows(setupPartProgress(journal, planned.scope), "end"));
234
294
  if (screen.readinessCause)
235
295
  renderer.line(say(screen.readinessCause));
236
- if (screen.actions.length) {
237
- renderer.line(`${screen.actions.length} ${screen.actions.length === 1 ? "thing needs" : "things need"} you:`);
238
- screen.actions.forEach((action, i) => renderer.item(`${i + 1}. `, say(action.text)));
296
+ if (mine.length) {
297
+ renderer.line(`${mine.length} ${mine.length === 1 ? "thing needs" : "things need"} you:`);
298
+ mine.forEach((action, i) => renderer.item(`${i + 1}. `, sorted.text(action)));
239
299
  if (screen.more)
240
300
  renderer.line(`and ${screen.more} more after these`);
241
- const continuation = pendingContinuation(journal, new Set(screen.actions.map((action) => action.id)));
301
+ }
302
+ if (sorted.waiting) {
303
+ renderer.line("Waiting on an owner or admin of your Catalyst workspace:");
304
+ for (const action of theirs)
305
+ renderer.item("· ", sorted.text(action));
306
+ if (skipped.length) {
307
+ renderer.item("· ", `Steps only they can do: ${skipped.map((s) => title(s.id)).join(", ")}.`);
308
+ const base = deps.baseUrl?.();
309
+ const integrations = base
310
+ ? new URL("/settings/connections", base).href
311
+ : undefined;
312
+ if (integrations && !theirs.some((a) => a.text.includes(integrations)))
313
+ renderer.item("· ", `They start at ${integrations}, the Integrations page.`);
314
+ }
315
+ }
316
+ // A step skipped with Ctrl-C is no action, but it is still to come: the line below names it.
317
+ if (screen.actions.length || skipped.length) {
318
+ const continuation = pendingContinuation(journal,
319
+ // A step paused with Ctrl-C is still to come, so it stays in this line.
320
+ new Set([
321
+ ...sorted.live.map((action) => action.id),
322
+ ...skipped.map((s) => s.id),
323
+ ]));
242
324
  if (continuation)
243
325
  renderer.line(continuation);
244
326
  }
@@ -246,7 +328,7 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
246
328
  renderer.line("Catalyst is ready to work on your team's tickets.");
247
329
  if (deps.logPath?.())
248
330
  renderer.line(`Full log: ${deps.logPath()}`);
249
- return screen;
331
+ return sorted;
250
332
  };
251
333
  const ui = {
252
334
  signal: abort.signal,
@@ -264,14 +346,16 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
264
346
  machineChecks.clear();
265
347
  machineBlocked = false;
266
348
  stop();
349
+ // Signed in already, the header carries who and where, so the lines below would repeat it.
350
+ let identityShown = false;
267
351
  if (!introduced) {
268
352
  if (renderer)
269
- renderer.brand("setup", deps.version);
353
+ identityShown = renderer.brand("setup", deps.version, identity ? onboardHeaderIdentity(identity) : undefined);
270
354
  else
271
355
  prompts.intro("Catalyst setup", { output: streams.output });
272
356
  introduced = true;
273
357
  }
274
- if (identity !== undefined)
358
+ if (identity !== undefined && !identityShown)
275
359
  for (const line of onboardIdentityLines(identity))
276
360
  if (renderer)
277
361
  renderer.line(line);
@@ -465,16 +549,35 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
465
549
  ...(prompts.multiselect
466
550
  ? {
467
551
  async chooseRepositories(repositories) {
552
+ const projectsPage = () => {
553
+ try {
554
+ const base = deps.baseUrl?.();
555
+ return base ? ` (${new URL("/settings/projects", base).href})` : "";
556
+ }
557
+ catch {
558
+ return "";
559
+ }
560
+ };
468
561
  stop();
469
562
  if (!repositories.length || abort.signal.aborted)
470
563
  return null;
564
+ // CTC-4742: what the team already uses starts selected. Unselecting never removes one.
565
+ const selected = repositories
566
+ .filter((row) => row.registered === true)
567
+ .map((row) => `${row.owner}/${row.name}`);
471
568
  const answer = await multiselect({
472
569
  ...options,
473
- message: "Which repositories should use this Linear team?",
570
+ message: selected.length
571
+ ? `Which repositories should use this Linear team? Leaving a repository unselected does not remove it from this team; an admin removes one in Settings → Projects${projectsPage()}.`
572
+ : "Which repositories should use this Linear team?",
474
573
  required: true,
574
+ ...(selected.length ? { initialValues: selected } : {}),
475
575
  options: repositories.map((row) => ({
476
576
  value: `${row.owner}/${row.name}`,
477
577
  label: `${row.owner}/${row.name}`,
578
+ ...(row.registered === false
579
+ ? { hint: "not used by this team yet; selecting it adds it" }
580
+ : {}),
478
581
  })),
479
582
  });
480
583
  if (prompts.isCancel(answer)) {
@@ -558,9 +661,21 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
558
661
  return typeof answer === "string" ? answer : null;
559
662
  },
560
663
  async chooseRunner() {
561
- stop();
562
664
  if (abort.signal.aborted)
563
665
  return null;
666
+ // CTC-4739: say what each answer does to this computer before asking, and how to skip it.
667
+ stop();
668
+ if (renderer) {
669
+ renderer.begin(number("runner"), title("runner"), "", "ask");
670
+ for (const line of RUNNER_EXPLAINED)
671
+ renderer.detail(line);
672
+ active = true;
673
+ questionOpen = true;
674
+ }
675
+ else
676
+ prompts.log.message(RUNNER_EXPLAINED.join("\n"), {
677
+ output: streams.output,
678
+ });
564
679
  const answer = await select({
565
680
  ...options,
566
681
  message: "Run Catalyst's work on this machine?",
@@ -586,25 +701,35 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
586
701
  async retryTimedOut(id) {
587
702
  if (abort.signal.aborted)
588
703
  return false;
704
+ // The Connect accounts page does not expire: setup stopped waiting, and can wait again.
705
+ const page = connectWaits.has(id);
706
+ const why = page
707
+ ? "Setup stopped waiting for the Connect accounts page."
708
+ : "The link timed out.";
589
709
  if (renderer) {
590
710
  // The question draws under this row, so the reason stays on screen while it is asked.
591
711
  stop();
592
- renderer.begin(number(id), title(id), "the link timed out", "ask");
712
+ renderer.begin(number(id), title(id), page ? "setup stopped waiting" : "the link timed out", "ask");
713
+ if (page)
714
+ renderer.detail(why);
593
715
  active = true;
594
716
  questionOpen = true;
595
717
  }
596
718
  else {
597
719
  stop();
598
- prompts.log.message(`${title(id)}: the link timed out.`, {
720
+ prompts.log.message(`${title(id)}: ${why}`, {
599
721
  output: streams.output,
600
722
  });
601
723
  }
602
724
  const answer = await select({
603
725
  ...options,
604
- message: "Ready to try again?",
726
+ message: page ? "Keep waiting?" : "Ready to try again?",
605
727
  initialValue: "again",
606
728
  options: [
607
- { value: "again", label: "Yes, give me a new link" },
729
+ {
730
+ value: "again",
731
+ label: page ? "Yes, keep waiting" : "Yes, give me a new link",
732
+ },
608
733
  { value: "stop", label: "Stop here. Run catalyst setup later to carry on." },
609
734
  ],
610
735
  });
@@ -716,7 +841,7 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
716
841
  }
717
842
  if (step.id === "ready" || (hidden(step.id) && step.state !== "failed"))
718
843
  return;
719
- if ((step.id === "github.repos" || step.id === "settings") &&
844
+ if (step.id === "github.repos" &&
720
845
  step.state === "done")
721
846
  return;
722
847
  if (step.id === "projects" &&
@@ -725,7 +850,7 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
725
850
  const view = setupStepView(step, journal);
726
851
  if (hidden(step.id))
727
852
  enterPart(step.id);
728
- renderer.step(view.mark, view.number, view.title, view.outcome);
853
+ renderer.step(view.mark, view.number, view.title, outcome(step, view));
729
854
  const granted = deps.verbose ? onboardStepDetail(step) : undefined;
730
855
  if (granted)
731
856
  renderer.detail(granted);
@@ -772,7 +897,7 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
772
897
  if (!renderer ||
773
898
  !interactive ||
774
899
  abort.signal.aborted ||
775
- !setupFinalScreen(journal, deps.baseUrl?.()).actions.length)
900
+ !sortActions(journal).mine.length)
776
901
  return false;
777
902
  active = false;
778
903
  summary(journal);
@@ -819,15 +944,14 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
819
944
  }
820
945
  if (renderer) {
821
946
  active = false;
822
- const screen = summaryShown
823
- ? setupFinalScreen(journal, deps.baseUrl?.(), abort.signal.aborted)
824
- : summary(journal);
947
+ const sorted = summaryShown ? sortActions(journal) : summary(journal);
948
+ const { screen } = sorted;
825
949
  if (summaryShown && screen.paused) {
826
950
  renderer.heading(screen.heading);
827
951
  renderer.line("Your progress is saved.");
828
952
  }
829
953
  summaryShown = false;
830
- renderer.outro(say(screen.next));
954
+ renderer.outro(sorted.next);
831
955
  return;
832
956
  }
833
957
  stop();
@@ -852,6 +976,19 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
852
976
  prompts.outro(text, { output: streams.output });
853
977
  },
854
978
  async wait(text, run, page) {
979
+ if (currentStep && page) {
980
+ let path = "";
981
+ try {
982
+ path = new URL(page.url).pathname;
983
+ }
984
+ catch {
985
+ // Not a URL, so not the Connect accounts page.
986
+ }
987
+ if (path === CONNECT_PAGE_PATH)
988
+ connectWaits.set(currentStep, page.url);
989
+ else
990
+ connectWaits.delete(currentStep);
991
+ }
855
992
  const browser = currentStep && renderer && page
856
993
  ? page
857
994
  : currentStep &&
package/dist/onboard.js CHANGED
@@ -45,16 +45,25 @@ function dailyUpdateChoice(value) {
45
45
  return { state: "skipped", reason: "no_scheduler" };
46
46
  return undefined;
47
47
  }
48
+ const identityLabel = (text) => text
49
+ .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ")
50
+ .replace(/\s+/g, " ")
51
+ .trim()
52
+ .slice(0, 180);
53
+ /** CTC-4680: who and where for the lines beside setup's mark, "user: …" and "workspace: …". */
54
+ export function onboardHeaderIdentity(identity) {
55
+ const display = identity.display;
56
+ return {
57
+ user: identityLabel(display?.email || display?.personLabel || identity.membershipId),
58
+ workspace: identityLabel(display?.workspaceName || identity.account),
59
+ };
60
+ }
48
61
  export function onboardIdentityLines(identity) {
49
62
  if (!identity)
50
63
  return [
51
64
  "Use an existing Catalyst account or accept your invitation before approving sign-in.",
52
65
  ];
53
- const label = (text) => text
54
- .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ")
55
- .replace(/\s+/g, " ")
56
- .trim()
57
- .slice(0, 180);
66
+ const label = identityLabel;
58
67
  const display = identity.display;
59
68
  const person = label(display?.personLabel || identity.membershipId);
60
69
  const email = display?.email ? label(display.email) : "";
@@ -591,7 +600,7 @@ function printPlan(ctx, journal, identity, options) {
591
600
  ctx.stdout(line);
592
601
  let group;
593
602
  // Beside --json and headless output: the numbers JSON actions carry (CTC-4680).
594
- for (const row of standalonePlan(journal, options?.scope, options?.runner, "json")) {
603
+ for (const row of standalonePlan(journal, options?.scope, options?.runner, "json", options?.runnerDeclinedBy)) {
595
604
  if (row.group !== group) {
596
605
  ctx.stdout("");
597
606
  ctx.stdout(row.group);
@@ -788,7 +797,15 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
788
797
  for (const id of ONBOARD_STEPS)
789
798
  reviewedScope.add(id);
790
799
  const planOptions = (journal) => ({
791
- runner: args.flags.runner === true,
800
+ runner: args.flags.runner === true
801
+ ? true
802
+ : args.flags["no-runner"] === true
803
+ ? false
804
+ : undefined,
805
+ // Headless turns --runner no and CATALYST_ONBOARD_RUNNER=no into --no-runner; say which.
806
+ runnerDeclinedBy: args.flags.headless === true
807
+ ? "the headless runner input is no"
808
+ : "--no-runner was passed",
792
809
  localSync: args.flags["local-sync"] === true || journal.localSync === true,
793
810
  scope: ONBOARD_STEPS.filter((id) => reviewedScope.has(id)),
794
811
  });
@@ -0,0 +1,202 @@
1
+ /** Ring order runs from the top tip, round the back, to the bottom tip, as in the brand script. */
2
+ export const RING = [
3
+ [3, 0],
4
+ [2, 0],
5
+ [1, 0],
6
+ [0, 1],
7
+ [0, 2],
8
+ [1, 3],
9
+ [2, 3],
10
+ [3, 3],
11
+ ];
12
+ export const CORE = [
13
+ [1, 1],
14
+ [2, 1],
15
+ [1, 2],
16
+ [2, 2],
17
+ ];
18
+ export const MARK_SIZE = { dot: 3, gap: 1 };
19
+ export const markSpan = (size = MARK_SIZE) => 4 * size.dot + 3 * size.gap;
20
+ export const markLines = (size = MARK_SIZE) => Math.ceil(markSpan(size) / 2);
21
+ const paintOf = (col, row) => RING.some(([c, r]) => c === col && r === row)
22
+ ? "ring"
23
+ : CORE.some(([c, r]) => c === col && r === row)
24
+ ? "core"
25
+ : null;
26
+ /** The mark as a grid of square units, `markSpan` rows of half lines by `markSpan` columns. */
27
+ export function markUnits(size = MARK_SIZE) {
28
+ const span = markSpan(size);
29
+ const pitch = size.dot + size.gap;
30
+ return Array.from({ length: span }, (_, y) => Array.from({ length: span }, (_, x) => x % pitch >= size.dot || y % pitch >= size.dot
31
+ ? null
32
+ : paintOf(Math.floor(x / pitch), Math.floor(y / pitch))));
33
+ }
34
+ const BRAND = {
35
+ ink: "#11100e",
36
+ cream: "#f1ebe2",
37
+ copper: "#d28e63",
38
+ rust: "#a9512f",
39
+ };
40
+ /** The brand rules: a cream C and copper core on dark, an ink C and rust core on light. */
41
+ const PALETTES = {
42
+ dark: { ring: BRAND.cream, core: BRAND.copper },
43
+ light: { ring: BRAND.ink, core: BRAND.rust },
44
+ };
45
+ export function colourLevel(env, terminal) {
46
+ if (terminal.noColor)
47
+ return "none";
48
+ const force = env.FORCE_COLOR;
49
+ if (force !== undefined) {
50
+ if (force === "0" || force === "false")
51
+ return "none";
52
+ if (force === "" || force === "1" || force === "true")
53
+ return "16";
54
+ if (force === "2")
55
+ return "256";
56
+ if (force === "3")
57
+ return "truecolor";
58
+ }
59
+ if (env.NO_COLOR !== undefined || !terminal.tty)
60
+ return "none";
61
+ if (!env.TERM || env.TERM === "dumb")
62
+ return "none";
63
+ if (/^(truecolor|24bit)$/i.test(env.COLORTERM ?? ""))
64
+ return "truecolor";
65
+ return /256/.test(env.TERM) ? "256" : "16";
66
+ }
67
+ /** CI as most tools read it: set, and not "", "0" or "false". */
68
+ export function inCi(env) {
69
+ return ![undefined, "", "0", "false"].includes(env.CI);
70
+ }
71
+ export const MARK_MIN_COLUMNS = 60;
72
+ /**
73
+ * Why the mark is left off, or null to draw it. Off for every verb but setup, without a terminal,
74
+ * in CI, with --no-color or NO_COLOR or no colour at all, without UTF-8 (the blocks are UTF-8), and
75
+ * below 60 columns. The text lines are printed either way.
76
+ */
77
+ export function markSkipped(options) {
78
+ const { env } = options;
79
+ if (!options.setup)
80
+ return "not catalyst setup";
81
+ if (!options.tty)
82
+ return "not a terminal";
83
+ if (inCi(env))
84
+ return "CI";
85
+ if (options.noColor)
86
+ return "--no-color";
87
+ if (env.NO_COLOR !== undefined)
88
+ return "NO_COLOR";
89
+ if (colourLevel(env, options) === "none")
90
+ return "no colour";
91
+ if (!/utf-?8/i.test(env.LC_ALL || env.LC_CTYPE || env.LANG || ""))
92
+ return "not UTF-8";
93
+ if (options.columns < MARK_MIN_COLUMNS)
94
+ return "narrow";
95
+ return null;
96
+ }
97
+ const rgb = (hex) => [
98
+ Number.parseInt(hex.slice(1, 3), 16),
99
+ Number.parseInt(hex.slice(3, 5), 16),
100
+ Number.parseInt(hex.slice(5, 7), 16),
101
+ ];
102
+ const distance = (a, b) => (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2;
103
+ /** The nearest xterm-256 colour: the 6×6×6 cube or the 24-step grey ramp, whichever is closer. */
104
+ export function nearest256(hex) {
105
+ const colour = rgb(hex);
106
+ const levels = [0, 95, 135, 175, 215, 255];
107
+ const step = (v) => levels.reduce((best, l, i) => (Math.abs(l - v) < Math.abs(levels[best] - v) ? i : best), 0);
108
+ const [ri, gi, bi] = colour.map(step);
109
+ const avg = (colour[0] + colour[1] + colour[2]) / 3;
110
+ const greyIndex = Math.min(23, Math.max(0, Math.round((avg - 8) / 10)));
111
+ const grey = 8 + 10 * greyIndex;
112
+ return distance([levels[ri], levels[gi], levels[bi]], colour) <=
113
+ distance([grey, grey, grey], colour)
114
+ ? 16 + 36 * ri + 6 * gi + bi
115
+ : 232 + greyIndex;
116
+ }
117
+ /** xterm's default 16 colours, as SGR foreground codes. */
118
+ const XTERM16 = [
119
+ [30, [0, 0, 0]],
120
+ [31, [205, 0, 0]],
121
+ [32, [0, 205, 0]],
122
+ [33, [205, 205, 0]],
123
+ [34, [0, 0, 238]],
124
+ [35, [205, 0, 205]],
125
+ [36, [0, 205, 205]],
126
+ [37, [229, 229, 229]],
127
+ [90, [127, 127, 127]],
128
+ [91, [255, 0, 0]],
129
+ [92, [0, 255, 0]],
130
+ [93, [255, 255, 0]],
131
+ [94, [92, 92, 255]],
132
+ [95, [255, 0, 255]],
133
+ [96, [0, 255, 255]],
134
+ [97, [255, 255, 255]],
135
+ ];
136
+ /**
137
+ * The nearest of xterm's 16 default colours. Plain distance puts copper nearest grey (90), and the
138
+ * core is the mark's one accent, so the core picks from the coloured entries only: copper is then
139
+ * yellow (33) and rust red (31). The ring takes plain distance: cream is white (37), ink black (30).
140
+ */
141
+ export function nearest16(hex, accent = false) {
142
+ const colour = rgb(hex);
143
+ const greys = [30, 37, 90, 97];
144
+ return XTERM16.filter(([code]) => !accent || !greys.includes(code)).reduce((best, entry) => distance(entry[1], colour) < distance(best[1], colour) ? entry : best)[0];
145
+ }
146
+ /**
147
+ * The SGR parameters for one of the mark's colours on a `theme` background, as a foreground or a
148
+ * background. With 16 colours the core is also bold, which most terminals draw brighter.
149
+ */
150
+ export function markColour(paint, theme, level, layer = "fg") {
151
+ const hex = PALETTES[theme][paint];
152
+ const lead = layer === "fg" ? 38 : 48;
153
+ if (level === "truecolor")
154
+ return `${lead};2;${rgb(hex).join(";")}`;
155
+ if (level === "256")
156
+ return `${lead};5;${nearest256(hex)}`;
157
+ const code = nearest16(hex, paint === "core") + (layer === "fg" ? 0 : 10);
158
+ return paint === "core" && layer === "fg" ? `1;${code}` : String(code);
159
+ }
160
+ /**
161
+ * Units to terminal lines, two unit rows per line: both halves set is a full block (or an upper
162
+ * half block on the lower colour's background when they differ), one half is a half block, neither
163
+ * is a space. Each run of one glyph colour is closed with a reset.
164
+ */
165
+ export function unitLines(units, theme, level) {
166
+ const lines = [];
167
+ for (let y = 0; y < units.length; y += 2) {
168
+ const upper = units[y];
169
+ const lower = units[y + 1] ?? [];
170
+ let out = "";
171
+ let open = "";
172
+ for (let x = 0; x < upper.length; x++) {
173
+ const [top, bottom] = [upper[x] ?? null, lower[x] ?? null];
174
+ const sgr = top && bottom && top !== bottom
175
+ ? `${markColour(top, theme, level)};${markColour(bottom, theme, level, "bg")}`
176
+ : top || bottom
177
+ ? markColour((top ?? bottom), theme, level)
178
+ : "";
179
+ if (sgr !== open) {
180
+ if (open)
181
+ out += "\u001b[0m";
182
+ if (sgr)
183
+ out += `\u001b[${sgr}m`;
184
+ open = sgr;
185
+ }
186
+ out +=
187
+ !top && !bottom
188
+ ? " "
189
+ : top && bottom && top === bottom
190
+ ? "█"
191
+ : top
192
+ ? "▀"
193
+ : "▄";
194
+ }
195
+ lines.push(open ? `${out}\u001b[0m` : out);
196
+ }
197
+ return lines;
198
+ }
199
+ /** The mark, one string per terminal line: `markLines` lines of `markSpan` columns. */
200
+ export function pixelNucleusLines(theme, level, size = MARK_SIZE) {
201
+ return unitLines(markUnits(size), theme, level);
202
+ }