@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/prompt.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// Interactive selection. No dependencies: raw stdin plus the same ANSI codes
|
|
2
|
+
// log.js already uses.
|
|
3
|
+
//
|
|
4
|
+
// The state machine (buildTree / toggle / groupState / selectedRepos) is pure
|
|
5
|
+
// and separate from the rendering, so the selection logic is testable without
|
|
6
|
+
// a terminal — which is the only reason it can be trusted at all.
|
|
7
|
+
|
|
8
|
+
import readline from 'node:readline/promises';
|
|
9
|
+
|
|
10
|
+
import { c, glyph } from './log.js';
|
|
11
|
+
import { repoGroup } from './config.js';
|
|
12
|
+
|
|
13
|
+
const ESC = '\u001b';
|
|
14
|
+
const CTRL_C = '\u0003';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A flat list of rows: each group followed by its repos. Flat rather than a
|
|
18
|
+
* nested structure because the thing being rendered is a list, and the thing
|
|
19
|
+
* being navigated is a list.
|
|
20
|
+
*
|
|
21
|
+
* `preselected` is either a boolean for every row, or a predicate — which is
|
|
22
|
+
* how the picker opens with your default set already ticked and everything
|
|
23
|
+
* else visible but off.
|
|
24
|
+
*/
|
|
25
|
+
export function buildTree(manifest, repos = manifest.repos, preselected = true) {
|
|
26
|
+
const tick = typeof preselected === 'function' ? preselected : () => preselected;
|
|
27
|
+
|
|
28
|
+
const byGroup = new Map();
|
|
29
|
+
for (const repo of repos) {
|
|
30
|
+
const group = repoGroup(repo);
|
|
31
|
+
if (!byGroup.has(group)) byGroup.set(group, []);
|
|
32
|
+
byGroup.get(group).push(repo);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The catalogue's group order first, then any group the catalogue does not
|
|
36
|
+
// name — a repo's owner stands in for a group it was never given, so a
|
|
37
|
+
// freshly discovered catalogue has a sensible tree with no curation at all.
|
|
38
|
+
const order = [
|
|
39
|
+
...Object.keys(manifest.groups ?? {}),
|
|
40
|
+
...[...byGroup.keys()].filter((g) => !(g in (manifest.groups ?? {}))).sort(),
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const rows = [];
|
|
44
|
+
for (const group of order) {
|
|
45
|
+
const members = byGroup.get(group);
|
|
46
|
+
if (!members?.length) continue;
|
|
47
|
+
rows.push({ kind: 'group', group, label: group, title: manifest.groups?.[group]?.title });
|
|
48
|
+
for (const repo of members) {
|
|
49
|
+
rows.push({ kind: 'repo', group, label: repo.name, repo, checked: Boolean(tick(repo)) });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return rows;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 'all' | 'some' | 'none' for a group, derived from its repos — never stored. */
|
|
56
|
+
export function groupState(rows, group) {
|
|
57
|
+
const members = rows.filter((r) => r.kind === 'repo' && r.group === group);
|
|
58
|
+
const on = members.filter((r) => r.checked).length;
|
|
59
|
+
if (on === 0) return 'none';
|
|
60
|
+
return on === members.length ? 'all' : 'some';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Toggle row `i`, in place.
|
|
65
|
+
*
|
|
66
|
+
* A group row toggles every repo under it — checking `V1` takes all of V1,
|
|
67
|
+
* which is what selecting a folder is expected to mean. A partially selected
|
|
68
|
+
* group fills up rather than emptying, because that is the direction someone
|
|
69
|
+
* pressing space on it almost always wants.
|
|
70
|
+
*/
|
|
71
|
+
export function toggle(rows, i) {
|
|
72
|
+
const row = rows[i];
|
|
73
|
+
if (!row) return rows;
|
|
74
|
+
|
|
75
|
+
if (row.kind === 'repo') {
|
|
76
|
+
row.checked = !row.checked;
|
|
77
|
+
return rows;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const next = groupState(rows, row.group) !== 'all';
|
|
81
|
+
for (const r of rows) {
|
|
82
|
+
if (r.kind === 'repo' && r.group === row.group) r.checked = next;
|
|
83
|
+
}
|
|
84
|
+
return rows;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function setAll(rows, checked) {
|
|
88
|
+
for (const r of rows) if (r.kind === 'repo') r.checked = checked;
|
|
89
|
+
return rows;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const selectedRepos = (rows) =>
|
|
93
|
+
rows.filter((r) => r.kind === 'repo' && r.checked).map((r) => r.repo);
|
|
94
|
+
|
|
95
|
+
const GROUP_MARK = { all: '◼', some: '◐', none: '◻' };
|
|
96
|
+
|
|
97
|
+
/** Visible length, ignoring ANSI colour codes. */
|
|
98
|
+
const visible = (s) => s.replace(/\u001b\[[0-9;]*m/g, '').length;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Cut a line to `width` visible characters, keeping colour codes intact.
|
|
102
|
+
* Written by hand rather than by slicing the string, because a naive slice cuts
|
|
103
|
+
* an escape sequence in half and bleeds colour across the rest of the screen.
|
|
104
|
+
*/
|
|
105
|
+
export function truncate(line, width) {
|
|
106
|
+
if (visible(line) <= width) return line;
|
|
107
|
+
let out = '';
|
|
108
|
+
let shown = 0;
|
|
109
|
+
let i = 0;
|
|
110
|
+
while (i < line.length && shown < width - 1) {
|
|
111
|
+
if (line[i] === '\u001b') {
|
|
112
|
+
const end = line.indexOf('m', i);
|
|
113
|
+
if (end === -1) break;
|
|
114
|
+
out += line.slice(i, end + 1);
|
|
115
|
+
i = end + 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
out += line[i];
|
|
119
|
+
shown++;
|
|
120
|
+
i++;
|
|
121
|
+
}
|
|
122
|
+
return `${out}\u001b[0m…`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One rendered line. Exported so a test can assert what the user sees. */
|
|
126
|
+
export function renderRow(rows, i, cursor) {
|
|
127
|
+
const row = rows[i];
|
|
128
|
+
const pointer = i === cursor ? c.cyan('❯') : ' ';
|
|
129
|
+
|
|
130
|
+
if (row.kind === 'group') {
|
|
131
|
+
const state = groupState(rows, row.group);
|
|
132
|
+
const box = state === 'none' ? c.grey(GROUP_MARK.none) : c.cyan(GROUP_MARK[state]);
|
|
133
|
+
return `${pointer} ${box} ${c.bold(row.label)} ${c.dim(row.title ?? '')}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const box = row.checked ? c.green('◼') : c.grey('◻');
|
|
137
|
+
return `${pointer} ${box} ${i === cursor ? c.bold(row.label) : row.label}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Split one stdin chunk into individual keypresses.
|
|
142
|
+
*
|
|
143
|
+
* stdin delivers whatever arrived since the last read, so fast typing, key
|
|
144
|
+
* repeat and paste all produce several keys in a single event — and an escape
|
|
145
|
+
* sequence like an arrow key is itself multiple bytes. Comparing a whole chunk
|
|
146
|
+
* against one key silently drops everything the user did, which is how this
|
|
147
|
+
* was first found: a pty test that fed five keys at once and hung forever.
|
|
148
|
+
*/
|
|
149
|
+
export function splitKeys(chunk) {
|
|
150
|
+
const keys = [];
|
|
151
|
+
let i = 0;
|
|
152
|
+
while (i < chunk.length) {
|
|
153
|
+
if (chunk[i] === ESC && chunk[i + 1] === '[') {
|
|
154
|
+
// CSI sequence: ESC [ ... final byte in @-~
|
|
155
|
+
let j = i + 2;
|
|
156
|
+
while (j < chunk.length && !/[A-Za-z~]/.test(chunk[j])) j++;
|
|
157
|
+
keys.push(chunk.slice(i, j + 1));
|
|
158
|
+
i = j + 1;
|
|
159
|
+
} else if (chunk[i] === ESC && chunk[i + 1] === 'O') {
|
|
160
|
+
// Application cursor mode: ESC O A. Some terminals send arrows this way.
|
|
161
|
+
keys.push(chunk.slice(i, i + 3));
|
|
162
|
+
i += 3;
|
|
163
|
+
} else {
|
|
164
|
+
keys.push(chunk[i]);
|
|
165
|
+
i++;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return keys;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** What a key did, so the caller knows whether to repaint or stop reading. */
|
|
172
|
+
export function applyKey(rows, key, cursor, pageSize) {
|
|
173
|
+
const clamp = (n) => Math.max(0, Math.min(rows.length - 1, n));
|
|
174
|
+
|
|
175
|
+
switch (key) {
|
|
176
|
+
case CTRL_C:
|
|
177
|
+
case ESC:
|
|
178
|
+
case 'q':
|
|
179
|
+
return { action: 'cancel', cursor };
|
|
180
|
+
case '\r':
|
|
181
|
+
case '\n':
|
|
182
|
+
return { action: 'confirm', cursor };
|
|
183
|
+
case ' ':
|
|
184
|
+
toggle(rows, cursor);
|
|
185
|
+
return { action: 'changed', cursor };
|
|
186
|
+
case 'a':
|
|
187
|
+
setAll(rows, true);
|
|
188
|
+
return { action: 'changed', cursor };
|
|
189
|
+
case 'n':
|
|
190
|
+
setAll(rows, false);
|
|
191
|
+
return { action: 'changed', cursor };
|
|
192
|
+
case `${ESC}[A`:
|
|
193
|
+
case `${ESC}OA`:
|
|
194
|
+
case 'k':
|
|
195
|
+
return { action: 'changed', cursor: clamp(cursor - 1) };
|
|
196
|
+
case `${ESC}[B`:
|
|
197
|
+
case `${ESC}OB`:
|
|
198
|
+
case 'j':
|
|
199
|
+
return { action: 'changed', cursor: clamp(cursor + 1) };
|
|
200
|
+
case `${ESC}[5~`:
|
|
201
|
+
return { action: 'changed', cursor: clamp(cursor - pageSize) };
|
|
202
|
+
case `${ESC}[6~`:
|
|
203
|
+
return { action: 'changed', cursor: clamp(cursor + pageSize) };
|
|
204
|
+
case `${ESC}[H`:
|
|
205
|
+
return { action: 'changed', cursor: 0 };
|
|
206
|
+
case `${ESC}[F`:
|
|
207
|
+
return { action: 'changed', cursor: rows.length - 1 };
|
|
208
|
+
default:
|
|
209
|
+
return { action: 'ignored', cursor };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The checkbox list. Resolves to the selected repos, or null if cancelled.
|
|
215
|
+
* The caller must have already confirmed this is a TTY.
|
|
216
|
+
*/
|
|
217
|
+
export function pickRepos(rows, { title = 'Select what to clone' } = {}) {
|
|
218
|
+
return new Promise((resolve, reject) => {
|
|
219
|
+
const out = process.stdout;
|
|
220
|
+
let cursor = rows.findIndex((r) => r.kind === 'repo');
|
|
221
|
+
if (cursor < 0) cursor = 0;
|
|
222
|
+
let top = 0;
|
|
223
|
+
let painted = 0;
|
|
224
|
+
let done = false;
|
|
225
|
+
|
|
226
|
+
// Leave room for the title, the counter and the key hints.
|
|
227
|
+
const viewport = () => Math.max(5, (out.rows || 24) - 6);
|
|
228
|
+
|
|
229
|
+
const draw = () => {
|
|
230
|
+
const height = viewport();
|
|
231
|
+
if (cursor < top) top = cursor;
|
|
232
|
+
if (cursor >= top + height) top = cursor - height + 1;
|
|
233
|
+
|
|
234
|
+
const total = rows.filter((r) => r.kind === 'repo').length;
|
|
235
|
+
const lines = [
|
|
236
|
+
c.bold(title),
|
|
237
|
+
c.dim(`${selectedRepos(rows).length} of ${total} selected`),
|
|
238
|
+
'',
|
|
239
|
+
];
|
|
240
|
+
for (let i = top; i < Math.min(rows.length, top + height); i++) {
|
|
241
|
+
lines.push(renderRow(rows, i, cursor));
|
|
242
|
+
}
|
|
243
|
+
const more = rows.length - (top + height);
|
|
244
|
+
lines.push('');
|
|
245
|
+
lines.push(
|
|
246
|
+
c.dim(`${glyph.up}${glyph.down} move space toggle a all n none enter ok q cancel`) +
|
|
247
|
+
(more > 0 ? c.cyan(` +${more} below`) : ''),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Repaint in place: back up over what was drawn last time and clear down.
|
|
251
|
+
// Every line is clamped to the terminal width first — a line that wraps
|
|
252
|
+
// occupies two rows, `painted` would undercount, and the list smears.
|
|
253
|
+
const width = Math.max(20, out.columns || 80);
|
|
254
|
+
const clamped = lines.map((line) => truncate(line, width));
|
|
255
|
+
if (painted) out.write(`${ESC}[${painted}A${ESC}[0J`);
|
|
256
|
+
out.write(clamped.join('\n') + '\n');
|
|
257
|
+
painted = clamped.length;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// A process that exits while raw mode is on leaves the shell with no echo
|
|
261
|
+
// and no line editing, which looks like a hung terminal.
|
|
262
|
+
const restore = () => {
|
|
263
|
+
try {
|
|
264
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
265
|
+
} catch {
|
|
266
|
+
// Nothing useful to do while unwinding.
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
process.once('exit', restore);
|
|
270
|
+
|
|
271
|
+
const finish = (value) => {
|
|
272
|
+
done = true;
|
|
273
|
+
process.stdin.off('data', onData);
|
|
274
|
+
process.off('exit', restore);
|
|
275
|
+
restore();
|
|
276
|
+
process.stdin.pause();
|
|
277
|
+
if (painted) out.write(`${ESC}[${painted}A${ESC}[0J`);
|
|
278
|
+
resolve(value);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const onData = (chunk) => {
|
|
282
|
+
let dirty = false;
|
|
283
|
+
for (const key of splitKeys(chunk)) {
|
|
284
|
+
if (done) return;
|
|
285
|
+
const res = applyKey(rows, key, cursor, viewport());
|
|
286
|
+
cursor = res.cursor;
|
|
287
|
+
if (res.action === 'cancel') return finish(null);
|
|
288
|
+
if (res.action === 'confirm') return finish(selectedRepos(rows));
|
|
289
|
+
if (res.action === 'changed') dirty = true;
|
|
290
|
+
}
|
|
291
|
+
if (dirty) draw();
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// Some Windows console hosts refuse raw mode outright. Surface that so the
|
|
295
|
+
// caller can fall back to typed names, rather than dying mid-prompt.
|
|
296
|
+
try {
|
|
297
|
+
process.stdin.setRawMode(true);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
process.off('exit', restore);
|
|
300
|
+
reject(err);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
process.stdin.resume();
|
|
304
|
+
process.stdin.setEncoding('utf8');
|
|
305
|
+
process.stdin.on('data', onData);
|
|
306
|
+
draw();
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The opening question. Cloning everything is the default because a workspace
|
|
312
|
+
* with repos missing is one where cross-repo search quietly lies to you — but
|
|
313
|
+
* disk is disk, so choosing is one keypress away.
|
|
314
|
+
*/
|
|
315
|
+
export async function askScope({ defaults, total, groups }) {
|
|
316
|
+
console.log(`\n${c.bold('This machine has not chosen what it keeps.')}`);
|
|
317
|
+
console.log(
|
|
318
|
+
` ${c.cyan('1')} the default set — ${defaults} repo${defaults === 1 ? '' : 's'} ${c.dim('(default)')}`,
|
|
319
|
+
);
|
|
320
|
+
console.log(
|
|
321
|
+
` ${c.cyan('2')} choose from all ${total} across ${groups} owner${groups === 1 ? '' : 's'}`,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
325
|
+
const answer = (await rl.question(`\n${c.dim('[1/2]')} `)).trim();
|
|
326
|
+
rl.close();
|
|
327
|
+
return answer === '2' || /^c(hoose)?$/i.test(answer) ? 'choose' : 'defaults';
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Selection without raw mode: a numbered list read as one line. Used when the
|
|
332
|
+
* terminal cannot do raw mode — a real possibility on Windows consoles, which
|
|
333
|
+
* this project has not been able to test.
|
|
334
|
+
*/
|
|
335
|
+
export async function pickByLine(rows) {
|
|
336
|
+
const groups = [...new Set(rows.map((r) => r.group))];
|
|
337
|
+
console.log(`\n${c.bold('Groups')}`);
|
|
338
|
+
for (const g of groups) {
|
|
339
|
+
const members = rows.filter((r) => r.kind === 'repo' && r.group === g);
|
|
340
|
+
console.log(` ${c.cyan(g.padEnd(10))} ${c.dim(`${members.length} repos`)}`);
|
|
341
|
+
}
|
|
342
|
+
console.log(c.dim('\nEnter group and/or repo names, comma separated. Empty = everything.'));
|
|
343
|
+
|
|
344
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
345
|
+
const answer = (await rl.question('\n> ')).trim();
|
|
346
|
+
rl.close();
|
|
347
|
+
|
|
348
|
+
if (!answer) return { picked: selectedRepos(setAll(rows, true)), unknown: [] };
|
|
349
|
+
return applyNames(rows, answer.split(','));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Resolve typed group/repo names against the tree. Unknown names are returned
|
|
354
|
+
* rather than ignored — a typo that silently clones nothing is the failure
|
|
355
|
+
* mode this project treats as worse than stopping.
|
|
356
|
+
*/
|
|
357
|
+
export function applyNames(rows, names) {
|
|
358
|
+
const wanted = new Set(names.map((s) => String(s).trim().toLowerCase()).filter(Boolean));
|
|
359
|
+
setAll(rows, false);
|
|
360
|
+
|
|
361
|
+
for (const r of rows) {
|
|
362
|
+
if (r.kind !== 'repo') continue;
|
|
363
|
+
if (wanted.has(r.group.toLowerCase()) || wanted.has(r.label.toLowerCase())) r.checked = true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const known = new Set();
|
|
367
|
+
for (const r of rows) {
|
|
368
|
+
known.add(r.group.toLowerCase());
|
|
369
|
+
if (r.kind === 'repo') known.add(r.label.toLowerCase());
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
picked: selectedRepos(rows),
|
|
373
|
+
unknown: [...wanted].filter((w) => !known.has(w)),
|
|
374
|
+
};
|
|
375
|
+
}
|
package/src/select.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// "What does this machine keep?" — asked once, remembered forever.
|
|
2
|
+
//
|
|
3
|
+
// The answer lives in `.talea.json` as a list of repo names. Until it exists,
|
|
4
|
+
// the catalogue's `default: true` repos stand in, and the first interactive
|
|
5
|
+
// `sync` or `init` offers the picker with exactly those ticked.
|
|
6
|
+
//
|
|
7
|
+
// Why a list of names rather than a filter: a filter re-evaluates. Adding
|
|
8
|
+
// `default: true` to a repo in the catalogue would silently start cloning it on
|
|
9
|
+
// every machine you own, which is not a decision the catalogue gets to make.
|
|
10
|
+
|
|
11
|
+
import { saveState } from './config.js';
|
|
12
|
+
import { c, fail, plain, warn } from './log.js';
|
|
13
|
+
import { askScope, buildTree, pickByLine, pickRepos, selectedRepos } from './prompt.js';
|
|
14
|
+
import { hasChosen, machineRepos } from './workspace.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Show the checklist and return what was ticked, or null if cancelled.
|
|
18
|
+
*
|
|
19
|
+
* The full-screen list needs raw mode. `setRawMode` always *exists* on a TTY
|
|
20
|
+
* stream, so the failure to plan for is it THROWING — some Windows console
|
|
21
|
+
* hosts raise ERR_TTY_INIT_FAILED. Fall back to typing names rather than
|
|
22
|
+
* letting that escape and taking a default nobody chose.
|
|
23
|
+
*/
|
|
24
|
+
export async function runPicker(rows, title) {
|
|
25
|
+
try {
|
|
26
|
+
return await pickRepos(rows, { title });
|
|
27
|
+
} catch (err) {
|
|
28
|
+
warn(`This terminal could not enter raw mode (${err.code ?? err.message}).`);
|
|
29
|
+
const { picked, unknown } = await pickByLine(rows);
|
|
30
|
+
if (unknown.length) {
|
|
31
|
+
fail(`Unknown name${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
return picked;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const interactive = () => process.stdin.isTTY && process.stdout.isTTY;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The repos this run is about, asking the machine's owner if nobody has.
|
|
42
|
+
*
|
|
43
|
+
* `--pick` re-opens the checklist with the current selection ticked, which is
|
|
44
|
+
* how you change your mind without editing JSON.
|
|
45
|
+
*
|
|
46
|
+
* An explicit -g/-r never prompts, so scripts and CI behave the same on a fresh
|
|
47
|
+
* machine as on an old one.
|
|
48
|
+
*/
|
|
49
|
+
export async function chooseRepos({ manifest, root, state, opts = {} }) {
|
|
50
|
+
const already = machineRepos(manifest, state);
|
|
51
|
+
|
|
52
|
+
if (opts.group || opts.repo) return { repos: already, asked: false };
|
|
53
|
+
|
|
54
|
+
const mustAsk = opts.pick || !hasChosen(state);
|
|
55
|
+
if (!mustAsk) return { repos: already, asked: false };
|
|
56
|
+
|
|
57
|
+
if (!interactive()) {
|
|
58
|
+
// Nobody to ask. The defaults are a sane answer and saying which one was
|
|
59
|
+
// taken is better than silently picking it.
|
|
60
|
+
if (!hasChosen(state)) {
|
|
61
|
+
plain(c.dim(` Not a terminal — taking the catalogue's default set (${already.length} repos).`));
|
|
62
|
+
}
|
|
63
|
+
return { repos: already, asked: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const groups = new Set(manifest.repos.map((r) => r.group ?? r.owner ?? 'repos'));
|
|
67
|
+
const chosenNames = new Set(already.map((r) => r.name));
|
|
68
|
+
|
|
69
|
+
if (!opts.pick) {
|
|
70
|
+
const scope = await askScope({
|
|
71
|
+
defaults: already.length,
|
|
72
|
+
total: manifest.repos.length,
|
|
73
|
+
groups: groups.size,
|
|
74
|
+
});
|
|
75
|
+
if (scope === 'defaults') {
|
|
76
|
+
saveState(root, { ...state, selected: already.map((r) => r.name) });
|
|
77
|
+
return { repos: already, asked: true };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Everything is listed, including the archived and the forks. The point of
|
|
82
|
+
// the checklist is that this machine can take something the default set does
|
|
83
|
+
// not have — a repo you only touch on the desktop should not need a catalogue
|
|
84
|
+
// edit to reach.
|
|
85
|
+
const rows = buildTree(manifest, manifest.repos, (repo) => chosenNames.has(repo.name));
|
|
86
|
+
const picked = await runPicker(rows, 'What should this machine keep?');
|
|
87
|
+
|
|
88
|
+
if (picked === null) {
|
|
89
|
+
plain(c.dim('\nCancelled — nothing changed.'));
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
saveState(root, { ...state, selected: picked.map((r) => r.name) });
|
|
94
|
+
return { repos: picked, asked: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export { selectedRepos };
|
package/src/theme.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The look of every byte this CLI prints.
|
|
2
|
+
//
|
|
3
|
+
// Colours, glyphs, spinner frames, box-drawing and the width maths that keeps
|
|
4
|
+
// columns aligned once colour codes are in the string. Nothing here knows what
|
|
5
|
+
// a repo or a branch is — `log.js` and `live.js` compose these into output, and
|
|
6
|
+
// commands use those. Re-skin the whole tool by editing this file alone.
|
|
7
|
+
//
|
|
8
|
+
// Zero dependencies: raw ANSI escapes, same as the rest of the project.
|
|
9
|
+
|
|
10
|
+
// ── Gating ─────────────────────────────────────────────────────
|
|
11
|
+
// NO_COLOR disables colour. FORCE_COLOR forces it on, which is how the tests
|
|
12
|
+
// and `talea ... | less -R` get colour out of a pipe. Otherwise colour follows
|
|
13
|
+
// TTY detection, so piping into a file or CI log stays free of escape codes.
|
|
14
|
+
export const useColor =
|
|
15
|
+
process.env.NO_COLOR || process.env.TERM === 'dumb'
|
|
16
|
+
? false
|
|
17
|
+
: process.env.FORCE_COLOR
|
|
18
|
+
? true
|
|
19
|
+
: !!process.stdout.isTTY;
|
|
20
|
+
|
|
21
|
+
// 24-bit colour is not universal — Apple Terminal, tmux without `-2` and the
|
|
22
|
+
// older Windows consoles all lie about it or drop the escape. COLORTERM is the
|
|
23
|
+
// only signal that is actually reliable, so the palette carries a basic-16
|
|
24
|
+
// fallback and uses it whenever COLORTERM is not set.
|
|
25
|
+
const trueColor = /^(truecolor|24bit)$/i.test(process.env.COLORTERM ?? '');
|
|
26
|
+
|
|
27
|
+
// ── Phosphor palette ───────────────────────────────────────────
|
|
28
|
+
// [24-bit hex, basic-16 SGR code]. The basic code is what a terminal without
|
|
29
|
+
// COLORTERM gets; it is deliberately the closest *readable* match rather than
|
|
30
|
+
// the closest numerically — `faint` has no bright equivalent, so it falls back
|
|
31
|
+
// to grey rather than a green nobody can read on a light background.
|
|
32
|
+
const PALETTE = {
|
|
33
|
+
green: ['#39ff14', 92], // hot phosphor — something worked
|
|
34
|
+
aged: ['#1f8a3b', 32], // settled green — a branch, a path, a detail
|
|
35
|
+
faint: ['#0e3b1c', 90], // barely lit — rules and fills
|
|
36
|
+
amber: ['#ffb000', 33], // attention, but not a failure
|
|
37
|
+
red: ['#ff3b30', 31], // failure
|
|
38
|
+
bone: ['#d6dbd6', 37], // plain text that still wants to be lit
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
const hexToRgb = (hex) => [
|
|
43
|
+
parseInt(hex.slice(1, 3), 16),
|
|
44
|
+
parseInt(hex.slice(3, 5), 16),
|
|
45
|
+
parseInt(hex.slice(5, 7), 16),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** Wrap `str` in one of the palette colours. A no-op when colour is off. */
|
|
49
|
+
function tint(name, str) {
|
|
50
|
+
if (!useColor) return String(str);
|
|
51
|
+
const [hex, basic] = PALETTE[name];
|
|
52
|
+
// Both branches close with 39 — "default foreground" — and never with 0.
|
|
53
|
+
// A full reset also clears bold and dim, so a colour nested inside `dim()`
|
|
54
|
+
// would un-dim the rest of the line on a truecolor terminal and not on a
|
|
55
|
+
// basic one: a rendering difference gated on COLORTERM, which is the worst
|
|
56
|
+
// kind to reproduce.
|
|
57
|
+
if (!trueColor) return `\x1b[${basic}m${str}\x1b[39m`;
|
|
58
|
+
const [r, g, b] = hexToRgb(hex);
|
|
59
|
+
return `\x1b[38;2;${r};${g};${b}m${str}\x1b[39m`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const bold = (s) => (useColor ? `\x1b[1m${s}\x1b[22m` : String(s));
|
|
63
|
+
export const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[22m` : String(s));
|
|
64
|
+
|
|
65
|
+
/** The palette, by role. Everything that prints colour goes through here. */
|
|
66
|
+
export const paint = {
|
|
67
|
+
ok: (s) => tint('green', s),
|
|
68
|
+
aged: (s) => tint('aged', s),
|
|
69
|
+
faint: (s) => tint('faint', s),
|
|
70
|
+
warn: (s) => tint('amber', s),
|
|
71
|
+
fail: (s) => tint('red', s),
|
|
72
|
+
bone: (s) => tint('bone', s),
|
|
73
|
+
bold,
|
|
74
|
+
dim,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ── Glyphs ─────────────────────────────────────────────────────
|
|
78
|
+
// All BMP, all present in the fonts people actually run terminals in. Keep it
|
|
79
|
+
// that way — a dingbat that renders as a box wrecks every column after it.
|
|
80
|
+
export const glyph = {
|
|
81
|
+
ok: '▣',
|
|
82
|
+
skip: '◌',
|
|
83
|
+
fail: '▤',
|
|
84
|
+
warn: '!',
|
|
85
|
+
pending: '·',
|
|
86
|
+
arrow: '→',
|
|
87
|
+
up: '↑',
|
|
88
|
+
down: '↓',
|
|
89
|
+
maybe: '~',
|
|
90
|
+
groupMark: '◇',
|
|
91
|
+
rule: '─',
|
|
92
|
+
prompt: '==>',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Frames for work that is genuinely in flight. */
|
|
96
|
+
export const spinner = ['◜', '◠', '◝', '◞', '◡', '◟'];
|
|
97
|
+
|
|
98
|
+
export const box = { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' };
|
|
99
|
+
|
|
100
|
+
// ── Low-level ANSI, for the live block ─────────────────────────
|
|
101
|
+
export const ansi = {
|
|
102
|
+
hideCursor: '\x1b[?25l',
|
|
103
|
+
showCursor: '\x1b[?25h',
|
|
104
|
+
clearLine: '\x1b[2K',
|
|
105
|
+
cr: '\r',
|
|
106
|
+
up: (n = 1) => (n > 0 ? `\x1b[${n}A` : ''),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// ── Width maths ────────────────────────────────────────────────
|
|
110
|
+
// Every pad and centre in the tool goes through these. Measuring a coloured
|
|
111
|
+
// string with `.length` counts the escape codes and is the classic way to
|
|
112
|
+
// break a table one release after it was written.
|
|
113
|
+
|
|
114
|
+
// eslint-disable-next-line no-control-regex
|
|
115
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
116
|
+
|
|
117
|
+
export const stripAnsi = (s) => String(s).replace(ANSI_RE, '');
|
|
118
|
+
export const visibleWidth = (s) => [...stripAnsi(s)].length;
|
|
119
|
+
|
|
120
|
+
export function padEndVisible(s, width) {
|
|
121
|
+
const pad = width - visibleWidth(s);
|
|
122
|
+
return pad > 0 ? s + ' '.repeat(pad) : String(s);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function padStartVisible(s, width) {
|
|
126
|
+
const pad = width - visibleWidth(s);
|
|
127
|
+
return pad > 0 ? ' '.repeat(pad) + s : String(s);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function centerVisible(s, width) {
|
|
131
|
+
const pad = width - visibleWidth(s);
|
|
132
|
+
if (pad <= 0) return String(s);
|
|
133
|
+
const left = Math.floor(pad / 2);
|
|
134
|
+
return ' '.repeat(left) + s + ' '.repeat(pad - left);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Terminal width, clamped to something a human can read across. */
|
|
138
|
+
export const columns = () => Math.max(40, Math.min(process.stdout.columns || 80, 120));
|