OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-sync commits / b2a23b6

Add browser methods (mixed into HyperSync prototype) + examples
lib/browser.js (~250 lines): git repo browser — listFiles, readFile, isBinary, getCommits, getCommit, getCommitDiff, getBranches, getTags, getReadme, getDiff, searchFiles, and ~17 other read-side methods that shell out to the local git binary against bare repos. Ported from pear-git v1's GitBrowser. Mixed into the HyperSync prototype via Object.assign so callers do `sync.readFile(repo, path, ref)` etc. on the same instance that already does serve/pull. index.js change: one import + one Object.assign on the prototype. examples/cli.js + examples/web.js — standalone usage demos for the v3 standalone-CLI / web pattern (storage env vars, no shared state with the daemon). These have been on disk since 2026-04-09 / 2026-04-13 and are already in active use via pear-git-v3's file:../hyper-sync dep. This commit just makes git history match what's actually loading. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
b2a23b6244b0251f69c745303d994d44d8e14b9b
by plankatron <fleeky@gmail.com> · 2026-04-15T21:32:00.000Z

Changed files (4) +501 -0

examples/cli.js +119 -0
……@@ -0,0 +1,119 @@
1#!/usr/bin/env node
2/**
3 * Mini CLI example for hyper-sync
4 *
5 * Usage:
6 * node examples/cli.js status [--repos <dir>]
7 * node examples/cli.js serve [name] [--repos <dir>]
8 * node examples/cli.js add-remote <name> <serverKey>
9 * node examples/cli.js pull <remote> [repo]
10 */
11
12const HyperSync = require('../index')
13
14const args = process.argv.slice(2)
15const cmd = args[0]
16
17function getFlag (name) {
18 const i = args.indexOf('--' + name)
19 return i !== -1 && args[i + 1] ? args[i + 1] : null
20}
21
22function fmt (b) {
23 if (b < 1024) return b + ' B'
24 if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'
25 return (b / 1048576).toFixed(1) + ' MB'
26}
27
28async function main () {
29 const repos = getFlag('repos')
30 const sync = new HyperSync(repos ? { repos } : {})
31 await sync.ready()
32
33 if (cmd === 'status') {
34 const s = sync.status()
35 console.log('Server key: %s', s.serverKey)
36 console.log('Repos: %d, Serving: %d, Remotes: %d\n', s.repoCount, s.serving, s.remotes)
37
38 if (s.repos.length === 0) {
39 console.log(' (no repos found)')
40 } else {
41 for (const r of s.repos) {
42 console.log(' %s %d branches %s %s',
43 r.name.padEnd(30), r.branches, fmt(r.packSize).padStart(8),
44 r.head ? r.head : '(empty)')
45 }
46 }
47
48 await sync.close()
49 } else if (cmd === 'serve') {
50 const name = args[1] && !args[1].startsWith('--') ? args[1] : null
51
52 sync.on('serve', (n, topic) => console.log(' %s → %s', n, topic.slice(0, 16) + '...'))
53 sync.on('connection', (peer, repo) => console.log('Peer %s connected (repo: %s)', peer, repo))
54
55 await sync.serve(name)
56 console.log('Serving %d repo(s). Server key: %s', sync.serving, sync.serverKey)
57 console.log('Ctrl+C to stop')
58 } else if (cmd === 'add-remote') {
59 const name = args[1]
60 const key = args[2]
61 if (!name || !key) {
62 console.error('Usage: cli.js add-remote <name> <serverKey>')
63 await sync.close()
64 process.exit(1)
65 }
66
67 sync.addRemote(name, key)
68 console.log('Added remote "%s" → %s', name, key.slice(0, 16) + '...')
69
70 // Note: remotes are in-memory only — a real app would persist them
71 console.log('(remote stored in memory — will not persist across restarts)')
72 await sync.close()
73 } else if (cmd === 'pull') {
74 const remoteName = args[1]
75 const repoName = args[2] && !args[2].startsWith('--') ? args[2] : null
76
77 if (!remoteName) {
78 console.error('Usage: cli.js pull <remote> [repo]')
79 await sync.close()
80 process.exit(1)
81 }
82
83 // For this example, accept serverKey directly as the remote name
84 // and auto-register it
85 if (remoteName.length === 64) {
86 sync.addRemote('remote', remoteName)
87 console.log('Auto-registered remote from key: %s...', remoteName.slice(0, 16))
88 }
89
90 const remote = remoteName.length === 64 ? 'remote' : remoteName
91
92 sync.on('pull', (r, name, state) => console.log(' %s/%s: %s', r, name, state))
93
94 try {
95 const result = await sync.pull(remote, repoName)
96 console.log('Synced: %d, Failed: %d, Elapsed: %dms',
97 result.synced.length, result.failed.length, result.elapsed)
98 if (result.failed.length) {
99 for (const f of result.failed) console.log(' FAIL: %s — %s', f.name, f.error)
100 }
101 } catch (err) {
102 console.error('Pull failed: %s', err.message)
103 }
104
105 await sync.close()
106 } else {
107 console.log('hyper-sync example CLI')
108 console.log(' status List repos')
109 console.log(' serve [name] Announce repos on DHT')
110 console.log(' add-remote <name> <key> Register a remote server')
111 console.log(' pull <remote|key> [repo] Fetch updates from remote')
112 console.log('')
113 console.log('Options:')
114 console.log(' --repos <dir> Git repos directory (default: ~/git-repos)')
115 await sync.close()
116 }
117}
118
119main().catch((err) => { console.error(err.message); process.exit(1) })
examples/web.js +151 -0
……@@ -0,0 +1,151 @@
1#!/usr/bin/env node
2/**
3 * Mini web UI example for hyper-sync
4 *
5 * Usage:
6 * node examples/web.js [--port 9091] [--repos <dir>]
7 *
8 * Starts a sync server and HTTP dashboard showing repo status.
9 */
10
11const http = require('http')
12const HyperSync = require('../index')
13
14const args = process.argv.slice(2)
15
16function getFlag (name) {
17 const i = args.indexOf('--' + name)
18 return i !== -1 && args[i + 1] ? args[i + 1] : null
19}
20
21function fmt (b) {
22 if (b < 1024) return b + ' B'
23 if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'
24 return (b / 1048576).toFixed(1) + ' MB'
25}
26
27const port = parseInt(getFlag('port')) || 9091
28const reposDir = getFlag('repos')
29
30const sync = new HyperSync(reposDir ? { repos: reposDir } : {})
31const events = []
32
33function pushEvent (msg) {
34 events.push({ t: Date.now(), msg })
35 if (events.length > 200) events.shift()
36}
37
38sync.on('serve', (name) => pushEvent('serving ' + name))
39sync.on('connection', (peer, repo) => pushEvent('peer ' + peer + ' → ' + (repo || '?')))
40sync.on('pull', (remote, name, state) => pushEvent('pull ' + remote + '/' + name + ': ' + state))
41
42const httpServer = http.createServer((req, res) => {
43 const url = req.url.split('?')[0]
44
45 if (url === '/api/status') {
46 res.writeHead(200, { 'Content-Type': 'application/json' })
47 res.end(JSON.stringify(sync.status()))
48 return
49 }
50
51 if (url === '/api/rescan' && req.method === 'POST') {
52 sync.scan().then(() => {
53 res.writeHead(200, { 'Content-Type': 'application/json' })
54 res.end(JSON.stringify(sync.status()))
55 }).catch((err) => {
56 res.writeHead(500, { 'Content-Type': 'application/json' })
57 res.end(JSON.stringify({ error: err.message }))
58 })
59 return
60 }
61
62 if (url === '/api/events') {
63 res.writeHead(200, { 'Content-Type': 'application/json' })
64 res.end(JSON.stringify(events.slice(-50)))
65 return
66 }
67
68 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
69 res.end(renderPage())
70})
71
72function renderPage () {
73 return `<!DOCTYPE html>
74<html><head><title>hyper-sync</title>
75<style>
76 body { font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; max-width: 720px; margin: 0 auto; }
77 h1 { color: #58a6ff; }
78 .card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin: 1rem 0; }
79 .card h2 { font-size: 0.75rem; text-transform: uppercase; color: #8b949e; margin: 0 0 0.5rem; }
80 .key { font-size: 0.75rem; word-break: break-all; color: #79c0ff; }
81 .row { display: flex; justify-content: space-between; padding: 0.3rem 0; border-bottom: 1px solid #21262d; }
82 .row:last-child { border-bottom: none; }
83 .label { color: #8b949e; }
84 table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
85 th { text-align: left; color: #8b949e; padding: 0.3rem 0.5rem; border-bottom: 1px solid #30363d; }
86 td { padding: 0.3rem 0.5rem; border-bottom: 1px solid #21262d; }
87 .serving { color: #3fb950; }
88 .btn { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; padding: 0.3rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.8rem; }
89 .btn:hover { background: #30363d; }
90 #log { max-height: 200px; overflow-y: auto; font-size: 0.8rem; color: #8b949e; }
91 #log div { padding: 2px 0; }
92</style></head><body>
93<h1>hyper-sync</h1>
94
95<div class="card">
96 <h2>Server <button class="btn" onclick="rescan()">Rescan</button></h2>
97 <div class="key" id="key">...</div>
98 <div class="row" style="margin-top:0.5rem"><span class="label">Repos</span><span id="count">-</span></div>
99 <div class="row"><span class="label">Serving</span><span id="serving">-</span></div>
100</div>
101
102<div class="card">
103 <h2>Repositories</h2>
104 <table><thead><tr><th>Name</th><th>Branches</th><th>Size</th><th>HEAD</th><th>Status</th></tr></thead>
105 <tbody id="repos"></tbody></table>
106</div>
107
108<div class="card"><h2>Events</h2><div id="log"></div></div>
109
110<script>
111const $ = id => document.getElementById(id)
112function fmt(b) {
113 if (b < 1024) return b + ' B'
114 if (b < 1048576) return (b / 1024).toFixed(1) + ' KB'
115 return (b / 1048576).toFixed(1) + ' MB'
116}
117
118function refresh() {
119 fetch('/api/status').then(r => r.json()).then(d => {
120 $('key').textContent = d.serverKey || '(none)'
121 $('count').textContent = d.repoCount
122 $('serving').textContent = d.serving
123 $('repos').innerHTML = d.repos.map(r =>
124 '<tr><td>' + r.name + '</td><td>' + r.branches + '</td><td>' + fmt(r.packSize) +
125 '</td><td>' + (r.head || '-') + '</td><td>' +
126 (r.serving ? '<span class="serving">serving</span>' : '-') + '</td></tr>'
127 ).join('')
128 })
129 fetch('/api/events').then(r => r.json()).then(evts => {
130 $('log').innerHTML = evts.reverse().map(e =>
131 '<div>' + new Date(e.t).toLocaleTimeString() + ' ' + e.msg + '</div>'
132 ).join('')
133 })
134}
135
136function rescan() { fetch('/api/rescan', { method: 'POST' }).then(refresh) }
137
138refresh()
139setInterval(refresh, 5000)
140</script></body></html>`
141}
142
143sync.ready().then(async () => {
144 await sync.serve()
145 httpServer.listen(port, () => {
146 console.log('Server key: %s', sync.serverKey)
147 console.log('Repos: %d', sync.repos.size)
148 console.log('Serving: %d topics', sync.serving)
149 console.log('Web UI: http://localhost:%d', port)
150 })
151}).catch((err) => { console.error(err.message); process.exit(1) })
index.js +4 -0
……@@ -8,6 +8,7 @@ const { spawn } = require('child_process');
88
99const { scanRepos, repoInfo, readRefs } = require('./lib/repos');
1010const { deriveTopic } = require('./lib/topics');
11const { browserMethods } = require('./lib/browser');
1112
1213module.exports = class HyperSync extends ReadyResource {
1314 constructor (opts = {}) {
……@@ -349,3 +350,6 @@ module.exports = class HyperSync extends ReadyResource {
349350 });
350351 }
351352};
353
354// Mix in browser methods (listFiles, readFile, getCommits, getDiff, etc.)
355Object.assign(module.exports.prototype, browserMethods);
lib/browser.js +227 -0
……@@ -0,0 +1,227 @@
1const { spawn } = require('child_process')
2const path = require('path')
3
4/**
5 * Git repo browser — reads files, commits, diffs from bare git repos
6 * using native git commands. Ported from pear-git v1's GitBrowser.
7 *
8 * Not a standalone class — methods are mixed into HyperSync.
9 */
10
11function execGit (repoPath, args, timeout) {
12 return new Promise((resolve, reject) => {
13 const proc = spawn('git', ['-C', repoPath, ...args], {
14 stdio: ['ignore', 'pipe', 'pipe']
15 })
16
17 const chunks = []
18 const errorChunks = []
19
20 const timer = setTimeout(() => {
21 proc.kill('SIGTERM')
22 reject(new Error('git command timed out: git ' + args.join(' ')))
23 }, timeout || 30000)
24
25 proc.stdout.on('data', (chunk) => chunks.push(chunk))
26 proc.stderr.on('data', (chunk) => errorChunks.push(chunk))
27
28 proc.on('close', (code) => {
29 clearTimeout(timer)
30 if (code !== 0) {
31 const errMsg = Buffer.concat(errorChunks).toString().trim()
32 reject(new Error(errMsg || 'git exited with code ' + code))
33 } else {
34 resolve(Buffer.concat(chunks).toString())
35 }
36 })
37
38 proc.on('error', (err) => {
39 clearTimeout(timer)
40 reject(err)
41 })
42 })
43}
44
45/**
46 * Browser methods to mix into HyperSync.prototype
47 */
48const browserMethods = {
49 _repoPath (name) {
50 const resolved = path.resolve(this._reposDir, name + '.git')
51 if (!resolved.startsWith(path.resolve(this._reposDir))) {
52 throw new Error('Invalid repository name')
53 }
54 return resolved
55 },
56
57 async listFiles (name, ref, dirPath) {
58 ref = ref || 'HEAD'
59 dirPath = dirPath || ''
60 const repoPath = this._repoPath(name)
61 const treePath = dirPath ? ref + ':' + dirPath : ref
62
63 const output = await execGit(repoPath, ['ls-tree', '-l', '--', treePath])
64 return output.trim().split('\n').filter(Boolean).map(line => {
65 const parts = line.split(/\s+/)
66 const type = parts[1]
67 return {
68 name: parts.slice(4).join(' '),
69 type: type === 'tree' ? 'directory' : type === 'commit' ? 'submodule' : 'file',
70 hash: parts[2],
71 size: parts[3] === '-' ? null : parseInt(parts[3]),
72 mode: parts[0],
73 path: dirPath ? dirPath + '/' + parts.slice(4).join(' ') : parts.slice(4).join(' ')
74 }
75 })
76 },
77
78 async readFile (name, filePath, ref) {
79 ref = ref || 'HEAD'
80 const repoPath = this._repoPath(name)
81 return execGit(repoPath, ['show', ref + ':' + filePath])
82 },
83
84 async getCommits (name, ref, limit) {
85 ref = ref || 'HEAD'
86 limit = limit || 20
87 const repoPath = this._repoPath(name)
88 const format = '%H%n%an%n%ae%n%at%n%s%n%b%n--END--'
89
90 const output = await execGit(repoPath, [
91 'log', '--max-count=' + limit, '--format=' + format, ref, '--'
92 ])
93
94 return output.split('--END--\n').filter(e => e.trim()).map(entry => {
95 const lines = entry.trim().split('\n')
96 if (lines.length < 5) return null
97 return {
98 hash: lines[0],
99 author: lines[1],
100 email: lines[2],
101 timestamp: parseInt(lines[3]),
102 subject: lines[4],
103 body: lines.slice(5).join('\n').trim(),
104 shortHash: lines[0].substring(0, 7),
105 date: new Date(parseInt(lines[3]) * 1000).toISOString()
106 }
107 }).filter(Boolean)
108 },
109
110 async getCommit (name, hash) {
111 const commits = await this.getCommits(name, hash, 1)
112 return commits[0] || null
113 },
114
115 async getBranches (name) {
116 const repoPath = this._repoPath(name)
117 try {
118 const output = await execGit(repoPath, ['branch', '-a'])
119 return output.trim().split('\n').filter(Boolean).map(line => ({
120 name: line.replace(/^\*?\s+/, '').trim(),
121 current: line.startsWith('*')
122 }))
123 } catch { return [] }
124 },
125
126 async getTags (name) {
127 const repoPath = this._repoPath(name)
128 try {
129 const output = await execGit(repoPath, ['tag', '-l'])
130 return output.trim().split('\n').filter(Boolean)
131 } catch { return [] }
132 },
133
134 async getStats (name) {
135 const repoPath = this._repoPath(name)
136 try {
137 const [commitCount, branches, tags] = await Promise.all([
138 execGit(repoPath, ['rev-list', '--count', 'HEAD']).catch(() => '0'),
139 this.getBranches(name),
140 this.getTags(name)
141 ])
142 return { commits: parseInt(commitCount.trim()), branches: branches.length, tags: tags.length }
143 } catch { return { commits: 0, branches: 0, tags: 0 } }
144 },
145
146 async getReadme (name, ref) {
147 ref = ref || 'HEAD'
148 const files = await this.listFiles(name, ref, '')
149 const readme = files.find(f =>
150 f.name.toLowerCase() === 'readme.md' ||
151 f.name.toLowerCase() === 'readme' ||
152 f.name.toLowerCase() === 'readme.txt'
153 )
154 if (!readme) return null
155 const content = await this.readFile(name, readme.name, ref)
156 return { name: readme.name, content }
157 },
158
159 async getDiff (name, fromCommit, toCommit) {
160 toCommit = toCommit || 'HEAD'
161 const repoPath = this._repoPath(name)
162 return execGit(repoPath, ['diff', fromCommit, toCommit])
163 },
164
165 async getCommitDiff (name, commitHash) {
166 const repoPath = this._repoPath(name)
167 return execGit(repoPath, ['diff-tree', '-p', '--root', commitHash])
168 },
169
170 async searchFiles (name, query, ref) {
171 ref = ref || 'HEAD'
172 const repoPath = this._repoPath(name)
173 const output = await execGit(repoPath, ['ls-tree', '-r', '--name-only', '--', ref])
174 const allFiles = output.trim().split('\n').filter(Boolean)
175 return allFiles.filter(f => f.toLowerCase().includes(query.toLowerCase())).slice(0, 50)
176 },
177
178 async isBinary (name, filePath, ref) {
179 ref = ref || 'HEAD'
180 try {
181 const content = await this.readFile(name, filePath, ref)
182 const sample = content.substring(0, 8192)
183 const nullCount = (sample.match(/\0/g) || []).length
184 return nullCount / sample.length > 0.01
185 } catch { return false }
186 },
187
188 /**
189 * Handle git smart HTTP info/refs request
190 */
191 async handleInfoRefs (repoPath, service) {
192 if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
193 throw new Error('Invalid service: ' + service)
194 }
195 // git-upload-pack and git-receive-pack are standalone binaries, not git subcommands
196 const output = await new Promise((resolve, reject) => {
197 const proc = spawn(service, ['--stateless-rpc', '--advertise-refs', repoPath], {
198 stdio: ['ignore', 'pipe', 'pipe']
199 })
200 const chunks = []
201 proc.stdout.on('data', (c) => chunks.push(c))
202 proc.on('close', (code) => {
203 if (code !== 0) reject(new Error(service + ' exited with code ' + code))
204 else resolve(Buffer.concat(chunks).toString())
205 })
206 proc.on('error', reject)
207 })
208 // Prepend service announcement per smart HTTP protocol
209 const announcement = '# service=' + service + '\n'
210 const lenHex = (announcement.length + 4).toString(16).padStart(4, '0')
211 return lenHex + announcement + '0000' + output
212 },
213
214 /**
215 * Spawn git service process for smart HTTP pack exchange
216 */
217 spawnGitService (repoPath, service) {
218 if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
219 throw new Error('Invalid service: ' + service)
220 }
221 return spawn(service, ['--stateless-rpc', repoPath], {
222 stdio: ['pipe', 'pipe', 'pipe']
223 })
224 }
225}
226
227module.exports = { browserMethods, execGit }