gitroots 0.0.0-stage → 0.5.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 +137 -2
- package/assets/logo.png +0 -0
- package/bin/gitroots.js +11 -0
- package/package.json +35 -4
- package/src/api.js +121 -0
- package/src/cli.js +150 -0
- package/src/commands/attach.js +262 -0
- package/src/commands/audit.js +150 -0
- package/src/commands/auth.js +99 -0
- package/src/commands/backup.js +106 -0
- package/src/commands/completion.js +69 -0
- package/src/commands/demo.js +12 -0
- package/src/commands/doctor.js +81 -0
- package/src/commands/pr.js +322 -0
- package/src/commands/push.js +273 -0
- package/src/commands/repos.js +122 -0
- package/src/commands/restore.js +125 -0
- package/src/commands/splash.js +95 -0
- package/src/commands/status.js +84 -0
- package/src/commands/transfer.js +79 -0
- package/src/commands/tunnel.js +116 -0
- package/src/commands/update.js +69 -0
- package/src/commands/watch.js +347 -0
- package/src/connected.js +82 -0
- package/src/data/live.js +49 -0
- package/src/data/sample.js +163 -0
- package/src/data/store.js +145 -0
- package/src/git/checks.js +96 -0
- package/src/installer.js +96 -0
- package/src/runtime.js +63 -0
- package/src/ui/ansi.js +138 -0
- package/src/ui/components.js +85 -0
- package/src/ui/term.js +296 -0
package/src/ui/ansi.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Colors, widths and padding. Palette comes straight from the design tokens:
|
|
2
|
+
// g #60DD65 success and the one next action
|
|
3
|
+
// m #A5B8B1 muted
|
|
4
|
+
// s #C9D6D0 secondary
|
|
5
|
+
// w #F5BF57 needs you
|
|
6
|
+
// r #FF7474 real block or failure
|
|
7
|
+
// i #73C5FF in progress / info
|
|
8
|
+
// fg #F1F7F3
|
|
9
|
+
// Colors drop when output is piped or NO_COLOR is set.
|
|
10
|
+
|
|
11
|
+
const env = process.env;
|
|
12
|
+
|
|
13
|
+
function detectColor(stream = process.stdout) {
|
|
14
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return false;
|
|
15
|
+
if (env.FORCE_COLOR !== undefined) return env.FORCE_COLOR !== '0';
|
|
16
|
+
if (env.TERM === 'dumb') return false;
|
|
17
|
+
return Boolean(stream.isTTY);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function detectTruecolor() {
|
|
21
|
+
const ct = (env.COLORTERM || '').toLowerCase();
|
|
22
|
+
if (ct === 'truecolor' || ct === '24bit') return true;
|
|
23
|
+
const tp = env.TERM_PROGRAM || '';
|
|
24
|
+
if (['iTerm.app', 'WezTerm', 'ghostty', 'vscode', 'Hyper'].includes(tp)) return true;
|
|
25
|
+
if (env.WT_SESSION) return true;
|
|
26
|
+
const term = env.TERM || '';
|
|
27
|
+
return /kitty|ghostty|direct|truecolor/.test(term);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const state = {
|
|
31
|
+
color: detectColor(),
|
|
32
|
+
truecolor: detectTruecolor(),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function setColor(on) { state.color = on; }
|
|
36
|
+
|
|
37
|
+
const PALETTE = {
|
|
38
|
+
g: [0x60, 0xdd, 0x65, 77],
|
|
39
|
+
m: [0xa5, 0xb8, 0xb1, 109],
|
|
40
|
+
s: [0xc9, 0xd6, 0xd0, 152],
|
|
41
|
+
w: [0xf5, 0xbf, 0x57, 221],
|
|
42
|
+
r: [0xff, 0x74, 0x74, 210],
|
|
43
|
+
i: [0x73, 0xc5, 0xff, 117],
|
|
44
|
+
fg: [0xf1, 0xf7, 0xf3, 255],
|
|
45
|
+
ink: [0x07, 0x14, 0x11, 233],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const BG = {
|
|
49
|
+
bar: [0x0f, 0x26, 0x20, 234],
|
|
50
|
+
attach: [0x12, 0x44, 0x35, 23],
|
|
51
|
+
sel: [0x12, 0x44, 0x35, 23],
|
|
52
|
+
key: [0x1a, 0x33, 0x2c, 236],
|
|
53
|
+
red: [0x3a, 0x1c, 0x1c, 52],
|
|
54
|
+
green: [0x60, 0xdd, 0x65, 77],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function fgCode([r, g, b, n]) { return state.truecolor ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${n}m`; }
|
|
58
|
+
function bgCode([r, g, b, n]) { return state.truecolor ? `\x1b[48;2;${r};${g};${b}m` : `\x1b[48;5;${n}m`; }
|
|
59
|
+
|
|
60
|
+
function paint(code, reset) {
|
|
61
|
+
return (s) => (state.color ? `${code()}${s}${reset}` : String(s));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const c = {
|
|
65
|
+
g: paint(() => fgCode(PALETTE.g), '\x1b[39m'),
|
|
66
|
+
m: paint(() => fgCode(PALETTE.m), '\x1b[39m'),
|
|
67
|
+
s: paint(() => fgCode(PALETTE.s), '\x1b[39m'),
|
|
68
|
+
w: paint(() => fgCode(PALETTE.w), '\x1b[39m'),
|
|
69
|
+
r: paint(() => fgCode(PALETTE.r), '\x1b[39m'),
|
|
70
|
+
i: paint(() => fgCode(PALETTE.i), '\x1b[39m'),
|
|
71
|
+
fg: paint(() => fgCode(PALETTE.fg), '\x1b[39m'),
|
|
72
|
+
ink: paint(() => fgCode(PALETTE.ink), '\x1b[39m'),
|
|
73
|
+
b: paint(() => '\x1b[1m', '\x1b[22m'),
|
|
74
|
+
dim: paint(() => '\x1b[2m', '\x1b[22m'),
|
|
75
|
+
inv: paint(() => '\x1b[7m', '\x1b[27m'),
|
|
76
|
+
// Backgrounds nest: an inner reset falls back to this background, not the terminal's.
|
|
77
|
+
bg: (name) => (s) => {
|
|
78
|
+
if (!state.color) return String(s);
|
|
79
|
+
const code = bgCode(BG[name]);
|
|
80
|
+
return `${code}${String(s).split('\x1b[49m').join(code).split('\x1b[0m').join(`\x1b[0m${code}`)}\x1b[49m`;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Header labels: muted caps, like the design's small letter-spaced headings.
|
|
85
|
+
export const h = (s) => c.m(String(s).toUpperCase());
|
|
86
|
+
|
|
87
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b_[^\x1b]*\x1b\\/g;
|
|
88
|
+
|
|
89
|
+
export function strip(s) { return String(s).replace(ANSI_RE, ''); }
|
|
90
|
+
|
|
91
|
+
// Visible width. All glyphs the CLI prints (box drawing, ●◐○✓✕, braille spinners) are single-width.
|
|
92
|
+
export function width(s) {
|
|
93
|
+
let n = 0;
|
|
94
|
+
for (const ch of strip(s)) {
|
|
95
|
+
const cp = ch.codePointAt(0);
|
|
96
|
+
if (cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0x300 && cp <= 0x36f)) continue;
|
|
97
|
+
n += isWide(cp) ? 2 : 1;
|
|
98
|
+
}
|
|
99
|
+
return n;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isWide(cp) {
|
|
103
|
+
return (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
104
|
+
(cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
|
|
105
|
+
(cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function pad(s, n) {
|
|
109
|
+
const w = width(s);
|
|
110
|
+
return w >= n ? s : s + ' '.repeat(n - w);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function padStart(s, n) {
|
|
114
|
+
const w = width(s);
|
|
115
|
+
return w >= n ? s : ' '.repeat(n - w) + s;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Cut a (possibly colored) string to n visible columns.
|
|
119
|
+
export function truncate(s, n) {
|
|
120
|
+
if (width(s) <= n) return s;
|
|
121
|
+
let out = '';
|
|
122
|
+
let w = 0;
|
|
123
|
+
let i = 0;
|
|
124
|
+
const str = String(s);
|
|
125
|
+
while (i < str.length) {
|
|
126
|
+
ANSI_RE.lastIndex = i;
|
|
127
|
+
const m = ANSI_RE.exec(str);
|
|
128
|
+
if (m && m.index === i) { out += m[0]; i += m[0].length; continue; }
|
|
129
|
+
const ch = String.fromCodePoint(str.codePointAt(i));
|
|
130
|
+
const cw = width(ch);
|
|
131
|
+
if (w + cw > n - 1) { out += '…'; break; }
|
|
132
|
+
out += ch; w += cw; i += ch.length;
|
|
133
|
+
}
|
|
134
|
+
return state.color ? `${out}\x1b[0m` : out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Fit to exactly n columns: truncate or pad.
|
|
138
|
+
export function fit(s, n) { return pad(truncate(s, n), n); }
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { c, h, pad, width, strip, state as colorState } from './ansi.js';
|
|
2
|
+
import { line } from './term.js';
|
|
3
|
+
|
|
4
|
+
export const brand = () => c.g(c.b('gitroots'));
|
|
5
|
+
export const dot = () => c.m('·');
|
|
6
|
+
export const sep = (...parts) => parts.filter((p) => p !== undefined && p !== null && p !== '').join(` ${dot()} `);
|
|
7
|
+
|
|
8
|
+
// Every screen ends with the next command.
|
|
9
|
+
export function next(cmd, alt) {
|
|
10
|
+
const tail = alt ? ` ${c.m('or')} ${c.s(alt)}` : '';
|
|
11
|
+
return `${c.g(`→ ${cmd}`)}${tail}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// State words always pair a symbol with a word.
|
|
15
|
+
export const mark = {
|
|
16
|
+
running: (w = 'running') => c.g(`● ${w}`),
|
|
17
|
+
waiting: (w = 'waiting') => c.w(`◐ ${w}`),
|
|
18
|
+
queued: (w = 'queued') => c.m(`○ ${w}`),
|
|
19
|
+
done: (w = 'done') => c.g(`✓ ${w}`),
|
|
20
|
+
blocked: (w = 'blocked') => c.r(`✕ ${w}`),
|
|
21
|
+
idle: (w = 'idle') => c.s(`○ ${w}`),
|
|
22
|
+
suspended: (w = 'suspended') => c.m(`⏸ ${w}`),
|
|
23
|
+
offline: (w = 'offline') => c.m(`○ ${w}`),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function sessionState(s) {
|
|
27
|
+
switch (s.state) {
|
|
28
|
+
case 'running': return mark.running();
|
|
29
|
+
case 'waiting': return mark.waiting('waiting input');
|
|
30
|
+
case 'idle': return mark.idle();
|
|
31
|
+
case 'suspended': return mark.suspended();
|
|
32
|
+
case 'paused': return c.w('⏸ paused');
|
|
33
|
+
case 'stopped': return c.m('■ stopped');
|
|
34
|
+
default: return c.m(s.state);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function decision(d) {
|
|
39
|
+
if (d === 'allowed') return c.g('✓ allowed');
|
|
40
|
+
if (d === 'recorded') return c.g('✓ recorded');
|
|
41
|
+
if (d === 'blocked') return c.r('✕ blocked');
|
|
42
|
+
if (d === 'held') return c.w('◐ held');
|
|
43
|
+
return c.m(d);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Columns: [{ key, label, w, fmt?(row) }]. Last column has no padding.
|
|
48
|
+
*/
|
|
49
|
+
export function table(cols, rows, { indent = ' ' } = {}) {
|
|
50
|
+
const head = cols.map((col, i) => (i === cols.length - 1 ? col.label : pad(col.label, col.w))).join('');
|
|
51
|
+
line(indent + h(head));
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
const cells = cols.map((col, i) => {
|
|
54
|
+
const v = col.fmt ? col.fmt(row) : String(row[col.key] ?? '');
|
|
55
|
+
return i === cols.length - 1 ? v : pad(v, col.w);
|
|
56
|
+
});
|
|
57
|
+
line(indent + cells.join('').replace(/\s+$/, ''));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Bordered box (blocked pushes etc). tone: 'r' | 'w' | 'g'
|
|
62
|
+
export function box(lines, tone = 'r') {
|
|
63
|
+
const col = c[tone];
|
|
64
|
+
const inner = Math.max(...lines.map((l) => width(l))) + 2;
|
|
65
|
+
line(col(`╭${'─'.repeat(inner)}╮`));
|
|
66
|
+
for (const l of lines) line(`${col('│')} ${pad(l, inner - 2)} ${col('│')}`);
|
|
67
|
+
line(col(`╰${'─'.repeat(inner)}╯`));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function heading(text) { line(h(text)); }
|
|
71
|
+
|
|
72
|
+
export function json(data) {
|
|
73
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function csv(rows, keys) {
|
|
77
|
+
const esc = (v) => {
|
|
78
|
+
const s = v === undefined || v === null ? '' : String(v);
|
|
79
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
80
|
+
};
|
|
81
|
+
process.stdout.write(`${keys.join(',')}\n`);
|
|
82
|
+
for (const r of rows) process.stdout.write(`${keys.map((k) => esc(r[k])).join(',')}\n`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { c, h, pad, width, strip, colorState };
|
package/src/ui/term.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Terminal plumbing: motion, spinners, step runners, prompts, raw keys, full-screen apps, inline images.
|
|
2
|
+
import { asset } from '../runtime.js';
|
|
3
|
+
import { c, state as colorState, pad, width } from './ansi.js';
|
|
4
|
+
|
|
5
|
+
const env = process.env;
|
|
6
|
+
export const out = process.stdout;
|
|
7
|
+
|
|
8
|
+
export const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
9
|
+
|
|
10
|
+
// Motion is functional and stops with NO_COLOR, piped output or reduced motion.
|
|
11
|
+
export function motion() {
|
|
12
|
+
if (!out.isTTY) return false;
|
|
13
|
+
if (!colorState.color) return false;
|
|
14
|
+
if (env.GITROOTS_REDUCED_MOTION || env.REDUCED_MOTION || env.CI) return false;
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function interactive() { return Boolean(process.stdin.isTTY && out.isTTY); }
|
|
19
|
+
|
|
20
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
|
|
22
|
+
export function write(s) { out.write(s); }
|
|
23
|
+
export function line(s = '') { out.write(`${s}\n`); }
|
|
24
|
+
|
|
25
|
+
const CLEAR_LINE = '\r\x1b[2K';
|
|
26
|
+
const HIDE = '\x1b[?25l';
|
|
27
|
+
const SHOW = '\x1b[?25h';
|
|
28
|
+
|
|
29
|
+
let cursorHidden = false;
|
|
30
|
+
function hideCursor() { if (out.isTTY && !cursorHidden) { out.write(HIDE); cursorHidden = true; } }
|
|
31
|
+
function showCursor() { if (cursorHidden) { out.write(SHOW); cursorHidden = false; } }
|
|
32
|
+
process.on('exit', () => { if (cursorHidden) out.write(SHOW); });
|
|
33
|
+
|
|
34
|
+
// A single line that animates in place, then settles.
|
|
35
|
+
export function spinner(render) {
|
|
36
|
+
if (!motion()) return { update() {}, stop(final) { if (final !== undefined) line(final); } };
|
|
37
|
+
let i = 0;
|
|
38
|
+
let current = render;
|
|
39
|
+
hideCursor();
|
|
40
|
+
const draw = () => out.write(CLEAR_LINE + current(FRAMES[i++ % FRAMES.length]));
|
|
41
|
+
draw();
|
|
42
|
+
const timer = setInterval(draw, 80);
|
|
43
|
+
return {
|
|
44
|
+
update(r) { current = r; draw(); },
|
|
45
|
+
stop(final) {
|
|
46
|
+
clearInterval(timer);
|
|
47
|
+
out.write(CLEAR_LINE);
|
|
48
|
+
if (final !== undefined) out.write(`${final}\n`);
|
|
49
|
+
showCursor();
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Progress bar like the design: ██████░░░░
|
|
55
|
+
export function bar(p, w = 34) {
|
|
56
|
+
const n = Math.max(0, Math.min(w, Math.round(p * w)));
|
|
57
|
+
return c.g('█'.repeat(n)) + c.m('░'.repeat(w - n));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run steps the way every long gitroots command shows them:
|
|
62
|
+
* ✓ Name done detail time
|
|
63
|
+
* ⠼ Name what it is doing…
|
|
64
|
+
* ○ Name (queued)
|
|
65
|
+
* Each step: { name, live, run?: async (ctx) => ({ ok?, detail?, status?: 'ok'|'warn'|'fail' }), ms?, bar?, time? }
|
|
66
|
+
*/
|
|
67
|
+
export async function runSteps(steps, opts = {}) {
|
|
68
|
+
const nameW = opts.nameWidth ?? 20;
|
|
69
|
+
const indent = opts.indent ?? ' ';
|
|
70
|
+
const results = [];
|
|
71
|
+
const anim = motion();
|
|
72
|
+
let queued = 0;
|
|
73
|
+
if (anim && opts.showQueued) {
|
|
74
|
+
for (const s of steps) line(`${indent}${c.m(`○ ${pad(s.name, nameW)}`)}`);
|
|
75
|
+
queued = steps.length;
|
|
76
|
+
out.write(`\x1b[${queued}A`);
|
|
77
|
+
}
|
|
78
|
+
for (const step of steps) {
|
|
79
|
+
if (opts.cancelled && opts.cancelled()) {
|
|
80
|
+
if (anim && queued) out.write('\x1b[J');
|
|
81
|
+
results.cancelled = true;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
const started = Date.now();
|
|
85
|
+
let progress = 0;
|
|
86
|
+
const sp = spinner((f) => {
|
|
87
|
+
if (step.bar) {
|
|
88
|
+
const p = progress;
|
|
89
|
+
return `${indent}${c.i(f)} ${c.b(pad(step.name, nameW))}${bar(p)} ${c.s(step.barLabel ? step.barLabel(p) : `${Math.round(p * 100)}%`)}`;
|
|
90
|
+
}
|
|
91
|
+
return `${indent}${c.i(f)} ${c.b(pad(step.name, nameW))}${c.s(step.live || '')}`;
|
|
92
|
+
});
|
|
93
|
+
let res = {};
|
|
94
|
+
let error;
|
|
95
|
+
try {
|
|
96
|
+
const work = step.run ? step.run({ progress: (p) => { progress = p; } }) : null;
|
|
97
|
+
const ms = anim ? (step.ms ?? 0) : 0;
|
|
98
|
+
if (step.bar && anim && !step.run) {
|
|
99
|
+
const t0 = Date.now();
|
|
100
|
+
while (Date.now() - t0 < ms) { progress = (Date.now() - t0) / ms; await sleep(60); }
|
|
101
|
+
progress = 1;
|
|
102
|
+
}
|
|
103
|
+
res = (await work) || {};
|
|
104
|
+
const left = ms - (Date.now() - started);
|
|
105
|
+
if (left > 0) await sleep(left);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
error = e;
|
|
108
|
+
res = { status: 'fail', detail: e.message };
|
|
109
|
+
}
|
|
110
|
+
const status = res.status || 'ok';
|
|
111
|
+
const detail = res.detail ?? step.ok ?? '';
|
|
112
|
+
const mark = status === 'ok' ? c.g('✓') : status === 'warn' ? c.w('◐') : c.r('✕');
|
|
113
|
+
const detailColored = status === 'ok' ? c.m(detail) : status === 'warn' ? c.w(detail) : c.r(detail);
|
|
114
|
+
const time = step.time && status === 'ok' ? ` ${c.m(step.time)}` : '';
|
|
115
|
+
const label = status === 'ok' ? pad(step.name, nameW) : c.b(pad(step.name, nameW));
|
|
116
|
+
sp.stop(`${indent}${mark} ${label}${opts.detailWidth ? pad(detailColored, opts.detailWidth) : detailColored}${time}`);
|
|
117
|
+
if (queued) queued--;
|
|
118
|
+
results.push({ ...step, ...res, status });
|
|
119
|
+
if (error && !opts.continueOnError) throw error;
|
|
120
|
+
if (status === 'fail' && opts.stopOnFail) {
|
|
121
|
+
if (anim && queued) out.write('\x1b[J');
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- keyboard ---------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export function parseKeys(buf) {
|
|
131
|
+
const s = buf.toString('utf8');
|
|
132
|
+
const keys = [];
|
|
133
|
+
let i = 0;
|
|
134
|
+
while (i < s.length) {
|
|
135
|
+
const rest = s.slice(i);
|
|
136
|
+
const seqs = [
|
|
137
|
+
['\x1b[A', 'up'], ['\x1b[B', 'down'], ['\x1b[C', 'right'], ['\x1b[D', 'left'],
|
|
138
|
+
['\x1bOA', 'up'], ['\x1bOB', 'down'], ['\x1bOC', 'right'], ['\x1bOD', 'left'],
|
|
139
|
+
['\x1b[Z', 'shift-tab'], ['\x1b[5~', 'pageup'], ['\x1b[6~', 'pagedown'],
|
|
140
|
+
];
|
|
141
|
+
const hit = seqs.find(([q]) => rest.startsWith(q));
|
|
142
|
+
if (hit) { keys.push({ name: hit[1] }); i += hit[0].length; continue; }
|
|
143
|
+
const ch = String.fromCodePoint(s.codePointAt(i));
|
|
144
|
+
i += ch.length;
|
|
145
|
+
const code = ch.codePointAt(0);
|
|
146
|
+
if (ch === '\r' || ch === '\n') keys.push({ name: 'enter' });
|
|
147
|
+
else if (ch === '\x1b') keys.push({ name: 'escape' });
|
|
148
|
+
else if (ch === '\t') keys.push({ name: 'tab' });
|
|
149
|
+
else if (ch === '\x7f' || ch === '\b') keys.push({ name: 'backspace' });
|
|
150
|
+
else if (code < 27) keys.push({ name: `ctrl-${String.fromCharCode(code + 96)}`, ctrl: true });
|
|
151
|
+
else keys.push({ name: ch, ch });
|
|
152
|
+
}
|
|
153
|
+
return keys;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Raw key session. handler returns true to stop.
|
|
157
|
+
export function keySession(handler) {
|
|
158
|
+
const stdin = process.stdin;
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
const wasRaw = stdin.isRaw;
|
|
161
|
+
stdin.setRawMode(true);
|
|
162
|
+
stdin.resume();
|
|
163
|
+
const onData = async (buf) => {
|
|
164
|
+
for (const k of parseKeys(buf)) {
|
|
165
|
+
const r = await handler(k);
|
|
166
|
+
if (r !== undefined && r !== false) { cleanup(); resolve(r); return; }
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const cleanup = () => {
|
|
170
|
+
stdin.off('data', onData);
|
|
171
|
+
stdin.setRawMode(Boolean(wasRaw));
|
|
172
|
+
stdin.pause();
|
|
173
|
+
};
|
|
174
|
+
stdin.on('data', onData);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ? Question (Y/n) y
|
|
179
|
+
export async function confirm(question, { def = true, assumeYes = false } = {}) {
|
|
180
|
+
const hint = def ? '(Y/n)' : '(y/N)';
|
|
181
|
+
const prefix = `${c.i(c.b('?'))} ${question} ${c.m(hint)} `;
|
|
182
|
+
if (assumeYes) { line(`${prefix}y`); return true; }
|
|
183
|
+
if (!interactive()) throw new Error(`${question.replace(/\x1b\[[0-9;]*m/g, '')} needs an answer · rerun with --yes`);
|
|
184
|
+
write(prefix);
|
|
185
|
+
const ans = await keySession((k) => {
|
|
186
|
+
if (k.name === 'enter') return def ? 'y' : 'n';
|
|
187
|
+
if (k.ch && /[yY]/.test(k.ch)) return 'y';
|
|
188
|
+
if (k.ch && /[nN]/.test(k.ch)) return 'n';
|
|
189
|
+
if (k.name === 'ctrl-c' || k.name === 'escape') return 'n';
|
|
190
|
+
return false;
|
|
191
|
+
});
|
|
192
|
+
line(ans);
|
|
193
|
+
return ans === 'y';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ? Question: answer (hidden: typed characters are not shown)
|
|
197
|
+
export async function ask(question, { hidden = false, def = '' } = {}) {
|
|
198
|
+
if (!interactive()) throw new Error(`${question} needs an answer · pass it as a flag`);
|
|
199
|
+
write(`${c.i(c.b('?'))} ${question}${def ? ` ${c.m(`(${def})`)}` : ''} `);
|
|
200
|
+
let text = '';
|
|
201
|
+
const res = await keySession((k) => {
|
|
202
|
+
if (k.name === 'enter') return { ok: true };
|
|
203
|
+
if (k.name === 'ctrl-c' || k.name === 'escape') return { ok: false };
|
|
204
|
+
if (k.name === 'backspace') { if (text) { text = text.slice(0, -1); if (!hidden) write('\b \b'); } return false; }
|
|
205
|
+
if (k.ch) { text += k.ch; if (!hidden) write(k.ch); }
|
|
206
|
+
return false;
|
|
207
|
+
});
|
|
208
|
+
line(hidden && text ? c.m('••••••••') : '');
|
|
209
|
+
if (!res.ok) throw new Error('cancelled');
|
|
210
|
+
return text || def;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Arrow-key list picker that redraws a block in place.
|
|
214
|
+
export async function select({ render, count, initial = 0, keys = {} }) {
|
|
215
|
+
let idx = initial;
|
|
216
|
+
let drawn = 0;
|
|
217
|
+
const draw = () => {
|
|
218
|
+
if (drawn) write(`\x1b[${drawn}A\x1b[J`);
|
|
219
|
+
const text = render(idx);
|
|
220
|
+
write(text.endsWith('\n') ? text : `${text}\n`);
|
|
221
|
+
drawn = text.replace(/\n$/, '').split('\n').length;
|
|
222
|
+
};
|
|
223
|
+
hideCursor();
|
|
224
|
+
draw();
|
|
225
|
+
const result = await keySession((k) => {
|
|
226
|
+
if (k.name === 'up' || k.ch === 'k') { idx = (idx - 1 + count) % count; draw(); return false; }
|
|
227
|
+
if (k.name === 'down' || k.ch === 'j') { idx = (idx + 1) % count; draw(); return false; }
|
|
228
|
+
if (k.name === 'enter') return { action: 'enter', index: idx };
|
|
229
|
+
if (k.name === 'escape' || k.name === 'ctrl-c' || k.ch === 'q') return { action: 'cancel', index: idx };
|
|
230
|
+
if (k.ch && keys[k.ch]) return { action: keys[k.ch], index: idx };
|
|
231
|
+
if (k.ch && /^[1-9]$/.test(k.ch) && Number(k.ch) <= count) { idx = Number(k.ch) - 1; draw(); return false; }
|
|
232
|
+
return false;
|
|
233
|
+
});
|
|
234
|
+
showCursor();
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- full screen ------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
export function enterFullscreen() {
|
|
241
|
+
out.write('\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J');
|
|
242
|
+
cursorHidden = true;
|
|
243
|
+
}
|
|
244
|
+
export function leaveFullscreen() {
|
|
245
|
+
out.write('\x1b[?25h\x1b[?1049l');
|
|
246
|
+
cursorHidden = false;
|
|
247
|
+
}
|
|
248
|
+
export function size() { return { cols: out.columns || 148, rows: out.rows || 38 }; }
|
|
249
|
+
|
|
250
|
+
// Paint a whole frame without flicker: home, each row cleared to end.
|
|
251
|
+
export function paintFrame(rows) {
|
|
252
|
+
out.write(`\x1b[H${rows.map((r) => `${r}\x1b[0m\x1b[K`).join('\r\n')}\x1b[J`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---- inline logo image ------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
export function imageProtocol() {
|
|
258
|
+
if (!out.isTTY || env.GITROOTS_NO_IMAGES) return null;
|
|
259
|
+
if (env.TMUX || (env.TERM || '').startsWith('screen')) return null;
|
|
260
|
+
const tp = env.TERM_PROGRAM || '';
|
|
261
|
+
if (env.KITTY_WINDOW_ID || (env.TERM || '') === 'xterm-kitty' || tp === 'ghostty' || env.GHOSTTY_RESOURCES_DIR) return 'kitty';
|
|
262
|
+
if (tp === 'iTerm.app' || tp === 'WezTerm' || env.WEZTERM_EXECUTABLE) return 'iterm';
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Draws the logo at the cursor, cols×rows cells, without moving the cursor (caller positions text).
|
|
267
|
+
export function logoImage(cols, rows) {
|
|
268
|
+
const proto = imageProtocol();
|
|
269
|
+
if (!proto) return null;
|
|
270
|
+
let data;
|
|
271
|
+
try { data = asset('logo.png').toString('base64'); } catch { return null; }
|
|
272
|
+
if (proto === 'iterm') {
|
|
273
|
+
return `\x1b]1337;File=inline=1;width=${cols};height=${rows};preserveAspectRatio=1;size=${Buffer.byteLength(data, 'base64')}:${data}\x07`;
|
|
274
|
+
}
|
|
275
|
+
const chunks = data.match(/.{1,4096}/g);
|
|
276
|
+
return chunks.map((chunk, i) => {
|
|
277
|
+
const more = i < chunks.length - 1 ? 1 : 0;
|
|
278
|
+
const head = i === 0 ? `a=T,f=100,q=2,C=1,c=${cols},r=${rows},m=${more}` : `m=${more}`;
|
|
279
|
+
return `\x1b_G${head};${chunk}\x1b\\`;
|
|
280
|
+
}).join('');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Copy to clipboard where a tool exists. Returns true only when it worked.
|
|
284
|
+
export async function copyToClipboard(text) {
|
|
285
|
+
const { spawnSync } = await import('node:child_process');
|
|
286
|
+
const tries = process.platform === 'darwin' ? [['pbcopy', []]]
|
|
287
|
+
: process.platform === 'win32' ? [['clip', []]]
|
|
288
|
+
: [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
|
|
289
|
+
for (const [cmd, args] of tries) {
|
|
290
|
+
const r = spawnSync(cmd, args, { input: text });
|
|
291
|
+
if (r.status === 0) return true;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export { width };
|