gitroots 0.0.0-stage → 0.6.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 +19 -0
- package/package.json +36 -4
- package/src/agents/local.js +219 -0
- package/src/api.js +84 -0
- package/src/cli.js +76 -0
- package/src/commands/completion.js +46 -0
- package/src/commands/push.js +207 -0
- package/src/commands/update.js +69 -0
- package/src/config.js +66 -0
- package/src/git/checks.js +96 -0
- package/src/installer.js +96 -0
- package/src/rules.js +88 -0
- package/src/runtime.js +63 -0
- package/src/session/account.js +133 -0
- package/src/session/agents.js +416 -0
- package/src/session/helpers.js +71 -0
- package/src/session/logo.js +40 -0
- package/src/session/registry.js +61 -0
- package/src/session/repl.js +332 -0
- package/src/session/setup.js +101 -0
- package/src/session/workspace.js +270 -0
- package/src/ui/ansi.js +138 -0
- package/src/ui/components.js +85 -0
- package/src/ui/term.js +298 -0
package/src/ui/term.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
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
|
+
['\x1b[H', 'home'], ['\x1b[F', 'end'], ['\x1b[1~', 'home'], ['\x1b[4~', 'end'], ['\x1bOH', 'home'], ['\x1bOF', 'end'], ['\x1b[3~', 'delete'],
|
|
141
|
+
['\x1b[1;3D', 'word-left'], ['\x1b[1;3C', 'word-right'], ['\x1bb', 'word-left'], ['\x1bf', 'word-right'], ['\x1b\x7f', 'ctrl-w'],
|
|
142
|
+
];
|
|
143
|
+
const hit = seqs.find(([q]) => rest.startsWith(q));
|
|
144
|
+
if (hit) { keys.push({ name: hit[1] }); i += hit[0].length; continue; }
|
|
145
|
+
const ch = String.fromCodePoint(s.codePointAt(i));
|
|
146
|
+
i += ch.length;
|
|
147
|
+
const code = ch.codePointAt(0);
|
|
148
|
+
if (ch === '\r' || ch === '\n') keys.push({ name: 'enter' });
|
|
149
|
+
else if (ch === '\x1b') keys.push({ name: 'escape' });
|
|
150
|
+
else if (ch === '\t') keys.push({ name: 'tab' });
|
|
151
|
+
else if (ch === '\x7f' || ch === '\b') keys.push({ name: 'backspace' });
|
|
152
|
+
else if (code < 27) keys.push({ name: `ctrl-${String.fromCharCode(code + 96)}`, ctrl: true });
|
|
153
|
+
else keys.push({ name: ch, ch });
|
|
154
|
+
}
|
|
155
|
+
return keys;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Raw key session. handler returns true to stop.
|
|
159
|
+
export function keySession(handler) {
|
|
160
|
+
const stdin = process.stdin;
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
const wasRaw = stdin.isRaw;
|
|
163
|
+
stdin.setRawMode(true);
|
|
164
|
+
stdin.resume();
|
|
165
|
+
const onData = async (buf) => {
|
|
166
|
+
for (const k of parseKeys(buf)) {
|
|
167
|
+
const r = await handler(k);
|
|
168
|
+
if (r !== undefined && r !== false) { cleanup(); resolve(r); return; }
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const cleanup = () => {
|
|
172
|
+
stdin.off('data', onData);
|
|
173
|
+
stdin.setRawMode(Boolean(wasRaw));
|
|
174
|
+
stdin.pause();
|
|
175
|
+
};
|
|
176
|
+
stdin.on('data', onData);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ? Question (Y/n) y
|
|
181
|
+
export async function confirm(question, { def = true, assumeYes = false } = {}) {
|
|
182
|
+
const hint = def ? '(Y/n)' : '(y/N)';
|
|
183
|
+
const prefix = `${c.i(c.b('?'))} ${question} ${c.m(hint)} `;
|
|
184
|
+
if (assumeYes) { line(`${prefix}y`); return true; }
|
|
185
|
+
if (!interactive()) throw new Error(`${question.replace(/\x1b\[[0-9;]*m/g, '')} needs an answer · rerun with --yes`);
|
|
186
|
+
write(prefix);
|
|
187
|
+
const ans = await keySession((k) => {
|
|
188
|
+
if (k.name === 'enter') return def ? 'y' : 'n';
|
|
189
|
+
if (k.ch && /[yY]/.test(k.ch)) return 'y';
|
|
190
|
+
if (k.ch && /[nN]/.test(k.ch)) return 'n';
|
|
191
|
+
if (k.name === 'ctrl-c' || k.name === 'escape') return 'n';
|
|
192
|
+
return false;
|
|
193
|
+
});
|
|
194
|
+
line(ans);
|
|
195
|
+
return ans === 'y';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ? Question: answer (hidden: typed characters are not shown)
|
|
199
|
+
export async function ask(question, { hidden = false, def = '' } = {}) {
|
|
200
|
+
if (!interactive()) throw new Error(`${question} needs an answer · pass it as a flag`);
|
|
201
|
+
write(`${c.i(c.b('?'))} ${question}${def ? ` ${c.m(`(${def})`)}` : ''} `);
|
|
202
|
+
let text = '';
|
|
203
|
+
const res = await keySession((k) => {
|
|
204
|
+
if (k.name === 'enter') return { ok: true };
|
|
205
|
+
if (k.name === 'ctrl-c' || k.name === 'escape') return { ok: false };
|
|
206
|
+
if (k.name === 'backspace') { if (text) { text = text.slice(0, -1); if (!hidden) write('\b \b'); } return false; }
|
|
207
|
+
if (k.ch) { text += k.ch; if (!hidden) write(k.ch); }
|
|
208
|
+
return false;
|
|
209
|
+
});
|
|
210
|
+
line(hidden && text ? c.m('••••••••') : '');
|
|
211
|
+
if (!res.ok) throw new Error('cancelled');
|
|
212
|
+
return text || def;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Arrow-key list picker that redraws a block in place.
|
|
216
|
+
export async function select({ render, count, initial = 0, keys = {} }) {
|
|
217
|
+
let idx = initial;
|
|
218
|
+
let drawn = 0;
|
|
219
|
+
const draw = () => {
|
|
220
|
+
if (drawn) write(`\x1b[${drawn}A\x1b[J`);
|
|
221
|
+
const text = render(idx);
|
|
222
|
+
write(text.endsWith('\n') ? text : `${text}\n`);
|
|
223
|
+
drawn = text.replace(/\n$/, '').split('\n').length;
|
|
224
|
+
};
|
|
225
|
+
hideCursor();
|
|
226
|
+
draw();
|
|
227
|
+
const result = await keySession((k) => {
|
|
228
|
+
if (k.name === 'up' || k.ch === 'k') { idx = (idx - 1 + count) % count; draw(); return false; }
|
|
229
|
+
if (k.name === 'down' || k.ch === 'j') { idx = (idx + 1) % count; draw(); return false; }
|
|
230
|
+
if (k.name === 'enter') return { action: 'enter', index: idx };
|
|
231
|
+
if (k.name === 'escape' || k.name === 'ctrl-c' || k.ch === 'q') return { action: 'cancel', index: idx };
|
|
232
|
+
if (k.ch && keys[k.ch]) return { action: keys[k.ch], index: idx };
|
|
233
|
+
if (k.ch && /^[1-9]$/.test(k.ch) && Number(k.ch) <= count) { idx = Number(k.ch) - 1; draw(); return false; }
|
|
234
|
+
return false;
|
|
235
|
+
});
|
|
236
|
+
showCursor();
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---- full screen ------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
export function enterFullscreen() {
|
|
243
|
+
out.write('\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J');
|
|
244
|
+
cursorHidden = true;
|
|
245
|
+
}
|
|
246
|
+
export function leaveFullscreen() {
|
|
247
|
+
out.write('\x1b[?25h\x1b[?1049l');
|
|
248
|
+
cursorHidden = false;
|
|
249
|
+
}
|
|
250
|
+
export function size() { return { cols: out.columns || 148, rows: out.rows || 38 }; }
|
|
251
|
+
|
|
252
|
+
// Paint a whole frame without flicker: home, each row cleared to end.
|
|
253
|
+
export function paintFrame(rows) {
|
|
254
|
+
out.write(`\x1b[H${rows.map((r) => `${r}\x1b[0m\x1b[K`).join('\r\n')}\x1b[J`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---- inline logo image ------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
export function imageProtocol() {
|
|
260
|
+
if (!out.isTTY || env.GITROOTS_NO_IMAGES) return null;
|
|
261
|
+
if (env.TMUX || (env.TERM || '').startsWith('screen')) return null;
|
|
262
|
+
const tp = env.TERM_PROGRAM || '';
|
|
263
|
+
if (env.KITTY_WINDOW_ID || (env.TERM || '') === 'xterm-kitty' || tp === 'ghostty' || env.GHOSTTY_RESOURCES_DIR) return 'kitty';
|
|
264
|
+
if (tp === 'iTerm.app' || tp === 'WezTerm' || env.WEZTERM_EXECUTABLE) return 'iterm';
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Draws the logo at the cursor, cols×rows cells, without moving the cursor (caller positions text).
|
|
269
|
+
export function logoImage(cols, rows) {
|
|
270
|
+
const proto = imageProtocol();
|
|
271
|
+
if (!proto) return null;
|
|
272
|
+
let data;
|
|
273
|
+
try { data = asset('logo.png').toString('base64'); } catch { return null; }
|
|
274
|
+
if (proto === 'iterm') {
|
|
275
|
+
return `\x1b]1337;File=inline=1;width=${cols};height=${rows};preserveAspectRatio=1;size=${Buffer.byteLength(data, 'base64')}:${data}\x07`;
|
|
276
|
+
}
|
|
277
|
+
const chunks = data.match(/.{1,4096}/g);
|
|
278
|
+
return chunks.map((chunk, i) => {
|
|
279
|
+
const more = i < chunks.length - 1 ? 1 : 0;
|
|
280
|
+
const head = i === 0 ? `a=T,f=100,q=2,C=1,c=${cols},r=${rows},m=${more}` : `m=${more}`;
|
|
281
|
+
return `\x1b_G${head};${chunk}\x1b\\`;
|
|
282
|
+
}).join('');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Copy to clipboard where a tool exists. Returns true only when it worked.
|
|
286
|
+
export async function copyToClipboard(text) {
|
|
287
|
+
const { spawnSync } = await import('node:child_process');
|
|
288
|
+
const tries = process.platform === 'darwin' ? [['pbcopy', []]]
|
|
289
|
+
: process.platform === 'win32' ? [['clip', []]]
|
|
290
|
+
: [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
|
|
291
|
+
for (const [cmd, args] of tries) {
|
|
292
|
+
const r = spawnSync(cmd, args, { input: text });
|
|
293
|
+
if (r.status === 0) return true;
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export { width };
|