@ajaykumarnpm/talea 0.1.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.
package/src/github.js ADDED
@@ -0,0 +1,208 @@
1
+ // Talking to GitHub.
2
+ //
3
+ // Two calls and no SDK: list the repos an account can see, and read/write a
4
+ // gist. `fetch` is in Node 20, which is the floor this package already sets, so
5
+ // the dependency count stays at zero.
6
+ //
7
+ // The token is whatever the machine already has. `gh auth token` first, because
8
+ // anyone who clones private repos over SSH almost certainly has the GitHub CLI
9
+ // logged in and that saves inventing a second credential to keep in sync.
10
+
11
+ import { spawnSync } from 'node:child_process';
12
+
13
+ const API = 'https://api.github.com';
14
+
15
+ /**
16
+ * A token, and where it came from.
17
+ *
18
+ * No token is a usable state, not an error: the public repos of a named user
19
+ * are enough to build a catalogue with, and saying "public only" is better than
20
+ * demanding a credential for a read anyone can do.
21
+ */
22
+ export function token() {
23
+ if (process.env.GITHUB_TOKEN) return { token: process.env.GITHUB_TOKEN, from: 'GITHUB_TOKEN' };
24
+ if (process.env.GH_TOKEN) return { token: process.env.GH_TOKEN, from: 'GH_TOKEN' };
25
+
26
+ const res = spawnSync('gh', ['auth', 'token'], { encoding: 'utf8', shell: false });
27
+ const out = res.stdout?.trim();
28
+ if (res.status === 0 && out) return { token: out, from: 'gh auth token' };
29
+
30
+ return { token: null, from: null };
31
+ }
32
+
33
+ /**
34
+ * Is the `gh` CLI on this machine?
35
+ *
36
+ * Worth knowing beyond the token: `gh` is a Go binary that trusts the system
37
+ * certificate store, and Node's `fetch` trusts a CA list compiled into Node.
38
+ * On any machine behind TLS interception — a corporate proxy, a VPN, a
39
+ * security agent — `curl` and `gh` work and `fetch` fails with
40
+ * UNABLE_TO_GET_ISSUER_CERT_LOCALLY. Preferring `gh` when it is there means
41
+ * that machine works with no configuration at all.
42
+ */
43
+ let ghPresent = null;
44
+ export function haveGh() {
45
+ if (ghPresent === null) {
46
+ ghPresent = spawnSync('gh', ['--version'], { encoding: 'utf8', shell: false }).status === 0;
47
+ }
48
+ return ghPresent;
49
+ }
50
+
51
+ function viaGh(pathname, { method = 'GET', body, paginate } = {}) {
52
+ const args = ['api', pathname.replace(`${API}`, ''), '-X', method];
53
+ // --slurp because without it `--paginate` prints each page as its own JSON
54
+ // document, and stitching those back together by string surgery would break
55
+ // on a repo whose description happens to contain "] [".
56
+ if (paginate) args.push('--paginate', '--slurp');
57
+ if (body) args.push('--input', '-');
58
+
59
+ const res = spawnSync('gh', args, {
60
+ encoding: 'utf8',
61
+ shell: false,
62
+ input: body ? JSON.stringify(body) : undefined,
63
+ maxBuffer: 64 * 1024 * 1024,
64
+ });
65
+
66
+ if (res.status !== 0) {
67
+ const detail = (res.stderr ?? '').trim().split('\n')[0] ?? `gh api exited ${res.status}`;
68
+ throw new Error(`GitHub — ${detail}`);
69
+ }
70
+
71
+ const json = JSON.parse(res.stdout);
72
+ // --slurp wraps the pages in an outer array: [[page1…], [page2…]].
73
+ return paginate ? json.flat() : json;
74
+ }
75
+
76
+ async function call(pathname, { token: tok, method = 'GET', body } = {}) {
77
+ if (haveGh()) return { json: viaGh(pathname, { method, body }), link: null };
78
+
79
+ const res = await fetch(pathname.startsWith('http') ? pathname : `${API}${pathname}`, {
80
+ method,
81
+ headers: {
82
+ accept: 'application/vnd.github+json',
83
+ 'user-agent': 'talea',
84
+ ...(tok ? { authorization: `Bearer ${tok}` } : {}),
85
+ ...(body ? { 'content-type': 'application/json' } : {}),
86
+ },
87
+ body: body ? JSON.stringify(body) : undefined,
88
+ });
89
+
90
+ if (!res.ok) {
91
+ const text = await res.text().catch(() => '');
92
+ let detail = '';
93
+ try {
94
+ detail = JSON.parse(text).message ?? '';
95
+ } catch {
96
+ detail = text.slice(0, 200);
97
+ }
98
+ throw new Error(`GitHub ${res.status} on ${pathname}${detail ? ` — ${detail}` : ''}`);
99
+ }
100
+
101
+ return { json: await res.json(), link: res.headers.get('link') };
102
+ }
103
+
104
+ /**
105
+ * Follow `Link: <…>; rel="next"` until it stops.
106
+ *
107
+ * The page count is not knowable in advance and `?page=N` past the end returns
108
+ * an empty array rather than an error, so walking the header is both the
109
+ * cheapest and the only correct way to know when to stop.
110
+ */
111
+ async function paginate(pathname, opts) {
112
+ if (haveGh()) return viaGh(pathname, { paginate: true });
113
+
114
+ const all = [];
115
+ let next = pathname;
116
+ while (next) {
117
+ const { json, link } = await call(next, opts);
118
+ all.push(...json);
119
+ next = /<([^>]+)>;\s*rel="next"/.exec(link ?? '')?.[1] ?? null;
120
+ }
121
+ return all;
122
+ }
123
+
124
+ /**
125
+ * Every repo this account can see: its own, and every org it belongs to.
126
+ *
127
+ * `affiliation` is what makes one call do the job of four — without it you
128
+ * would list the user's repos, then list each org, and miss anything shared
129
+ * with the account directly.
130
+ */
131
+ export async function listRepos({ token: tok, user }) {
132
+ if (tok) {
133
+ return paginate('/user/repos?per_page=100&sort=pushed&affiliation=owner,organization_member,collaborator', {
134
+ token: tok,
135
+ });
136
+ }
137
+ if (!user) {
138
+ throw new Error(
139
+ 'No GitHub token, and no --user to fall back on.\n' +
140
+ ' Run `gh auth login`, or set GITHUB_TOKEN, or pass --user <login> for public repos.',
141
+ );
142
+ }
143
+ return paginate(`/users/${encodeURIComponent(user)}/repos?per_page=100&sort=pushed`, {});
144
+ }
145
+
146
+ export async function whoami(tok) {
147
+ const { json } = await call('/user', { token: tok });
148
+ return json.login;
149
+ }
150
+
151
+ /** The catalogue entry for one API repo. Shape lives here, in one place. */
152
+ export function toEntry(api, { activeSince } = {}) {
153
+ return {
154
+ name: api.name,
155
+ owner: api.owner?.login,
156
+ defaultBranch: api.default_branch ?? null,
157
+ private: Boolean(api.private),
158
+ fork: Boolean(api.fork),
159
+ // A fork with no upstream recorded is a fork whose parent was deleted, so
160
+ // the field is honestly null rather than absent.
161
+ upstream: api.parent?.full_name ?? null,
162
+ pushedAt: api.pushed_at ?? null,
163
+ // Archived means "not touched lately", not GitHub's archived flag — a repo
164
+ // that has gone quiet is still listed, just off by default, so nothing is
165
+ // ever lost by narrowing the window.
166
+ archived: Boolean(api.archived) || (activeSince ? (api.pushed_at ?? '') < activeSince : false),
167
+ description: api.description ?? '',
168
+ };
169
+ }
170
+
171
+ // ── gists ────────────────────────────────────────────────────────
172
+ // The whole cross-machine story. A private gist is a file with a URL and an
173
+ // edit history, reachable with the token the machine already has — no server to
174
+ // run, no repo to create, nothing to remember to commit.
175
+
176
+ export async function createGist({ token: tok, filename, content, description }) {
177
+ const { json } = await call('/gists', {
178
+ token: tok,
179
+ method: 'POST',
180
+ body: { description, public: false, files: { [filename]: { content } } },
181
+ });
182
+ return json.id;
183
+ }
184
+
185
+ export async function updateGist({ token: tok, id, filename, content }) {
186
+ const { json } = await call(`/gists/${id}`, {
187
+ token: tok,
188
+ method: 'PATCH',
189
+ body: { files: { [filename]: { content } } },
190
+ });
191
+ return json.id;
192
+ }
193
+
194
+ export async function readGist({ token: tok, id, filename }) {
195
+ const { json } = await call(`/gists/${id}`, { token: tok });
196
+ const file = json.files?.[filename] ?? Object.values(json.files ?? {})[0];
197
+ if (!file) throw new Error(`Gist ${id} has no files.`);
198
+
199
+ // GitHub truncates a file inline past ~1MB and hands back a raw_url instead.
200
+ // A catalogue can reach that size, and silently syncing half of one would be
201
+ // worse than any error this can throw.
202
+ if (file.truncated) {
203
+ const res = await fetch(file.raw_url, { headers: { 'user-agent': 'talea' } });
204
+ if (!res.ok) throw new Error(`Could not read the full gist (${res.status}).`);
205
+ return res.text();
206
+ }
207
+ return file.content;
208
+ }
package/src/live.js ADDED
@@ -0,0 +1,197 @@
1
+ // The live block: rows redrawn in place while `pooled()` works.
2
+ //
3
+ // Bulk commands used to print a line per repo as each one landed, which under
4
+ // `-j 6` meant the order was whichever clone the network finished first. The
5
+ // board fixes the order (catalogue order, grouped by folder) and shows what is
6
+ // queued, in flight and done while it happens.
7
+ //
8
+ // There are two renderings and ONE formatter. On a terminal the rows are
9
+ // redrawn in place; on a pipe, in CI, or when the board would not fit the
10
+ // window, each row prints once as it settles — exactly what this tool printed
11
+ // before. Both go through `statusLine()` in log.js, so the animated and the
12
+ // plain rendering of a finished repo cannot drift apart.
13
+
14
+ import {
15
+ ansi,
16
+ glyph,
17
+ groupHeader,
18
+ padEndVisible,
19
+ paint,
20
+ spinner,
21
+ statusLine,
22
+ visibleWidth,
23
+ } from './log.js';
24
+
25
+ const FRAME_MS = 80;
26
+ const SETTLED = new Set(['ok', 'skip', 'fail', 'warn']);
27
+ const LABEL_MAX = 46;
28
+
29
+ const out = (s) => process.stdout.write(s);
30
+
31
+ /**
32
+ * Start a board over `items` — `{ id, group, label }`, already in the order
33
+ * they should appear.
34
+ *
35
+ * `set(id, status, note)` moves a row; `note(id, text)` attaches detail that
36
+ * belongs under the block rather than in the row (a git error, say); `stop()`
37
+ * freezes the block and prints the detail.
38
+ */
39
+ export function board(items, { live = process.stdout.isTTY } = {}) {
40
+ const rows = items.map((it) => ({ ...it, status: 'pending', note: null }));
41
+ const byId = new Map(rows.map((r) => [r.id, r]));
42
+ const notes = [];
43
+
44
+ const width = Math.min(Math.max(0, ...rows.map((r) => visibleWidth(r.label))), LABEL_MAX);
45
+
46
+ // Group headers, rows, a blank line and the counter. When that does not fit
47
+ // the window, `ansi.up()` would address a region that has already scrolled
48
+ // and tear the screen — so settled rows scroll away above a small footer
49
+ // instead of everything being redrawn.
50
+ const groups = [...new Set(rows.map((r) => r.group))];
51
+ const fits = () =>
52
+ live && groups.length + rows.length + 3 <= (process.stdout.rows || 24) - 1;
53
+ let full = fits();
54
+
55
+ let frame = 0;
56
+ let drawn = 0;
57
+ let timer = null;
58
+
59
+ const counter = () => {
60
+ const done = rows.filter((r) => SETTLED.has(r.status)).length;
61
+ return done < rows.length
62
+ ? `${paint.dim('working …')} ${paint.ok(String(done))}${paint.dim(`/${rows.length}`)}`
63
+ : `${paint.ok(glyph.arrow)} ${paint.bold(`${done} of ${rows.length} done`)}`;
64
+ };
65
+
66
+ const rowLine = (r) => {
67
+ if (r.status !== 'busy') return ' ' + statusLine(r.status, r.label, r.note, width);
68
+ const f = paint.warn(spinner[frame % spinner.length]);
69
+ return ` ${f} ${paint.dim(padEndVisible(r.label, width))} ${paint.dim(r.note ?? 'working …')}`;
70
+ };
71
+
72
+ const lines = () => {
73
+ const body = [];
74
+ if (full) {
75
+ for (const g of groups) {
76
+ const mine = rows.filter((r) => r.group === g);
77
+ body.push(groupHeader(g, mine.length));
78
+ for (const r of mine) body.push(rowLine(r));
79
+ }
80
+ } else {
81
+ // Only what is still moving, so the footer stays a fixed handful of lines
82
+ // however many repos are queued behind it.
83
+ for (const r of rows.filter((r) => r.status === 'busy')) body.push(rowLine(r));
84
+ }
85
+ body.push('');
86
+ body.push(counter());
87
+ return body;
88
+ };
89
+
90
+ const erase = () => {
91
+ if (!drawn) return;
92
+ let s = ansi.up(drawn) + ansi.cr;
93
+ s += (ansi.clearLine + '\n').repeat(drawn);
94
+ out(s + ansi.up(drawn));
95
+ drawn = 0;
96
+ };
97
+
98
+ const draw = () => {
99
+ const body = lines();
100
+ let s = (drawn ? ansi.up(drawn) : '') + ansi.cr;
101
+ for (const l of body) s += ansi.clearLine + l + '\n';
102
+ // The previous block may have been taller — clear what is left of it, then
103
+ // put the cursor back under the new one.
104
+ const extra = Math.max(0, drawn - body.length);
105
+ s += (ansi.clearLine + '\n').repeat(extra) + ansi.up(extra);
106
+ out(s);
107
+ drawn = body.length;
108
+ };
109
+
110
+ /** Print above the live block without tearing it. */
111
+ const emit = (text) => {
112
+ erase();
113
+ console.log(text);
114
+ draw();
115
+ };
116
+
117
+ // A window the board once fitted can stop fitting mid-run. Redrawing a block
118
+ // taller than the window makes `ansi.up()` address rows that have already
119
+ // scrolled off, which tears the screen — exactly what `full` exists to avoid —
120
+ // so re-decide on resize and forget the frame that is no longer up there.
121
+ const onResize = () => {
122
+ full = fits();
123
+ drawn = 0;
124
+ };
125
+
126
+ if (live) {
127
+ out(ansi.hideCursor);
128
+ process.on('exit', restoreCursor);
129
+ process.on('SIGINT', onSigint);
130
+ process.stdout.on('resize', onResize);
131
+ draw();
132
+ timer = setInterval(() => {
133
+ frame++;
134
+ draw();
135
+ }, FRAME_MS);
136
+ timer.unref?.();
137
+ }
138
+
139
+ return {
140
+ /** Move a row. `status` is pending | busy | ok | skip | fail | warn. */
141
+ set(id, status, note = null) {
142
+ const r = byId.get(id);
143
+ if (!r) return;
144
+ r.status = status;
145
+ r.note = note;
146
+
147
+ if (live) {
148
+ // In the rolling footer a finished row leaves the block, so print it on
149
+ // the way out — otherwise the run would keep no record of it at all.
150
+ if (!full && SETTLED.has(status)) emit(' ' + statusLine(status, r.label, note, width));
151
+ return;
152
+ }
153
+
154
+ // Piped: one line per row, as it settles. Failures keep going to stderr,
155
+ // which is where every script and CI job already looks for them.
156
+ if (!SETTLED.has(status)) return;
157
+ const line = statusLine(status, r.label, note, width);
158
+ if (status === 'fail') console.error(line);
159
+ else console.log(line);
160
+ },
161
+
162
+ /** Detail that belongs under the block rather than in the row. */
163
+ note(id, text) {
164
+ if (text) notes.push([id, text]);
165
+ },
166
+
167
+ /** Freeze the block, then print whatever detail was collected. */
168
+ stop() {
169
+ if (timer) {
170
+ clearInterval(timer);
171
+ timer = null;
172
+ }
173
+ if (live) {
174
+ draw();
175
+ restoreCursor();
176
+ process.off('exit', restoreCursor);
177
+ process.off('SIGINT', onSigint);
178
+ process.stdout.off('resize', onResize);
179
+ }
180
+ for (const [id, text] of notes) {
181
+ const label = byId.get(id)?.label ?? id;
182
+ const detail = ` ${paint.dim(label)}\n ${paint.dim(text)}`;
183
+ if (live) console.log(detail);
184
+ else console.error(detail);
185
+ }
186
+ },
187
+ };
188
+ }
189
+
190
+ const restoreCursor = () => out(ansi.showCursor);
191
+
192
+ // A hidden cursor outlives the process, so Ctrl-C has to put it back. Node runs
193
+ // no exit handlers for a default SIGINT, which is why this is its own listener.
194
+ function onSigint() {
195
+ restoreCursor();
196
+ process.exit(130);
197
+ }
package/src/log.js ADDED
@@ -0,0 +1,190 @@
1
+ // Terminal output. Every colour, glyph and width comes from `theme.js` — this
2
+ // module only composes them into the shapes commands actually print, so
3
+ // re-skinning the whole CLI is a change to one file and nothing else.
4
+ //
5
+ // `log.js` also owns the process exit code: `summary()` is what makes a partial
6
+ // failure visible to a script, so every bulk command must end with it.
7
+
8
+ import {
9
+ ansi,
10
+ bold,
11
+ box,
12
+ centerVisible,
13
+ columns,
14
+ dim,
15
+ glyph,
16
+ paint,
17
+ padEndVisible,
18
+ spinner,
19
+ stripAnsi,
20
+ useColor,
21
+ visibleWidth,
22
+ } from './theme.js';
23
+
24
+ export { ansi, glyph, padEndVisible, paint, spinner, useColor, visibleWidth };
25
+
26
+ /**
27
+ * Named colours, kept because every command's `help` string and most inline
28
+ * detail text is written against them. They are roles in the phosphor palette
29
+ * now rather than raw ANSI, so re-skinning `theme.js` moves them all at once.
30
+ */
31
+ export const c = {
32
+ bold,
33
+ dim,
34
+ red: paint.fail,
35
+ green: paint.ok,
36
+ yellow: paint.warn,
37
+ blue: paint.aged,
38
+ cyan: paint.aged,
39
+ grey: paint.faint,
40
+ };
41
+
42
+ export const icon = {
43
+ ok: paint.ok(glyph.ok),
44
+ skip: paint.warn(glyph.skip),
45
+ fail: paint.fail(glyph.fail),
46
+ warn: paint.warn(glyph.warn),
47
+ arrow: paint.aged(glyph.arrow),
48
+ };
49
+
50
+ export const plain = (s = '') => console.log(s);
51
+ export const heading = (s) => console.log(`\n${bold(s)}`);
52
+ export const info = (s) => console.log(`${paint.aged(glyph.prompt)} ${s}`);
53
+ export const ok = (s) => console.log(`${icon.ok} ${s}`);
54
+ export const skip = (s) => console.log(`${icon.skip} ${dim(s)}`);
55
+ export const warn = (s) => console.log(`${icon.warn} ${s}`);
56
+ export const fail = (s) => console.error(`${icon.fail} ${s}`);
57
+
58
+ /**
59
+ * A group header: the name, a rule filling the line, and a count on the right.
60
+ * Used by the live block and anywhere else output is bucketed by folder group.
61
+ */
62
+ export function groupHeader(name, count, noun = 'repo') {
63
+ const left = `${glyph.groupMark} ${name} `;
64
+ const right = ` ${count} ${noun}${count === 1 ? '' : 's'}`;
65
+ const width = Math.min(columns() - 2, 58);
66
+ const fill = Math.max(2, width - visibleWidth(left) - visibleWidth(right));
67
+ return paint.aged(left) + paint.faint(glyph.rule.repeat(fill)) + dim(right);
68
+ }
69
+
70
+ export const group = (name, count, noun) => console.log(groupHeader(name, count, noun));
71
+
72
+ /**
73
+ * The context strip under a heading — where the command is working, on which
74
+ * environment, how wide. `parts` is a list of [label, value] pairs.
75
+ */
76
+ export function context(parts) {
77
+ const bits = parts
78
+ .filter(Boolean)
79
+ .map(([k, v]) => `${dim(k)} ${v}`)
80
+ .join(dim(' '));
81
+ console.log(`${paint.ok(glyph.ok)} ${bits}`);
82
+ }
83
+
84
+ /** Strip ANSI so padding math stays correct on coloured cells. */
85
+ const visibleLength = (s) => stripAnsi(s).length;
86
+
87
+ /**
88
+ * Render an aligned table. `rows` is an array of arrays; `head` is optional.
89
+ * Columns are left-aligned and padded to the widest visible cell.
90
+ */
91
+ export function table(rows, head) {
92
+ const all = head ? [head, ...rows] : rows;
93
+ if (all.length === 0) return;
94
+ const widths = [];
95
+ for (const row of all) {
96
+ row.forEach((cell, i) => {
97
+ widths[i] = Math.max(widths[i] ?? 0, visibleLength(cell));
98
+ });
99
+ }
100
+ const render = (row) =>
101
+ row
102
+ .map((cell, i) =>
103
+ i === row.length - 1
104
+ ? String(cell)
105
+ : String(cell) + ' '.repeat(widths[i] - visibleLength(cell)),
106
+ )
107
+ .join(' ')
108
+ .trimEnd();
109
+
110
+ if (head) console.log(dim(render(head)));
111
+ for (const row of rows) console.log(render(row));
112
+ }
113
+
114
+ /**
115
+ * One settled row: a glyph, an aligned name, and a note.
116
+ *
117
+ * Both output paths go through this one formatter — the live block freezes its
118
+ * rows with it, and the piped path prints them with it — so the animated and
119
+ * the plain rendering of a finished repo can never drift apart.
120
+ */
121
+ export function statusLine(status, label, note, width = 0) {
122
+ const name = padEndVisible(label, width);
123
+ switch (status) {
124
+ case 'ok':
125
+ return `${paint.ok(glyph.ok)} ${paint.ok(name)} ${note ?? ''}`.trimEnd();
126
+ case 'skip':
127
+ return `${paint.warn(glyph.skip)} ${paint.warn(name)} ${dim(note ?? 'skipped')}`;
128
+ case 'fail':
129
+ return `${paint.fail(glyph.fail)} ${paint.fail(name)} ${dim(note ?? 'failed')}`;
130
+ case 'warn':
131
+ return `${paint.warn(glyph.warn)} ${bold(name)} ${dim(note ?? '')}`.trimEnd();
132
+ default:
133
+ return `${dim(glyph.pending)} ${dim(name)} ${dim(note ?? 'queued')}`;
134
+ }
135
+ }
136
+
137
+ // ── The results box ────────────────────────────────────────────
138
+
139
+ const BOX_INNER = 46;
140
+
141
+ const boxRow = (content) =>
142
+ paint.aged(box.v) + centerVisible(content, BOX_INNER) + paint.aged(box.v);
143
+
144
+ /**
145
+ * The closing counters, e.g. "12 cloned, 3 skipped, 1 failed", in a box.
146
+ *
147
+ * Also sets a non-zero exit code when anything failed. Bulk commands print
148
+ * per-repo errors and keep going, so without this a script or pipeline would
149
+ * read a partial failure as complete success.
150
+ */
151
+ export function summary(counts) {
152
+ const parts = [];
153
+ if (counts.ok) parts.push(paint.ok(`${glyph.ok} ${counts.ok} ${counts.okLabel}`));
154
+ if (counts.skipped) parts.push(paint.warn(`${glyph.skip} ${counts.skipped} skipped`));
155
+ if (counts.failed) parts.push(paint.fail(`${glyph.fail} ${counts.failed} failed`));
156
+
157
+ console.log('');
158
+ if (parts.length === 0) {
159
+ console.log(dim('nothing to do'));
160
+ return;
161
+ }
162
+
163
+ console.log(paint.aged(box.tl + box.h.repeat(BOX_INNER) + box.tr));
164
+ console.log(boxRow(bold('R E S U L T S')));
165
+ console.log(boxRow(parts.join(' ')));
166
+ console.log(paint.aged(box.bl + box.h.repeat(BOX_INNER) + box.br));
167
+
168
+ if (counts.failed) process.exitCode = 1;
169
+ }
170
+
171
+ /**
172
+ * The closing line under the box: what the run means, in one sentence.
173
+ *
174
+ * Three states, not two. `clear` is an absolute claim — "every repo is level
175
+ * with origin" — and anything skipped falsifies it, so a run with skips gets
176
+ * `partial` instead. Reporting an all-skipped run as ALL CLEAR is the kind of
177
+ * lie that only shows up when somebody trusts it.
178
+ */
179
+ export function verdict(counts, { clear, partial, trouble }) {
180
+ console.log('');
181
+ if (counts.failed) {
182
+ console.log(`${paint.warn(glyph.fail)} ${bold(paint.warn(trouble))}`);
183
+ return;
184
+ }
185
+ if (counts.skipped && partial) {
186
+ console.log(`${paint.warn(glyph.skip)} ${bold(paint.warn(partial))}`);
187
+ return;
188
+ }
189
+ console.log(`${paint.ok(glyph.ok)} ${bold(paint.ok(clear))}`);
190
+ }