@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/README.md +174 -0
- package/bin/talea.js +11 -0
- package/manifest/talea.repos.json +11 -0
- package/package.json +39 -0
- package/src/adopt.js +983 -0
- package/src/cli.js +219 -0
- package/src/commands/add.js +136 -0
- package/src/commands/adopt.js +441 -0
- package/src/commands/clone.js +233 -0
- package/src/commands/discover.js +194 -0
- package/src/commands/doctor.js +142 -0
- package/src/commands/exec.js +78 -0
- package/src/commands/init.js +106 -0
- package/src/commands/list.js +100 -0
- package/src/commands/manifest.js +190 -0
- package/src/commands/status.js +114 -0
- package/src/commands/sync.js +203 -0
- package/src/commands/tree.js +103 -0
- package/src/commands/upgrade.js +84 -0
- package/src/commands/where.js +67 -0
- package/src/config.js +155 -0
- package/src/docs.js +122 -0
- package/src/git.js +291 -0
- package/src/github.js +208 -0
- package/src/live.js +197 -0
- package/src/log.js +190 -0
- package/src/prompt.js +375 -0
- package/src/select.js +97 -0
- package/src/theme.js +138 -0
- package/src/update.js +119 -0
- package/src/workspace.js +116 -0
- package/templates/.gitkeep +0 -0
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
|
+
}
|