@catalyst-cloud/cli 0.15.2 → 0.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +4 -2
  3. package/dist/args.js +5 -1
  4. package/dist/cli.js +44 -20
  5. package/dist/oauth.js +1 -1
  6. package/dist/onboard-accounts.js +67 -171
  7. package/dist/onboard-checklist.js +310 -0
  8. package/dist/onboard-existing.js +1 -1
  9. package/dist/onboard-first-ticket.js +199 -0
  10. package/dist/onboard-headless.js +2 -2
  11. package/dist/onboard-housekeeping.js +93 -0
  12. package/dist/onboard-next.js +108 -9
  13. package/dist/onboard-ready.js +61 -0
  14. package/dist/onboard-runtime.js +60 -27
  15. package/dist/onboard-standalone-copy.js +73 -66
  16. package/dist/onboard-ui.js +278 -105
  17. package/dist/onboard-values.js +47 -0
  18. package/dist/onboard.js +63 -18
  19. package/dist/ready-copy.js +87 -0
  20. package/dist/ready.js +1 -57
  21. package/dist/setup-brand.js +202 -0
  22. package/dist/setup-onboard-copy.js +314 -42
  23. package/dist/setup-render.js +99 -17
  24. package/dist/setup.js +6 -3
  25. package/dist/terminal-background.js +220 -0
  26. package/package.json +1 -1
  27. package/skills/catalyst-github/SKILL.md +1 -1
  28. package/skills/catalyst-linear/SKILL.md +1 -1
  29. package/skills/catalyst-onboard/SKILL.md +3 -3
  30. package/skills/catalyst-onboard/references/replacing-a-credential.md +9 -15
  31. package/skills/catalyst-onboard/references/what-a-phase-needs.md +4 -11
  32. package/skills/catalyst-onboard/scripts/where-am-i.mjs +1 -1
  33. package/skills/run-this-project/SKILL.md +2 -2
  34. package/skills/unstick/SKILL.md +1 -1
  35. package/skills/what-needs-me/SKILL.md +1 -1
  36. package/skills/what-this-repo-needs/SKILL.md +1 -1
  37. package/skills/whats-happening/SKILL.md +2 -2
  38. package/skills/whats-happening/references/coding-accounts.md +1 -1
  39. package/skills/whats-happening/scripts/snapshot.mjs +1 -1
@@ -0,0 +1,47 @@
1
+ import { loadConfig } from "./config.js";
2
+ import { loadContract } from "./contract.js";
3
+ import { selectedOnboardTeam } from "./onboard-existing.js";
4
+ import { valuesFacts } from "./ready-copy.js";
5
+ const waiting = (reason, evidence) => ({
6
+ state: "waiting",
7
+ reason,
8
+ ...(evidence ? { evidence } : {}),
9
+ });
10
+ /** The selected team's `required_values` readiness check decides this step. It passes when the
11
+ * selected repositories declare no required value, or every one has a value. Nothing here reads,
12
+ * imports or prints a value: a missing one is named, with the page where it is set. A stale, absent
13
+ * or unknown check waits, so the step never claims values it could not see. */
14
+ export function onboardValuesAdapter() {
15
+ return {
16
+ check: async (ctx, journal, signal) => {
17
+ const cfg = loadConfig(ctx.home);
18
+ const teamId = selectedOnboardTeam(journal);
19
+ if (!cfg || !teamId)
20
+ return waiting("required_values_unverified");
21
+ let loaded;
22
+ try {
23
+ loaded = await loadContract(ctx, cfg, { refresh: true, signal });
24
+ }
25
+ catch {
26
+ return waiting(signal?.aborted ? "interrupted" : "required_values_unverified");
27
+ }
28
+ if (loaded.source === "cache")
29
+ return waiting("required_values_unverified");
30
+ const readiness = loaded.doc.teams.find((team) => team.id === teamId)?.readiness;
31
+ const fresh = readiness?.checkedAt != null &&
32
+ readiness.expiresAt != null &&
33
+ readiness.expiresAt > ctx.now().getTime();
34
+ const check = fresh
35
+ ? readiness.checks.find((row) => row.id === "required_values")
36
+ : undefined;
37
+ if (check?.state === "pass")
38
+ return { state: "done", evidence: { team: teamId } };
39
+ if (check?.state !== "fail")
40
+ return waiting("required_values_unverified");
41
+ return waiting("required_values_missing", {
42
+ team: teamId,
43
+ requiredValues: JSON.stringify(valuesFacts(check)),
44
+ });
45
+ },
46
+ };
47
+ }
package/dist/onboard.js CHANGED
@@ -10,7 +10,7 @@ import { onboardJsonView, TIMED_OUT_REASONS } from "./setup-onboard-copy.js";
10
10
  import { standalonePlan, standalonePlanNotes, } from "./onboard-standalone-copy.js";
11
11
  import { CliError, UsageError } from "./errors.js";
12
12
  import { onboardFileSnapshot } from "./onboard-file-snapshot.js";
13
- import { onboardReasonText, onboardTeamKey, savedOnboardBaseUrl, } from "./onboard-next.js";
13
+ import { dispatchStageName, onboardReasonText, onboardTeamKey, savedOnboardBaseUrl, } from "./onboard-next.js";
14
14
  import { parseBootstrapPlan, bootstrapPlanHash, bootstrapPlanLines, } from "./onboard-bootstrap.js";
15
15
  export const ONBOARD_STEPS = [
16
16
  "machine",
@@ -37,16 +37,33 @@ export const ONBOARD_STEPS = [
37
37
  "first-ticket",
38
38
  "ready",
39
39
  ];
40
+ function dailyUpdateChoice(value) {
41
+ const row = object(value);
42
+ if (row?.state === "on" || row?.state === "off")
43
+ return { state: row.state };
44
+ if (row?.state === "skipped" && row.reason === "no_scheduler")
45
+ return { state: "skipped", reason: "no_scheduler" };
46
+ return undefined;
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
+ }
40
61
  export function onboardIdentityLines(identity) {
41
62
  if (!identity)
42
63
  return [
43
64
  "Use an existing Catalyst account or accept your invitation before approving sign-in.",
44
65
  ];
45
- const label = (text) => text
46
- .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ")
47
- .replace(/\s+/g, " ")
48
- .trim()
49
- .slice(0, 180);
66
+ const label = identityLabel;
50
67
  const display = identity.display;
51
68
  const person = label(display?.personLabel || identity.membershipId);
52
69
  const email = display?.email ? label(display.email) : "";
@@ -104,7 +121,8 @@ export const ONBOARD_DEPENDENCIES = {
104
121
  projects: ["linear.team", "github.repos"],
105
122
  accounts: ["signin"],
106
123
  settings: ["projects"],
107
- values: ["settings"],
124
+ // The cloud's required-values check reads the registered repositories, not a local checkout.
125
+ values: ["projects"],
108
126
  capacity: ["projects"],
109
127
  // The join token names the selected team's pool.
110
128
  runner: ["linear.team"],
@@ -127,7 +145,7 @@ const ADMIN_STEPS = new Set([
127
145
  "github.repos",
128
146
  "projects",
129
147
  "settings",
130
- "values",
148
+ // `values` is not here: its check only reads, so a member's run finds missing values too.
131
149
  "capacity",
132
150
  "runner",
133
151
  "first-ticket",
@@ -147,9 +165,9 @@ export const ONBOARD_TITLES = {
147
165
  "github.personal": "Connect your GitHub account",
148
166
  "github.repos": "Choose repositories",
149
167
  projects: "Register your projects",
150
- accounts: "Check coding accounts",
168
+ accounts: "Check AI accounts",
151
169
  settings: "Review repository settings",
152
- values: "Import selected local values",
170
+ values: "Check the repository's values",
153
171
  capacity: "Check runner capacity",
154
172
  runner: "Run Catalyst's work on this machine",
155
173
  daemon: "Check optional local sync",
@@ -251,6 +269,10 @@ function normalizeStep(value, fallbackAt) {
251
269
  "repository",
252
270
  "installation",
253
271
  "ticket",
272
+ // The team's dispatch stage name, as the contract gave it.
273
+ "stage",
274
+ // Admin-owned step ids a member's run found unfinished.
275
+ "admin",
254
276
  "cursor",
255
277
  "lag",
256
278
  "heartbeatAgeMs",
@@ -288,6 +310,8 @@ function normalizeStep(value, fallbackAt) {
288
310
  "org",
289
311
  "url",
290
312
  "actor",
313
+ // CTC-4716: which AI accounts the workspace may add, so the step's text offers only those.
314
+ "aiAccountKinds",
291
315
  ]) {
292
316
  const item = evidence?.[key];
293
317
  if (item === null ||
@@ -323,6 +347,7 @@ export function readOnboardJournal(path, cliVersion = "0.13.1", now = new Date()
323
347
  : typeof value.exitCode === "number"
324
348
  ? value.exitCode
325
349
  : null;
350
+ const dailyUpdate = dailyUpdateChoice(value.dailyUpdate);
326
351
  return {
327
352
  schema: 1,
328
353
  runId: typeof value.runId === "string" ? value.runId : randomUUID(),
@@ -354,6 +379,7 @@ export function readOnboardJournal(path, cliVersion = "0.13.1", now = new Date()
354
379
  ...(typeof value.localSync === "boolean"
355
380
  ? { localSync: value.localSync }
356
381
  : {}),
382
+ ...(dailyUpdate ? { dailyUpdate } : {}),
357
383
  operations: Object.fromEntries(Object.entries(object(value.operations) ?? {}).filter(([key, v]) => ONBOARD_STEPS.includes(key) &&
358
384
  typeof v === "string" &&
359
385
  /^[a-zA-Z0-9_.:-]{1,160}$/.test(v))),
@@ -573,7 +599,8 @@ function printPlan(ctx, journal, identity, options) {
573
599
  for (const line of onboardIdentityLines(identity))
574
600
  ctx.stdout(line);
575
601
  let group;
576
- for (const row of standalonePlan(journal, options?.scope, options?.runner)) {
602
+ // Beside --json and headless output: the numbers JSON actions carry (CTC-4680).
603
+ for (const row of standalonePlan(journal, options?.scope, options?.runner, "json")) {
577
604
  if (row.group !== group) {
578
605
  ctx.stdout("");
579
606
  ctx.stdout(row.group);
@@ -629,14 +656,28 @@ export function stepSatisfied(step) {
629
656
  return (step?.state === "done" ||
630
657
  (step?.state === "skipped" &&
631
658
  (step.id === "legacy" ||
659
+ // Optional: a computer without the daily update still runs work.
660
+ step.id === "housekeeping" ||
632
661
  step.reason === "member_scope" ||
633
662
  (step.id === "daemon" && step.reason === "local_sync_not_selected") ||
634
663
  (step.id === "runner" && step.reason === "runner_not_selected") ||
664
+ (step.id === "first-ticket" && step.reason === "first_ticket_skipped") ||
635
665
  (step.id === "linear.automations" &&
636
666
  step.reason === "automation_management_unavailable"))));
637
667
  }
668
+ /** Optional work this computer or checkout cannot do. Work runs without it, so its wait never holds
669
+ * a complete setup. */
670
+ const OPTIONAL_WAITS = new Set([
671
+ "settings_checkout_unverified",
672
+ "settings_approval_unverified",
673
+ ]);
674
+ /** Satisfied, or an optional wait. What a complete setup needs of every step. */
675
+ export function stepSettled(step) {
676
+ return (stepSatisfied(step) ||
677
+ (step?.state === "waiting" && OPTIONAL_WAITS.has(step.reason ?? "")));
678
+ }
638
679
  /** Steps whose wait does not hold a full run's exit code: work runs without them. A failure still
639
- * fails the run, and the receipt's `complete` still needs them satisfied. Never used for gating. */
680
+ * fails the run, and the receipt's `complete` still needs them settled. Never used for gating. */
640
681
  export const ONBOARD_DEFERRED_STEPS = new Set([
641
682
  "settings",
642
683
  "values",
@@ -677,8 +718,11 @@ export function onboardNextActions(journal, baseUrl, only) {
677
718
  .filter((step) => (!only || step.id === only) && !stepSatisfied(step))
678
719
  .sort((a, b) => ONBOARD_STEPS.indexOf(a.id) - ONBOARD_STEPS.indexOf(b.id))
679
720
  .map((step) => `${ONBOARD_TITLES[step.id]}: ${onboardStepAction(journal, step, baseUrl)}`);
680
- if (["projects", "accounts", "linear.adopt", "capacity"].every((id) => journalStep(journal, id)?.state === "done"))
681
- lines.push(`Move a ticket in ${onboardTeamKey(journal) ?? "<TEAM KEY>"} to Todo; \`catalyst explain <ticket>\` says why it is or is not starting.`);
721
+ // A waiting first ticket already says to move a ticket, in its own line.
722
+ const firstTicket = journalStep(journal, "first-ticket");
723
+ if (["projects", "accounts", "linear.adopt", "capacity"].every((id) => journalStep(journal, id)?.state === "done") &&
724
+ firstTicket?.state !== "waiting")
725
+ lines.push(`Move a ticket in ${onboardTeamKey(journal) ?? "<TEAM KEY>"} to ${dispatchStageName(firstTicket?.evidence?.stage)}; \`catalyst explain <ticket>\` says why it is or is not starting.`);
682
726
  return lines;
683
727
  }
684
728
  function requireMatchingIdentity(journal, identity) {
@@ -727,9 +771,10 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
727
771
  const only = typeof args.flags.only === "string"
728
772
  ? args.flags.only
729
773
  : undefined;
774
+ // A member's values check reads the selected team, so without --team it waits on the same skip.
730
775
  const memberScopeSkips = (id) => ADMIN_STEPS.has(id) ||
731
- (id === "linear.team" &&
732
- only !== "linear.team" &&
776
+ ((id === "linear.team" || id === "values") &&
777
+ only !== id &&
733
778
  typeof args.flags.team !== "string");
734
779
  const resume = typeof args.flags["resume-from"] === "string"
735
780
  ? args.flags["resume-from"]
@@ -1069,7 +1114,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
1069
1114
  journal.complete =
1070
1115
  !only &&
1071
1116
  code === 0 &&
1072
- ONBOARD_STEPS.every((id) => stepSatisfied(journalStep(journal, id)));
1117
+ ONBOARD_STEPS.every((id) => stepSettled(journalStep(journal, id)));
1073
1118
  writeOnboardJournal(statePath, journal);
1074
1119
  if (args.json)
1075
1120
  ctx.stdout(JSON.stringify(journal));
@@ -1442,7 +1487,7 @@ export async function cmdOnboard(args, ctx, deps = {}, cliVersion = "0.13.1") {
1442
1487
  journal.complete =
1443
1488
  !only &&
1444
1489
  code === 0 &&
1445
- ONBOARD_STEPS.every((id) => stepSatisfied(journalStep(journal, id)));
1490
+ ONBOARD_STEPS.every((id) => stepSettled(journalStep(journal, id)));
1446
1491
  const unfinished = journal.steps.filter((step) => !stepSatisfied(step));
1447
1492
  if (!only &&
1448
1493
  !refused &&
@@ -65,6 +65,93 @@ function slotList(slots) {
65
65
  function origin(baseUrl) {
66
66
  return baseUrl.replace(/\/+$/, "");
67
67
  }
68
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
69
+ const REPOSITORY = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
70
+ const identifiers = (value) => Array.isArray(value)
71
+ ? value.filter((name) => typeof name === "string" && NAME.test(name))
72
+ : [];
73
+ const unresolvedFacts = (value) => Array.isArray(value)
74
+ ? value.flatMap((row) => typeof row?.name === "string" && NAME.test(row.name) && identifiers(row.references).length
75
+ ? [{ name: row.name, references: identifiers(row.references) }]
76
+ : [])
77
+ : [];
78
+ /** The declared names a failing check carries, kept in the receipt for the fix line: which variables
79
+ * need a value, which refer to a secret with no value, and the same for the team's other
80
+ * repositories. Identifiers and repository names only. */
81
+ export function valuesFacts(value) {
82
+ const row = (value ?? {});
83
+ return {
84
+ names: identifiers(row.names),
85
+ unresolved: unresolvedFacts(row.unresolved),
86
+ repos: Array.isArray(row.repos)
87
+ ? row.repos.flatMap((note) => typeof note?.repo === "string" && REPOSITORY.test(note.repo)
88
+ ? [{ repo: note.repo, names: identifiers(note.names), unresolved: unresolvedFacts(note.unresolved) }]
89
+ : [])
90
+ : [],
91
+ };
92
+ }
93
+ /** Where the named variables get their values. Names only: nothing here reads or prints a value. */
94
+ function setValuesFix(baseUrl, names) {
95
+ return baseUrl
96
+ ? `set ${names.join(", ")} on the repository's Environment page: open ${origin(baseUrl)}/settings/projects, then the project, then the repository`
97
+ : `set ${names.join(", ")} on the repository's Environment page in Catalyst's project settings`;
98
+ }
99
+ function nameList(v) {
100
+ return Array.isArray(v)
101
+ ? v.filter((n) => typeof n === "string" && n.length > 0)
102
+ : [];
103
+ }
104
+ /** Entries whose name and references are both present; anything malformed is dropped, not guessed. */
105
+ function unresolvedList(v) {
106
+ if (!Array.isArray(v))
107
+ return [];
108
+ return v.flatMap((u) => {
109
+ if (typeof u !== "object" || u === null)
110
+ return [];
111
+ const { name, references } = u;
112
+ const refs = nameList(references);
113
+ return typeof name === "string" && name.length > 0 && refs.length > 0
114
+ ? [{ name, references: refs }]
115
+ : [];
116
+ });
117
+ }
118
+ function unresolvedLine(u) {
119
+ const which = u.references.length === 1 ? "which has" : "which have";
120
+ return `${u.name} refers to ${u.references.join(", ")}, ${which} no value, so Catalyst can't start work there`;
121
+ }
122
+ /** A team check's fix line. A check that carries `names` (CTC-3561: `required_values`, contract
123
+ * 1.24.0) names them and where to set them. CTC-3606: it also names each `unresolved` variable and
124
+ * the reference with no value, and each other repository's missing names from `repos[]`. `unresolved`
125
+ * is a subset of `names` (the variable exists; its reference does not), so those are not told to be
126
+ * "set". Every field is optional so an older cloud still works. Everything printed is a declared
127
+ * identifier or a repository name, never a value, and nothing here reads a value. Any other check
128
+ * keeps the generic line. Shared with setup's values step, which has no web address when the
129
+ * saved login has none. */
130
+ export function valuesFix(base, c) {
131
+ const unresolved = unresolvedList(c.unresolved);
132
+ const unresolvedNames = new Set(unresolved.map((u) => u.name));
133
+ const names = nameList(c.names).filter((n) => !unresolvedNames.has(n));
134
+ const parts = [];
135
+ if (names.length > 0)
136
+ parts.push(setValuesFix(base, names));
137
+ for (const u of unresolved)
138
+ parts.push(unresolvedLine(u));
139
+ for (const note of Array.isArray(c.repos) ? c.repos : []) {
140
+ if (typeof note !== "object" ||
141
+ note === null ||
142
+ typeof note.repo !== "string" ||
143
+ note.repo.length === 0)
144
+ continue;
145
+ const repoUnresolved = unresolvedList(note.unresolved);
146
+ const skip = new Set(repoUnresolved.map((u) => u.name));
147
+ const missing = nameList(note.names).filter((n) => !skip.has(n));
148
+ if (missing.length > 0)
149
+ parts.push(`${note.repo} is missing ${missing.join(", ")}`);
150
+ for (const u of repoUnresolved)
151
+ parts.push(`in ${note.repo}, ${unresolvedLine(u)}`);
152
+ }
153
+ return parts.length === 0 ? null : parts.join(". ");
154
+ }
68
155
  /** The team's own page in Catalyst settings, optionally one of its sub-pages (`map`, `adopt`). */
69
156
  export function teamPage(baseUrl, key, sub) {
70
157
  return `${origin(baseUrl)}/settings/linear-teams/${encodeURIComponent(key)}${sub ? `/${sub}` : ""}`;
package/dist/ready.js CHANGED
@@ -15,7 +15,7 @@ import { FIRST_STAMPED_VERSION } from "./skill-shape.js";
15
15
  import { installedBundleVersion } from "./skills.js";
16
16
  import { onboardingReadyReport, observeCloudOnboarding, } from "./onboard-ready.js";
17
17
  import { selectedOnboardTeam } from "./onboard-existing.js";
18
- import { dispatchGateCopy, teamCheckCopy, teamPage, whoFixes } from "./ready-copy.js";
18
+ import { dispatchGateCopy, teamCheckCopy, teamPage, valuesFix, whoFixes } from "./ready-copy.js";
19
19
  import { onboardStatePath, readOnboardJournal } from "./onboard.js";
20
20
  export { semverOlder };
21
21
  /** `ready`'s own wording for the replica. It never recommends adopting the replica — until the
@@ -112,62 +112,6 @@ export function catalystCommandCheck(env, platform = process.platform) {
112
112
  "Keep using catalyst-skills on this machine, or put the npm global bin directory ahead of that one on PATH.",
113
113
  };
114
114
  }
115
- function nameList(v) {
116
- return Array.isArray(v)
117
- ? v.filter((n) => typeof n === "string" && n.length > 0)
118
- : [];
119
- }
120
- /** Entries whose name and references are both present; anything malformed is dropped, not guessed. */
121
- function unresolvedList(v) {
122
- if (!Array.isArray(v))
123
- return [];
124
- return v.flatMap((u) => {
125
- if (typeof u !== "object" || u === null)
126
- return [];
127
- const { name, references } = u;
128
- const refs = nameList(references);
129
- return typeof name === "string" && name.length > 0 && refs.length > 0
130
- ? [{ name, references: refs }]
131
- : [];
132
- });
133
- }
134
- function unresolvedLine(u) {
135
- const which = u.references.length === 1 ? "which has" : "which have";
136
- return `${u.name} refers to ${u.references.join(", ")}, ${which} no value, so Catalyst can't start work there`;
137
- }
138
- /** A team check's fix line. A check that carries `names` (CTC-3561: `required_values`, contract
139
- * 1.24.0) names them and where to set them. CTC-3606: it also names each `unresolved` variable and
140
- * the reference with no value, and each other repository's missing names from `repos[]`. `unresolved`
141
- * is a subset of `names` (the variable exists; its reference does not), so those are not told to be
142
- * "set". Every field is optional so an older cloud still works. Everything printed is a declared
143
- * identifier or a repository name, never a value, and nothing here reads a value. Any other check
144
- * keeps the generic line. */
145
- function valuesFix(base, c) {
146
- const unresolved = unresolvedList(c.unresolved);
147
- const unresolvedNames = new Set(unresolved.map((u) => u.name));
148
- const names = nameList(c.names).filter((n) => !unresolvedNames.has(n));
149
- const parts = [];
150
- if (names.length > 0) {
151
- parts.push(`set ${names.join(", ")} on the repository's Environment page: open ${base}/settings/projects, then the project, then the repository`);
152
- }
153
- for (const u of unresolved)
154
- parts.push(unresolvedLine(u));
155
- for (const note of Array.isArray(c.repos) ? c.repos : []) {
156
- if (typeof note !== "object" ||
157
- note === null ||
158
- typeof note.repo !== "string" ||
159
- note.repo.length === 0)
160
- continue;
161
- const repoUnresolved = unresolvedList(note.unresolved);
162
- const skip = new Set(repoUnresolved.map((u) => u.name));
163
- const missing = nameList(note.names).filter((n) => !skip.has(n));
164
- if (missing.length > 0)
165
- parts.push(`${note.repo} is missing ${missing.join(", ")}`);
166
- for (const u of repoUnresolved)
167
- parts.push(`in ${note.repo}, ${unresolvedLine(u)}`);
168
- }
169
- return parts.length === 0 ? null : parts.join(". ");
170
- }
171
115
  export async function readyReport(ctx, deps) {
172
116
  const checks = [];
173
117
  const facts = deps.runtime ?? detectRuntime();
@@ -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
+ }