gitroots 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
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
+ }
@@ -0,0 +1,79 @@
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
+ }
@@ -0,0 +1,116 @@
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
+ }
@@ -0,0 +1,69 @@
1
+ // gitroots update · uninstall
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { c } from '../ui/ansi.js';
6
+ import { line, spinner, bar, confirm } from '../ui/term.js';
7
+ import { json } from '../ui/components.js';
8
+ import { VERSION, NPM_PACKAGE, DOWNLOAD_BASE, installMethod, nativePaths, platformKey } from '../runtime.js';
9
+ import { latestVersion, installNative, newer, onPath } from '../installer.js';
10
+ import { home } from '../data/store.js';
11
+
12
+ export async function run(cmd, rest, flags) {
13
+ if (cmd === 'uninstall') return uninstall(flags);
14
+ return update(flags);
15
+ }
16
+
17
+ async function update(flags) {
18
+ const method = installMethod();
19
+ if (method === 'npm') {
20
+ const target = `${NPM_PACKAGE}@${flags.version || 'latest'}`;
21
+ line(`${c.i('○')} installed with npm · running ${c.b(`npm install -g ${target}`)}`);
22
+ const r = spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['install', '-g', target], { stdio: 'inherit' });
23
+ return r.status ?? 1;
24
+ }
25
+ if (method === 'source') {
26
+ line(`${c.m('○')} running from a source checkout · update it with git pull, or install a release:`);
27
+ line(` ${c.g(`curl -fsSL ${DOWNLOAD_BASE}/install.sh | bash`)}`);
28
+ return 0;
29
+ }
30
+ const sp = spinner((f) => `${c.i(f)} ${c.s(`Checking ${DOWNLOAD_BASE}…`)}`);
31
+ let target;
32
+ try { target = flags.version || await latestVersion(); } catch (e) { sp.stop(`${c.r('✕')} ${e.message}`); return 1; }
33
+ if (!flags.version && !newer(target, VERSION) && !flags.force) {
34
+ sp.stop(`${c.g('✓')} gitroots ${VERSION} is the latest`);
35
+ if (flags.json) json({ current: VERSION, latest: target, updated: false });
36
+ return 0;
37
+ }
38
+ sp.update((f) => `${c.i(f)} ${c.s(`Downloading gitroots ${target} for ${platformKey()}…`)}`);
39
+ let progress = 0;
40
+ sp.update((f) => `${c.i(f)} ${c.b('Downloading')} ${bar(progress, 30)} ${c.s(`${Math.round(progress * 100)}% · gitroots ${target}`)}`);
41
+ try {
42
+ const r = await installNative(target, { onProgress: (p) => { progress = p; } });
43
+ sp.stop(`${c.g('✓')} Updated ${VERSION} → ${c.b(target)} ${c.m(`checksum verified · ${r.launcher}`)}`);
44
+ if (flags.json) json({ current: VERSION, latest: target, updated: true });
45
+ return 0;
46
+ } catch (e) {
47
+ sp.stop(`${c.r('✕')} ${e.message}`);
48
+ return 1;
49
+ }
50
+ }
51
+
52
+ async function uninstall(flags) {
53
+ const method = installMethod();
54
+ if (method === 'npm') { line(`installed with npm · run ${c.b(`npm uninstall -g ${NPM_PACKAGE}`)}`); return 0; }
55
+ const { data, launcher, bin } = nativePaths();
56
+ const targets = [launcher, data];
57
+ if (flags.purge) targets.push(home());
58
+ line('This removes:');
59
+ for (const t of targets) line(` ${c.s(t)}${fs.existsSync(t) ? '' : c.m(' (not there)')}`);
60
+ line(c.w('◐ push hooks block pushes once gitroots is gone · run gitroots hooks uninstall in each repo first'));
61
+ const ok = await confirm('Uninstall gitroots?', { def: false, assumeYes: flags.yes || flags.y });
62
+ if (!ok) { line(c.m(' nothing removed')); return 1; }
63
+ for (const t of targets) fs.rmSync(t, { recursive: true, force: true });
64
+ line(`${c.g('✓')} gitroots removed${flags.purge ? '' : c.m(` · settings kept in ${home()} (--purge removes them)`)}`);
65
+ if (onPath(bin)) line(c.m(` ${bin} stays on your PATH; remove the line the installer added to your shell profile if you no longer need it`));
66
+ return 0;
67
+ }
68
+
69
+ export { path };