gitroots 0.5.0 → 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 +76 -76
- package/bin/gitroots.js +8 -0
- package/package.json +2 -1
- package/src/agents/local.js +219 -0
- package/src/api.js +61 -98
- package/src/cli.js +36 -110
- package/src/commands/completion.js +14 -37
- package/src/commands/push.js +65 -131
- package/src/commands/update.js +1 -1
- package/src/config.js +66 -0
- package/src/rules.js +88 -0
- package/src/runtime.js +1 -1
- 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/term.js +2 -0
- package/src/commands/attach.js +0 -262
- package/src/commands/audit.js +0 -150
- package/src/commands/auth.js +0 -99
- package/src/commands/backup.js +0 -106
- package/src/commands/demo.js +0 -12
- package/src/commands/doctor.js +0 -81
- package/src/commands/pr.js +0 -322
- package/src/commands/repos.js +0 -122
- package/src/commands/restore.js +0 -125
- package/src/commands/splash.js +0 -95
- package/src/commands/status.js +0 -84
- package/src/commands/transfer.js +0 -79
- package/src/commands/tunnel.js +0 -116
- package/src/commands/watch.js +0 -347
- package/src/connected.js +0 -82
- package/src/data/live.js +0 -49
- package/src/data/sample.js +0 -163
- package/src/data/store.js +0 -145
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// The gitroots session: run `gitroots`, get a welcome box and an input; type / to pick a command.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { c, h, pad, fit, width } from '../ui/ansi.js';
|
|
4
|
+
import { out, write, size, parseKeys } from '../ui/term.js';
|
|
5
|
+
import { Api, ApiError } from '../api.js';
|
|
6
|
+
import { readConfig, historyFile } from '../config.js';
|
|
7
|
+
import { VERSION } from '../runtime.js';
|
|
8
|
+
import { tryGit } from '../git/checks.js';
|
|
9
|
+
import { localSessions, shortPath } from '../agents/local.js';
|
|
10
|
+
import { remoteFullName } from '../rules.js';
|
|
11
|
+
import { welcomeBox } from './logo.js';
|
|
12
|
+
import { COMMANDS, find, match, suggest } from './registry.js';
|
|
13
|
+
|
|
14
|
+
const MENU_ROWS = 8;
|
|
15
|
+
|
|
16
|
+
export function makeCtx({ session }) {
|
|
17
|
+
const ctx = {
|
|
18
|
+
session,
|
|
19
|
+
json: false,
|
|
20
|
+
api: Api.fromConfig(),
|
|
21
|
+
signal: null,
|
|
22
|
+
reload() { ctx.api = Api.fromConfig(); },
|
|
23
|
+
};
|
|
24
|
+
return ctx;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Split a line into words, keeping "quoted text" together.
|
|
28
|
+
export function words(text) {
|
|
29
|
+
const outw = [];
|
|
30
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
31
|
+
let m;
|
|
32
|
+
while ((m = re.exec(text))) outw.push(m[1] ?? m[2] ?? m[3]);
|
|
33
|
+
return outw;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function header(ctx) {
|
|
37
|
+
const cfg = readConfig();
|
|
38
|
+
const { cols } = size();
|
|
39
|
+
const top = tryGit(['rev-parse', '--show-toplevel']);
|
|
40
|
+
const branch = top ? tryGit(['rev-parse', '--abbrev-ref', 'HEAD']) : '';
|
|
41
|
+
const repo = top ? remoteFullName(tryGit(['remote', 'get-url', 'origin'])) || top.split('/').pop() : '';
|
|
42
|
+
const who = ctx.api.signedIn
|
|
43
|
+
? `${c.b(cfg.user?.name || cfg.username)} ${c.m('·')} ${cfg.org ? `${cfg.org.name} ${c.m(`(${cfg.org.role})`)}` : c.w('no workspace · /workspace')} ${c.m(`· ${ctx.api.host}`)}`
|
|
44
|
+
: `${c.w('Not signed in')} ${c.m('· /login to connect your gitroots account')}`;
|
|
45
|
+
return welcomeBox([
|
|
46
|
+
`${c.b('gitroots')} ${c.m(`v${VERSION}`)}`,
|
|
47
|
+
c.m('code governance and agent sessions'),
|
|
48
|
+
who,
|
|
49
|
+
c.m(`${shortPath(process.cwd())}${repo ? ` · ${repo}${branch ? ` @ ${branch}` : ''}` : ''}`),
|
|
50
|
+
], cols);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class Repl {
|
|
54
|
+
constructor(ctx) {
|
|
55
|
+
this.ctx = ctx;
|
|
56
|
+
this.input = '';
|
|
57
|
+
this.pos = 0;
|
|
58
|
+
this.menuIdx = 0;
|
|
59
|
+
this.menuDismissed = false;
|
|
60
|
+
this.drawn = 0;
|
|
61
|
+
this.cursorRow = 0;
|
|
62
|
+
this.notice = null;
|
|
63
|
+
this.lastCtrlC = 0;
|
|
64
|
+
this.running = false;
|
|
65
|
+
this.history = [];
|
|
66
|
+
this.hIdx = -1;
|
|
67
|
+
this.draft = '';
|
|
68
|
+
try { this.history = fs.readFileSync(historyFile(), 'utf8').split('\n').filter(Boolean).slice(-300); } catch { /* none */ }
|
|
69
|
+
this.onData = (buf) => { for (const k of parseKeys(buf)) this.key(k); };
|
|
70
|
+
this.onResize = () => { if (!this.running) { this.drawn = 0; write('\r\x1b[J'); this.draw(); } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
menuItems() {
|
|
74
|
+
if (this.menuDismissed || !this.input.startsWith('/') || /\s/.test(this.input)) return null;
|
|
75
|
+
return match(this.input.slice(1));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
render() {
|
|
79
|
+
const { cols } = size();
|
|
80
|
+
const W = Math.max(30, cols - 1);
|
|
81
|
+
const inner = W - 2;
|
|
82
|
+
const vis = inner - 4;
|
|
83
|
+
let shown = this.input;
|
|
84
|
+
let cur = this.pos;
|
|
85
|
+
if (width(shown) > vis - 1) {
|
|
86
|
+
const start = Math.max(0, this.pos - (vis - 1));
|
|
87
|
+
shown = shown.slice(start, start + vis - 1);
|
|
88
|
+
cur = this.pos - start;
|
|
89
|
+
}
|
|
90
|
+
const body = this.input ? shown : c.m('Type / for commands');
|
|
91
|
+
const lines = [
|
|
92
|
+
c.m(`╭${'─'.repeat(inner)}╮`),
|
|
93
|
+
`${c.m('│')} ${c.g('>')} ${fit(body, vis)} ${c.m('│')}`,
|
|
94
|
+
c.m(`╰${'─'.repeat(inner)}╯`),
|
|
95
|
+
];
|
|
96
|
+
const items = this.menuItems();
|
|
97
|
+
if (items && items.length) {
|
|
98
|
+
this.menuIdx = Math.min(this.menuIdx, items.length - 1);
|
|
99
|
+
const start = Math.max(0, Math.min(this.menuIdx - MENU_ROWS + 1, items.length - MENU_ROWS));
|
|
100
|
+
for (let i = start; i < Math.min(items.length, start + MENU_ROWS); i++) {
|
|
101
|
+
const it = items[i];
|
|
102
|
+
const sel = i === this.menuIdx;
|
|
103
|
+
const name = `/${it.name}`;
|
|
104
|
+
const text = `${pad(sel ? c.g(c.b(name)) : name, 16)}${sel ? it.desc : c.s(it.desc)}${it.args ? c.m(` ${it.args}`) : ''}`;
|
|
105
|
+
lines.push(fit(`${sel ? c.g(' ❯ ') : ' '}${text}`, W));
|
|
106
|
+
}
|
|
107
|
+
if (items.length > MENU_ROWS) lines.push(c.m(fit(` ${items.length} commands · ↑↓ choose · tab complete · enter run · esc close`, W)));
|
|
108
|
+
} else if (items && !items.length) {
|
|
109
|
+
lines.push(c.m(fit(` no command matches “${this.input.slice(1)}” · /help lists them all`, W)));
|
|
110
|
+
} else {
|
|
111
|
+
const cfg = readConfig();
|
|
112
|
+
const left = this.notice || c.m(' / commands · ↑↓ history · esc clear · ctrl-c twice to exit');
|
|
113
|
+
const right = this.ctx.api.signedIn && cfg.org ? c.m(`${cfg.org.name} `) : '';
|
|
114
|
+
lines.push(`${fit(left, Math.max(0, W - width(right)))}${right}`);
|
|
115
|
+
}
|
|
116
|
+
return { lines, cursorRow: 1, cursorCol: 4 + width(this.input ? shown.slice(0, cur) : '') };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
draw() {
|
|
120
|
+
if (this.running) return;
|
|
121
|
+
const { lines, cursorRow, cursorCol } = this.render();
|
|
122
|
+
let s = '';
|
|
123
|
+
if (this.drawn) s += `${this.cursorRow ? `\x1b[${this.cursorRow}A` : ''}\r\x1b[J`;
|
|
124
|
+
s += lines.join('\n');
|
|
125
|
+
const up = lines.length - 1 - cursorRow;
|
|
126
|
+
if (up) s += `\x1b[${up}A`;
|
|
127
|
+
s += `\r${cursorCol ? `\x1b[${cursorCol}C` : ''}`;
|
|
128
|
+
write(s);
|
|
129
|
+
this.drawn = lines.length;
|
|
130
|
+
this.cursorRow = cursorRow;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
clearPrompt() {
|
|
134
|
+
if (!this.drawn) return;
|
|
135
|
+
write(`${this.cursorRow ? `\x1b[${this.cursorRow}A` : ''}\r\x1b[J`);
|
|
136
|
+
this.drawn = 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Print above the prompt, e.g. a background notice.
|
|
140
|
+
print(text) {
|
|
141
|
+
if (this.running) { write(`${text}\n`); return; }
|
|
142
|
+
this.clearPrompt();
|
|
143
|
+
write(`${text}\n`);
|
|
144
|
+
this.draw();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
setInput(text) { this.input = text; this.pos = text.length; this.menuIdx = 0; this.menuDismissed = false; }
|
|
148
|
+
|
|
149
|
+
key(k) {
|
|
150
|
+
if (this.running) return;
|
|
151
|
+
this.notice = null;
|
|
152
|
+
const items = this.menuItems();
|
|
153
|
+
const n = k.name;
|
|
154
|
+
if (items && items.length) {
|
|
155
|
+
if (n === 'up') { this.menuIdx = (this.menuIdx - 1 + items.length) % items.length; return this.draw(); }
|
|
156
|
+
if (n === 'down') { this.menuIdx = (this.menuIdx + 1) % items.length; return this.draw(); }
|
|
157
|
+
if (n === 'tab') { const it = items[this.menuIdx]; this.setInput(`/${it.name}${it.args ? ' ' : ''}`); return this.draw(); }
|
|
158
|
+
if (n === 'enter') { this.setInput(`/${items[this.menuIdx].name}`); return this.submit(); }
|
|
159
|
+
if (n === 'escape') { this.menuDismissed = true; return this.draw(); }
|
|
160
|
+
}
|
|
161
|
+
switch (n) {
|
|
162
|
+
case 'enter': return this.submit();
|
|
163
|
+
case 'escape': this.setInput(''); break;
|
|
164
|
+
case 'tab': if (!this.input) this.setInput('/'); break;
|
|
165
|
+
case 'ctrl-c':
|
|
166
|
+
if (this.input) { this.setInput(''); break; }
|
|
167
|
+
if (Date.now() - this.lastCtrlC < 1500) return this.exit();
|
|
168
|
+
this.lastCtrlC = Date.now();
|
|
169
|
+
this.notice = c.w(' press ctrl-c again to exit');
|
|
170
|
+
break;
|
|
171
|
+
case 'ctrl-d': if (!this.input) return this.exit(); this.input = this.input.slice(0, this.pos) + this.input.slice(this.pos + 1); break;
|
|
172
|
+
case 'ctrl-l': write('\x1b[2J\x1b[H'); this.drawn = 0; write(`${header(this.ctx).join('\n')}\n\n`); break;
|
|
173
|
+
case 'left': this.pos = Math.max(0, this.pos - 1); break;
|
|
174
|
+
case 'right': this.pos = Math.min(this.input.length, this.pos + 1); break;
|
|
175
|
+
case 'home': case 'ctrl-a': this.pos = 0; break;
|
|
176
|
+
case 'end': case 'ctrl-e': this.pos = this.input.length; break;
|
|
177
|
+
case 'word-left': { const m = /\S*\s*$/.exec(this.input.slice(0, this.pos)); this.pos -= m ? m[0].length : 0; break; }
|
|
178
|
+
case 'word-right': { const m = /^\s*\S*/.exec(this.input.slice(this.pos)); this.pos += m ? m[0].length : 0; break; }
|
|
179
|
+
case 'backspace': if (this.pos) { this.input = this.input.slice(0, this.pos - 1) + this.input.slice(this.pos); this.pos--; this.menuDismissed = false; this.menuIdx = 0; } break;
|
|
180
|
+
case 'delete': this.input = this.input.slice(0, this.pos) + this.input.slice(this.pos + 1); break;
|
|
181
|
+
case 'ctrl-u': this.input = this.input.slice(this.pos); this.pos = 0; break;
|
|
182
|
+
case 'ctrl-k': this.input = this.input.slice(0, this.pos); break;
|
|
183
|
+
case 'ctrl-w': { const m = /\S*\s*$/.exec(this.input.slice(0, this.pos)); const cut = m ? m[0].length : 0; this.input = this.input.slice(0, this.pos - cut) + this.input.slice(this.pos); this.pos -= cut; break; }
|
|
184
|
+
case 'up':
|
|
185
|
+
if (!this.history.length) break;
|
|
186
|
+
if (this.hIdx === -1) { this.draft = this.input; this.hIdx = this.history.length; }
|
|
187
|
+
this.hIdx = Math.max(0, this.hIdx - 1);
|
|
188
|
+
this.input = this.history[this.hIdx]; this.pos = this.input.length; this.menuDismissed = true;
|
|
189
|
+
break;
|
|
190
|
+
case 'down':
|
|
191
|
+
if (this.hIdx === -1) break;
|
|
192
|
+
this.hIdx++;
|
|
193
|
+
if (this.hIdx >= this.history.length) { this.hIdx = -1; this.input = this.draft; } else this.input = this.history[this.hIdx];
|
|
194
|
+
this.pos = this.input.length; this.menuDismissed = true;
|
|
195
|
+
break;
|
|
196
|
+
default:
|
|
197
|
+
if (k.ch && !k.ctrl) {
|
|
198
|
+
this.input = this.input.slice(0, this.pos) + k.ch + this.input.slice(this.pos);
|
|
199
|
+
this.pos += k.ch.length;
|
|
200
|
+
this.menuDismissed = false;
|
|
201
|
+
this.menuIdx = 0;
|
|
202
|
+
this.hIdx = -1;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
this.draw();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
remember(lineText) {
|
|
209
|
+
if (this.history[this.history.length - 1] !== lineText) {
|
|
210
|
+
this.history.push(lineText);
|
|
211
|
+
try { fs.appendFileSync(historyFile(), `${lineText}\n`, { mode: 0o600 }); } catch { /* read-only */ }
|
|
212
|
+
}
|
|
213
|
+
this.hIdx = -1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async submit() {
|
|
217
|
+
const text = this.input.trim();
|
|
218
|
+
this.clearPrompt();
|
|
219
|
+
this.setInput('');
|
|
220
|
+
if (!text) { this.draw(); return; }
|
|
221
|
+
this.remember(text);
|
|
222
|
+
write(`${c.m('>')} ${c.b(text)}\n`);
|
|
223
|
+
const [first, ...rest] = words(text);
|
|
224
|
+
const cmd = find(first);
|
|
225
|
+
if (!cmd) {
|
|
226
|
+
const near = suggest(first.replace(/^\//, ''));
|
|
227
|
+
write(`${c.r('✕')} ${first.startsWith('/') ? 'No command' : 'Not a command:'} ${c.b(first)}${near.length ? ` ${c.m('did you mean')} ${near.map((x) => c.g(`/${x.name}`)).join(c.m(', '))}${c.m('?')}` : ''} ${c.m('· type / to pick one')}\n\n`);
|
|
228
|
+
this.draw();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (cmd.name === 'exit') return this.exit();
|
|
232
|
+
if (cmd.name === 'clear') { write('\x1b[2J\x1b[3J\x1b[H'); write(`${header(this.ctx).join('\n')}\n\n`); this.draw(); return; }
|
|
233
|
+
if (cmd.name === 'help') { printHelp(true); write('\n'); this.draw(); return; }
|
|
234
|
+
await this.run(cmd, rest);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async run(cmd, rest) {
|
|
238
|
+
const { parseArgs } = await import('../cli.js');
|
|
239
|
+
const parsed = parseArgs(rest);
|
|
240
|
+
this.running = true;
|
|
241
|
+
process.stdin.off('data', this.onData);
|
|
242
|
+
process.stdin.setRawMode(false);
|
|
243
|
+
const ac = new AbortController();
|
|
244
|
+
this.ctx.signal = ac.signal;
|
|
245
|
+
const onInt = () => ac.abort();
|
|
246
|
+
process.on('SIGINT', onInt);
|
|
247
|
+
try {
|
|
248
|
+
await cmd.run(this.ctx, parsed._, parsed.flags);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
const msg = e instanceof ApiError || e instanceof Error ? e.message : String(e);
|
|
251
|
+
write(`${ac.signal.aborted ? c.m('○ cancelled') : `${c.r('✕')} ${msg}`}\n`);
|
|
252
|
+
if (process.env.GITROOTS_DEBUG && e && e.stack) write(`${e.stack}\n`);
|
|
253
|
+
} finally {
|
|
254
|
+
process.off('SIGINT', onInt);
|
|
255
|
+
this.ctx.signal = null;
|
|
256
|
+
this.running = false;
|
|
257
|
+
write('\n');
|
|
258
|
+
process.stdin.setRawMode(true);
|
|
259
|
+
process.stdin.resume();
|
|
260
|
+
process.stdin.on('data', this.onData);
|
|
261
|
+
this.draw();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
exit() {
|
|
266
|
+
this.clearPrompt();
|
|
267
|
+
write(`${c.m('bye ·')} ${c.g('gitroots')} ${c.m('to come back')}\n`);
|
|
268
|
+
process.stdin.setRawMode(false);
|
|
269
|
+
process.exit(0);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async greet() {
|
|
273
|
+
// What needs you, fetched after the prompt is up so the session opens instantly.
|
|
274
|
+
const ctx = this.ctx;
|
|
275
|
+
const local = localSessions({ limit: 40 });
|
|
276
|
+
const liveLocal = local.filter((s) => s.status === 'live').length;
|
|
277
|
+
const parts = [];
|
|
278
|
+
let hint = '';
|
|
279
|
+
if (ctx.api.signedIn && ctx.api.orgId != null) {
|
|
280
|
+
const [home, agents] = await Promise.all([
|
|
281
|
+
ctx.api.home({ timeout: 8000 }).catch((e) => ({ error: e.message })),
|
|
282
|
+
ctx.api.agents({ timeout: 8000 }).catch(() => null),
|
|
283
|
+
]);
|
|
284
|
+
if (home.error) { this.print(` ${c.w(`◐ couldn't reach gitroots: ${home.error}`)}\n`); return; }
|
|
285
|
+
if (home.pending_count) { parts.push(c.w(`◐ ${home.pending_count} approval${home.pending_count > 1 ? 's' : ''} waiting`)); hint = '/approvals'; }
|
|
286
|
+
const waiting = (agents?.sessions || []).filter((s) => s.needs_you).length;
|
|
287
|
+
if (waiting) { parts.push(c.w(`◐ ${waiting} agent${waiting > 1 ? 's' : ''} need${waiting > 1 ? '' : 's'} you`)); hint = hint || '/agents'; }
|
|
288
|
+
if (home.blocked_24h) { parts.push(c.r(`✕ ${home.blocked_24h} blocked push${home.blocked_24h > 1 ? 'es' : ''} today`)); hint = hint || '/pushes --blocked'; }
|
|
289
|
+
if (!parts.length) parts.push(c.g('✓ nothing waiting on you'));
|
|
290
|
+
}
|
|
291
|
+
if (liveLocal) parts.push(c.g(`● ${liveLocal} agent session${liveLocal > 1 ? 's' : ''} live on this computer`));
|
|
292
|
+
if (parts.length) this.print(`${fit(` ${parts.join(c.m(' · '))}${hint ? ` ${c.g(`→ ${hint}`)}` : ''}`, size().cols - 1)}\n`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
start() {
|
|
296
|
+
write(`\n${header(this.ctx).join('\n')}\n`);
|
|
297
|
+
write(` ${c.m('Type')} ${c.g('/')} ${c.m('to pick a command, or')} ${c.g('/help')} ${c.m('for all of them.')}${this.ctx.api.signedIn ? '' : ` ${c.m('Start with')} ${c.g('/login')}${c.m('.')}`}\n\n`);
|
|
298
|
+
process.stdin.setRawMode(true);
|
|
299
|
+
process.stdin.resume();
|
|
300
|
+
process.stdin.on('data', this.onData);
|
|
301
|
+
out.on('resize', this.onResize);
|
|
302
|
+
this.draw();
|
|
303
|
+
this.greet().catch(() => {});
|
|
304
|
+
return new Promise(() => {}); // runs until exit
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function printHelp(session) {
|
|
309
|
+
const p = session ? '/' : 'gitroots ';
|
|
310
|
+
const groups = [...new Set(COMMANDS.map((x) => x.group))];
|
|
311
|
+
for (const g of groups) {
|
|
312
|
+
write(`${h(g)}\n`);
|
|
313
|
+
for (const x of COMMANDS.filter((y) => y.group === g && (session || !y.session))) {
|
|
314
|
+
const left = c.g(`${p}${x.name}`) + (x.args ? ` ${c.m(x.args)}` : '');
|
|
315
|
+
write(` ${width(left) >= 48 ? `${left} ` : pad(left, 50)}${x.desc}\n`);
|
|
316
|
+
}
|
|
317
|
+
write('\n');
|
|
318
|
+
}
|
|
319
|
+
if (session) {
|
|
320
|
+
write(`${h('Keys')}\n`);
|
|
321
|
+
write(` ${pad(c.g('/'), 14)}open the command list · type to filter · ↑↓ choose · tab complete · enter run\n`);
|
|
322
|
+
write(` ${pad(c.g('↑ ↓'), 14)}history · ${c.g('esc')} clear · ${c.g('ctrl-l')} clear screen · ${c.g('ctrl-c')} cancel a running command, twice to exit\n`);
|
|
323
|
+
} else {
|
|
324
|
+
write(` ${c.m('Run gitroots with no command to open a session. --json on list commands.')}\n`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function startSession() {
|
|
329
|
+
const ctx = makeCtx({ session: true });
|
|
330
|
+
const repl = new Repl(ctx);
|
|
331
|
+
return repl.start();
|
|
332
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// /hooks · /check · /doctor
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { json } from '../ui/components.js';
|
|
6
|
+
import { state as colorState } from '../ui/ansi.js';
|
|
7
|
+
import { motion } from '../ui/term.js';
|
|
8
|
+
import { installHook, uninstallHook, hookStatus, checkLocal } from '../commands/push.js';
|
|
9
|
+
import { loadRules } from '../rules.js';
|
|
10
|
+
import { tryGit } from '../git/checks.js';
|
|
11
|
+
import { VERSION, DOWNLOAD_BASE, installMethod, nativePaths, platformKey, isBinary } from '../runtime.js';
|
|
12
|
+
import { latestVersion, newer, onPath } from '../installer.js';
|
|
13
|
+
import { home, readConfig } from '../config.js';
|
|
14
|
+
import { localSessions } from '../agents/local.js';
|
|
15
|
+
import { c, line, pad, next, load, cmdText } from './helpers.js';
|
|
16
|
+
|
|
17
|
+
export async function hooks(ctx, args, flags) {
|
|
18
|
+
const sub = args[0] || 'status';
|
|
19
|
+
if (sub === 'install') {
|
|
20
|
+
const r = installHook('.', { force: flags.force });
|
|
21
|
+
if (ctx.json) { json(r); return 0; }
|
|
22
|
+
line(`${c.g('✓')} Push checks installed in ${c.b(path.basename(tryGit(['rev-parse', '--show-toplevel'])))}${r.chained ? c.m(' · your previous hook still runs first') : ''}`);
|
|
23
|
+
line(` ${c.m('every git push now checks branch rules, secrets, authors and migrations first')}`);
|
|
24
|
+
next(ctx, 'check');
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
if (sub === 'uninstall') {
|
|
28
|
+
const r = uninstallHook('.');
|
|
29
|
+
if (ctx.json) { json(r); return 0; }
|
|
30
|
+
line(r.removed ? `${c.g('✓')} Push checks removed from this repository` : c.m('○ no gitroots hook in this repository'));
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
const st = hookStatus('.');
|
|
34
|
+
if (!st.repo) throw new Error('not inside a git repository · cd into one first');
|
|
35
|
+
const set = await load(ctx, 'Reading branch rules…', () => loadRules(process.cwd(), { api: ctx.api }));
|
|
36
|
+
if (ctx.json) { json({ ...st, rules: set }); return 0; }
|
|
37
|
+
line(`${c.b(set.repo || path.basename(st.repo))} ${st.installed ? c.g('● push checks on') : c.w('○ push checks off')}`);
|
|
38
|
+
const src = { gitroots: `from gitroots (${ctx.api.host})`, cache: 'from gitroots, cached', '.gitroots.json': 'from .gitroots.json', none: 'none for this repo' }[set.source];
|
|
39
|
+
line(` ${c.s(pad('rules', 9))}${src}`);
|
|
40
|
+
for (const r of set.rules) line(` ${' '.repeat(9)}${c.b(pad(r.pattern, 20))}${r.allow_direct_push === false ? c.r('✕ no direct push') : c.g('✓ direct push')} ${c.m(r.description || r.level)}`);
|
|
41
|
+
if (!st.installed) next(ctx, 'hooks', 'install');
|
|
42
|
+
else next(ctx, 'check');
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function check(ctx, args, flags) {
|
|
47
|
+
return checkLocal(args, { api: ctx.api, branch: flags.branch });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function doctor(ctx) {
|
|
51
|
+
const rows = [];
|
|
52
|
+
const add = (group, name, status, detail, fix) => rows.push({ group, name, status, detail, fix });
|
|
53
|
+
add('install', 'gitroots', 'ok', `${VERSION} · ${installMethod()}${isBinary ? ` · ${process.execPath}` : ''}`);
|
|
54
|
+
add('install', 'platform', 'ok', platformKey());
|
|
55
|
+
if (!isBinary) {
|
|
56
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
57
|
+
add('install', 'node', major >= 18 ? 'ok' : 'fail', process.version, major >= 18 ? null : 'install Node 18+ or the standalone build');
|
|
58
|
+
}
|
|
59
|
+
if (installMethod() === 'native') {
|
|
60
|
+
const { bin } = nativePaths();
|
|
61
|
+
add('install', 'PATH', onPath(bin) ? 'ok' : 'warn', onPath(bin) ? `${bin} is on PATH` : `${bin} is not on PATH`, onPath(bin) ? null : `export PATH="${bin}:$PATH"`);
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const latest = await latestVersion(DOWNLOAD_BASE, 4000);
|
|
65
|
+
add('install', 'updates', newer(latest, VERSION) ? 'warn' : 'ok', newer(latest, VERSION) ? `${latest} is available` : 'up to date', newer(latest, VERSION) ? cmdText(ctx, 'update') : null);
|
|
66
|
+
} catch (e) { add('install', 'updates', 'warn', `couldn't check (${e.message})`); }
|
|
67
|
+
|
|
68
|
+
const cfg = readConfig();
|
|
69
|
+
if (ctx.api.signedIn) {
|
|
70
|
+
try {
|
|
71
|
+
const me = await ctx.api.me({ timeout: 6000 });
|
|
72
|
+
add('account', 'signed in', 'ok', `${me.user.username} · ${ctx.api.host}`);
|
|
73
|
+
add('account', 'workspace', cfg.org ? 'ok' : 'warn', cfg.org ? `${cfg.org.name} (${cfg.org.role})` : 'none chosen', cfg.org ? null : cmdText(ctx, 'workspace'));
|
|
74
|
+
} catch (e) { add('account', 'signed in', 'fail', e.message, cmdText(ctx, 'login')); }
|
|
75
|
+
} else add('account', 'signed in', 'warn', 'no', cmdText(ctx, 'login'));
|
|
76
|
+
try { fs.mkdirSync(home(), { recursive: true }); fs.accessSync(home(), fs.constants.W_OK); add('account', 'settings', 'ok', home()); } catch { add('account', 'settings', 'fail', `${home()} is not writable`); }
|
|
77
|
+
|
|
78
|
+
const git = spawnSync('git', ['--version'], { encoding: 'utf8' });
|
|
79
|
+
add('git', 'git', git.status === 0 ? 'ok' : 'fail', git.status === 0 ? git.stdout.trim() : 'not found', git.status === 0 ? null : 'install git');
|
|
80
|
+
const st = hookStatus('.');
|
|
81
|
+
if (st.repo) add('git', 'push checks', st.installed ? 'ok' : 'warn', `${st.installed ? 'on' : 'off'} in ${path.basename(st.repo)}`, st.installed ? null : cmdText(ctx, 'hooks', 'install'));
|
|
82
|
+
else add('git', 'repository', 'ok', 'not inside a git repo (skipped)');
|
|
83
|
+
|
|
84
|
+
const local = localSessions({ limit: 200 });
|
|
85
|
+
add('agents', 'this computer', 'ok', `${local.filter((s) => s.agent === 'claude').length} Claude Code · ${local.filter((s) => s.agent === 'codex').length} Codex sessions in the last 14 days`);
|
|
86
|
+
add('terminal', 'colors', colorState.color ? 'ok' : 'warn', colorState.color ? (colorState.truecolor ? 'truecolor' : '256 colors') : 'off (piped or NO_COLOR)');
|
|
87
|
+
add('terminal', 'motion', 'ok', motion() ? 'on' : 'off');
|
|
88
|
+
|
|
89
|
+
if (ctx.json) { json(rows); return rows.some((r) => r.status === 'fail') ? 1 : 0; }
|
|
90
|
+
let group = '';
|
|
91
|
+
for (const r of rows) {
|
|
92
|
+
if (r.group !== group) { if (group) line(); line(c.m(r.group.toUpperCase())); group = r.group; }
|
|
93
|
+
const mark = r.status === 'ok' ? c.g('✓') : r.status === 'warn' ? c.w('◐') : c.r('✕');
|
|
94
|
+
line(` ${mark} ${pad(r.name, 14)}${r.status === 'fail' ? c.r(r.detail) : r.status === 'warn' ? c.w(r.detail) : c.s(r.detail)}${r.fix ? ` ${c.g(`→ ${r.fix}`)}` : ''}`);
|
|
95
|
+
}
|
|
96
|
+
const fails = rows.filter((r) => r.status === 'fail').length;
|
|
97
|
+
const warns = rows.filter((r) => r.status === 'warn').length;
|
|
98
|
+
line();
|
|
99
|
+
line(fails ? c.r(`✕ ${fails} problem${fails > 1 ? 's' : ''}`) : warns ? c.w(`◐ ${warns} thing${warns > 1 ? 's' : ''} to look at`) : c.g('✓ all good'));
|
|
100
|
+
return fails ? 1 : 0;
|
|
101
|
+
}
|