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/config.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Where gitroots keeps its settings and sign-in.
|
|
2
|
+
// ~/.gitroots/config.json server, user, chosen workspace (no secrets)
|
|
3
|
+
// token macOS Keychain, or ~/.gitroots/credentials.json (mode 600) elsewhere
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_SERVER = process.env.GITROOTS_SERVER || 'https://gitroots.com';
|
|
10
|
+
|
|
11
|
+
export function home() {
|
|
12
|
+
return process.env.GITROOTS_HOME || path.join(os.homedir(), '.gitroots');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const file = (name) => path.join(home(), name);
|
|
16
|
+
|
|
17
|
+
function readJson(name, fallback) {
|
|
18
|
+
try { return JSON.parse(fs.readFileSync(file(name), 'utf8')); } catch { return fallback; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeJson(name, data) {
|
|
22
|
+
fs.mkdirSync(home(), { recursive: true });
|
|
23
|
+
fs.writeFileSync(file(name), JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
24
|
+
try { fs.chmodSync(file(name), 0o600); } catch { /* windows */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readConfig() { return readJson('config.json', {}); }
|
|
28
|
+
export function writeConfig(cfg) { writeJson('config.json', cfg); }
|
|
29
|
+
export function updateConfig(patch) { const c = { ...readConfig(), ...patch }; writeConfig(c); return c; }
|
|
30
|
+
|
|
31
|
+
const useKeychain = () => process.platform === 'darwin' && !process.env.GITROOTS_NO_KEYCHAIN;
|
|
32
|
+
const service = (server) => `gitroots-cli:${server}`;
|
|
33
|
+
|
|
34
|
+
export function saveToken(server, username, token) {
|
|
35
|
+
if (useKeychain()) {
|
|
36
|
+
const r = spawnSync('security', ['add-generic-password', '-U', '-a', username, '-s', service(server), '-w', token], { stdio: 'ignore' });
|
|
37
|
+
if (r.status === 0) return 'keychain';
|
|
38
|
+
}
|
|
39
|
+
const all = readJson('credentials.json', {});
|
|
40
|
+
all[`${username}@${server}`] = token;
|
|
41
|
+
writeJson('credentials.json', all);
|
|
42
|
+
return 'file';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function loadToken(server, username) {
|
|
46
|
+
if (process.env.GITROOTS_TOKEN) return process.env.GITROOTS_TOKEN;
|
|
47
|
+
if (!server || !username) return null;
|
|
48
|
+
if (useKeychain()) {
|
|
49
|
+
const r = spawnSync('security', ['find-generic-password', '-a', username, '-s', service(server), '-w'], { encoding: 'utf8' });
|
|
50
|
+
if (r.status === 0) return r.stdout.replace(/\n$/, '');
|
|
51
|
+
}
|
|
52
|
+
return readJson('credentials.json', {})[`${username}@${server}`] || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function forgetToken(server, username) {
|
|
56
|
+
if (process.platform === 'darwin') spawnSync('security', ['delete-generic-password', '-a', username, '-s', service(server)], { stdio: 'ignore' });
|
|
57
|
+
const all = readJson('credentials.json', {});
|
|
58
|
+
delete all[`${username}@${server}`];
|
|
59
|
+
writeJson('credentials.json', all);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Small local caches (branch rules for the push hook, command history).
|
|
63
|
+
export function readCache(name, fallback) { return readJson(`cache-${name}.json`, fallback); }
|
|
64
|
+
export function writeCache(name, data) { try { writeJson(`cache-${name}.json`, data); } catch { /* read-only home */ } }
|
|
65
|
+
|
|
66
|
+
export function historyFile() { return file('history'); }
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Branch rules for the repo being pushed, from (in order):
|
|
2
|
+
// 1. gitroots: the workspace's branch rules for this GitHub repo (cached for offline pushes)
|
|
3
|
+
// 2. .gitroots.json at the repo root: { "protected": ["main", "release/*"], "paths": [{ "pattern": "infra/**", "needs": "an ops review" }] }
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { tryGit } from './git/checks.js';
|
|
7
|
+
import { readCache, writeCache, readConfig } from './config.js';
|
|
8
|
+
|
|
9
|
+
export function remoteFullName(url) {
|
|
10
|
+
if (!url) return null;
|
|
11
|
+
const m = /github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url.trim());
|
|
12
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function globMatch(pattern, value) {
|
|
16
|
+
const re = new RegExp(`^${pattern
|
|
17
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
18
|
+
.replace(/\*\*/g, '\u0000')
|
|
19
|
+
.replace(/\*/g, '[^/]*')
|
|
20
|
+
.replace(/\u0000/g, '.*')
|
|
21
|
+
.replace(/\?/g, '.')}$`);
|
|
22
|
+
return re.test(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function localConfig(top) {
|
|
26
|
+
try {
|
|
27
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(top, '.gitroots.json'), 'utf8'));
|
|
28
|
+
return {
|
|
29
|
+
rules: (cfg.protected || []).map((p) => ({ pattern: p, level: 'protected', allow_direct_push: false, required_approvals: 0, description: 'No direct pushes · .gitroots.json' })),
|
|
30
|
+
paths: (cfg.paths || []).map((p) => (typeof p === 'string' ? { pattern: p, needs: 'a review' } : p)),
|
|
31
|
+
suggest: cfg.suggest,
|
|
32
|
+
};
|
|
33
|
+
} catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Rules for the repository at cwd. api is optional; with it, rules are refreshed from gitroots. */
|
|
37
|
+
export async function loadRules(cwd, { api = null, timeout = 3000 } = {}) {
|
|
38
|
+
const top = tryGit(['rev-parse', '--show-toplevel'], cwd);
|
|
39
|
+
if (!top) return { source: 'none', repo: null, rules: [], paths: [] };
|
|
40
|
+
const url = tryGit(['remote', 'get-url', 'origin'], cwd);
|
|
41
|
+
const fullName = remoteFullName(url);
|
|
42
|
+
const local = localConfig(top);
|
|
43
|
+
const cache = readCache('rules', {});
|
|
44
|
+
let source = 'none';
|
|
45
|
+
let rules = [];
|
|
46
|
+
if (fullName && api && api.signedIn) {
|
|
47
|
+
try {
|
|
48
|
+
const d = await api.repo(fullName, { timeout });
|
|
49
|
+
rules = d.branch_rules || [];
|
|
50
|
+
cache[fullName.toLowerCase()] = { rules, at: new Date().toISOString(), org: api.orgId };
|
|
51
|
+
writeCache('rules', cache);
|
|
52
|
+
source = 'gitroots';
|
|
53
|
+
} catch (e) {
|
|
54
|
+
if (e.status === 404) { delete cache[fullName.toLowerCase()]; writeCache('rules', cache); }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (source === 'none' && fullName && cache[fullName.toLowerCase()]) {
|
|
58
|
+
rules = cache[fullName.toLowerCase()].rules;
|
|
59
|
+
source = 'cache';
|
|
60
|
+
}
|
|
61
|
+
if (local) {
|
|
62
|
+
rules = [...rules, ...local.rules];
|
|
63
|
+
if (source === 'none') source = '.gitroots.json';
|
|
64
|
+
}
|
|
65
|
+
return { source, repo: fullName || path.basename(top), top, rules, paths: local ? local.paths : [], suggest: local?.suggest };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function ruleFor(rules, branch) {
|
|
69
|
+
return rules.find((r) => r.pattern === branch) || rules.find((r) => globMatch(r.pattern, branch)) || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const LEAD_ROLES = new Set(['owner', 'lead', 'admin']);
|
|
73
|
+
|
|
74
|
+
/** Is a direct push of `branch` allowed? */
|
|
75
|
+
export function decide(set, branch) {
|
|
76
|
+
const rule = ruleFor(set.rules, branch);
|
|
77
|
+
if (!rule) return { blocked: false, rule: null, label: set.source === 'none' ? 'no rules for this repo' : `${branch} · no rule, developers can push` };
|
|
78
|
+
const role = (readConfig().org?.role || '').toLowerCase();
|
|
79
|
+
const label = `${rule.pattern}: ${rule.description || rule.level}`;
|
|
80
|
+
if (rule.allow_direct_push === false) {
|
|
81
|
+
const needs = rule.required_approvals ? `, it needs ${rule.required_approvals} approval${rule.required_approvals > 1 ? 's' : ''}` : '';
|
|
82
|
+
return { blocked: true, rule, label, suggest: set.suggest || `push to a feature branch, then open a pull request into ${branch}${needs}` };
|
|
83
|
+
}
|
|
84
|
+
if (rule.level === 'leads' && !LEAD_ROLES.has(role)) {
|
|
85
|
+
return { blocked: true, rule, label, suggest: 'only workspace leads push here · push to a feature branch and ask a lead to merge' };
|
|
86
|
+
}
|
|
87
|
+
return { blocked: false, rule, label };
|
|
88
|
+
}
|
package/src/runtime.js
CHANGED
|
@@ -5,7 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
|
|
8
|
-
export const VERSION = '0.
|
|
8
|
+
export const VERSION = '0.6.0';
|
|
9
9
|
|
|
10
10
|
// Default download location for install.sh / gitroots update. Override with GITROOTS_DOWNLOAD_BASE.
|
|
11
11
|
export const DOWNLOAD_BASE = process.env.GITROOTS_DOWNLOAD_BASE || 'https://gitroots.com/cli';
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// /login · /logout · /whoami · /workspace
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { Api, ApiError } from '../api.js';
|
|
5
|
+
import { DEFAULT_SERVER, readConfig, writeConfig, updateConfig, saveToken, forgetToken } from '../config.js';
|
|
6
|
+
import { select, interactive, spinner, sleep } from '../ui/term.js';
|
|
7
|
+
import { json } from '../ui/components.js';
|
|
8
|
+
import { c, line, pad, next, needSignIn, load, cmdText } from './helpers.js';
|
|
9
|
+
|
|
10
|
+
function openBrowser(url) {
|
|
11
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
12
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
13
|
+
try {
|
|
14
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
15
|
+
child.on('error', () => {});
|
|
16
|
+
child.unref();
|
|
17
|
+
return true;
|
|
18
|
+
} catch { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function pickWorkspace(ctx, workspaces, current) {
|
|
22
|
+
if (!workspaces.length) return null;
|
|
23
|
+
if (workspaces.length === 1 || !interactive() || ctx.json) return workspaces.find((w) => w.id === current) || workspaces[0];
|
|
24
|
+
line(`${c.i(c.b('?'))} Which workspace?`);
|
|
25
|
+
const start = Math.max(0, workspaces.findIndex((w) => w.id === current));
|
|
26
|
+
const res = await select({
|
|
27
|
+
count: workspaces.length,
|
|
28
|
+
initial: start,
|
|
29
|
+
render: (i) => workspaces.map((w, j) => `${j === i ? c.g('❯ ') : ' '}${j === i ? c.b(pad(w.name, 32)) : pad(w.name, 32)}${c.m(`${w.slug}${w.role ? ` · ${w.role}` : ''}`)}`).join('\n'),
|
|
30
|
+
});
|
|
31
|
+
if (res.action === 'cancel') return null;
|
|
32
|
+
return workspaces[res.index];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function roleOf(ws, me) {
|
|
36
|
+
const m = (me.memberships || []).find((x) => x.organization === ws.id);
|
|
37
|
+
return m ? m.role : ws.role_flags?.is_admin ? 'admin' : 'member';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function workspaceList(me) {
|
|
41
|
+
return (me.workspaces || []).map((w) => ({ id: w.id, name: w.name, slug: w.slug, role: roleOf(w, me) }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function login(ctx, args, flags) {
|
|
45
|
+
const server = String(flags.server || readConfig().server || DEFAULT_SERVER).replace(/\/+$/, '');
|
|
46
|
+
const anon = new Api({ server });
|
|
47
|
+
const start = await load(ctx, `Contacting ${anon.host}…`, () => anon.authStart(`gitroots CLI on ${os.hostname().replace(/\.local$/, '')}`));
|
|
48
|
+
line();
|
|
49
|
+
line(` Sign in by approving this code in your browser:`);
|
|
50
|
+
line();
|
|
51
|
+
line(` ${c.b(c.g(start.user_code))}`);
|
|
52
|
+
line();
|
|
53
|
+
const opened = !flags['no-browser'] && interactive() && openBrowser(start.verification_url);
|
|
54
|
+
line(` ${opened ? c.m('Opened') : c.m('Open')} ${c.i(start.verification_url)}`);
|
|
55
|
+
line(` ${c.m('Check the code matches, then press Approve. ctrl-c cancels.')}`);
|
|
56
|
+
line();
|
|
57
|
+
|
|
58
|
+
const deadline = Date.now() + start.expires_in * 1000;
|
|
59
|
+
const sp = spinner((f) => `${c.i(f)} ${c.s('Waiting for approval in the browser…')}`);
|
|
60
|
+
let result = null;
|
|
61
|
+
try {
|
|
62
|
+
while (Date.now() < deadline) {
|
|
63
|
+
if (ctx.signal?.aborted) throw new ApiError('sign-in cancelled', 0);
|
|
64
|
+
const r = await anon.authPoll(start.device_code, ctx.signal);
|
|
65
|
+
if (r.status === 200) { result = r.data; break; }
|
|
66
|
+
if (r.status !== 202) throw new ApiError(r.data?.detail || 'sign-in failed', r.status);
|
|
67
|
+
const t0 = Date.now();
|
|
68
|
+
while (Date.now() - t0 < (start.interval || 2) * 1000) { if (ctx.signal?.aborted) break; await sleep(100); }
|
|
69
|
+
}
|
|
70
|
+
} finally { sp.stop(); }
|
|
71
|
+
if (!result) throw new ApiError(`the code expired · run ${cmdText(ctx, 'login')} again`, 410);
|
|
72
|
+
|
|
73
|
+
const user = result.user;
|
|
74
|
+
const where = saveToken(server, user.username, result.token);
|
|
75
|
+
const api = new Api({ server, token: result.token });
|
|
76
|
+
const me = await load(ctx, 'Loading your workspaces…', () => api.me());
|
|
77
|
+
const prev = readConfig();
|
|
78
|
+
const ws = await pickWorkspace(ctx, workspaceList(me), prev.org?.id);
|
|
79
|
+
writeConfig({ server, username: user.username, user: { id: user.id, name: user.name, email: user.email }, org: ws, signedInAt: new Date().toISOString() });
|
|
80
|
+
ctx.reload();
|
|
81
|
+
if (ctx.json) { json({ user, org: ws, server }); return 0; }
|
|
82
|
+
line(`${c.g('✓')} Signed in as ${c.b(user.name || user.username)} ${c.m(`<${user.email}>`)}${ws ? ` ${c.m('·')} workspace ${c.b(ws.name)} ${c.m(`(${ws.role})`)}` : ''}`);
|
|
83
|
+
line(` ${c.m(`token kept in ${where === 'keychain' ? 'the macOS Keychain' : '~/.gitroots/credentials.json (only you can read it)'}`)}`);
|
|
84
|
+
if (!ws) line(` ${c.w('◐ you are not in a workspace yet · create or join one on gitroots.com')}`);
|
|
85
|
+
next(ctx, 'status');
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function logout(ctx) {
|
|
90
|
+
const cfg = readConfig();
|
|
91
|
+
if (!cfg.username) { line(c.m('○ not signed in')); return 0; }
|
|
92
|
+
forgetToken(cfg.server, cfg.username);
|
|
93
|
+
writeConfig({ server: cfg.server });
|
|
94
|
+
ctx.reload();
|
|
95
|
+
line(`${c.g('✓')} Signed out ${c.b(cfg.username)} on this computer ${c.m(`· ${cfg.server}`)}`);
|
|
96
|
+
line(` ${c.m('The token is shared with the gitroots app; revoke it under Settings › Account › Sessions on gitroots.com.')}`);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function whoami(ctx) {
|
|
101
|
+
needSignIn(ctx);
|
|
102
|
+
const me = await load(ctx, 'Checking your account…', () => ctx.api.me({ signal: ctx.signal }));
|
|
103
|
+
const cfg = readConfig();
|
|
104
|
+
const wss = workspaceList(me);
|
|
105
|
+
if (ctx.json) { json({ user: me.user, server: ctx.api.server, workspace: cfg.org, workspaces: wss }); return 0; }
|
|
106
|
+
const u = me.user;
|
|
107
|
+
line(`${c.g(c.b('gitroots'))} ${c.m('·')} ${c.b(u.display_name || u.username)} ${c.m(`<${u.email}>`)}${u.is_staff ? c.m(' · staff') : ''}`);
|
|
108
|
+
line(` ${c.s(pad('server', 11))}${ctx.api.server}`);
|
|
109
|
+
line(` ${c.s(pad('workspace', 11))}${cfg.org ? `${c.b(cfg.org.name)} ${c.m(`(${cfg.org.role})`)}` : c.m('none')}`);
|
|
110
|
+
if (wss.length > 1) line(` ${c.s(pad('also in', 11))}${wss.filter((w) => w.id !== cfg.org?.id).map((w) => w.name).join(', ')} ${c.g(`→ ${cmdText(ctx, 'workspace')}`)}`);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function workspace(ctx, args) {
|
|
115
|
+
needSignIn(ctx);
|
|
116
|
+
const me = await load(ctx, 'Loading your workspaces…', () => ctx.api.me({ signal: ctx.signal }));
|
|
117
|
+
const wss = workspaceList(me);
|
|
118
|
+
if (!wss.length) { line(c.w('◐ you are not in a workspace yet · create or join one on gitroots.com')); return 1; }
|
|
119
|
+
let ws;
|
|
120
|
+
if (args[0]) {
|
|
121
|
+
ws = wss.find((w) => w.slug === args[0] || w.name.toLowerCase() === args.join(' ').toLowerCase() || String(w.id) === args[0]);
|
|
122
|
+
if (!ws) throw new Error(`no workspace "${args.join(' ')}" · yours: ${wss.map((w) => w.slug).join(', ')}`);
|
|
123
|
+
} else {
|
|
124
|
+
ws = await pickWorkspace(ctx, wss, readConfig().org?.id);
|
|
125
|
+
if (!ws) return 1;
|
|
126
|
+
}
|
|
127
|
+
updateConfig({ org: ws });
|
|
128
|
+
ctx.reload();
|
|
129
|
+
if (ctx.json) { json(ws); return 0; }
|
|
130
|
+
line(`${c.g('✓')} Workspace ${c.b(ws.name)} ${c.m(`(${ws.role})`)}`);
|
|
131
|
+
next(ctx, 'status');
|
|
132
|
+
return 0;
|
|
133
|
+
}
|