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.
- package/README.md +137 -2
- package/assets/logo.png +0 -0
- package/bin/gitroots.js +11 -0
- package/package.json +35 -4
- package/src/api.js +121 -0
- package/src/cli.js +150 -0
- package/src/commands/attach.js +262 -0
- package/src/commands/audit.js +150 -0
- package/src/commands/auth.js +99 -0
- package/src/commands/backup.js +106 -0
- package/src/commands/completion.js +69 -0
- package/src/commands/demo.js +12 -0
- package/src/commands/doctor.js +81 -0
- package/src/commands/pr.js +322 -0
- package/src/commands/push.js +273 -0
- package/src/commands/repos.js +122 -0
- package/src/commands/restore.js +125 -0
- package/src/commands/splash.js +95 -0
- package/src/commands/status.js +84 -0
- package/src/commands/transfer.js +79 -0
- package/src/commands/tunnel.js +116 -0
- package/src/commands/update.js +69 -0
- package/src/commands/watch.js +347 -0
- package/src/connected.js +82 -0
- package/src/data/live.js +49 -0
- package/src/data/sample.js +163 -0
- package/src/data/store.js +145 -0
- package/src/git/checks.js +96 -0
- package/src/installer.js +96 -0
- package/src/runtime.js +63 -0
- package/src/ui/ansi.js +138 -0
- package/src/ui/components.js +85 -0
- package/src/ui/term.js +296 -0
|
@@ -0,0 +1,262 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// gitroots audit · audit <ev-id> · audit follow
|
|
2
|
+
import { c, h, pad, fit, state as colorState } from '../ui/ansi.js';
|
|
3
|
+
import { line, write, motion, sleep } from '../ui/term.js';
|
|
4
|
+
import { dot, decision, json, csv, next } from '../ui/components.js';
|
|
5
|
+
import { FOLLOW_POOL } from '../data/sample.js';
|
|
6
|
+
import { apiAudit } from '../connected.js';
|
|
7
|
+
|
|
8
|
+
export async function run(_cmd, rest, flags, store) {
|
|
9
|
+
const [sub] = rest;
|
|
10
|
+
if (sub === 'follow' || flags.follow) return follow(flags, store);
|
|
11
|
+
if (sub && /^ev-\d+$/.test(sub)) return detail(sub, flags, store);
|
|
12
|
+
if (sub) throw new Error(`unknown: gitroots audit ${sub} · try gitroots audit, audit <ev-id>, audit follow`);
|
|
13
|
+
return list(flags, store);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sinceMinutes(s) {
|
|
17
|
+
const m = /^(\d+)\s*(m|h|d)$/.exec(String(s));
|
|
18
|
+
if (!m) throw new Error(`--since takes 30m, 2h or 1d (got ${s})`);
|
|
19
|
+
return Number(m[1]) * { m: 1, h: 60, d: 1440 }[m[2]];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function list(flags, store) {
|
|
23
|
+
let ev = store.api ? await apiAudit(store) : [...store.audit()];
|
|
24
|
+
if (flags.repo) ev = ev.filter((e) => e.repo === flags.repo);
|
|
25
|
+
if (flags.actor) ev = ev.filter((e) => e.actor === flags.actor);
|
|
26
|
+
if (flags.action) ev = ev.filter((e) => e.action === flags.action);
|
|
27
|
+
if (flags.decision) ev = ev.filter((e) => e.decision === flags.decision);
|
|
28
|
+
if (flags.pr) ev = ev.filter((e) => String(e.pr) === String(flags.pr).replace('#', ''));
|
|
29
|
+
if (flags.since) {
|
|
30
|
+
const mins = sinceMinutes(flags.since);
|
|
31
|
+
ev = ev.filter((e) => {
|
|
32
|
+
if (e.at) return (Date.now() - Date.parse(e.at)) / 60000 <= mins;
|
|
33
|
+
// sample events carry only a clock time, as seen from the sample's 14:21
|
|
34
|
+
const [hh, mm] = e.time.split(':').map(Number);
|
|
35
|
+
return 14 * 60 + 21 - (hh * 60 + mm) <= mins;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
ev.sort((a, b) => (b.at || '').localeCompare(a.at || '') || b.time.localeCompare(a.time) || b.id.localeCompare(a.id));
|
|
39
|
+
const total = ev.length;
|
|
40
|
+
if (flags.json) { json(ev); return 0; }
|
|
41
|
+
if (flags.csv) { csv(ev, ['id', 'time', 'at', 'actor', 'action', 'target', 'decision', 'note', 'repo', 'pr']); return 0; }
|
|
42
|
+
const limit = flags.all ? ev.length : Number(flags.limit || 7);
|
|
43
|
+
const shown = ev.slice(0, limit);
|
|
44
|
+
line(` ${h(`${pad('TIME', 8)}${pad('ACTOR', 13)}${pad('ACTION', 16)}${pad('TARGET', 40)}${pad('DECISION', 35)}ID`)}`);
|
|
45
|
+
for (const e of shown) {
|
|
46
|
+
line(` ${c.m(pad(e.time, 8))}${fit(e.actor, 12)} ${fit(e.action, 15)} ${fit(e.target, 39)} ${pad(decision(e.decision), 12)}${c.m(pad(e.note || '', 23))}${c.m(e.id)}`);
|
|
47
|
+
}
|
|
48
|
+
if (!shown.length) line(c.m(' no events match'));
|
|
49
|
+
line(` ${c.m(`${shown.length} of ${total} events · --actor · --action · --decision blocked · --json · --csv for compliance exports`)}`);
|
|
50
|
+
const blocked = shown.find((e) => e.decision === 'blocked');
|
|
51
|
+
line();
|
|
52
|
+
line(next(blocked ? `gitroots audit ${blocked.id}` : 'gitroots audit follow'));
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function detail(id, flags, store) {
|
|
57
|
+
let e;
|
|
58
|
+
if (store.api) {
|
|
59
|
+
e = (await apiAudit(store)).find((x) => x.id === id);
|
|
60
|
+
if (!e) throw new Error(`no audit event ${id}`);
|
|
61
|
+
} else e = store.event(id);
|
|
62
|
+
if (flags.json) { json(e); return 0; }
|
|
63
|
+
line(` ${c.b(e.id)} ${decision(e.decision)} ${c.m(`${e.time}${e.at ? ` · ${e.at}` : ''}`)}`);
|
|
64
|
+
line();
|
|
65
|
+
const row = (k, v) => v && line(` ${c.s(pad(k, 10))}${v}`);
|
|
66
|
+
row('actor', e.actor);
|
|
67
|
+
row('action', e.action);
|
|
68
|
+
row('repo', e.repo);
|
|
69
|
+
row('target', e.target);
|
|
70
|
+
row('rule', e.rule);
|
|
71
|
+
if (e.note !== e.rule) row('note', e.note);
|
|
72
|
+
if (e.pr) row('pr', `#${e.pr}`);
|
|
73
|
+
if (e.metadata && Object.keys(e.metadata).length) row('metadata', c.m(JSON.stringify(e.metadata)));
|
|
74
|
+
line();
|
|
75
|
+
if (e.decision === 'blocked' && e.action === 'push') {
|
|
76
|
+
line(` ${c.m('nothing left the machine · the attempt is kept for review')}`);
|
|
77
|
+
if (!e.ack && !store.api) { e.ack = true; store.save(); }
|
|
78
|
+
line();
|
|
79
|
+
line(next(e.repo ? `gitroots branch rules ${e.repo} ${e.target.split(' · ')[0]}` : 'gitroots audit'));
|
|
80
|
+
} else if (e.pr) line(next(`gitroots pr view ${e.pr}`));
|
|
81
|
+
else line(next('gitroots audit'));
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function hms(d) { return d.toTimeString().slice(0, 8); }
|
|
86
|
+
|
|
87
|
+
// Signed in: poll the API and print events as they arrive.
|
|
88
|
+
async function followApi(flags, store) {
|
|
89
|
+
if (!flags.json) {
|
|
90
|
+
line(`${c.g('● live')} ${c.s(`${store.org.name} · ${store.api.server}`)} ${dot()} ${c.m('ctrl-c to stop')}`);
|
|
91
|
+
line();
|
|
92
|
+
}
|
|
93
|
+
let stop = false;
|
|
94
|
+
process.on('SIGINT', () => { stop = true; });
|
|
95
|
+
const seen = new Set((await apiAudit(store)).map((e) => e.id));
|
|
96
|
+
const every = Number(flags.interval || 3) * 1000;
|
|
97
|
+
let shown = 0;
|
|
98
|
+
while (!stop) {
|
|
99
|
+
const t0 = Date.now();
|
|
100
|
+
while (!stop && Date.now() - t0 < every) await sleep(100);
|
|
101
|
+
if (stop) break;
|
|
102
|
+
let events;
|
|
103
|
+
try { events = await apiAudit(store); } catch (e) { if (!flags.json) line(c.w(`◐ ${e.message} · retrying`)); continue; }
|
|
104
|
+
const fresh = events.filter((e) => !seen.has(e.id)).sort((a, b) => a.at.localeCompare(b.at));
|
|
105
|
+
for (const e of fresh) {
|
|
106
|
+
seen.add(e.id);
|
|
107
|
+
shown++;
|
|
108
|
+
if (flags.json) process.stdout.write(`${JSON.stringify(e)}\n`);
|
|
109
|
+
else line(`${c.m(new Date(e.at).toTimeString().slice(0, 8))} ${pad(e.actor, 13)}${pad(e.action, 16)}${pad(e.target, 40)}${pad(decision(e.decision), 11)} ${c.m(e.note || '')}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!flags.json) { line(); line(c.m(` stopped · ${shown} new events`)); }
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function follow(flags, store) {
|
|
117
|
+
if (store.api) return followApi(flags, store);
|
|
118
|
+
const anim = motion();
|
|
119
|
+
if (!flags.json) {
|
|
120
|
+
line(`${c.g('● live')} ${c.s('all repos · all actors')} ${dot()} ${c.m('ctrl-c to stop')}`);
|
|
121
|
+
line();
|
|
122
|
+
}
|
|
123
|
+
let i = 0;
|
|
124
|
+
let stop = false;
|
|
125
|
+
process.on('SIGINT', () => { stop = true; });
|
|
126
|
+
const max = flags.count ? Number(flags.count) : anim ? Infinity : 10;
|
|
127
|
+
while (!stop && i < max) {
|
|
128
|
+
const e = FOLLOW_POOL[i % FOLLOW_POOL.length];
|
|
129
|
+
// sample stream: shown live, not written to your audit log
|
|
130
|
+
const ev = { id: `ev-${store.d.counters.ev + i}`, at: new Date().toISOString(), time: hms(new Date()), ...e, sample: true };
|
|
131
|
+
if (flags.json) process.stdout.write(`${JSON.stringify(ev)}\n`);
|
|
132
|
+
else {
|
|
133
|
+
const row = `${c.m(hms(new Date()))} ${pad(e.actor, 13)}${pad(e.action, 16)}${pad(e.target, 40)}${pad(decision(e.decision), 11)} ${c.m(e.note || '')}`;
|
|
134
|
+
if (anim && colorState.color) {
|
|
135
|
+
// new rows flash with a soft green background, then settle
|
|
136
|
+
write(`${c.bg('attach')(row)}\n`);
|
|
137
|
+
await sleep(450);
|
|
138
|
+
write(`\x1b[1A\r\x1b[2K${row}\n`);
|
|
139
|
+
} else line(row);
|
|
140
|
+
}
|
|
141
|
+
i++;
|
|
142
|
+
if (anim) {
|
|
143
|
+
const wait = 1400 + Math.random() * 1800;
|
|
144
|
+
const t0 = Date.now();
|
|
145
|
+
while (!stop && Date.now() - t0 < wait) await sleep(100);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!flags.json) { line(); line(c.m(` stopped · ${i} events`)); }
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// gitroots login · logout · whoami
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { c, pad } from '../ui/ansi.js';
|
|
4
|
+
import { line, ask, select, interactive, spinner } from '../ui/term.js';
|
|
5
|
+
import { brand, dot, json, next } from '../ui/components.js';
|
|
6
|
+
import { Api, DEFAULT_SERVER, readConfig, writeConfig, savePassword, forgetPassword } from '../api.js';
|
|
7
|
+
|
|
8
|
+
export async function run(cmd, rest, flags) {
|
|
9
|
+
if (cmd === 'logout') return logout(flags);
|
|
10
|
+
if (cmd === 'whoami') return whoami(flags);
|
|
11
|
+
return login(flags);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function login(flags) {
|
|
15
|
+
const prev = readConfig();
|
|
16
|
+
const server = String(flags.server || prev.server || DEFAULT_SERVER).replace(/\/+$/, '');
|
|
17
|
+
const username = flags.username || flags.u || (interactive() ? await ask('Username or email', { def: prev.username }) : null);
|
|
18
|
+
if (!username) throw new Error('pass --username');
|
|
19
|
+
let password;
|
|
20
|
+
if (flags['password-stdin']) password = fs.readFileSync(0, 'utf8').replace(/\r?\n$/, '');
|
|
21
|
+
else if (process.env.GITROOTS_PASSWORD) password = process.env.GITROOTS_PASSWORD;
|
|
22
|
+
else password = await ask('Password', { hidden: true });
|
|
23
|
+
if (!password) throw new Error('no password given');
|
|
24
|
+
|
|
25
|
+
const api = new Api({ server, username, password });
|
|
26
|
+
const sp = spinner((f) => `${c.i(f)} ${c.s(`Signing in to ${server}…`)}`);
|
|
27
|
+
let me;
|
|
28
|
+
let orgs;
|
|
29
|
+
let memberships;
|
|
30
|
+
try {
|
|
31
|
+
me = await api.me();
|
|
32
|
+
[orgs, memberships] = await Promise.all([api.orgs(), api.memberships().catch(() => [])]);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
sp.stop(`${c.r('✕')} ${e.message}`);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
sp.stop();
|
|
38
|
+
if (!me) { line(`${c.r('✕')} signed in, but the API did not return a user named ${username}`); return 1; }
|
|
39
|
+
|
|
40
|
+
const mine = memberships.filter((m) => m.user === me.id && m.is_active !== false);
|
|
41
|
+
const roleOf = (o) => (mine.find((m) => m.organization === o.id) || {}).role || (me.is_staff ? 'staff' : 'member');
|
|
42
|
+
let org = null;
|
|
43
|
+
if (flags.org) org = orgs.find((o) => o.slug === flags.org || o.name === flags.org || String(o.id) === String(flags.org));
|
|
44
|
+
else if (orgs.length === 1) org = orgs[0];
|
|
45
|
+
else if (orgs.length > 1) {
|
|
46
|
+
if (!interactive()) org = orgs[0];
|
|
47
|
+
else {
|
|
48
|
+
line(`${c.i(c.b('?'))} Which organisation?`);
|
|
49
|
+
const res = await select({
|
|
50
|
+
count: orgs.length,
|
|
51
|
+
render: (idx) => orgs.map((o, i) => `${i === idx ? c.g('❯') : ' '} ${pad(o.name, 30)}${c.m(`${o.slug} · ${roleOf(o)}`)}`).join('\n'),
|
|
52
|
+
});
|
|
53
|
+
if (res.action === 'cancel') return 1;
|
|
54
|
+
org = orgs[res.index];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const store = savePassword(server, username, password);
|
|
59
|
+
writeConfig({
|
|
60
|
+
server, username, userId: me.id, email: me.email,
|
|
61
|
+
org: org ? { id: org.id, name: org.name, slug: org.slug, role: roleOf(org), plan: org.subscription_plan } : null,
|
|
62
|
+
signedInAt: new Date().toISOString(),
|
|
63
|
+
});
|
|
64
|
+
if (flags.json) { json({ server, user: me.username, org: org?.slug || null, credentials: store }); return 0; }
|
|
65
|
+
line(`${c.g('✓')} Signed in as ${c.b(me.username)} ${c.m(`${server}${org ? ` · org ${org.name} · ${roleOf(org)}` : ' · no organisation yet'}`)}`);
|
|
66
|
+
line(` ${c.m(`password kept in ${store === 'keychain' ? 'the macOS Keychain' : 'credentials.json (mode 600)'}`)}`);
|
|
67
|
+
line(next('gitroots status'));
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function logout(flags) {
|
|
72
|
+
const cfg = readConfig();
|
|
73
|
+
if (!cfg.username) { line(c.m('○ not signed in')); return 0; }
|
|
74
|
+
forgetPassword(cfg.server, cfg.username);
|
|
75
|
+
writeConfig({ server: cfg.server });
|
|
76
|
+
if (flags.json) { json({ signedOut: cfg.username }); return 0; }
|
|
77
|
+
line(`${c.g('✓')} Signed out ${c.b(cfg.username)} ${c.m(`· ${cfg.server}`)}`);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function whoami(flags) {
|
|
82
|
+
const cfg = readConfig();
|
|
83
|
+
const api = Api.fromConfig();
|
|
84
|
+
if (!api) {
|
|
85
|
+
if (flags.json) { json({ signedIn: false }); return 1; }
|
|
86
|
+
line(`${c.m('○ not signed in')} ${dot()} sample org in use`);
|
|
87
|
+
line(next('gitroots login'));
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
let me = null;
|
|
91
|
+
let err = null;
|
|
92
|
+
try { me = await api.me(); } catch (e) { err = e.message; }
|
|
93
|
+
if (flags.json) { json({ signedIn: Boolean(me), server: cfg.server, user: me, org: cfg.org, error: err }); return me ? 0 : 1; }
|
|
94
|
+
if (!me) { line(`${c.r('✕')} ${err || 'user not found'}`); return 1; }
|
|
95
|
+
line(`${brand()} ${dot()} ${c.b(me.username)} ${c.m(`<${me.email}>`)}${me.is_staff ? c.m(' · staff') : ''}`);
|
|
96
|
+
line(` ${c.s(pad('server', 8))}${cfg.server}`);
|
|
97
|
+
line(` ${c.s(pad('org', 8))}${cfg.org ? `${cfg.org.name} ${c.m(`(${cfg.org.slug} · ${cfg.org.role}${cfg.org.plan ? ` · ${cfg.org.plan}` : ''})`)}` : c.m('none')}`);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// gitroots backup <session> · backups · backup policy
|
|
2
|
+
import { c, h, pad } from '../ui/ansi.js';
|
|
3
|
+
import { line, runSteps } from '../ui/term.js';
|
|
4
|
+
import { dot, json, next } from '../ui/components.js';
|
|
5
|
+
|
|
6
|
+
export async function run(cmd, rest, flags, store) {
|
|
7
|
+
if (cmd === 'backups' || rest[0] === 'list' || rest[0] === 'ls') return list(flags, store);
|
|
8
|
+
if (rest[0] === 'policy' && rest[1] === 'set') return policySet(flags, store);
|
|
9
|
+
if (rest[0] === 'policy') return policy(flags, store);
|
|
10
|
+
const id = rest[0];
|
|
11
|
+
if (!id) throw new Error('which session? gitroots backup <session> [--label "…"]');
|
|
12
|
+
const s = store.session(id);
|
|
13
|
+
if (flags.json) { const b = createBackup(store, s, flags.label); store.save(); json(b); return 0; }
|
|
14
|
+
const b = await backupWithSteps(store, s, flags.label || 'manual');
|
|
15
|
+
line(` ${next(`gitroots restore ${b.id}`)}`);
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createBackup(store, s, label = 'manual') {
|
|
20
|
+
const sizeMb = Math.round((0.3 + s.turns * 0.0135 + s.changed * 0.05) * 10) / 10;
|
|
21
|
+
const b = {
|
|
22
|
+
id: store.nextBackupId(), session: s.id, agent: s.agent, machine: s.machine, label, turn: s.turns, sizeMb,
|
|
23
|
+
created: 'just now', at: new Date().toISOString(), head: s.head, files: s.changed, prompt: s.lastPrompt || '', repo: s.repo,
|
|
24
|
+
};
|
|
25
|
+
store.d.backups.push(b);
|
|
26
|
+
s.backup = 'just now';
|
|
27
|
+
return b;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function backupWithSteps(store, s, label, { indent = ' ' } = {}) {
|
|
31
|
+
const convoMb = Math.round(s.turns * 0.0127 * 10) / 10;
|
|
32
|
+
let b;
|
|
33
|
+
await runSteps([
|
|
34
|
+
{ name: 'Conversation', live: `reading ${s.turns} turns…`, ms: 500, ok: `${s.turns} turns · ${convoMb} MB` },
|
|
35
|
+
{ name: 'Workspace', live: 'git stash · diff · untracked files…', ms: 700, ok: `HEAD ${s.head} · ${s.changed} file${s.changed === 1 ? '' : 's'} changed${s.stash ? ` · ${s.stash} stash` : ''}` },
|
|
36
|
+
{ name: 'Environment', live: 'sealing secrets…', ms: 300, ok: '12 vars · 2 secrets sealed' },
|
|
37
|
+
], { nameWidth: 15, indent });
|
|
38
|
+
b = createBackup(store, s, label);
|
|
39
|
+
store.record({ actor: store.org.user, action: 'backup', target: `${s.id} · ${b.id}`, decision: 'allowed', note: label, repo: s.repo });
|
|
40
|
+
store.save();
|
|
41
|
+
const pol = store.d.backupPolicy;
|
|
42
|
+
line(`${indent}${c.g('✓')} Backup ${c.b(b.id)} saved ${c.m(`${b.sizeMb} MB · encrypted · ${pol.storage}, ${pol.region}`)}`);
|
|
43
|
+
return b;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function list(flags, store) {
|
|
47
|
+
let rows = [...store.backups()];
|
|
48
|
+
if (flags.repo) rows = rows.filter((b) => b.repo === flags.repo);
|
|
49
|
+
if (flags.session) rows = rows.filter((b) => b.session === flags.session);
|
|
50
|
+
rows.sort((a, b) => b.id.localeCompare(a.id));
|
|
51
|
+
if (flags.json) { json(rows); return 0; }
|
|
52
|
+
const shown = flags.all ? rows : rows.slice(0, 7);
|
|
53
|
+
line(` ${h(`${pad('ID', 9)}${pad('SESSION', 10)}${pad('AGENT', 14)}${pad('MACHINE', 13)}${pad('LABEL', 22)}${pad('TURN', 8)}${pad('SIZE', 10)}CREATED`)}`);
|
|
54
|
+
for (const b of shown) {
|
|
55
|
+
const fresh = b.created === 'just now';
|
|
56
|
+
line(` ${fresh ? c.b(pad(b.id, 9)) : pad(b.id, 9)}${pad(b.session, 10)}${pad(b.agent, 14)}${pad(b.machine, 13)}${pad(b.label, 22)}${pad(String(b.turn), 8)}${pad(`${b.sizeMb} MB`, 10)}${fresh ? c.g(b.created) : c.m(b.created)}`);
|
|
57
|
+
}
|
|
58
|
+
line();
|
|
59
|
+
line(` ${c.m(`${shown.length} of ${rows.length} backups${rows.length > shown.length ? ' · gitroots backups --all for more' : ''}`)}`);
|
|
60
|
+
line();
|
|
61
|
+
policy({}, store, true);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function policy(flags, store, embedded = false) {
|
|
66
|
+
const p = store.d.backupPolicy;
|
|
67
|
+
if (flags.json) { json(p); return 0; }
|
|
68
|
+
const on = (v) => (v ? c.g('on') : c.m('off'));
|
|
69
|
+
line(`${h('Policy')} ${c.m('(gitroots backup policy)')}`);
|
|
70
|
+
line(` ${pad('every', 13)}${pad(p.every, 34)}${pad('before risky commands', 24)}${on(p.beforeRisky)}`);
|
|
71
|
+
line(` ${pad('every turn', 13)}${pad(p.everyTurn ? c.g('on') : c.m('off'), 34)}${pad('before transfer', 24)}${on(p.beforeTransfer)}`);
|
|
72
|
+
line(` ${pad('keep', 13)}${pad(p.keep, 34)}${pad('storage', 24)}${p.storage}${p.storage === 'gitroots cloud' ? ` ${dot()} ${c.m('or --store s3://your-bucket')}` : ''}`);
|
|
73
|
+
if (!embedded) { line(); line(next('gitroots backups')); }
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// gitroots backup policy set --every 10m --every-turn on --before-risky on --before-transfer on --keep "30 days" --store s3://bucket
|
|
78
|
+
function policySet(flags, store) {
|
|
79
|
+
const p = store.d.backupPolicy;
|
|
80
|
+
const onoff = (v, name) => {
|
|
81
|
+
if (v === true || v === 'on' || v === 'true' || v === 'yes') return true;
|
|
82
|
+
if (v === false || v === 'off' || v === 'false' || v === 'no') return false;
|
|
83
|
+
throw new Error(`--${name} takes on or off`);
|
|
84
|
+
};
|
|
85
|
+
const changed = [];
|
|
86
|
+
if (flags.every !== undefined) {
|
|
87
|
+
const m = /^(\d+)\s*(m|min|h)$/.exec(String(flags.every));
|
|
88
|
+
if (!m) throw new Error('--every takes a time like 10m or 1h');
|
|
89
|
+
p.every = `${m[1]} ${m[2] === 'h' ? 'h' : 'min'} while running`; changed.push('every');
|
|
90
|
+
}
|
|
91
|
+
if (flags['every-turn'] !== undefined) { p.everyTurn = onoff(flags['every-turn'], 'every-turn'); changed.push('every turn'); }
|
|
92
|
+
if (flags['before-risky'] !== undefined) { p.beforeRisky = onoff(flags['before-risky'], 'before-risky'); changed.push('before risky commands'); }
|
|
93
|
+
if (flags['before-transfer'] !== undefined) { p.beforeTransfer = onoff(flags['before-transfer'], 'before-transfer'); changed.push('before transfer'); }
|
|
94
|
+
if (flags.keep !== undefined) { p.keep = String(flags.keep); changed.push('keep'); }
|
|
95
|
+
if (flags.store !== undefined) {
|
|
96
|
+
if (!/^(s3|gs|az):\/\/[^/]+/.test(flags.store) && flags.store !== 'gitroots') throw new Error('--store takes gitroots or s3://bucket, gs://bucket, az://container');
|
|
97
|
+
p.storage = flags.store === 'gitroots' ? 'gitroots cloud' : flags.store; changed.push('storage');
|
|
98
|
+
}
|
|
99
|
+
if (!changed.length) throw new Error('nothing to change · --every, --every-turn, --before-risky, --before-transfer, --keep, --store');
|
|
100
|
+
store.record({ actor: store.org.user, action: 'backup policy', target: changed.join(', '), decision: 'allowed' });
|
|
101
|
+
store.save();
|
|
102
|
+
if (flags.json) { json(p); return 0; }
|
|
103
|
+
line(` ${c.g('✓')} Backup policy updated ${c.m(`· ${changed.join(', ')}`)}`);
|
|
104
|
+
line();
|
|
105
|
+
return policy({}, store);
|
|
106
|
+
}
|