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,163 @@
|
|
|
1
|
+
// Sample organisation used in demo mode. Mirrors the gitroots CLI Terminal design canvas.
|
|
2
|
+
export function sampleData() {
|
|
3
|
+
return {
|
|
4
|
+
org: {
|
|
5
|
+
name: 'acme-erp',
|
|
6
|
+
user: 'shawki',
|
|
7
|
+
role: 'tech lead',
|
|
8
|
+
relay: { name: 'fra-1', latencyMs: 18, connected: true },
|
|
9
|
+
},
|
|
10
|
+
repos: [
|
|
11
|
+
{ name: 'erp', provider: 'GitHub', remote: 'github.com:acme/erp.git', default: 'main', protected: ['main', 'staging', '17.0'], branches: 22, openPrs: 5, role: 'tech lead', lastPush: '2m ago', lastBranch: 'feature/inventory' },
|
|
12
|
+
{ name: 'portal', provider: 'GitLab', remote: 'gitlab.com:acme/portal.git', default: 'main', protected: ['main'], branches: 11, openPrs: 2, role: 'tech lead', lastPush: '40m ago', lastBranch: 'fix/login-redirect' },
|
|
13
|
+
{ name: 'ml-forecast', provider: 'GitHub', remote: 'github.com:acme/ml-forecast.git', default: 'main', protected: ['main'], branches: 8, openPrs: 0, role: 'reviewer', lastPush: '3h ago', lastBranch: 'exp/lstm' },
|
|
14
|
+
],
|
|
15
|
+
branches: {
|
|
16
|
+
erp: [
|
|
17
|
+
{ name: 'main', rule: 'protected', ruleText: 'no direct push · PR only', approvals: 3, commit: 'a77d1e0', by: 'lead', age: '1d', aheadBehind: '' },
|
|
18
|
+
{ name: 'staging', rule: 'protected', ruleText: 'PR only · checks required', approvals: 2, commit: 'c12f9a4', by: 'lead', age: '32m', aheadBehind: '4 ↑ 0 ↓ vs main' },
|
|
19
|
+
{ name: '17.0', rule: 'protected', ruleText: 'release · read only', approvals: 3, commit: '9e0b311', by: 'lead', age: '9d', aheadBehind: '' },
|
|
20
|
+
{ name: 'feature/inventory', rule: 'open', ruleText: 'developers push', commit: 'a91c2e5', by: 's-7f3a', age: '2m', aheadBehind: '6 ↑ 2 ↓ vs staging' },
|
|
21
|
+
{ name: 'feature/pos-refund', rule: 'open', ruleText: 'developers push', commit: '5d20b7c', by: 'dev-01', age: '1h', aheadBehind: '3 ↑ 0 ↓ · PR #412' },
|
|
22
|
+
{ name: 'feature/hr-payroll', rule: 'open', ruleText: 'developers push', commit: '0f61a2d', by: 's-a017', age: '4s', aheadBehind: '1 ↑ 0 ↓' },
|
|
23
|
+
...['fix/stock-picking-batch', 'fix/invoice-rounding', 'feature/mrp-routing', 'feature/crm-scoring', 'feature/website-b2b', 'fix/payroll-timezone',
|
|
24
|
+
'feature/purchase-approval', 'fix/pos-session-close', 'feature/helpdesk-sla', 'chore/upgrade-deps', 'feature/fleet-costs', 'fix/report-margins',
|
|
25
|
+
'feature/l10n-sa-vat', 'exp/forecast-hook', 'fix/multicompany-rules', 'feature/subscription-renewal']
|
|
26
|
+
.map((name, i) => ({ name, rule: 'open', ruleText: 'developers push', commit: (0x1a2b3c0 + i * 7919).toString(16).slice(0, 7), by: ['dev-01', 'dev-02', 'dev-03', 'ahmed', 'nora'][i % 5], age: `${i + 2}d`, aheadBehind: `${(i % 4) + 1} ↑ ${i % 3} ↓` })),
|
|
27
|
+
],
|
|
28
|
+
portal: [
|
|
29
|
+
{ name: 'main', rule: 'protected', ruleText: 'no direct push · PR only', approvals: 2, commit: 'b3e1f70', by: 'lead', age: '2d', aheadBehind: '' },
|
|
30
|
+
{ name: 'fix/login-redirect', rule: 'open', ruleText: 'developers push', commit: '7aa0c19', by: 'dev-03', age: '40m', aheadBehind: '2 ↑ 0 ↓ · PR #405' },
|
|
31
|
+
],
|
|
32
|
+
'ml-forecast': [
|
|
33
|
+
{ name: 'main', rule: 'protected', ruleText: 'no direct push · PR only', approvals: 1, commit: '44c09de', by: 'lead', age: '5d', aheadBehind: '' },
|
|
34
|
+
{ name: 'exp/lstm', rule: 'open', ruleText: 'developers push', commit: 'e10fa33', by: 's-c3f8', age: '3h', aheadBehind: '9 ↑ 1 ↓' },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
// Branch rules used by `branch rules` and by the real pre-push checks.
|
|
38
|
+
rules: {
|
|
39
|
+
erp: {
|
|
40
|
+
main: { protected: true, directPush: false, approvals: 3, checks: true, push: 'direct pushes blocked for everyone', mergeText: '3 approvals · checks green · release manager', label: 'main: no direct push, PR only', suggest: 'push to a feature branch, then open a PR into staging' },
|
|
41
|
+
staging: { protected: true, directPush: false, approvals: 2, checks: true, push: 'direct pushes blocked for everyone', mergeText: '2 approvals · checks green · no unresolved comments', label: 'staging: PR only', suggest: 'push to a feature branch, then open a PR into staging' },
|
|
42
|
+
'17.0': { protected: true, directPush: false, approvals: 3, checks: true, push: 'release branch · read only', mergeText: 'release managers only', label: '17.0: release, read only', suggest: 'cherry-pick fixes through a PR into staging' },
|
|
43
|
+
'feature/*': { protected: false, directPush: true, label: 'feature/* · developers can push' },
|
|
44
|
+
'fix/*': { protected: false, directPush: true, label: 'fix/* · developers can push' },
|
|
45
|
+
'*': { protected: false, directPush: true, label: 'developers can push' },
|
|
46
|
+
paths: [{ pattern: 'addons/*/security/**', needs: 'a tech lead approval' }],
|
|
47
|
+
agents: 'agent commits allowed on feature/* only, tagged in audit',
|
|
48
|
+
},
|
|
49
|
+
portal: {
|
|
50
|
+
main: { protected: true, directPush: false, approvals: 2, checks: true, push: 'direct pushes blocked for everyone', mergeText: '2 approvals · checks green', label: 'main: no direct push, PR only', suggest: 'push to a fix/ or feature/ branch, then open a PR into main' },
|
|
51
|
+
'*': { protected: false, directPush: true, label: 'developers can push' },
|
|
52
|
+
paths: [],
|
|
53
|
+
agents: 'agent commits allowed on feature/* only, tagged in audit',
|
|
54
|
+
},
|
|
55
|
+
'ml-forecast': {
|
|
56
|
+
main: { protected: true, directPush: false, approvals: 1, checks: true, push: 'direct pushes blocked for everyone', mergeText: '1 approval · checks green', label: 'main: no direct push, PR only', suggest: 'push to an exp/ branch, then open a PR into main' },
|
|
57
|
+
'*': { protected: false, directPush: true, label: 'developers can push' },
|
|
58
|
+
paths: [],
|
|
59
|
+
agents: 'agent commits allowed on exp/* and feature/*',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
prs: [
|
|
63
|
+
{
|
|
64
|
+
number: 412, repo: 'erp', title: 'Partial refunds in POS', from: 'feature/pos-refund', into: 'staging', author: 'dev-01', opened: '54m ago',
|
|
65
|
+
required: 2, approvals: [{ by: 'nora', ago: '20m ago' }], checks: { state: 'pass', tests: '24 / 24' }, state: 'open', commits: 3,
|
|
66
|
+
needs: { who: 'shawki', why: 'tech lead for migrations' },
|
|
67
|
+
files: [
|
|
68
|
+
{ path: 'addons/pos_refund/models/pos_order.py', add: 168, del: 22 },
|
|
69
|
+
{ path: 'addons/pos_refund/wizard/refund_wizard.py', add: 96, del: 0 },
|
|
70
|
+
{ path: 'migrations/17.0.1.3/post-migrate.py', add: 31, del: 0, flag: 'changes data' },
|
|
71
|
+
{ path: 'addons/pos_refund/views/refund_wizard_views.xml', add: 4, del: 20 },
|
|
72
|
+
{ path: 'addons/pos_refund/tests/test_partial_refund.py', add: 6, del: 3 },
|
|
73
|
+
{ path: 'addons/pos_refund/__manifest__.py', add: 2, del: 1 },
|
|
74
|
+
{ path: 'addons/pos_refund/static/src/js/refund_button.js', add: 5, del: 2 },
|
|
75
|
+
],
|
|
76
|
+
notes: ['migration backfills about 18,000 orders on staging · review the SQL first'],
|
|
77
|
+
},
|
|
78
|
+
{ number: 410, repo: 'erp', title: 'Inventory valuation rounding', from: 'feature/inventory', into: 'staging', author: 's-7f3a', agent: 'Claude Code', opened: '25m ago', required: 2, approvals: [], checks: { state: 'running' }, state: 'open', commits: 6, files: [], notes: [] },
|
|
79
|
+
{ number: 409, repo: 'erp', title: 'Payroll export to bank format', from: 'feature/hr-payroll', into: 'staging', author: 'dev-02', opened: '3h ago', required: 2, approvals: [{ by: 'nora', ago: '1h ago' }, { by: 'lead', ago: '12m ago' }], checks: { state: 'pass', tests: '31 / 31' }, state: 'merged', mergedAt: '14:12', commits: 4, files: [], notes: [] },
|
|
80
|
+
{ number: 407, repo: 'erp', title: 'Release 17.0.1.3', from: 'staging', into: 'main', author: 'lead', opened: '1d ago', required: 3, approvals: [{ by: 'nora', ago: '3h ago' }], checks: { state: 'pass', tests: '212 / 212' }, state: 'open', commits: 14, files: [], notes: [] },
|
|
81
|
+
{ number: 405, repo: 'portal', title: 'Login redirect after SSO', from: 'fix/login-redirect', into: 'staging', author: 'dev-03', opened: '2h ago', required: 2, approvals: [], checks: { state: 'fail', failed: 'secrets scan · AWS key in config.py' }, state: 'open', commits: 2, files: [], notes: [] },
|
|
82
|
+
],
|
|
83
|
+
audit: [
|
|
84
|
+
{ id: 'ev-8790', time: '13:22', actor: 's-a017', action: 'push', target: 'addons/hr/security/ir.model.access.csv', decision: 'held', note: 'needs tech lead', repo: 'erp' },
|
|
85
|
+
{ id: 'ev-8797', time: '13:40', actor: 'dev-03', action: 'push', target: 'fix/login-redirect · secrets scan', decision: 'blocked', note: 'AWS key in config.py', repo: 'portal' },
|
|
86
|
+
{ id: 'ev-8801', time: '13:51', actor: 'dev-01', action: 'open pr', target: 'PR #412 feature/pos-refund → staging', decision: 'allowed', note: '', repo: 'erp', pr: 412 },
|
|
87
|
+
{ id: 'ev-8806', time: '13:58', actor: 'nora', action: 'approve', target: 'PR #412', decision: 'recorded', note: '', repo: 'erp', pr: 412 },
|
|
88
|
+
{ id: 'ev-8809', time: '14:05', actor: 's-7f3a', action: 'push', target: 'feature/inventory · 3 commits', decision: 'allowed', note: 'Claude Code on vps-1', repo: 'erp' },
|
|
89
|
+
{ id: 'ev-8812', time: '14:03', actor: 'ahmed', action: 'push', target: 'main · 1 commit', decision: 'blocked', note: 'main: no direct push', repo: 'erp', rule: 'main: no direct push' },
|
|
90
|
+
{ id: 'ev-8811', time: '14:12', actor: 'lead', action: 'merge', target: 'PR #409 → staging', decision: 'allowed', note: '2 approvals', repo: 'erp', pr: 409 },
|
|
91
|
+
],
|
|
92
|
+
machines: [
|
|
93
|
+
{ name: 'mbp-shawki', os: 'macOS', online: true, cpu: 22, self: true },
|
|
94
|
+
{ name: 'vps-1', os: 'Linux', online: true, cpu: 61 },
|
|
95
|
+
{ name: 'gpu-a100', os: 'Linux', online: true, cpu: 12 },
|
|
96
|
+
{ name: 'win-qa', os: 'Windows', online: false, offlineFor: '2h' },
|
|
97
|
+
],
|
|
98
|
+
sessions: [
|
|
99
|
+
{ id: 's-7f3a', agent: 'Claude Code', resume: 'claude --resume', machine: 'vps-1', repo: 'erp', branch: 'feature/inventory', state: 'running', turns: 142, cpu: 38, updated: '12s ago', head: 'a91c2e4', changed: 3, stash: 1, backup: '4m ago' },
|
|
100
|
+
{ id: 's-2c91', agent: 'Codex', resume: 'codex resume', machine: 'vps-1', repo: 'erp', branch: 'feature/pos-refund', state: 'waiting', turns: 37, cpu: 0, updated: '1m ago', head: '5d20b7c', changed: 7, stash: 0, backup: '2m ago',
|
|
101
|
+
ask: { question: 'Apply the migration to the staging database?', short: 'apply the migration to staging?', options: ['Yes, run it on staging', 'Run it on erp_test first', 'No, show me the SQL'] } },
|
|
102
|
+
{ id: 's-a017', agent: 'Codex', resume: 'codex resume', machine: 'vps-1', repo: 'erp', branch: 'feature/hr-payroll', state: 'running', turns: 9, cpu: 21, updated: '4s ago', head: '0f61a2d', changed: 1, stash: 0, backup: '12m ago' },
|
|
103
|
+
{ id: 's-91be', agent: 'Claude Code', resume: 'claude --resume', machine: 'mbp-shawki', repo: 'portal', branch: 'fix/login-redirect', state: 'running', turns: 23, cpu: 14, updated: '30s ago', head: '7aa0c19', changed: 2, stash: 0, backup: '8m ago' },
|
|
104
|
+
{ id: 's-d402', agent: 'Claude Code', resume: 'claude --resume', machine: 'mbp-shawki', repo: 'erp', branch: 'feature/mrp-routing', state: 'idle', turns: 58, cpu: 0, updated: '25m ago', head: '3c8e2a1', changed: 0, stash: 0, backup: '25m ago' },
|
|
105
|
+
{ id: 's-c3f8', agent: 'Claude Code', resume: 'claude --resume', machine: 'gpu-a100', repo: 'ml-forecast', branch: 'exp/lstm', state: 'running', turns: 77, cpu: 12, updated: '9s ago', head: 'e10fa33', changed: 4, stash: 0, backup: '6m ago' },
|
|
106
|
+
{ id: 's-e55b', agent: 'OpenCode', resume: 'opencode --continue', machine: 'win-qa', repo: 'portal', branch: 'main', state: 'suspended', turns: 44, cpu: 0, updated: '2h ago', head: 'b3e1f70', changed: 0, stash: 0, backup: '2h ago' },
|
|
107
|
+
],
|
|
108
|
+
backups: [
|
|
109
|
+
{ id: 'b-0098', session: 's-7f3a', agent: 'Claude Code', machine: 'mbp-shawki', label: 'moved to vps-1', turn: 61, sizeMb: 0.8, created: 'yesterday', head: '1be03c7', files: 1, prompt: 'first pass on inventory valuation', repo: 'erp' },
|
|
110
|
+
{ id: 'b-0120', session: 's-e55b', agent: 'OpenCode', machine: 'win-qa', label: 'before offline', turn: 44, sizeMb: 1.1, created: '2h ago', head: 'b3e1f70', files: 0, prompt: 'check SSO callback on Edge', repo: 'portal' },
|
|
111
|
+
{ id: 'b-0133', session: 's-7f3a', agent: 'Claude Code', machine: 'vps-1', label: 'auto', turn: 114, sizeMb: 1.7, created: '20m ago', head: '52f9e80', files: 4, prompt: 'move quant logic into stock_ext', repo: 'erp' },
|
|
112
|
+
{ id: 'b-0137', session: 's-a017', agent: 'Codex', machine: 'vps-1', label: 'auto', turn: 6, sizeMb: 0.4, created: '12m ago', head: '0f61a2d', files: 1, prompt: 'add bank export format for payroll', repo: 'erp' },
|
|
113
|
+
{ id: 'b-0139', session: 's-7f3a', agent: 'Claude Code', machine: 'vps-1', label: 'auto', turn: 128, sizeMb: 1.9, created: '10m ago', head: '7c04b19', files: 2, prompt: 'refactor _compute_qty_available', repo: 'erp',
|
|
114
|
+
diff: [{ path: 'models/stock_move.py', add: 48, del: 12 }, { path: 'models/stock_quant.py', add: 9, del: 3 }] },
|
|
115
|
+
{ id: 'b-0141', session: 's-2c91', agent: 'Codex', machine: 'vps-1', label: 'auto · before risky', turn: 36, sizeMb: 0.9, created: '2m ago', head: '5d20b7c', files: 7, prompt: 'write the migration for refunded_amount', repo: 'erp' },
|
|
116
|
+
{ id: 'b-0142', session: 's-7f3a', agent: 'Claude Code', machine: 'vps-1', label: 'before refactor', turn: 142, sizeMb: 2.1, created: 'just now', head: 'a91c2e4', files: 3, prompt: 'add tests for stock.move.line rounding', repo: 'erp' },
|
|
117
|
+
],
|
|
118
|
+
backupPolicy: { every: '10 min while running', everyTurn: false, beforeRisky: true, beforeTransfer: true, keep: '30 days · last 20 per session', storage: 'gitroots cloud', region: 'eu-central' },
|
|
119
|
+
tunnels: [
|
|
120
|
+
{ id: 't-27', session: 's-c3f8', machine: 'gpu-a100', target: ':8888 Jupyter', access: 'only you', viewers: 0, traffic: '41 MB', openFor: '1h 12m' },
|
|
121
|
+
],
|
|
122
|
+
// What an attached agent said recently (attach + watch live view).
|
|
123
|
+
transcripts: {
|
|
124
|
+
's-2c91': [
|
|
125
|
+
{ who: 'you', text: 'Add partial refunds to the POS order flow, then write the migration.' },
|
|
126
|
+
{ who: 'agent', text: 'Added refund_line_ids to pos.order and a wizard for partial amounts.\nTests pass locally (24 / 24).\n\nNext step needs a database change:\n§migrations/17.0.1.3/post-migrate.py§ adds column pos_order.refunded_amount\nand backfills 18,402 orders on *staging*.' },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
hiddenTurns: { 's-2c91': 34 },
|
|
130
|
+
counters: { ev: 8813, backup: 143, tunnel: 31, session: 0 },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Live log pool for the watch screen (cycled in demo mode).
|
|
135
|
+
export const LIVE_POOL = (file = 'addons/stock_ext/models/stock_move.py') => [
|
|
136
|
+
{ verb: 'read', body: file },
|
|
137
|
+
{ verb: 'edit', body: `${file} `, add: '+24', del: ' −6' },
|
|
138
|
+
{ verb: 'run', body: 'odoo-bin -d erp_test --test-tags /stock_ext -u stock_ext --stop-after-init', running: true },
|
|
139
|
+
{ verb: 'output', body: 'Ran 18 tests in 7.42s' },
|
|
140
|
+
{ verb: 'output', body: '', fail: 'FAIL: test_move_line_qty_rounding expected 2.0, got 1.999' },
|
|
141
|
+
{ verb: 'think', body: 'Rounding should use the product UoM precision, not the company one.' },
|
|
142
|
+
{ verb: 'edit', body: `${file} `, add: '+3', del: ' −1' },
|
|
143
|
+
{ verb: 'run', body: 'odoo-bin -d erp_test --test-tags /stock_ext --stop-after-init', running: true },
|
|
144
|
+
{ verb: 'output', body: 'Ran 18 tests in 7.38s · ', add: 'OK' },
|
|
145
|
+
{ verb: 'git', body: 'commit -m "fix: round move line qty with product UoM" ', add: 'a91c2e5' },
|
|
146
|
+
{ verb: 'backup', body: 'auto · b-0144 · 2.1 MB', add: ' ✓' },
|
|
147
|
+
{ verb: 'read', body: 'addons/stock_ext/models/stock_quant.py' },
|
|
148
|
+
{ verb: 'think', body: 'Quant reservation has the same rounding issue; checking callers.' },
|
|
149
|
+
{ verb: 'edit', body: 'addons/stock_ext/models/stock_quant.py ', add: '+7', del: ' −2' },
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
export const FOLLOW_POOL = [
|
|
153
|
+
{ actor: 'shawki', action: 'approve', target: 'PR #412', decision: 'recorded' },
|
|
154
|
+
{ actor: 'shawki', action: 'merge', target: 'PR #412 → staging', decision: 'allowed', note: '2 approvals · checks green' },
|
|
155
|
+
{ actor: 's-7f3a', action: 'push', target: 'feature/inventory · 1 commit', decision: 'allowed', note: 'Claude Code on gpu-a100' },
|
|
156
|
+
{ actor: 'dev-02', action: 'push', target: 'staging · 1 commit', decision: 'blocked', note: 'staging: PR only' },
|
|
157
|
+
{ actor: 'portal', action: 'rule change', target: 'main · approvals 1 → 2', decision: 'held', note: 'needs a second admin' },
|
|
158
|
+
{ actor: 'dev-03', action: 'push', target: 'fix/login-redirect · 2 commits', decision: 'allowed', note: 'secrets scan clean' },
|
|
159
|
+
{ actor: 's-c3f8', action: 'push', target: 'ml-forecast exp/lstm', decision: 'allowed', note: 'Claude Code on gpu-a100' },
|
|
160
|
+
{ actor: 'nora', action: 'approve', target: 'PR #410', decision: 'recorded' },
|
|
161
|
+
{ actor: 'ahmed', action: 'clone', target: 'erp', decision: 'allowed', note: 'mbp-ahmed · macOS' },
|
|
162
|
+
{ actor: 's-2c91', action: 'push', target: 'migrations/17.0.1.3', decision: 'held', note: 'data migration · needs review' },
|
|
163
|
+
];
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Local store. In demo mode the org starts from the sample data and every change
|
|
2
|
+
// (approvals, merges, backups, transfers, tunnels, audit events) is kept in
|
|
3
|
+
// $GITROOTS_HOME/state.json (default ~/.gitroots) so commands build on each other.
|
|
4
|
+
//
|
|
5
|
+
// A connected backend would implement the same methods against the gitroots API.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { sampleData } from './sample.js';
|
|
10
|
+
|
|
11
|
+
export function home() {
|
|
12
|
+
return process.env.GITROOTS_HOME || path.join(os.homedir(), '.gitroots');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stateFile() { return path.join(home(), 'state.json'); }
|
|
16
|
+
|
|
17
|
+
function globMatch(pattern, value) {
|
|
18
|
+
const re = new RegExp(`^${pattern
|
|
19
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
20
|
+
.replace(/\*\*/g, '\u0000')
|
|
21
|
+
.replace(/\*/g, '[^/]*')
|
|
22
|
+
.replace(/\u0000/g, '.*')
|
|
23
|
+
.replace(/\?/g, '.')}$`);
|
|
24
|
+
return re.test(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class Store {
|
|
28
|
+
constructor(data, { persist = true } = {}) {
|
|
29
|
+
this.d = data;
|
|
30
|
+
this.persist = persist;
|
|
31
|
+
this.mode = 'demo';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static open() {
|
|
35
|
+
let data;
|
|
36
|
+
try { data = JSON.parse(fs.readFileSync(stateFile(), 'utf8')); } catch { data = null; }
|
|
37
|
+
if (!data || data.version !== 1) data = { version: 1, ...sampleData() };
|
|
38
|
+
return new Store(data);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static reset() {
|
|
42
|
+
try { fs.rmSync(stateFile()); } catch { /* none */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
save() {
|
|
46
|
+
if (!this.persist) return;
|
|
47
|
+
try {
|
|
48
|
+
fs.mkdirSync(home(), { recursive: true });
|
|
49
|
+
fs.writeFileSync(stateFile(), JSON.stringify(this.d, null, 1));
|
|
50
|
+
} catch { /* read-only home: keep going in memory */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get org() { return this.orgOverlay || this.d.org; }
|
|
54
|
+
|
|
55
|
+
// ---- repos & rules
|
|
56
|
+
repos() { return this.d.repos; }
|
|
57
|
+
repo(name) {
|
|
58
|
+
const r = this.d.repos.find((x) => x.name === name);
|
|
59
|
+
if (!r) throw new Error(`no repository "${name}" · see gitroots repo list`);
|
|
60
|
+
return r;
|
|
61
|
+
}
|
|
62
|
+
repoByRemote(url) {
|
|
63
|
+
if (!url) return null;
|
|
64
|
+
const base = url.replace(/\/+$/, '').split(/[/:]/).pop().replace(/\.git$/, '');
|
|
65
|
+
return this.d.repos.find((r) => r.name === base || (r.remote && url.includes(r.remote.replace(/^.*?:/, '').replace(/\.git$/, '')))) || null;
|
|
66
|
+
}
|
|
67
|
+
branches(repo) { this.repo(repo); return this.d.branches[repo] || []; }
|
|
68
|
+
rules(repo) { return this.d.rules[repo] || { '*': { protected: false, directPush: true, label: 'developers can push' }, paths: [] }; }
|
|
69
|
+
ruleFor(repo, branch) {
|
|
70
|
+
const rules = this.rules(repo);
|
|
71
|
+
if (rules[branch]) return { name: branch, ...rules[branch] };
|
|
72
|
+
const keys = Object.keys(rules).filter((k) => k.includes('*') && k !== '*' && globMatch(k, branch));
|
|
73
|
+
if (keys.length) return { name: keys[0], ...rules[keys[0]] };
|
|
74
|
+
return { name: '*', ...(rules['*'] || { protected: false, directPush: true, label: 'developers can push' }) };
|
|
75
|
+
}
|
|
76
|
+
protectedPaths(repo) { return (this.rules(repo).paths || []); }
|
|
77
|
+
matchPath(pattern, file) { return globMatch(pattern, file); }
|
|
78
|
+
|
|
79
|
+
// ---- PRs
|
|
80
|
+
prs(repo) { return this.d.prs.filter((p) => !repo || p.repo === repo); }
|
|
81
|
+
pr(n) {
|
|
82
|
+
const num = Number(String(n).replace(/^#/, ''));
|
|
83
|
+
const p = this.d.prs.find((x) => x.number === num);
|
|
84
|
+
if (!p) throw new Error(`no pull request #${n} · see gitroots pr list`);
|
|
85
|
+
return p;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- audit
|
|
89
|
+
audit() { return this.d.audit; }
|
|
90
|
+
event(id) {
|
|
91
|
+
const e = this.d.audit.find((x) => x.id === id);
|
|
92
|
+
if (!e) throw new Error(`no audit event ${id}`);
|
|
93
|
+
return e;
|
|
94
|
+
}
|
|
95
|
+
nextEventId() { return `ev-${this.d.counters.ev++}`; }
|
|
96
|
+
record(ev) {
|
|
97
|
+
const now = new Date();
|
|
98
|
+
const e = { id: this.nextEventId(), time: `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`, at: now.toISOString(), note: '', ...ev };
|
|
99
|
+
this.d.audit.push(e);
|
|
100
|
+
return e;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- agents
|
|
104
|
+
machines() { return this.d.machines; }
|
|
105
|
+
machine(name) {
|
|
106
|
+
const m = this.d.machines.find((x) => x.name === name);
|
|
107
|
+
if (!m) throw new Error(`no machine "${name}" · machines: ${this.d.machines.map((x) => x.name).join(', ')}`);
|
|
108
|
+
return m;
|
|
109
|
+
}
|
|
110
|
+
sessions() { return this.d.sessions; }
|
|
111
|
+
session(id) {
|
|
112
|
+
const s = this.d.sessions.find((x) => x.id === id || x.id === `s-${id}`);
|
|
113
|
+
if (!s) throw new Error(`no agent session "${id}" · see gitroots watch`);
|
|
114
|
+
return s;
|
|
115
|
+
}
|
|
116
|
+
transcript(id) { return this.d.transcripts[id] || []; }
|
|
117
|
+
|
|
118
|
+
backups() { return this.d.backups; }
|
|
119
|
+
backup(id) {
|
|
120
|
+
const b = this.d.backups.find((x) => x.id === id);
|
|
121
|
+
if (!b) throw new Error(`no backup ${id} · see gitroots backups`);
|
|
122
|
+
return b;
|
|
123
|
+
}
|
|
124
|
+
nextBackupId() { return `b-${String(this.d.counters.backup++).padStart(4, '0')}`; }
|
|
125
|
+
|
|
126
|
+
tunnels() { return this.d.tunnels; }
|
|
127
|
+
nextTunnelId() { return `t-${this.d.counters.tunnel++}`; }
|
|
128
|
+
nextSessionId() {
|
|
129
|
+
this.d.counters.session++;
|
|
130
|
+
return `s-${Math.floor(Math.random() * 0xffff).toString(16).padStart(4, '0')}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// What needs the signed-in user.
|
|
134
|
+
needsYou() {
|
|
135
|
+
const me = this.org.user;
|
|
136
|
+
const items = [];
|
|
137
|
+
for (const p of this.d.prs) {
|
|
138
|
+
if (p.state === 'open' && p.needs && p.needs.who === me && !p.approvals.some((a) => a.by === me)) items.push({ kind: 'review', pr: p });
|
|
139
|
+
}
|
|
140
|
+
for (const s of this.d.sessions) if (s.state === 'waiting' && s.ask) items.push({ kind: 'agent', session: s });
|
|
141
|
+
// signed in, blocked pushes come from the real audit log instead
|
|
142
|
+
if (!this.api) for (const e of this.d.audit.filter((x) => x.decision === 'blocked' && x.action === 'push' && x.rule && !x.ack)) items.push({ kind: 'blocked', event: e });
|
|
143
|
+
return items;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Real checks gitroots runs on commits before they leave the machine.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
export const ZERO = /^0+$/;
|
|
5
|
+
|
|
6
|
+
export function git(args, cwd = process.cwd()) {
|
|
7
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
8
|
+
if (r.error) throw r.error;
|
|
9
|
+
if (r.status !== 0) {
|
|
10
|
+
const err = new Error((r.stderr || '').trim() || `git ${args[0]} failed`);
|
|
11
|
+
err.status = r.status;
|
|
12
|
+
throw err;
|
|
13
|
+
}
|
|
14
|
+
return r.stdout;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function tryGit(args, cwd) { try { return git(args, cwd).trim(); } catch { return ''; } }
|
|
18
|
+
|
|
19
|
+
const MAX_COMMITS = 500;
|
|
20
|
+
|
|
21
|
+
// Commits that would be sent: local..remote, or everything not on any remote for a new branch.
|
|
22
|
+
export function commitsToPush(localSha, remoteSha, cwd) {
|
|
23
|
+
const args = remoteSha && !ZERO.test(remoteSha) && objectExists(remoteSha, cwd)
|
|
24
|
+
? ['rev-list', `--max-count=${MAX_COMMITS}`, `${remoteSha}..${localSha}`]
|
|
25
|
+
: ['rev-list', `--max-count=${MAX_COMMITS}`, localSha, '--not', '--remotes'];
|
|
26
|
+
return git(args, cwd).split('\n').filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function objectExists(sha, cwd) {
|
|
30
|
+
const r = spawnSync('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd });
|
|
31
|
+
return r.status === 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function changedFiles(commits, cwd) {
|
|
35
|
+
const files = new Set();
|
|
36
|
+
for (const sha of commits) {
|
|
37
|
+
for (const f of git(['diff-tree', '--no-commit-id', '--name-only', '-r', '-m', '--root', sha], cwd).split('\n')) if (f) files.add(f);
|
|
38
|
+
}
|
|
39
|
+
return [...files];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Added lines with file and line number, from each commit's patch.
|
|
43
|
+
export function addedLines(commits, cwd) {
|
|
44
|
+
const outLines = [];
|
|
45
|
+
for (const sha of commits) {
|
|
46
|
+
const patch = git(['show', '--format=', '--unified=0', '--no-color', '--no-ext-diff', '-m', '--first-parent', sha], cwd);
|
|
47
|
+
let file = null;
|
|
48
|
+
let ln = 0;
|
|
49
|
+
for (const l of patch.split('\n')) {
|
|
50
|
+
if (l.startsWith('+++ ')) { file = l.slice(4).replace(/^b\//, ''); continue; }
|
|
51
|
+
if (l.startsWith('@@')) { const m = /\+(\d+)/.exec(l); ln = m ? Number(m[1]) : 0; continue; }
|
|
52
|
+
if (l.startsWith('+') && file && file !== '/dev/null') { outLines.push({ sha, file, line: ln, text: l.slice(1) }); ln++; continue; }
|
|
53
|
+
if (!l.startsWith('-') && !l.startsWith('\\')) ln++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return outLines;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const SECRET_PATTERNS = [
|
|
60
|
+
{ name: 'AWS access key', re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
|
61
|
+
{ name: 'AWS secret key', re: /aws_secret_access_key\s*[:=]\s*['"]?[A-Za-z0-9/+=]{40}/i },
|
|
62
|
+
{ name: 'private key', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY( BLOCK)?-----/ },
|
|
63
|
+
{ name: 'GitHub token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{60,}\b/ },
|
|
64
|
+
{ name: 'GitLab token', re: /\bglpat-[A-Za-z0-9_-]{20}\b/ },
|
|
65
|
+
{ name: 'Slack token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
66
|
+
{ name: 'Stripe live key', re: /\b[sr]k_live_[A-Za-z0-9]{20,}\b/ },
|
|
67
|
+
{ name: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
68
|
+
{ name: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
|
|
69
|
+
{ name: 'OpenAI API key', re: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b/ },
|
|
70
|
+
{ name: 'password', re: /(?:password|passwd|pwd|secret|api_key|apikey|token)\w*['"]?\s*[:=]\s*['"][^'"\s]{8,}['"]/i, skip: /(example|changeme|placeholder|your[_-]|xxx|\*\*\*|<|\$\{|os\.environ|getenv|process\.env)/i },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export function scanSecrets(lines) {
|
|
74
|
+
const hits = [];
|
|
75
|
+
for (const l of lines) {
|
|
76
|
+
for (const p of SECRET_PATTERNS) {
|
|
77
|
+
if (p.re.test(l.text) && !(p.skip && p.skip.test(l.text))) { hits.push({ ...l, kind: p.name }); break; }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return hits;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function authors(commits, cwd) {
|
|
84
|
+
if (!commits.length) return [];
|
|
85
|
+
return git(['show', '-s', '--format=%H%x09%an%x09%ae%x09%G?', ...commits], cwd)
|
|
86
|
+
.split('\n').filter(Boolean)
|
|
87
|
+
.map((l) => { const [sha, name, email, sig] = l.split('\t'); return { sha, name, email, signed: sig === 'G' || sig === 'U', sig }; });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const MIGRATION_RE = /(^|\/)(migrations?|migrate|alembic\/versions|db\/migrate)\/|(^|\/)[^/]*migrat[^/]*\.(py|sql|rb|js|ts)$|\.sql$/i;
|
|
91
|
+
|
|
92
|
+
export function lastSubjectSlug(sha, cwd) {
|
|
93
|
+
const subj = tryGit(['log', '-1', '--format=%s', sha], cwd);
|
|
94
|
+
const slug = subj.toLowerCase().replace(/^[a-z]+(\([^)]*\))?:\s*/, '').replace(/[^a-z0-9]+/g, '-').split('-').filter(Boolean).slice(0, 4).join('-');
|
|
95
|
+
return slug || 'my-change';
|
|
96
|
+
}
|
package/src/installer.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Native install / update, mirroring install.sh:
|
|
2
|
+
// <base>/latest "0.5.0"
|
|
3
|
+
// <base>/<version>/manifest.json { version, platforms: { "darwin-arm64": { file, checksum, size } } }
|
|
4
|
+
// <base>/<version>/<file> gitroots-<version>-<platform>.tar.gz (.zip on Windows)
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { DOWNLOAD_BASE, nativePaths, platformKey } from './runtime.js';
|
|
11
|
+
|
|
12
|
+
async function fetchOk(url, timeout = 30000) {
|
|
13
|
+
let res;
|
|
14
|
+
try { res = await fetch(url, { signal: AbortSignal.timeout(timeout), headers: { 'User-Agent': 'gitroots-cli' } }); } catch (e) {
|
|
15
|
+
throw new Error(`can't reach ${url} (${e.cause?.code || e.name})`);
|
|
16
|
+
}
|
|
17
|
+
if (!res.ok) throw new Error(`${res.status} from ${url}`);
|
|
18
|
+
return res;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function latestVersion(base = DOWNLOAD_BASE, timeout) {
|
|
22
|
+
const v = (await (await fetchOk(`${base}/latest`, timeout)).text()).trim();
|
|
23
|
+
if (!/^\d+\.\d+\.\d+([-+][\w.]+)?$/.test(v)) throw new Error(`${base}/latest is not a version (${v.slice(0, 40)})`);
|
|
24
|
+
return v;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function manifest(version, base = DOWNLOAD_BASE) {
|
|
28
|
+
return (await fetchOk(`${base}/${version}/manifest.json`)).json();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function newer(a, b) {
|
|
32
|
+
const pa = a.split(/[.-]/).map(Number);
|
|
33
|
+
const pb = b.split(/[.-]/).map(Number);
|
|
34
|
+
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Download, verify and install one version. Returns the installed binary path.
|
|
39
|
+
export async function installNative(version, { base = DOWNLOAD_BASE, onProgress = () => {} } = {}) {
|
|
40
|
+
const key = platformKey();
|
|
41
|
+
const man = await manifest(version, base);
|
|
42
|
+
const entry = man.platforms && man.platforms[key];
|
|
43
|
+
if (!entry) throw new Error(`no gitroots ${version} build for ${key} · available: ${Object.keys(man.platforms || {}).join(', ')}`);
|
|
44
|
+
|
|
45
|
+
const res = await fetchOk(`${base}/${version}/${entry.file}`, 600000);
|
|
46
|
+
const total = Number(res.headers.get('content-length')) || entry.size || 0;
|
|
47
|
+
const chunks = [];
|
|
48
|
+
let got = 0;
|
|
49
|
+
for await (const chunk of res.body) { chunks.push(chunk); got += chunk.length; if (total) onProgress(got / total); }
|
|
50
|
+
const buf = Buffer.concat(chunks);
|
|
51
|
+
const sum = crypto.createHash('sha256').update(buf).digest('hex');
|
|
52
|
+
if (sum !== entry.checksum) throw new Error(`checksum mismatch for ${entry.file} · expected ${entry.checksum.slice(0, 12)}…, got ${sum.slice(0, 12)}… · nothing was installed`);
|
|
53
|
+
|
|
54
|
+
const { data, bin, launcher } = nativePaths();
|
|
55
|
+
const dir = path.join(data, 'versions', version);
|
|
56
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gitroots-'));
|
|
57
|
+
const archive = path.join(tmp, entry.file);
|
|
58
|
+
fs.writeFileSync(archive, buf);
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
const ok = entry.file.endsWith('.zip')
|
|
61
|
+
? spawnSync('powershell', ['-NoProfile', '-Command', `Expand-Archive -Force -Path '${archive}' -DestinationPath '${dir}'`], { stdio: 'ignore' }).status === 0
|
|
62
|
+
: spawnSync('tar', ['-xzf', archive, '-C', dir], { stdio: 'ignore' }).status === 0;
|
|
63
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
64
|
+
if (!ok) throw new Error(`could not unpack ${entry.file}`);
|
|
65
|
+
const exe = path.join(dir, process.platform === 'win32' ? 'gitroots.exe' : 'gitroots');
|
|
66
|
+
if (!fs.existsSync(exe)) throw new Error(`${entry.file} has no gitroots binary`);
|
|
67
|
+
if (process.platform !== 'win32') fs.chmodSync(exe, 0o755);
|
|
68
|
+
|
|
69
|
+
fs.mkdirSync(bin, { recursive: true });
|
|
70
|
+
if (process.platform === 'win32') {
|
|
71
|
+
// A running .exe can be renamed but not overwritten.
|
|
72
|
+
if (fs.existsSync(launcher)) { try { fs.rmSync(`${launcher}.old`, { force: true }); } catch { /* in use */ } fs.renameSync(launcher, `${launcher}.old`); }
|
|
73
|
+
fs.copyFileSync(exe, launcher);
|
|
74
|
+
} else {
|
|
75
|
+
const link = `${launcher}.new`;
|
|
76
|
+
try { fs.rmSync(link, { force: true }); } catch { /* none */ }
|
|
77
|
+
fs.symlinkSync(exe, link);
|
|
78
|
+
fs.renameSync(link, launcher);
|
|
79
|
+
}
|
|
80
|
+
pruneVersions(data, version);
|
|
81
|
+
return { exe, launcher, version, platform: key };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Keep the current and the previous version.
|
|
85
|
+
function pruneVersions(data, current) {
|
|
86
|
+
const vdir = path.join(data, 'versions');
|
|
87
|
+
let all = [];
|
|
88
|
+
try { all = fs.readdirSync(vdir); } catch { return; }
|
|
89
|
+
const sorted = all.filter((v) => /^\d/.test(v)).sort((a, b) => (newer(a, b) ? -1 : 1));
|
|
90
|
+
const keep = new Set([current, sorted.find((v) => v !== current)]);
|
|
91
|
+
for (const v of sorted) if (!keep.has(v)) fs.rmSync(path.join(vdir, v), { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function onPath(dir) {
|
|
95
|
+
return (process.env.PATH || '').split(path.delimiter).some((p) => path.resolve(p) === path.resolve(dir));
|
|
96
|
+
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Where gitroots is running from: an npm/source install (node + bin/gitroots.js)
|
|
2
|
+
// or a standalone binary (Node single executable application) from install.sh.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
export const VERSION = '0.5.0';
|
|
9
|
+
|
|
10
|
+
// Default download location for install.sh / gitroots update. Override with GITROOTS_DOWNLOAD_BASE.
|
|
11
|
+
export const DOWNLOAD_BASE = process.env.GITROOTS_DOWNLOAD_BASE || 'https://gitroots.com/cli';
|
|
12
|
+
export const NPM_PACKAGE = 'gitroots';
|
|
13
|
+
|
|
14
|
+
let sea = null;
|
|
15
|
+
try { sea = typeof process.getBuiltinModule === 'function' ? process.getBuiltinModule('node:sea') : null; } catch { sea = null; }
|
|
16
|
+
|
|
17
|
+
export const isBinary = Boolean(sea && typeof sea.isSea === 'function' && sea.isSea());
|
|
18
|
+
|
|
19
|
+
function here(rel) { return fileURLToPath(new URL(rel, import.meta.url)); }
|
|
20
|
+
|
|
21
|
+
export function asset(name) {
|
|
22
|
+
if (isBinary) return Buffer.from(sea.getAsset(name));
|
|
23
|
+
return fs.readFileSync(here(`../assets/${name}`));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Native install layout (same idea as Claude Code's native installer):
|
|
27
|
+
// ~/.local/share/gitroots/versions/<version>/gitroots the binaries
|
|
28
|
+
// ~/.local/bin/gitroots symlink to the current one
|
|
29
|
+
export function nativePaths() {
|
|
30
|
+
const home = os.homedir();
|
|
31
|
+
if (process.platform === 'win32') {
|
|
32
|
+
const base = path.join(home, '.local');
|
|
33
|
+
return { data: path.join(base, 'share', 'gitroots'), bin: path.join(base, 'bin'), launcher: path.join(base, 'bin', 'gitroots.exe') };
|
|
34
|
+
}
|
|
35
|
+
const data = process.env.XDG_DATA_HOME ? path.join(process.env.XDG_DATA_HOME, 'gitroots') : path.join(home, '.local', 'share', 'gitroots');
|
|
36
|
+
return { data, bin: path.join(home, '.local', 'bin'), launcher: path.join(home, '.local', 'bin', 'gitroots') };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function installMethod() {
|
|
40
|
+
if (isBinary) return 'native';
|
|
41
|
+
const bin = here('../bin/gitroots.js');
|
|
42
|
+
if (/[\\/]node_modules[\\/]/.test(bin)) return 'npm';
|
|
43
|
+
return 'source';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The command that runs this gitroots, for hooks: [executable, ...args]
|
|
47
|
+
export function selfCommand() {
|
|
48
|
+
if (isBinary) {
|
|
49
|
+
const { launcher } = nativePaths();
|
|
50
|
+
return [fs.existsSync(launcher) ? launcher : process.execPath];
|
|
51
|
+
}
|
|
52
|
+
return [process.execPath, here('../bin/gitroots.js')];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function platformKey() {
|
|
56
|
+
const os_ = process.platform === 'win32' ? 'win32' : process.platform;
|
|
57
|
+
const arch = process.arch === 'x64' ? 'x64' : process.arch === 'arm64' ? 'arm64' : process.arch;
|
|
58
|
+
let musl = false;
|
|
59
|
+
if (os_ === 'linux') {
|
|
60
|
+
try { musl = !process.report.getReport().header.glibcVersionRuntime; } catch { musl = false; }
|
|
61
|
+
}
|
|
62
|
+
return `${os_}-${arch}${musl ? '-musl' : ''}`;
|
|
63
|
+
}
|