gitroots 0.5.0 → 0.6.1
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 +107 -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,270 @@
|
|
|
1
|
+
// /status · /repos · /repo · /approvals · /pushes · /audit · /activity · /members (all live from gitroots)
|
|
2
|
+
import { select, interactive, ask, confirm } from '../ui/term.js';
|
|
3
|
+
import { json } from '../ui/components.js';
|
|
4
|
+
import { localSessions } from '../agents/local.js';
|
|
5
|
+
import { c, h, line, pad, fit, next, needWorkspace, load, ago, empty, table, cols, cmdText } from './helpers.js';
|
|
6
|
+
|
|
7
|
+
const plural = (n, w) => `${n} ${n === 1 ? w : w.endsWith('y') && !/[aeiou]y$/.test(w) ? `${w.slice(0, -1)}ies` : w.endsWith('s') || w.endsWith('sh') || w.endsWith('ch') ? `${w}es` : w === 'person' ? 'people' : `${w}s`}`;
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------- /status
|
|
10
|
+
|
|
11
|
+
export async function status(ctx) {
|
|
12
|
+
needWorkspace(ctx);
|
|
13
|
+
const [home, agents] = await load(ctx, 'Loading your workspace…', () => Promise.all([
|
|
14
|
+
ctx.api.home({ signal: ctx.signal }),
|
|
15
|
+
ctx.api.agents({ signal: ctx.signal }).catch(() => null),
|
|
16
|
+
]));
|
|
17
|
+
const local = localSessions({ limit: 30 });
|
|
18
|
+
const waitingAgents = (agents?.sessions || []).filter((s) => s.needs_you);
|
|
19
|
+
if (ctx.json) { json({ ...home, agents_waiting: waitingAgents, local_agents_live: local.filter((s) => s.status === 'live').length }); return 0; }
|
|
20
|
+
|
|
21
|
+
const p = home.posture || {};
|
|
22
|
+
const cov = p.coverage || {};
|
|
23
|
+
line(`${c.g(c.b('gitroots'))} ${c.m('·')} ${c.b(home.organization.name)} ${c.m('·')} ${home.user.display_name || home.user.name} ${c.m('·')} ${c.m(ctx.api.host)}`);
|
|
24
|
+
line();
|
|
25
|
+
line(h('Posture'));
|
|
26
|
+
const inPolicy = p.all_in_policy ? c.g('✓ all in policy') : c.w('◐ not all in policy');
|
|
27
|
+
line(` ${plural(p.repo_count || 0, 'repository')} ${c.m('·')} ${p.protected_count || 0} protected ${c.m('·')} ${cov.overlay || 0} shared by overlay ${c.m('·')} ${cov.open || 0} open ${inPolicy}`);
|
|
28
|
+
line(` ${plural(home.developer_count || 0, 'developer')} ${c.m('·')} ${home.blocked_24h ? c.r(`✕ ${plural(home.blocked_24h, 'blocked push')} in 24h`) : c.g('✓ no blocked pushes in 24h')}`);
|
|
29
|
+
line();
|
|
30
|
+
line(h('Needs you'));
|
|
31
|
+
let any = false;
|
|
32
|
+
for (const a of home.awaiting || []) {
|
|
33
|
+
any = true;
|
|
34
|
+
line(` ${c.w(pad('◐ review', 11))} ${fit(a.title, 44)} ${c.m(fit(`${a.meta || ''} · ${a.age || ''}`, Math.max(10, cols() - 62)))}`);
|
|
35
|
+
}
|
|
36
|
+
if (home.pending_count > (home.awaiting || []).length) line(` ${c.m(`+ ${home.pending_count - (home.awaiting || []).length} more`)}`);
|
|
37
|
+
for (const s of waitingAgents.slice(0, 4)) {
|
|
38
|
+
any = true;
|
|
39
|
+
line(` ${c.w(pad('◐ agent', 11))} ${fit(`${s.agent_name} on ${s.machine}: ${s.ask?.what || s.title}`, 44)} ${c.m(fit(s.ask?.cmd || '', Math.max(10, cols() - 62)))}`);
|
|
40
|
+
}
|
|
41
|
+
if (!any) line(` ${c.g('✓ nothing waiting on you')}`);
|
|
42
|
+
line();
|
|
43
|
+
const live = local.filter((s) => s.status === 'live').length;
|
|
44
|
+
const remote = (agents?.sessions || []).filter((s) => s.active).length;
|
|
45
|
+
line(h('Agents'));
|
|
46
|
+
line(` ${remote ? c.g(`● ${remote} active on your machines`) : c.m('○ none reported by the gitroots app')} ${c.m('·')} ${live ? c.g(`● ${live} live on this computer`) : c.m(`○ ${local.length} recent on this computer`)}`);
|
|
47
|
+
line();
|
|
48
|
+
if (home.pending_count) next(ctx, 'approvals');
|
|
49
|
+
else if (waitingAgents.length) next(ctx, 'agents');
|
|
50
|
+
else if (home.blocked_24h) next(ctx, 'pushes', '--blocked');
|
|
51
|
+
else next(ctx, 'repos');
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------- /repos, /repo
|
|
56
|
+
|
|
57
|
+
export async function repos(ctx) {
|
|
58
|
+
needWorkspace(ctx);
|
|
59
|
+
const d = await load(ctx, 'Loading repositories…', () => ctx.api.repos({ signal: ctx.signal }));
|
|
60
|
+
if (ctx.json) { json(d.repos); return 0; }
|
|
61
|
+
if (!d.repos.length) {
|
|
62
|
+
empty(d.github?.connected === false ? 'no repositories · connect GitHub in the gitroots dashboard' : 'no repositories synced yet');
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
table([
|
|
66
|
+
{ label: 'REPOSITORY', w: 34, fmt: (r) => c.b(r.full_name) },
|
|
67
|
+
{ label: 'DEFAULT', w: 12, key: 'default_branch' },
|
|
68
|
+
{ label: 'BRANCHES', w: 10, key: 'branch_count' },
|
|
69
|
+
{ label: 'RULES', w: 8, key: 'rule_count' },
|
|
70
|
+
{ label: 'OVERLAYS', w: 10, key: 'overlay_count' },
|
|
71
|
+
{ label: 'STATUS', w: 16, fmt: (r) => (r.protected ? c.g('● protected') : c.m('○ open')) },
|
|
72
|
+
], d.repos);
|
|
73
|
+
line();
|
|
74
|
+
next(ctx, 'repo', d.repos[0].name);
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function pickRepo(ctx) {
|
|
79
|
+
const d = await load(ctx, 'Loading repositories…', () => ctx.api.repos({ signal: ctx.signal }));
|
|
80
|
+
if (!d.repos.length) throw new Error('no repositories in this workspace yet');
|
|
81
|
+
if (!interactive()) throw new Error(`which repository? ${cmdText(ctx, 'repo', '<name>')}`);
|
|
82
|
+
line(`${c.i(c.b('?'))} Which repository?`);
|
|
83
|
+
const res = await select({
|
|
84
|
+
count: d.repos.length,
|
|
85
|
+
render: (i) => windowed(d.repos, i, 10, (r, sel) => `${sel ? c.g('❯ ') : ' '}${sel ? c.b(pad(r.full_name, 40)) : pad(r.full_name, 40)}${r.protected ? c.g('● protected') : c.m('○ open')}`),
|
|
86
|
+
});
|
|
87
|
+
return res.action === 'cancel' ? null : d.repos[res.index].full_name;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function windowed(items, idx, max, row) {
|
|
91
|
+
const start = Math.max(0, Math.min(idx - Math.floor(max / 2), items.length - max));
|
|
92
|
+
const out = items.slice(start, start + max).map((it, j) => row(it, start + j === idx));
|
|
93
|
+
if (items.length > max) out.push(c.m(` ${idx + 1} of ${items.length} · ↑↓ to move · enter to choose · esc to cancel`));
|
|
94
|
+
return out.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function repo(ctx, args) {
|
|
98
|
+
needWorkspace(ctx);
|
|
99
|
+
const name = args[0] || await pickRepo(ctx);
|
|
100
|
+
if (!name) return 1;
|
|
101
|
+
const r = await load(ctx, `Loading ${name}…`, () => ctx.api.repo(name, { signal: ctx.signal }));
|
|
102
|
+
if (ctx.json) { json(r); return 0; }
|
|
103
|
+
line(`${c.b(r.full_name)} ${r.protected ? c.g('● protected') : c.m('○ open')} ${c.m(`default ${r.default_branch} · ${plural(r.branches.length, 'branch')} · ${plural(r.overlay_count, 'overlay')}`)}`);
|
|
104
|
+
line();
|
|
105
|
+
line(h('Branch rules'));
|
|
106
|
+
if (!r.branch_rules.length) empty(`no rules${r.can_manage ? ' · add them on gitroots.com › Repositories' : ''}`);
|
|
107
|
+
for (const b of r.branch_rules) {
|
|
108
|
+
const push = b.allow_direct_push ? c.g('✓ direct push') : c.r('✕ no direct push');
|
|
109
|
+
line(` ${c.b(pad(b.pattern, 22))}${pad(b.level === 'leads' ? c.w('◐ leads only') : b.level === 'open' ? c.m('○ open') : c.g(`● ${b.level}`), 16)}${pad(push, 20)}${c.m(b.description)}`);
|
|
110
|
+
}
|
|
111
|
+
if (r.github_protected_branches?.length) line(` ${c.m(`GitHub protects: ${r.github_protected_branches.join(', ')}`)}`);
|
|
112
|
+
if (r.environments?.length) {
|
|
113
|
+
line();
|
|
114
|
+
line(h('Environments'));
|
|
115
|
+
for (const e of r.environments) line(` ${pad(e.name, 18)}${pad(e.branch || '', 22)}${c.m(`${e.audience || ''} · ${e.state || ''}`)}`);
|
|
116
|
+
}
|
|
117
|
+
line();
|
|
118
|
+
line(h(`People · ${r.developer_count}${r.external_count ? ` (${r.external_count} external)` : ''}`));
|
|
119
|
+
for (const p of r.people.slice(0, 12)) line(` ${pad(p.display_name || p.name, 26)}${c.m(p.detail || p.kind)}`);
|
|
120
|
+
if (r.people.length > 12) line(` ${c.m(`+ ${r.people.length - 12} more`)}`);
|
|
121
|
+
line();
|
|
122
|
+
line(h('Branches'));
|
|
123
|
+
if (!r.branches.length) empty('none synced yet · gitroots fetches them from GitHub');
|
|
124
|
+
else line(` ${fit(r.branches.slice(0, 30).join(' '), cols() - 4)}${r.branches.length > 30 ? c.m(` + ${r.branches.length - 30} more`) : ''}`);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------- /approvals
|
|
129
|
+
|
|
130
|
+
function approvalRow(a, sel) {
|
|
131
|
+
const mark = a.status === 'failed' ? c.r('✕ failed ') : a.status === 'queued' ? c.i('○ merging') : a.type === 'access' ? c.w('◐ access ') : c.w('◐ merge ');
|
|
132
|
+
const who = a.requested_by?.display_name || a.user?.display_name || '';
|
|
133
|
+
const t = `${fit(a.title, 40)} ${c.m(fit([who, a.meta, a.age].filter(Boolean).join(' · '), Math.max(10, cols() - 62)))}`;
|
|
134
|
+
return `${sel ? c.g('❯ ') : ' '}${mark} ${sel ? c.b(t) : t}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function approvals(ctx, args) {
|
|
138
|
+
needWorkspace(ctx);
|
|
139
|
+
const state = ['waiting', 'approved', 'declined'].includes(args[0]) ? args[0] : 'waiting';
|
|
140
|
+
const d = await load(ctx, 'Loading approvals…', () => ctx.api.approvals(state, { signal: ctx.signal }));
|
|
141
|
+
if (ctx.json) { json(d); return 0; }
|
|
142
|
+
if (!d.items.length) { line(`${c.g('✓')} nothing ${state === 'waiting' ? 'waiting on approval' : state}`); return 0; }
|
|
143
|
+
if (!ctx.session || !interactive() || state !== 'waiting') {
|
|
144
|
+
for (const a of d.items) line(approvalRow(a, false));
|
|
145
|
+
line();
|
|
146
|
+
next(ctx, 'approvals');
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
line(`${c.i(c.b('?'))} ${plural(d.waiting_count, 'request')} waiting · pick one`);
|
|
150
|
+
const res = await select({ count: d.items.length, render: (i) => windowed(d.items, i, 8, approvalRow) });
|
|
151
|
+
if (res.action === 'cancel') return 0;
|
|
152
|
+
return decideApproval(ctx, d.items[res.index]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function decideApproval(ctx, a) {
|
|
156
|
+
line();
|
|
157
|
+
line(`${c.b(a.title)} ${c.m(a.kind)}`);
|
|
158
|
+
if (a.type === 'sync') {
|
|
159
|
+
line(` ${c.s(pad('from', 10))}${a.from}:${a.from_branch} → ${a.to}:${a.to_branch}`);
|
|
160
|
+
line(` ${c.s(pad('changes', 10))}${plural(a.commits || 0, 'commit')}${a.files != null ? ` · ${plural(a.files, 'file')} ${c.g(`+${a.additions || 0}`)} ${c.r(`−${a.deletions || 0}`)}` : ''}`);
|
|
161
|
+
if (a.requested_by) line(` ${c.s(pad('by', 10))}${a.requested_by.display_name} ${c.m(a.requester_meta || '')}`);
|
|
162
|
+
if (a.ai_risk) line(` ${c.s(pad('ai review', 10))}${a.ai_risk}`);
|
|
163
|
+
} else {
|
|
164
|
+
line(` ${c.s(pad('who', 10))}${a.user?.display_name} ${c.m(a.requester_meta || '')}`);
|
|
165
|
+
line(` ${c.s(pad('wants', 10))}${a.rule.replace(/_/g, ' ')} on ${a.repo}${a.branch ? ` (${a.branch})` : ''} for ${a.days} days ${a.within_policy ? c.g('✓ within policy') : c.w(`◐ policy max ${a.policy_max_days} days`)}`);
|
|
166
|
+
}
|
|
167
|
+
if (a.note) line(` ${c.s(pad('note', 10))}${a.note}`);
|
|
168
|
+
line();
|
|
169
|
+
const actions = a.type === 'sync'
|
|
170
|
+
? (a.status === 'failed' ? [['retry', 'Retry the merge'], ['decline', 'Decline'], ['back', 'Back']]
|
|
171
|
+
: a.status === 'queued' ? [['back', 'Back (merge in progress)']]
|
|
172
|
+
: [['approve', 'Approve and merge'], ['changes', 'Request changes'], ['decline', 'Decline'], ['back', 'Back']])
|
|
173
|
+
: [['approve', 'Approve access'], ['decline', 'Decline'], ['back', 'Back']];
|
|
174
|
+
const pick = await select({ count: actions.length, render: (i) => actions.map(([, label], j) => `${j === i ? c.g('❯ ') : ' '}${j === i ? c.b(label) : label}`).join('\n') });
|
|
175
|
+
if (pick.action === 'cancel') return 0;
|
|
176
|
+
const act = actions[pick.index][0];
|
|
177
|
+
if (act === 'back') return 0;
|
|
178
|
+
let result;
|
|
179
|
+
if (a.type === 'sync' && (act === 'approve' || act === 'retry')) {
|
|
180
|
+
if (!(await confirm(`Merge ${a.from_branch} into ${a.to}:${a.to_branch}?`, { def: false }))) { line(c.m(' nothing changed')); return 0; }
|
|
181
|
+
const note = await ask('Note (optional)');
|
|
182
|
+
result = await load(ctx, 'Approving…', () => ctx.api.approveSync(a.id, note, { signal: ctx.signal }));
|
|
183
|
+
line(`${c.g('✓')} Approved ${c.b(a.title)} ${c.m(`· ${result?.detail === 'queued' ? 'merge queued' : 'merged'}`)}`);
|
|
184
|
+
} else if (a.type === 'sync') {
|
|
185
|
+
const reason = await ask(act === 'changes' ? 'What needs to change?' : 'Reason (optional)');
|
|
186
|
+
if (act === 'changes' && !reason) { line(c.m(' nothing sent')); return 0; }
|
|
187
|
+
await load(ctx, 'Sending…', () => ctx.api.declineSync(a.id, act === 'changes' ? 'changes' : 'decline', reason, { signal: ctx.signal }));
|
|
188
|
+
line(`${act === 'changes' ? c.w('◐ Changes requested on') : c.r('✕ Declined')} ${c.b(a.title)}`);
|
|
189
|
+
} else {
|
|
190
|
+
const reason = act === 'decline' ? await ask('Reason (optional)') : '';
|
|
191
|
+
await load(ctx, 'Saving…', () => ctx.api.decideAccess(a.id, act, reason, { signal: ctx.signal }));
|
|
192
|
+
line(`${act === 'approve' ? c.g('✓ Approved') : c.r('✕ Declined')} ${c.b(a.title)}`);
|
|
193
|
+
}
|
|
194
|
+
next(ctx, 'approvals');
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------- /pushes, /audit, /activity, /members
|
|
199
|
+
|
|
200
|
+
export async function pushes(ctx, args, flags) {
|
|
201
|
+
needWorkspace(ctx);
|
|
202
|
+
const blocked = Boolean(flags.blocked || args.includes('blocked'));
|
|
203
|
+
const d = await load(ctx, 'Loading pushes…', () => ctx.api.pushEvents(blocked, { signal: ctx.signal }));
|
|
204
|
+
if (ctx.json) { json(d.items); return 0; }
|
|
205
|
+
if (!d.items.length) { empty(blocked ? 'no blocked pushes' : 'no pushes recorded yet'); return 0; }
|
|
206
|
+
table([
|
|
207
|
+
{ label: 'WHEN', w: 18, fmt: (e) => c.m(`${e.date} ${e.time}`) },
|
|
208
|
+
{ label: 'REPO', w: 22, key: 'repo_name' },
|
|
209
|
+
{ label: 'BRANCH', w: 24, key: 'branch' },
|
|
210
|
+
{ label: 'BY', w: 16, key: 'pusher' },
|
|
211
|
+
{ label: 'COMMITS', w: 9, key: 'commit_count' },
|
|
212
|
+
{ label: 'RESULT', w: 30, fmt: (e) => (e.blocked ? c.r(`✕ blocked ${e.rule ? `· ${e.rule}` : ''}`) : c.g('✓ allowed')) },
|
|
213
|
+
], d.items.slice(0, 40));
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const CATEGORIES = ['All', 'Blocked', 'Merges', 'Access', 'Other'];
|
|
218
|
+
|
|
219
|
+
export async function audit(ctx, args, flags) {
|
|
220
|
+
needWorkspace(ctx);
|
|
221
|
+
const category = CATEGORIES.find((x) => x.toLowerCase() === String(args[0] || flags.category || 'all').toLowerCase()) || 'All';
|
|
222
|
+
const q = flags.q || flags.search || (CATEGORIES.some((x) => x.toLowerCase() === String(args[0]).toLowerCase()) ? args.slice(1) : args).join(' ');
|
|
223
|
+
const d = await load(ctx, 'Loading audit log…', () => ctx.api.audit(category, q, { signal: ctx.signal }));
|
|
224
|
+
if (ctx.json) { json(d.items); return 0; }
|
|
225
|
+
if (!d.items.length) { empty('no events match'); return 0; }
|
|
226
|
+
const summary = (m) => Object.entries(m || {}).filter(([, v]) => v !== '' && v !== null && typeof v !== 'object').slice(0, 3).map(([k, v]) => `${k} ${v}`).join(' · ');
|
|
227
|
+
table([
|
|
228
|
+
{ label: 'WHEN', w: 17, fmt: (e) => c.m(`${e.day.slice(5)} ${e.time}`) },
|
|
229
|
+
{ label: 'WHO', w: 20, key: 'actor_short' },
|
|
230
|
+
{ label: 'WHAT', w: 30, fmt: (e) => (e.category === 'Blocked' ? c.r(e.action_display) : e.action_display) },
|
|
231
|
+
{ label: 'DETAILS', w: 40, fmt: (e) => c.m(summary(e.metadata)) },
|
|
232
|
+
], d.items.slice(0, 50));
|
|
233
|
+
line(` ${c.m(`${Math.min(50, d.items.length)} of ${d.items.length} · categories: ${CATEGORIES.join(', ')} · e.g. ${cmdText(ctx, 'audit', 'blocked')}`)}`);
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function activity(ctx) {
|
|
238
|
+
needWorkspace(ctx);
|
|
239
|
+
const d = await load(ctx, 'Loading team activity…', () => ctx.api.teamActivity({ signal: ctx.signal }));
|
|
240
|
+
if (ctx.json) { json(d); return 0; }
|
|
241
|
+
const delta = d.delta_pct === null || d.delta_pct === undefined ? '' : d.delta_pct >= 0 ? c.g(` ↑ ${d.delta_pct}%`) : c.r(` ↓ ${Math.abs(d.delta_pct)}%`);
|
|
242
|
+
line(`${c.b(d.organization)} ${c.m(`· last ${d.weeks} weeks`)}`);
|
|
243
|
+
line(` ${plural(d.commit_count || 0, 'commit')} ${c.m('·')} ${plural(d.merged_count || 0, 'merge')} ${c.m('·')} ${plural(d.people_count || 0, 'person')}${delta}`);
|
|
244
|
+
// heatmap: one row of the weekly levels
|
|
245
|
+
const levels = (d.cells || []).map((x) => (typeof x === 'object' ? x.level ?? 0 : x));
|
|
246
|
+
if (levels.length) {
|
|
247
|
+
const shades = [c.m('·'), c.g('▁'), c.g('▃'), c.g('▅'), c.g('█')];
|
|
248
|
+
line(` ${levels.slice(-Math.min(levels.length, 70)).map((l) => shades[Math.max(0, Math.min(4, l))]).join('')} ${c.m('daily, oldest → today')}`);
|
|
249
|
+
}
|
|
250
|
+
for (const [title, rows] of [['Most active', d.most_active], ['Leads', d.leads], ['External', d.external]]) {
|
|
251
|
+
if (!rows || !rows.length) continue;
|
|
252
|
+
line();
|
|
253
|
+
line(h(title));
|
|
254
|
+
for (const p of rows.slice(0, 8)) line(` ${pad(p.display_name || p.name, 26)}${pad(`${p.commits} commits · ${p.merged} merged`, 30)}${c.m(p.detail || p.role || '')}`);
|
|
255
|
+
}
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function members(ctx) {
|
|
260
|
+
needWorkspace(ctx);
|
|
261
|
+
const d = await load(ctx, 'Loading members…', () => ctx.api.members({ signal: ctx.signal }));
|
|
262
|
+
if (ctx.json) { json(d.members); return 0; }
|
|
263
|
+
table([
|
|
264
|
+
{ label: 'NAME', w: 28, fmt: (m) => c.b(m.display_name || m.name) },
|
|
265
|
+
{ label: 'EMAIL', w: 34, key: 'email' },
|
|
266
|
+
{ label: 'ROLE', w: 16, fmt: (m) => m.role_display || m.role },
|
|
267
|
+
{ label: '', w: 10, fmt: (m) => (m.external ? c.w('external') : '') },
|
|
268
|
+
], d.members);
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
package/src/ui/term.js
CHANGED
|
@@ -137,6 +137,8 @@ export function parseKeys(buf) {
|
|
|
137
137
|
['\x1b[A', 'up'], ['\x1b[B', 'down'], ['\x1b[C', 'right'], ['\x1b[D', 'left'],
|
|
138
138
|
['\x1bOA', 'up'], ['\x1bOB', 'down'], ['\x1bOC', 'right'], ['\x1bOD', 'left'],
|
|
139
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'],
|
|
140
142
|
];
|
|
141
143
|
const hit = seqs.find(([q]) => rest.startsWith(q));
|
|
142
144
|
if (hit) { keys.push({ name: hit[1] }); i += hit[0].length; continue; }
|
package/src/commands/attach.js
DELETED
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
// gitroots attach <session> · full screen, prefix ctrl-g
|
|
2
|
-
import { c, pad, fit, width } from '../ui/ansi.js';
|
|
3
|
-
import { line, interactive, enterFullscreen, leaveFullscreen, paintFrame, size, parseKeys, FRAMES } from '../ui/term.js';
|
|
4
|
-
import { json, next, sessionState } from '../ui/components.js';
|
|
5
|
-
import { liveLog } from '../data/live.js';
|
|
6
|
-
import { createBackup } from './backup.js';
|
|
7
|
-
|
|
8
|
-
export async function run(_cmd, rest, flags, store) {
|
|
9
|
-
const [id] = rest;
|
|
10
|
-
if (!id) throw new Error('which session? gitroots attach <session> · see gitroots watch');
|
|
11
|
-
const s = store.session(id);
|
|
12
|
-
if (flags.shell) return shell(store, s);
|
|
13
|
-
if (flags.json) { json({ session: s, transcript: store.transcript(s.id) }); return 0; }
|
|
14
|
-
if (!interactive()) {
|
|
15
|
-
// Piped: print the transcript tail instead of taking over the terminal.
|
|
16
|
-
for (const t of store.transcript(s.id)) line(t.who === 'log' ? ` ${t.time} ${t.verb.padEnd(8)}${t.body}${t.add || ''}` : `${t.who === 'you' ? 'you ' : 'agent'} ${t.text.replace(/[§*]/g, '')}`);
|
|
17
|
-
if (s.ask) line(`◐ ${s.agent} asks: ${s.ask.question}`);
|
|
18
|
-
return 0;
|
|
19
|
-
}
|
|
20
|
-
return attach(store, s.id);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// A shell next to the agent. Sessions on this machine get a real local shell;
|
|
24
|
-
// remote machines need agentd and a connected relay.
|
|
25
|
-
async function shell(store, s) {
|
|
26
|
-
const m = store.machine(s.machine);
|
|
27
|
-
if (!m.self) {
|
|
28
|
-
throw new Error(`${s.id} runs on ${m.name} · a remote shell goes through agentd on ${m.name}, which this sample org does not have · sessions on ${store.machines().find((x) => x.self)?.name || 'this machine'} open a real shell`);
|
|
29
|
-
}
|
|
30
|
-
const { spawn } = await import('node:child_process');
|
|
31
|
-
const sh = process.env.SHELL || (process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : '/bin/sh');
|
|
32
|
-
line(`${c.g(c.b('gitroots'))} shell for ${c.b(s.id)} on ${m.name} ${c.m(`· ${s.repo} · ${s.branch} · exit to return`)}`);
|
|
33
|
-
store.record({ actor: store.org.user, action: 'shell', target: s.id, decision: 'allowed', note: m.name, repo: s.repo });
|
|
34
|
-
store.save();
|
|
35
|
-
return new Promise((resolve) => {
|
|
36
|
-
const child = spawn(sh, [], { stdio: 'inherit', env: { ...process.env, GITROOTS_SESSION: s.id } });
|
|
37
|
-
child.on('exit', (code) => { line(c.m(` shell closed · ${s.id} keeps running`)); resolve(code ?? 0); });
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const REPLIES = {
|
|
42
|
-
's-2c91': [
|
|
43
|
-
[ // Yes, run on staging
|
|
44
|
-
{ verb: 'run', body: 'odoo-bin -d staging -u pos_refund --stop-after-init', ms: 900 },
|
|
45
|
-
{ verb: 'output', body: 'post-migrate 17.0.1.3: adding column pos_order.refunded_amount', ms: 700 },
|
|
46
|
-
{ verb: 'output', body: 'backfilled 18,402 orders in 38.6s · ', add: 'OK', ms: 1400 },
|
|
47
|
-
{ verb: 'backup', body: 'auto · before risky · kept', add: ' ✓', ms: 400 },
|
|
48
|
-
{ say: 'The migration ran on staging: 18,402 orders backfilled, no errors. PR #412 is ready for your approval.' },
|
|
49
|
-
],
|
|
50
|
-
[ // erp_test first
|
|
51
|
-
{ verb: 'run', body: 'odoo-bin -d erp_test -u pos_refund --stop-after-init', ms: 900 },
|
|
52
|
-
{ verb: 'output', body: 'backfilled 2,117 orders in 6.1s · ', add: 'OK', ms: 1200 },
|
|
53
|
-
{ say: 'It ran cleanly on erp_test (2,117 orders). Say the word and I will run it on staging.' },
|
|
54
|
-
],
|
|
55
|
-
[ // show SQL
|
|
56
|
-
{ say: 'Here is what post-migrate.py runs:\n§ALTER TABLE pos_order ADD COLUMN refunded_amount numeric DEFAULT 0;§\n§UPDATE pos_order o SET refunded_amount = r.total FROM (§\n§ SELECT refund_order_id, SUM(amount_total) total FROM pos_order_refund GROUP BY 1) r§\n§WHERE o.id = r.refund_order_id;§' },
|
|
57
|
-
],
|
|
58
|
-
],
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export function attach(store, id) {
|
|
62
|
-
const s = store.session(id);
|
|
63
|
-
return new Promise((resolve) => {
|
|
64
|
-
const st = {
|
|
65
|
-
tick: 0, input: '', prefix: false, choice: 0, flash: null, busy: false, mode: null,
|
|
66
|
-
running: null, // the activity line in progress
|
|
67
|
-
};
|
|
68
|
-
const say = (msg, tone = 'g') => { st.flash = { msg, tone, until: Date.now() + 3500 }; };
|
|
69
|
-
const transcript = () => (store.d.transcripts[s.id] = store.d.transcripts[s.id] || []);
|
|
70
|
-
|
|
71
|
-
const play = async (steps) => {
|
|
72
|
-
st.busy = true;
|
|
73
|
-
s.state = 'running';
|
|
74
|
-
for (const step of steps) {
|
|
75
|
-
if (step.say) { transcript().push({ who: 'agent', text: step.say }); continue; }
|
|
76
|
-
const entry = { who: 'log', verb: step.verb, body: step.body, add: step.add, time: new Date().toTimeString().slice(0, 8) };
|
|
77
|
-
transcript().push(entry);
|
|
78
|
-
st.running = entry;
|
|
79
|
-
await new Promise((r) => setTimeout(r, step.ms || 600));
|
|
80
|
-
st.running = null;
|
|
81
|
-
}
|
|
82
|
-
s.turns++;
|
|
83
|
-
s.updated = 'just now';
|
|
84
|
-
st.busy = false;
|
|
85
|
-
store.save();
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
const answer = (idx) => {
|
|
89
|
-
const ask = s.ask;
|
|
90
|
-
transcript().push({ who: 'you', text: `${idx + 1}. ${ask.options[idx]}` });
|
|
91
|
-
store.record({ actor: store.org.user, action: 'agent approval', target: `${s.id} · ${ask.options[idx]}`, decision: 'recorded', note: s.agent, repo: s.repo });
|
|
92
|
-
s.ask = undefined;
|
|
93
|
-
store.save();
|
|
94
|
-
const script = (REPLIES[s.id] || [])[idx] || [{ say: `OK: ${ask.options[idx].toLowerCase()}.` }];
|
|
95
|
-
play(script);
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const send = (text) => {
|
|
99
|
-
transcript().push({ who: 'you', text });
|
|
100
|
-
store.save();
|
|
101
|
-
play([{ verb: 'think', body: `Reading the request: "${text.slice(0, 60)}"`, ms: 900 }, { say: 'On it. I will post progress here and ask before anything risky.' }]);
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
const detach = (msg) => {
|
|
105
|
-
clearInterval(timer);
|
|
106
|
-
process.stdin.off('data', onData);
|
|
107
|
-
process.stdout.off('resize', draw);
|
|
108
|
-
process.stdin.setRawMode(false);
|
|
109
|
-
process.stdin.pause();
|
|
110
|
-
leaveFullscreen();
|
|
111
|
-
store.save();
|
|
112
|
-
if (msg !== false) {
|
|
113
|
-
line(`${c.g('✓')} Detached from ${c.b(s.id)} ${c.m('·')} it keeps running on ${s.machine} ${sessionState(s)}`);
|
|
114
|
-
line(next(`gitroots attach ${s.id}`, 'gitroots watch'));
|
|
115
|
-
}
|
|
116
|
-
resolve(0);
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const onKey = async (k) => {
|
|
120
|
-
if (st.mode && st.mode.type === 'transfer') {
|
|
121
|
-
const m = st.mode;
|
|
122
|
-
if (k.name === 'escape' || k.name === 'ctrl-c') st.mode = null;
|
|
123
|
-
else if (k.name === 'up' || k.name === 'left') m.idx = (m.idx - 1 + m.targets.length) % m.targets.length;
|
|
124
|
-
else if (k.name === 'down' || k.name === 'right' || k.name === 'tab') m.idx = (m.idx + 1) % m.targets.length;
|
|
125
|
-
else if (k.name === 'enter') {
|
|
126
|
-
const to = m.targets[m.idx].name;
|
|
127
|
-
st.mode = null;
|
|
128
|
-
clearInterval(timer);
|
|
129
|
-
process.stdin.off('data', onData);
|
|
130
|
-
process.stdin.setRawMode(false);
|
|
131
|
-
leaveFullscreen();
|
|
132
|
-
const { transfer } = await import('./transfer.js');
|
|
133
|
-
try { await transfer(store, s.id, to); } catch (e) { line(`${c.r('✕')} ${e.message}`); }
|
|
134
|
-
await new Promise((r) => setTimeout(r, 1200));
|
|
135
|
-
enterFullscreen();
|
|
136
|
-
process.stdin.setRawMode(true);
|
|
137
|
-
process.stdin.resume();
|
|
138
|
-
process.stdin.on('data', onData);
|
|
139
|
-
timer = setInterval(tickDraw, 100);
|
|
140
|
-
}
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
if (st.prefix) {
|
|
144
|
-
st.prefix = false;
|
|
145
|
-
switch (k.ch || k.name) {
|
|
146
|
-
case 'd': case 'q': return detach();
|
|
147
|
-
case 'b': { const b = createBackup(store, s, 'manual'); store.record({ actor: store.org.user, action: 'backup', target: `${s.id} · ${b.id}`, decision: 'allowed', note: 'from attach', repo: s.repo }); store.save(); say(`✓ backup ${b.id} saved · ${b.sizeMb} MB`); return; }
|
|
148
|
-
case 't': {
|
|
149
|
-
const targets = store.machines().filter((m) => m.online && m.name !== s.machine);
|
|
150
|
-
if (targets.length) st.mode = { type: 'transfer', idx: 0, targets }; else say('no other machine is online', 'w');
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
case 'f': {
|
|
154
|
-
const nid = store.nextSessionId();
|
|
155
|
-
store.d.sessions.push({ ...s, id: nid, state: 'idle', ask: undefined, forkedFrom: s.id, updated: 'just now' });
|
|
156
|
-
store.record({ actor: store.org.user, action: 'fork', target: `${s.id} → ${nid}`, decision: 'allowed', repo: s.repo });
|
|
157
|
-
store.save();
|
|
158
|
-
say(`✓ forked → ${nid} · gitroots attach ${nid}`);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
case 's': {
|
|
162
|
-
const { default: crypto } = await import('node:crypto');
|
|
163
|
-
const url = `https://${process.env.GITROOTS_RELAY_DOMAIN || `${store.org.relay.name}.relay.gitroots.com`}/s/${crypto.randomBytes(4).toString('base64url').slice(0, 5)}`;
|
|
164
|
-
store.d.tunnels.push({ id: store.nextTunnelId(), session: s.id, machine: s.machine, target: 'live view', access: 'read-only link', viewers: 0, traffic: '0 B', at: new Date().toISOString(), url });
|
|
165
|
-
store.record({ actor: store.org.user, action: 'share', target: `${s.id} read-only`, decision: 'allowed', repo: s.repo });
|
|
166
|
-
store.save();
|
|
167
|
-
say(`✓ read-only link ${url}`);
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
case 'h': say(`${store.d.hiddenTurns?.[s.id] || 0} earlier turns are kept in the session backups · gitroots backups --session ${s.id}`, 's'); return;
|
|
171
|
-
case 'ctrl-g': st.input += ''; say('ctrl-g sent to the agent', 's'); return;
|
|
172
|
-
default: say('ctrl-g then d detach · b backup · t transfer · f fork · s share · h history', 'w'); return;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (k.name === 'ctrl-g') { st.prefix = true; return; }
|
|
176
|
-
if (k.name === 'ctrl-c') { say('^C goes to the agent · ctrl-g d detaches and keeps it running', 'w'); return; }
|
|
177
|
-
if (s.ask && !st.busy && !st.input) {
|
|
178
|
-
if (k.name === 'up') { st.choice = (st.choice - 1 + s.ask.options.length) % s.ask.options.length; return; }
|
|
179
|
-
if (k.name === 'down') { st.choice = (st.choice + 1) % s.ask.options.length; return; }
|
|
180
|
-
if (k.ch && /^[1-9]$/.test(k.ch) && Number(k.ch) <= s.ask.options.length) { st.choice = Number(k.ch) - 1; return; }
|
|
181
|
-
if (k.name === 'enter') { answer(st.choice); return; }
|
|
182
|
-
}
|
|
183
|
-
if (k.name === 'enter') { if (st.input.trim() && !st.busy) { send(st.input.trim()); st.input = ''; } return; }
|
|
184
|
-
if (k.name === 'backspace') { st.input = st.input.slice(0, -1); return; }
|
|
185
|
-
if (k.ch) st.input += k.ch;
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
const draw = () => {
|
|
189
|
-
const { cols, rows } = size();
|
|
190
|
-
const spin = FRAMES[st.tick % FRAMES.length];
|
|
191
|
-
const m = store.machines().find((x) => x.name === s.machine) || { os: '' };
|
|
192
|
-
const left = ` ${c.g(c.b('gitroots'))} ${c.b(`attached · ${s.id}`)} ${c.s(`${s.agent} on ${s.machine} (${m.os})`)} ${c.s(`${s.repo} · ${s.branch}`)} ${s.ask && !st.busy ? c.w('◐ waiting input') : st.busy ? c.g(`${spin} working`) : sessionState(s)}`;
|
|
193
|
-
const right = `${c.s(`latency 42 ms · backup ${s.backup}`)} `;
|
|
194
|
-
const top = c.bg('attach')(fit(left, cols - width(right)) + right);
|
|
195
|
-
|
|
196
|
-
const body = [];
|
|
197
|
-
body.push(c.m(`gitroots · connected through relay ${store.org.relay.name} · this is the agent's own session, streamed live`));
|
|
198
|
-
body.push(c.m('gitroots · other viewers: none · controls: you'));
|
|
199
|
-
body.push('');
|
|
200
|
-
const hidden = store.d.hiddenTurns?.[s.id];
|
|
201
|
-
if (hidden) { const t = `── earlier · ${hidden} turns hidden · press `; body.push(`${c.m(t)}${c.bg('key')(c.b(' ctrl-g h '))} ${c.m(`for history ${'─'.repeat(Math.max(0, cols - width(t) - 30))}`)}`); body.push(''); }
|
|
202
|
-
|
|
203
|
-
const fmt = (text) => text.replace(/§([^§]*)§/g, (_, x) => c.s(x)).replace(/\*([^*]+)\*/g, (_, x) => c.b(x));
|
|
204
|
-
const tr = store.transcript(s.id);
|
|
205
|
-
if (!tr.length) {
|
|
206
|
-
for (const e of liveLog(s, Math.floor(st.tick / 16), 8)) body.push(`${c.m(e.time)} ${pad(e.verb, 8)}${e.body}${e.add ? c.g(e.add) : ''}${e.del ? c.r(e.del) : ''}${e.fail ? c.r(e.fail) : ''}${e.last && e.running ? ` ${c.i(spin)}` : ''}`);
|
|
207
|
-
body.push('');
|
|
208
|
-
}
|
|
209
|
-
for (const [ti, t] of tr.entries()) {
|
|
210
|
-
if (t.who === 'log') {
|
|
211
|
-
body.push(`${' '.repeat(8)}${c.m(t.time)} ${c.i(pad(t.verb, 8))}${t.body}${t.add ? c.g(t.add) : ''}${st.running === t ? ` ${c.i(spin)} ${c.s('running')}` : ''}`);
|
|
212
|
-
if (tr[ti + 1] && tr[ti + 1].who !== 'log') body.push('');
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
const lines = t.text.split('\n');
|
|
216
|
-
const who = t.who === 'you' ? c.i(c.b(pad('you', 8))) : c.g(c.b(pad('agent', 8)));
|
|
217
|
-
lines.forEach((l, i) => body.push(`${i === 0 ? who : ' '.repeat(8)}${fmt(l)}`));
|
|
218
|
-
body.push('');
|
|
219
|
-
}
|
|
220
|
-
if (s.ask && !st.busy) {
|
|
221
|
-
body.push(`${c.w(c.b(` ◐ Approval needed`))} ${s.ask.question}`);
|
|
222
|
-
s.ask.options.forEach((o, i) => body.push(i === st.choice ? ` ${c.g(`❯ ${i + 1}. ${o}`)}` : ` ${i + 1}. ${o}`));
|
|
223
|
-
body.push('');
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const BH = rows - 4;
|
|
227
|
-
const view = body.slice(-BH);
|
|
228
|
-
while (view.length < BH) view.push('');
|
|
229
|
-
const sep = c.m('─'.repeat(cols - 2));
|
|
230
|
-
const cursor = c.bg('green')(c.ink(' '));
|
|
231
|
-
const prompt = `${c.g('❯')} ${st.input}${cursor}${st.prefix ? ` ${c.w('ctrl-g …')}` : ''}`;
|
|
232
|
-
|
|
233
|
-
let bottom;
|
|
234
|
-
if (st.mode && st.mode.type === 'transfer') {
|
|
235
|
-
const md = st.mode;
|
|
236
|
-
bottom = ` ${c.s(`transfer ${s.id} to`)} ${md.targets.map((t, i) => (i === md.idx ? c.bg('sel')(c.b(` ❯ ${t.name} `)) : c.m(` ${t.name} `))).join(' ')} ${c.m('←→ choose · enter go · esc cancel')}`;
|
|
237
|
-
} else {
|
|
238
|
-
const k = (key, label) => `${c.bg('key')(c.b(` ${key} `))} ${label}`;
|
|
239
|
-
const keys = ` ${[k('ctrl-g d', 'detach, keep running'), k('ctrl-g b', 'backup now'), k('ctrl-g t', 'transfer'), k('ctrl-g f', 'fork'), k('ctrl-g s', 'share read-only')].join(' ')}`;
|
|
240
|
-
const hint = c.m('prefix ctrl-g · everything else goes to the agent ');
|
|
241
|
-
bottom = `${fit(keys, Math.max(0, cols - width(hint)))}${hint}`;
|
|
242
|
-
}
|
|
243
|
-
if (st.flash && Date.now() < st.flash.until) {
|
|
244
|
-
const msg = c[st.flash.tone](st.flash.msg);
|
|
245
|
-
bottom = `${fit(bottom, Math.max(0, cols - width(msg) - 4))} ${msg} `;
|
|
246
|
-
}
|
|
247
|
-
const frame = [top, ...view.map((l) => ` ${fit(l, cols - 2)}`), ` ${sep}`, ` ${prompt}`, c.bg('bar')(fit(bottom, cols))];
|
|
248
|
-
paintFrame(frame.slice(0, rows));
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
const tickDraw = () => { st.tick++; draw(); };
|
|
252
|
-
const onData = (buf) => { (async () => { for (const k of parseKeys(buf)) await onKey(k); draw(); })(); };
|
|
253
|
-
|
|
254
|
-
enterFullscreen();
|
|
255
|
-
process.stdin.setRawMode(true);
|
|
256
|
-
process.stdin.resume();
|
|
257
|
-
process.stdin.on('data', onData);
|
|
258
|
-
process.stdout.on('resize', draw);
|
|
259
|
-
let timer = setInterval(tickDraw, 100);
|
|
260
|
-
draw();
|
|
261
|
-
});
|
|
262
|
-
}
|