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
package/src/commands/splash.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
// `gitroots` with no arguments: welcome and boot.
|
|
2
|
-
import { c, pad } from '../ui/ansi.js';
|
|
3
|
-
import { line, write, spinner, sleep, motion, logoImage } from '../ui/term.js';
|
|
4
|
-
import { brand, dot, json } from '../ui/components.js';
|
|
5
|
-
import { VERSION } from '../runtime.js';
|
|
6
|
-
import { apiRepos } from '../connected.js';
|
|
7
|
-
|
|
8
|
-
export function needsSummary(store) {
|
|
9
|
-
const items = store.needsYou();
|
|
10
|
-
const parts = [];
|
|
11
|
-
const reviews = items.filter((x) => x.kind === 'review');
|
|
12
|
-
const agents = items.filter((x) => x.kind === 'agent');
|
|
13
|
-
const blocked = items.filter((x) => x.kind === 'blocked');
|
|
14
|
-
for (const r of reviews) parts.push(`PR #${r.pr.number} review`);
|
|
15
|
-
for (const a of agents) parts.push(`${a.session.id} approval`);
|
|
16
|
-
if (blocked.length) parts.push(`${blocked.length} blocked push${blocked.length > 1 ? 'es' : ''}`);
|
|
17
|
-
return { count: items.length, text: parts.join(' · ') };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export default async function splash(store, args) {
|
|
21
|
-
const { org } = store;
|
|
22
|
-
const repos = store.repos();
|
|
23
|
-
const branchCount = repos.reduce((n, r) => n + r.branches, 0);
|
|
24
|
-
const providers = [...new Set(repos.map((r) => r.provider))].join(' · ');
|
|
25
|
-
const machines = store.machines().filter((m) => m.online);
|
|
26
|
-
const sessions = store.sessions();
|
|
27
|
-
const needs = needsSummary(store);
|
|
28
|
-
|
|
29
|
-
if (args.flags.json) {
|
|
30
|
-
json({ version: VERSION, org: org.name, user: org.user, role: org.role, relay: org.relay, repos: repos.length, branches: branchCount, machines: machines.length, sessions: sessions.length, needsYou: needs });
|
|
31
|
-
return 0;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const anim = motion();
|
|
35
|
-
line();
|
|
36
|
-
// Logo: the real image in Kitty, Ghostty, WezTerm and iTerm2; a plain gitroots line elsewhere.
|
|
37
|
-
const img = logoImage(26, 4);
|
|
38
|
-
const ver = `${c.s(`v${VERSION}`)} ${dot()} ${c.s('code governance and agent sessions')}`;
|
|
39
|
-
const tag = c.g('Secure it to the root, scale with confidence.');
|
|
40
|
-
if (img) {
|
|
41
|
-
write('\n\n\n\n\x1b[4A');
|
|
42
|
-
write(`\x1b7${img}\x1b8`);
|
|
43
|
-
const rows = ['', ver, tag, ''];
|
|
44
|
-
for (const r of rows) write(`\x1b[28C${c.m('│')} ${r}\n`);
|
|
45
|
-
} else {
|
|
46
|
-
line(`${brand()} ${c.m('│')} ${ver}`);
|
|
47
|
-
line(`${' '.repeat(8)} ${c.m('│')} ${tag}`);
|
|
48
|
-
}
|
|
49
|
-
line();
|
|
50
|
-
|
|
51
|
-
const checks = [
|
|
52
|
-
{ wait: 'Signing in…', ok: `Signed in as ${org.user}`, meta: `org ${org.name} ${dot()} ${org.role}` },
|
|
53
|
-
{ wait: 'Checking repositories…', ok: `${repos.length} repos · ${branchCount} branches`, meta: `${providers} ${dot()} rules in sync` },
|
|
54
|
-
{ wait: `Connecting agentd and relay ${org.relay.name}…`, ok: `${store.machines().length} machines · ${sessions.length} agent sessions`, meta: `relay ${org.relay.name} ${dot()} ${org.relay.latencyMs} ms` },
|
|
55
|
-
];
|
|
56
|
-
if (store.api) {
|
|
57
|
-
// Signed in: the first two checks are real calls to the gitroots server.
|
|
58
|
-
checks[0].run = async () => {
|
|
59
|
-
const me = await store.api.me();
|
|
60
|
-
if (!me) throw new Error('user not found');
|
|
61
|
-
return { ok: `Signed in as ${me.username}`, meta: `org ${org.name} ${dot()} ${org.role} ${dot()} ${store.api.server.replace(/^https?:\/\//, '')}` };
|
|
62
|
-
};
|
|
63
|
-
checks[1].run = async () => {
|
|
64
|
-
const rs = await apiRepos(store);
|
|
65
|
-
return { ok: `${rs.length} repos · ${rs.reduce((n, r) => n + (r.branches || 0), 0)} branches`, meta: 'GitHub · from the gitroots API' };
|
|
66
|
-
};
|
|
67
|
-
checks[2].meta += ` ${dot()} sample sessions`;
|
|
68
|
-
}
|
|
69
|
-
for (const ch of checks) {
|
|
70
|
-
const sp = spinner((f) => `${c.i(f)} ${c.s(ch.wait)}`);
|
|
71
|
-
const t0 = Date.now();
|
|
72
|
-
let r = {};
|
|
73
|
-
try { if (ch.run) r = await ch.run(); } catch (e) { sp.stop(`${c.r('✕')} ${e.message}`); continue; }
|
|
74
|
-
if (anim && Date.now() - t0 < 700) await sleep(700 - (Date.now() - t0));
|
|
75
|
-
sp.stop(`${c.g('✓')} ${r.ok || ch.ok} ${c.m(r.meta || ch.meta)}`);
|
|
76
|
-
}
|
|
77
|
-
line();
|
|
78
|
-
line(c.m('QUICK START'));
|
|
79
|
-
const qs = [
|
|
80
|
-
['repos', `${c.g('gitroots repo list')} ${dot()} ${c.g('branch list erp')} ${dot()} ${c.g('clone erp')}`, 'repositories and branch rules'],
|
|
81
|
-
['push', c.g('git push'), 'gitroots checks every push before it leaves'],
|
|
82
|
-
['review', `${c.g('gitroots pr list')} ${dot()} ${c.g('pr view 412')} ${dot()} ${c.g('pr approve')} ${dot()} ${c.g('pr merge')}`, 'reviews and merges'],
|
|
83
|
-
['audit', `${c.g('gitroots audit')} ${dot()} ${c.g('audit follow')}`, 'who did what, when, and what was blocked'],
|
|
84
|
-
['agents', `${c.g('gitroots watch')} ${dot()} ${c.g('attach')} ${dot()} ${c.g('transfer')} ${dot()} ${c.g('backup')} ${dot()} ${c.g('tunnel')}`, 'every agent session, one place'],
|
|
85
|
-
];
|
|
86
|
-
for (const [k, cmds, what] of qs) line(` ${c.s(pad(k, 8))} ${pad(cmds, 55)} ${c.m(what)}`);
|
|
87
|
-
line();
|
|
88
|
-
if (needs.count) {
|
|
89
|
-
line(`${c.w(`◐ ${needs.count} thing${needs.count > 1 ? 's' : ''} need${needs.count > 1 ? '' : 's'} you`)} ${c.m(needs.text)} ${c.g('→ gitroots status')}`);
|
|
90
|
-
} else {
|
|
91
|
-
line(`${c.g('✓ nothing needs you')} ${c.g('→ gitroots watch')}`);
|
|
92
|
-
}
|
|
93
|
-
if (store.mode === 'demo') line(c.m(` sample data · gitroots login connects your gitroots server · gitroots demo reset starts over`));
|
|
94
|
-
return 0;
|
|
95
|
-
}
|
package/src/commands/status.js
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
// gitroots status · everything at a glance
|
|
2
|
-
import { c, h, pad } from '../ui/ansi.js';
|
|
3
|
-
import { line } from '../ui/term.js';
|
|
4
|
-
import { brand, dot, decision, json } from '../ui/components.js';
|
|
5
|
-
import { apiRepos, apiAudit, sampleNote } from '../connected.js';
|
|
6
|
-
|
|
7
|
-
export async function run(_cmd, _rest, flags, store) {
|
|
8
|
-
const { org } = store;
|
|
9
|
-
const needs = store.needsYou();
|
|
10
|
-
let repos = store.repos();
|
|
11
|
-
let audit = store.audit();
|
|
12
|
-
let apiError = null;
|
|
13
|
-
if (store.api) {
|
|
14
|
-
try { [repos, audit] = await Promise.all([apiRepos(store), apiAudit(store)]); } catch (e) { apiError = e.message; repos = []; audit = []; }
|
|
15
|
-
}
|
|
16
|
-
const sessions = store.sessions();
|
|
17
|
-
const machines = new Set(sessions.map((s) => s.machine));
|
|
18
|
-
const count = (st) => sessions.filter((s) => s.state === st).length;
|
|
19
|
-
const recent = [...audit].sort((a, b) => (b.at || '').localeCompare(a.at || '') || b.time.localeCompare(a.time)).slice(0, 4);
|
|
20
|
-
|
|
21
|
-
if (flags.json) {
|
|
22
|
-
json({
|
|
23
|
-
org: org.name, user: org.user, role: org.role, relay: org.relay,
|
|
24
|
-
needsYou: needs.map((n) => (n.kind === 'review' ? { kind: 'review', pr: n.pr.number } : n.kind === 'agent' ? { kind: 'agent', session: n.session.id, question: n.session.ask.question } : { kind: 'blocked', event: n.event.id })),
|
|
25
|
-
repos: repos.map((r) => ({ name: r.name, provider: r.provider, protected: r.protected, branches: r.branches, openPrs: r.openPrs, lastPush: r.lastPush })),
|
|
26
|
-
sessions: { total: sessions.length, machines: machines.size, running: count('running'), waiting: count('waiting'), suspended: count('suspended'), idle: count('idle') },
|
|
27
|
-
recent,
|
|
28
|
-
});
|
|
29
|
-
return 0;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const relay = org.relay.connected ? c.g('● connected') : c.r('✕ disconnected');
|
|
33
|
-
const where = store.api ? `server ${apiError ? c.r(`✕ ${apiError}`) : c.g(`● ${store.api.server.replace(/^https?:\/\//, '')}`)}` : `relay ${relay}`;
|
|
34
|
-
line(`${brand()} ${dot()} org ${c.b(org.name)} ${dot()} signed in as ${c.b(org.user)} ${c.m(`(${org.role})`)} ${dot()} ${where}`);
|
|
35
|
-
line();
|
|
36
|
-
|
|
37
|
-
line(h('Needs you'));
|
|
38
|
-
if (!needs.length) line(` ${c.g('✓ nothing needs you')}`);
|
|
39
|
-
for (const n of needs) {
|
|
40
|
-
if (n.kind === 'review') {
|
|
41
|
-
const p = n.pr;
|
|
42
|
-
const what = pad(`PR #${p.number} ${p.repo} · ${p.from} → ${p.into}`, 52);
|
|
43
|
-
const checks = p.checks.state === 'pass' ? 'checks ✓' : p.checks.state === 'fail' ? 'checks ✕' : 'checks running';
|
|
44
|
-
line(` ${c.w(pad('◐ review', 11))} ${what}${pad(`${p.approvals.length} of ${p.required} approvals · ${checks}`, 32)}${c.g(`→ gitroots pr view ${p.number}`)}`);
|
|
45
|
-
} else if (n.kind === 'agent') {
|
|
46
|
-
const s = n.session;
|
|
47
|
-
line(` ${c.w(pad('◐ agent', 11))} ${pad(`${s.id} ${s.agent} asks: ${s.ask.short}`, 84)}${c.g(`→ gitroots attach ${s.id}`)}`);
|
|
48
|
-
} else {
|
|
49
|
-
const e = n.event;
|
|
50
|
-
line(` ${c.r(pad('✕ blocked', 11))} ${pad(`push ${e.actor} → ${e.repo}/${e.target.split(' · ')[0]} · rule "${e.rule}" · ${e.time}`, 84)}${c.g(`→ gitroots audit ${e.id}`)}`);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
sampleNote(store, 'pull requests and agent questions are');
|
|
54
|
-
line();
|
|
55
|
-
|
|
56
|
-
line(h('Repositories'));
|
|
57
|
-
line(` ${h(`${pad('NAME', 14)}${pad('PROVIDER', 11)}${pad('PROTECTED BRANCHES', 26)}${pad('BRANCHES', 11)}${pad('OPEN PRS', 11)}LAST PUSH`)}`);
|
|
58
|
-
for (const r of repos) {
|
|
59
|
-
line(` ${c.b(pad(r.name, 14))}${pad(r.provider, 11)}${pad(r.protected.length ? r.protected.join(' · ') : c.m('—'), 26)}${pad(String(r.branches), 11)}${pad(r.openPrs === null || r.openPrs === undefined ? c.m('—') : String(r.openPrs), 11)}${pad(r.lastPush || c.m('—'), 9)}${c.m(r.lastBranch || '')}`);
|
|
60
|
-
}
|
|
61
|
-
line();
|
|
62
|
-
|
|
63
|
-
line(`${h('Agent sessions')} ${c.m(`${sessions.length} on ${machines.size} machines`)}`);
|
|
64
|
-
const bits = [];
|
|
65
|
-
if (count('running')) bits.push(c.g(`● ${count('running')} running`));
|
|
66
|
-
if (count('waiting')) bits.push(c.w(`◐ ${count('waiting')} waiting`));
|
|
67
|
-
if (count('idle')) bits.push(c.s(`○ ${count('idle')} idle`));
|
|
68
|
-
if (count('suspended')) bits.push(c.m(`⏸ ${count('suspended')} suspended · backed up`));
|
|
69
|
-
line(` ${bits.join(' ')} ${c.g('→ gitroots watch')}`);
|
|
70
|
-
sampleNote(store, 'agent sessions are');
|
|
71
|
-
line();
|
|
72
|
-
|
|
73
|
-
line(h(store.api ? 'Latest activity' : 'Last hour'));
|
|
74
|
-
if (!recent.length) line(c.m(' no activity yet'));
|
|
75
|
-
for (const e of recent) {
|
|
76
|
-
const ok = e.decision === 'allowed' && e.note ? c.g(`✓ ${e.note}`) : e.decision === 'allowed' ? '' : decision(e.decision);
|
|
77
|
-
line(` ${c.m(e.time)} ${c.s(pad(e.actor, 10))}${pad(`${e.action} ${e.target}`, 44)}${ok}`);
|
|
78
|
-
}
|
|
79
|
-
line();
|
|
80
|
-
const first = needs[0];
|
|
81
|
-
if (first) line(c.g(`→ ${first.kind === 'review' ? `gitroots pr view ${first.pr.number}` : first.kind === 'agent' ? `gitroots attach ${first.session.id}` : `gitroots audit ${first.event.id}`}`));
|
|
82
|
-
else line(c.g('→ gitroots watch'));
|
|
83
|
-
return 0;
|
|
84
|
-
}
|
package/src/commands/transfer.js
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
// gitroots transfer <session> --to <machine>
|
|
2
|
-
import { c } from '../ui/ansi.js';
|
|
3
|
-
import { line, runSteps, motion } from '../ui/term.js';
|
|
4
|
-
import { brand, dot, json, next, sessionState } from '../ui/components.js';
|
|
5
|
-
import { createBackup } from './backup.js';
|
|
6
|
-
|
|
7
|
-
export async function run(_cmd, rest, flags, store) {
|
|
8
|
-
const [id] = rest;
|
|
9
|
-
const to = flags.to || rest[1];
|
|
10
|
-
if (!id || !to) throw new Error('usage: gitroots transfer <session> --to <machine>');
|
|
11
|
-
return transfer(store, id, to, flags);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function transfer(store, id, to, flags = {}) {
|
|
15
|
-
const s = store.session(id);
|
|
16
|
-
const from = store.machine(s.machine);
|
|
17
|
-
const dest = store.machine(to);
|
|
18
|
-
if (dest.name === from.name) throw new Error(`${s.id} is already on ${to}`);
|
|
19
|
-
if (!dest.online) throw new Error(`${to} is offline (${dest.offlineFor || 'unknown'}) · pick an online machine`);
|
|
20
|
-
if (s.state === 'stopped') throw new Error(`${s.id} is stopped · restore it instead`);
|
|
21
|
-
|
|
22
|
-
const convoMb = Math.round(s.turns * 0.0127 * 10) / 10;
|
|
23
|
-
const totalMb = Math.round((convoMb + 2 + s.changed * 4.6) * 10) / 10;
|
|
24
|
-
const uploadSecs = Math.round((totalMb / 4.1) * 10) / 10;
|
|
25
|
-
|
|
26
|
-
if (flags.json) {
|
|
27
|
-
const b = store.d.backupPolicy.beforeTransfer ? createBackup(store, s, `before move to ${to}`) : null;
|
|
28
|
-
s.machine = dest.name;
|
|
29
|
-
s.state = 'running';
|
|
30
|
-
const ev = store.record({ actor: store.org.user, action: 'transfer', target: `${s.id} ${from.name} → ${dest.name}`, decision: 'allowed', repo: s.repo });
|
|
31
|
-
store.save();
|
|
32
|
-
json({ session: s.id, from: from.name, to: dest.name, backup: b?.id, event: ev.id });
|
|
33
|
-
return 0;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
line(`${brand()} ${dot()} ${c.b(`Transfer ${s.id}`)} ${c.m(`${s.agent} · ${s.repo} · ${s.branch} · ${s.turns} turns`)}`);
|
|
37
|
-
line(` ${c.b(from.name)} ${c.m(from.os)} ${c.g('━━━━━━━━━━━▶')} ${c.b(dest.name)} ${c.m(dest.os)} ${c.m(`via relay ${store.org.relay.name} · end-to-end encrypted`)}`);
|
|
38
|
-
line(` ${c.m(`ctrl-c cancels safely · the session stays paused on ${from.name} until verify passes`)}`);
|
|
39
|
-
line();
|
|
40
|
-
|
|
41
|
-
let cancelled = false;
|
|
42
|
-
const onInt = () => { cancelled = true; };
|
|
43
|
-
process.on('SIGINT', onInt);
|
|
44
|
-
const t0 = Date.now();
|
|
45
|
-
const results = await runSteps([
|
|
46
|
-
{ name: 'Pause session', live: 'waiting for the agent to finish its turn…', ms: 500, ok: `agent stopped at a safe point, turn ${s.turns}`, time: '0.4s' },
|
|
47
|
-
{ name: 'Snapshot conversation', live: `reading ${s.turns} turns…`, ms: 400, ok: `${s.turns} turns · ${convoMb} MB`, time: '0.2s' },
|
|
48
|
-
{ name: 'Snapshot workspace', live: 'git stash · diff · untracked files…', ms: 600, ok: `HEAD ${s.head} · ${s.changed} files changed${s.stash ? ` · ${s.stash} stash` : ''}`, time: '0.9s' },
|
|
49
|
-
{ name: 'Capture environment', live: 'sealing secrets…', ms: 300, ok: `cwd ~/work/${s.repo} · 12 vars · 2 secrets sealed`, time: '0.1s' },
|
|
50
|
-
{ name: 'Upload', bar: true, ms: 3100, ok: `${totalMb} MB · 4.1 MB/s`, time: `${uploadSecs}s`, barLabel: (p) => `${Math.round(p * 100)}% ${(p * totalMb).toFixed(1)} / ${totalMb} MB · 4.1 MB/s` },
|
|
51
|
-
{ name: `Rebuild on ${dest.name}`, live: `checkout ${s.head} · apply diff · restore stash…`, ms: 800, ok: `checkout ${s.head} · diff applied${s.stash ? ' · stash restored' : ''}`, time: '0.6s' },
|
|
52
|
-
{ name: 'Resume agent', live: `${s.resume}…`, ms: 500, ok: `${s.resume}, same conversation`, time: '0.2s' },
|
|
53
|
-
{ name: `Verify, then release ${from.name}`, live: 'comparing checksums…', ms: 600, ok: 'files match · agent answers · source cleaned', time: '0.1s' },
|
|
54
|
-
], { nameWidth: 30, detailWidth: 56, showQueued: true, cancelled: () => cancelled });
|
|
55
|
-
process.off('SIGINT', onInt);
|
|
56
|
-
|
|
57
|
-
if (results.cancelled) {
|
|
58
|
-
line();
|
|
59
|
-
line(` ${c.w('◐ Transfer cancelled')} ${s.id} resumed on ${from.name} ${dot()} nothing changed on ${dest.name}`);
|
|
60
|
-
return 130;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const b = store.d.backupPolicy.beforeTransfer ? createBackup(store, s, `before move to ${dest.name}`) : null;
|
|
64
|
-
s.machine = dest.name;
|
|
65
|
-
s.state = s.state === 'suspended' || s.state === 'paused' ? 'running' : s.state;
|
|
66
|
-
s.updated = 'just now';
|
|
67
|
-
const ev = store.record({ actor: store.org.user, action: 'transfer', target: `${s.id} ${from.name} → ${dest.name}`, decision: 'allowed', note: s.agent, repo: s.repo });
|
|
68
|
-
store.save();
|
|
69
|
-
|
|
70
|
-
const nominal = results.reduce((n, r) => n + parseFloat(r.time || '0'), 0);
|
|
71
|
-
const secs = (motion() ? (Date.now() - t0) / 1000 : nominal).toFixed(1);
|
|
72
|
-
line();
|
|
73
|
-
line(` ${c.g(c.b(`✓ Transferred in ${secs}s`))} ${s.id} is now ${sessionState(s)} on ${c.b(dest.name)}`);
|
|
74
|
-
line(` ${c.s('conversation')} ${s.turns} turns, intact ${c.s('workspace')} ${s.changed} files changed${s.stash ? `, ${s.stash} stash` : ''}`);
|
|
75
|
-
line(` ${c.s('backup')} ${b ? `${b.id} taken before move` : 'off by policy'} ${c.s(from.name)} released ${dot()} ${c.m(ev.id)}`);
|
|
76
|
-
line();
|
|
77
|
-
line(` ${next(`gitroots attach ${s.id}`, `gitroots transfer ${s.id} --to ${from.name} to bring it back`)}`);
|
|
78
|
-
return 0;
|
|
79
|
-
}
|
package/src/commands/tunnel.js
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
// gitroots tunnel <session> --port n [--shell] · tunnel ls · tunnel close · share
|
|
2
|
-
import crypto from 'node:crypto';
|
|
3
|
-
import { c, h, pad } from '../ui/ansi.js';
|
|
4
|
-
import { line, runSteps, copyToClipboard } from '../ui/term.js';
|
|
5
|
-
import { dot, json, next } from '../ui/components.js';
|
|
6
|
-
|
|
7
|
-
const KNOWN_PORTS = { 8069: 'Odoo', 8888: 'Jupyter', 3000: 'web', 5173: 'Vite', 8000: 'web', 8080: 'web', 5432: 'Postgres', 6006: 'TensorBoard' };
|
|
8
|
-
|
|
9
|
-
function relayDomain(store) { return process.env.GITROOTS_RELAY_DOMAIN || `${store.org.relay.name}.relay.gitroots.com`; }
|
|
10
|
-
|
|
11
|
-
export async function run(cmd, rest, flags, store) {
|
|
12
|
-
if (cmd === 'share') return share(rest, flags, store);
|
|
13
|
-
const [sub] = rest;
|
|
14
|
-
if (sub === 'ls' || sub === 'list' || (!sub && !flags.port)) return list(flags, store);
|
|
15
|
-
if (sub === 'close') return close(rest.slice(1), flags, store);
|
|
16
|
-
return open(rest, flags, store);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function parseDuration(s) {
|
|
20
|
-
const m = /^(\d+)\s*(m|h|d)$/.exec(String(s));
|
|
21
|
-
if (!m) throw new Error(`--expires takes 30m, 1h or 1d (got ${s})`);
|
|
22
|
-
return Number(m[1]) * { m: 60000, h: 3600000, d: 86400000 }[m[2]];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async function open([id], flags, store) {
|
|
26
|
-
if (!id) throw new Error('usage: gitroots tunnel <session> --port <n> | --shell');
|
|
27
|
-
const s = store.session(id);
|
|
28
|
-
const m = store.machine(s.machine);
|
|
29
|
-
if (!m.online) throw new Error(`${m.name} is offline`);
|
|
30
|
-
const shell = Boolean(flags.shell);
|
|
31
|
-
const port = shell ? null : Number(flags.port);
|
|
32
|
-
if (!shell && !(port > 0 && port < 65536)) throw new Error('--port takes a port number, e.g. --port 8069');
|
|
33
|
-
const what = shell ? 'shell' : `${port}${KNOWN_PORTS[port] ? ` ${KNOWN_PORTS[port]}` : ''}`;
|
|
34
|
-
const t = {
|
|
35
|
-
id: store.nextTunnelId(), session: s.id, machine: m.name, target: shell ? 'shell' : `:${what}`, access: 'only you', viewers: 0, traffic: '0 B', openFor: 'just now', at: new Date().toISOString(),
|
|
36
|
-
url: shell ? null : `https://${s.id}-${port}.${relayDomain(store)}`,
|
|
37
|
-
};
|
|
38
|
-
store.d.tunnels.push(t);
|
|
39
|
-
const ev = store.record({ actor: store.org.user, action: 'tunnel open', target: `${s.id} ${t.target}`, decision: 'allowed', note: m.name, repo: s.repo });
|
|
40
|
-
store.save();
|
|
41
|
-
if (flags.json) { json({ ...t, event: ev.id }); return 0; }
|
|
42
|
-
|
|
43
|
-
const dest = shell ? `shell on ${m.name}` : `localhost:${port}${KNOWN_PORTS[port] ? ` (${KNOWN_PORTS[port]})` : ''}`;
|
|
44
|
-
line(` ${c.s('your browser')} ${c.g('◀━━▶')} ${c.s(`relay ${store.org.relay.name}`)} ${c.g('◀━━')} ${c.s(`agentd on ${m.name}`)} ${c.g('━━▶')} ${c.b(dest)}`);
|
|
45
|
-
line(` ${' '.repeat(30)}${c.m(`outbound only · no ports opened on ${m.name} · TLS end to end`)}`);
|
|
46
|
-
line();
|
|
47
|
-
await runSteps([{ name: 'Opening tunnel', live: `asking agentd on ${m.name}…`, ms: 700, ok: `${t.id} ready` }], { nameWidth: 16 });
|
|
48
|
-
line(` ${c.g('✓')} ${c.b(`Tunnel ${t.id} open`)}`);
|
|
49
|
-
if (t.url) line(` ${c.s(pad('url', 11))}${c.i(t.url)}`);
|
|
50
|
-
else line(` ${c.s(pad('shell', 11))}${c.g(`gitroots attach ${s.id} --shell`)} ${c.m('(no ssh keys needed)')}`);
|
|
51
|
-
line(` ${c.s(pad('access', 11))}only you ${dot()} signed in with gitroots`);
|
|
52
|
-
line(` ${c.s(pad('closes', 11))}when ${s.id} stops ${dot()} or gitroots tunnel close ${t.id}`);
|
|
53
|
-
line();
|
|
54
|
-
line(next(`gitroots share ${s.id} --read-only --expires 1h`));
|
|
55
|
-
return 0;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async function share([id], flags, store) {
|
|
59
|
-
if (!id) throw new Error('usage: gitroots share <session> --read-only [--expires 1h]');
|
|
60
|
-
const s = store.session(id);
|
|
61
|
-
if (flags['read-only'] === undefined && !flags.json) throw new Error('only read-only sharing is available · add --read-only');
|
|
62
|
-
const ms = parseDuration(flags.expires || '1h');
|
|
63
|
-
const exp = new Date(Date.now() + ms);
|
|
64
|
-
const token = crypto.randomBytes(4).toString('base64url').slice(0, 5);
|
|
65
|
-
const url = `https://${relayDomain(store)}/s/${token}`;
|
|
66
|
-
const t = { id: store.nextTunnelId(), session: s.id, machine: s.machine, target: 'live view', access: 'read-only link', viewers: 0, traffic: '0 B', openFor: 'just now', at: new Date().toISOString(), url, expires: exp.toISOString() };
|
|
67
|
-
store.d.tunnels.push(t);
|
|
68
|
-
const ev = store.record({ actor: store.org.user, action: 'share', target: `${s.id} read-only`, decision: 'allowed', note: `expires ${exp.toTimeString().slice(0, 5)}`, repo: s.repo });
|
|
69
|
-
store.save();
|
|
70
|
-
if (flags.json) { json({ ...t, event: ev.id }); return 0; }
|
|
71
|
-
const copied = await copyToClipboard(url);
|
|
72
|
-
const hhmm = exp.toTimeString().slice(0, 5);
|
|
73
|
-
line(` ${c.g('✓')} ${c.b('Live view shared')} ${c.m(`read-only · expires ${hhmm} (${flags.expires || '1h'})`)}`);
|
|
74
|
-
line(` ${c.s(pad('link', 11))}${c.i(url)}${copied ? ` ${c.m('copied to clipboard')}` : ''}`);
|
|
75
|
-
line(` ${c.s(pad('viewers', 11))}can watch output and diffs ${dot()} cannot type, approve or stop`);
|
|
76
|
-
line();
|
|
77
|
-
line(next('gitroots tunnel ls'));
|
|
78
|
-
return 0;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function list(flags, store) {
|
|
82
|
-
const rows = store.tunnels();
|
|
83
|
-
if (flags.json) { json(rows); return 0; }
|
|
84
|
-
line(` ${h(`${pad('ID', 8)}${pad('SESSION', 10)}${pad('MACHINE', 13)}${pad('TARGET', 18)}${pad('ACCESS', 16)}${pad('VIEWERS', 10)}${pad('TRAFFIC', 10)}OPEN FOR`)}`);
|
|
85
|
-
for (const t of [...rows].reverse()) {
|
|
86
|
-
line(` ${pad(t.id, 8)}${pad(t.session, 10)}${pad(t.machine, 13)}${pad(t.target, 18)}${pad(t.access, 16)}${pad(String(t.viewers), 10)}${pad(t.traffic, 10)}${t.at ? ago(t.at) : t.openFor}`);
|
|
87
|
-
}
|
|
88
|
-
if (!rows.length) line(c.m(' no open tunnels'));
|
|
89
|
-
line();
|
|
90
|
-
const first = rows[rows.length - 1];
|
|
91
|
-
line(` ${c.m('ssh-free shell:')} ${c.s(`gitroots tunnel ${first ? first.session : '<session>'} --shell`)} ${dot()} ${c.m('kill all:')} ${c.s('gitroots tunnel close --all')}`);
|
|
92
|
-
return 0;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function ago(iso) {
|
|
96
|
-
const m = Math.floor((Date.now() - Date.parse(iso)) / 60000);
|
|
97
|
-
if (m < 1) return 'just now';
|
|
98
|
-
if (m < 60) return `${m}m`;
|
|
99
|
-
return `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function close([id], flags, store) {
|
|
103
|
-
const before = store.d.tunnels.length;
|
|
104
|
-
if (flags.all) store.d.tunnels = [];
|
|
105
|
-
else {
|
|
106
|
-
if (!id) throw new Error('usage: gitroots tunnel close <t-id> | --all');
|
|
107
|
-
if (!store.d.tunnels.some((t) => t.id === id)) throw new Error(`no tunnel ${id} · see gitroots tunnel ls`);
|
|
108
|
-
store.d.tunnels = store.d.tunnels.filter((t) => t.id !== id);
|
|
109
|
-
}
|
|
110
|
-
const closed = before - store.d.tunnels.length;
|
|
111
|
-
store.record({ actor: store.org.user, action: 'tunnel close', target: flags.all ? 'all tunnels' : id, decision: 'allowed', note: `${closed} closed` });
|
|
112
|
-
store.save();
|
|
113
|
-
if (flags.json) { json({ closed }); return 0; }
|
|
114
|
-
line(` ${c.g('✓')} Closed ${closed} tunnel${closed === 1 ? '' : 's'}`);
|
|
115
|
-
return 0;
|
|
116
|
-
}
|