/**
* pgh repo commands
*
* Subcommands: list, info, search, url, storage, clone, scan, import, import-all, create, link
*/
import path from 'bare-path'
import subprocess from 'bare-subprocess'
import { table, jsonOut, log, error } from '../output.js'
import { gitExec } from '../git.js'
import { getServerKey } from '../config.js'
import { P2PClient } from '../p2p-client.js'
export async function repoCommand (client, sub, args, flags) {
switch (sub) {
case 'list':
case 'ls':
return repoList(client, flags)
case 'info':
return repoInfo(client, args[0], flags)
case 'search':
return repoSearch(client, args.join(' '), flags)
case 'url':
return repoUrl(client, args[0], flags)
case 'storage':
return repoStorage(client, args[0], flags)
case 'clone':
return repoClone(args[0], flags)
case 'scan':
return repoScan(client, args[0], flags)
case 'import':
return repoImport(client, args[0], flags)
case 'import-all':
return repoImportAll(client, flags)
case 'migrate':
return repoMigrate(client, args[0], flags)
case 'create':
return repoCreate(args[0], flags)
case 'link':
// 2 args = git remote link, 1 arg = Autobase collab link
if (args[1]) return repoLink(client, args[0], args[1], flags)
return repoCollabLink(client, args[0], flags)
case 'join':
return repoCollabJoin(client, args[0], flags)
case 'mirror':
return repoCollabMirror(client, args[0], flags)
case 'peers':
return repoCollabPeers(client, args[0], flags)
case 'unlink':
return repoCollabUnlink(client, args[0], flags)
case 'set':
return repoSet(client, args[0], flags)
case 'delete':
case 'rm':
return repoDelete(client, args[0], flags)
default:
repoHelp()
}
}
async function repoList (client, flags) {
const repos = await client.get('/api/repos')
if (flags.json) {
jsonOut(repos)
return
}
table(repos, [
{ key: 'name', label: 'NAME', width: 30 },
{ key: 'commits', label: 'COMMITS' },
{ key: 'branches', label: 'BRANCHES' },
{ key: 'visibility', label: 'VISIBILITY' }
])
}
async function repoInfo (client, name, flags) {
if (!name) error('Usage: pgh repo info <name>')
const repos = await client.get('/api/repos')
const repo = repos.find(r => r.name === name)
if (!repo) error(`Repository "${name}" not found`)
if (flags.json) {
jsonOut(repo)
return
}
log(`Name: ${repo.name}`)
log(`Path: ${repo.path || '-'}`)
log(`Commits: ${repo.commits || 0}`)
log(`Branches: ${repo.branches || 0}`)
log(`Visibility: ${repo.visibility || 'public'}`)
log(`Owner: ${repo.owner || '-'}`)
log(`Category: ${repo.category || '-'}`)
log(`Namespace: ${repo.namespace || '-'}`)
if (repo.lastCommit) {
const lc = repo.lastCommit
if (typeof lc === 'object') {
log(`Last Commit: ${lc.hash ? lc.hash.slice(0, 8) : '-'} ${lc.message || ''} (${lc.author || ''})`)
} else {
log(`Last Commit: ${lc}`)
}
}
}
async function repoSet (client, name, flags) {
if (!name) return error('Usage: pgh repo set <name> --visibility <public|private|internal> [--category <cat>]')
const updates = {}
if (flags.visibility) updates.visibility = flags.visibility
if (flags.category) updates.category = flags.category
if (Object.keys(updates).length === 0) {
return error('Specify at least one flag: --visibility or --category')
}
const data = await client.patch(`/api/admin/repos/${encodeURIComponent(name)}`, updates)
if (data.error) return error(data.error)
log(`Updated ${name}:`)
if (data.repo.visibility) log(` visibility: ${data.repo.visibility}`)
if (data.repo.category) log(` category: ${data.repo.category}`)
}
async function repoSearch (client, query, flags) {
if (!query) error('Usage: pgh repo search <query>')
const data = await client.get(`/api/repos/search?q=${encodeURIComponent(query)}`)
if (flags.json) {
jsonOut(data)
return
}
if (!data.repos || data.repos.length === 0) {
log(`No repos matching "${query}"`)
return
}
table(data.repos, [
{ key: 'name', label: 'NAME', width: 30 },
{ key: 'commits', label: 'COMMITS' },
{ key: 'category', label: 'CATEGORY' },
{ key: 'relevance', label: 'RELEVANCE' }
])
}
async function repoUrl (client, name, flags) {
if (!name) error('Usage: pgh repo url <name>')
// Try new /urls endpoint first, fall back to /punch-url
let data
try {
data = await client.get(`/api/repos/${encodeURIComponent(name)}/urls`)
} catch {
data = await client.get(`/api/repos/${encodeURIComponent(name)}/punch-url`)
}
if (flags.json) {
jsonOut(data)
return
}
if (data.cloneUrl) log(`Clone (P2P): ${data.cloneUrl}`)
if (data.punchUrl) log(`Punch URL: ${data.punchUrl}`)
if (data.viewerUrl && data.viewerUrl !== data.punchUrl) log(`Viewer URL: ${data.viewerUrl}`)
if (!data.cloneUrl && !data.punchUrl && !data.viewerUrl) log('No P2P URLs available.')
}
async function repoStorage (client, name, flags) {
if (!name) error('Usage: pgh repo storage <name>')
const data = await client.get(`/api/repos/${encodeURIComponent(name)}/storage`)
if (flags.json) {
jsonOut(data)
return
}
for (const [key, val] of Object.entries(data)) {
log(`${key}: ${typeof val === 'object' ? JSON.stringify(val) : val}`)
}
}
async function repoClone (url, flags) {
if (!url) error('Usage: pgh repo clone <url>')
const proc = subprocess.spawn('git', ['clone', url])
proc.stdout.on('data', (d) => process.stdout.write(d))
proc.stderr.on('data', (d) => process.stderr.write(d))
await new Promise((resolve, reject) => {
proc.on('close', (code) => {
if (code === 0) resolve()
else reject(new Error(`git clone exited with code ${code}`))
})
})
}
async function repoScan (client, scanPath, flags) {
const query = scanPath ? `?path=${encodeURIComponent(scanPath)}` : ''
const res = await client._request('GET', `/api/scan${query}`)
const text = await res.text()
for (const line of text.split('\n')) {
if (!line.trim()) continue
try {
const obj = JSON.parse(line)
if (obj.type === 'repo') {
log(`Found: ${obj.repo.name} (${obj.repo.path})`)
} else if (obj.type === 'progress') {
// skip progress messages in non-json mode
}
if (flags.json) {
console.log(line)
}
} catch {}
}
}
async function repoImport (client, name, flags) {
if (!name) error('Usage: pgh repo import <name>')
const data = await client.post(`/api/repos/${encodeURIComponent(name)}/import`)
if (flags.json) {
jsonOut(data)
return
}
log(`Imported ${name}`)
if (data.driveKey) log(`Drive key: ${data.driveKey}`)
if (data.url) log(`URL: ${data.url}`)
}
async function repoImportAll (client, flags) {
const res = await client._request('GET', '/api/import-git-repos')
const text = await res.text()
for (const line of text.split('\n')) {
if (!line.trim()) continue
try {
const obj = JSON.parse(line)
if (obj.type === 'imported') {
log(`Imported: ${obj.name}`)
} else if (obj.type === 'error') {
console.error(`Error: ${obj.name}: ${obj.error}`)
} else if (obj.type === 'complete') {
log(`\nDone: ${obj.imported} imported, ${obj.errors} errors`)
}
if (flags.json) {
console.log(line)
}
} catch {}
}
}
/**
* Migrate all repos to a new directory
* Usage: pgh repo migrate <new-path> [--server URL]
*/
async function repoMigrate (client, newPath, flags) {
if (!newPath) {
error('Usage: pgh repo migrate <new-path>')
return
}
newPath = path.resolve(newPath)
log(`Migrating repos to ${newPath}...`)
const data = await client.post('/api/repos/migrate-path', { newPath })
if (flags.json) {
jsonOut(data)
return
}
if (data.error) {
error(data.error)
return
}
log(`\u2713 Migrated ${data.moved} repos to ${newPath}`)
if (data.skipped > 0) log(` Skipped: ${data.skipped} (not in old repos path)`)
if (data.errors && data.errors.length > 0) {
log(` Errors:`)
for (const e of data.errors) log(` - ${e}`)
}
}
/**
* Create a new repository via P2P admin protocol
*
* Usage: pgh repo create <name> [--server-key KEY] [--from-path PATH]
* [--visibility public|private|internal] [--category CAT]
*/
async function repoCreate (name, flags) {
if (!name) {
error('Usage: pgh repo create <name> --server-key <key> [--from-path <path>] [--visibility public|private] [--category <cat>]')
return
}
const serverKey = getServerKey(flags)
if (!serverKey) {
error('Server key required. Use --server-key <key> or set serverKey in ~/.config/pear-git/pgh.json')
return
}
const p2p = new P2PClient({ serverKey, timeout: 60000 })
try {
log(`Connecting to server ${serverKey.slice(0, 8)}...`)
await p2p.init()
const request = {
command: 'create-repo',
name,
fromPath: flags['from-path'] || flags.fromPath || null,
visibility: flags.visibility || 'public',
category: flags.category || null
}
const result = await p2p.command(request)
if (flags.json) {
jsonOut(result)
return
}
if (result.error) {
error(result.error)
return
}
log('')
log(`\u2713 Repository created: ${result.name}`)
log(` Path: ${result.path}`)
log(` Visibility: ${result.visibility}`)
log(` Commits: ${result.commits || 0}`)
log(` Branches: ${result.branches || 0}`)
if (result.punchUrl) {
log('')
log(` Clone: git clone ${result.punchUrl}`)
}
// Auto-link if created from a local path
const fromPath = flags['from-path'] || flags.fromPath
if (fromPath && result.path) {
try {
const absFrom = path.resolve(fromPath)
await gitExec(['remote', 'add', 'pear', result.path], absFrom)
log('')
log(` Linked: pear remote added to ${absFrom}`)
log(` Push with: git push pear`)
} catch {
// Remote may already exist — not an error
}
}
} catch (err) {
error(`Failed to create repo: ${err.message}`)
} finally {
await p2p.destroy()
}
}
async function repoLink (client, name, targetPath, flags) {
if (!name || !targetPath) {
error('Usage: pgh repo link <repo-name> <path-to-working-repo>')
return
}
targetPath = path.resolve(targetPath)
const repos = await client.get('/api/repos')
const repo = repos.find(r => r.name === name)
if (!repo) {
error(`Repository "${name}" not found on server`)
return
}
if (!repo.path) {
error(`Repository "${name}" has no local path on server`)
return
}
const barePath = repo.path
try {
const existing = await gitExec(['remote', 'get-url', 'pear'], targetPath)
if (existing === barePath) {
if (flags.json) {
jsonOut({ status: 'already-linked', remote: 'pear', url: barePath, path: targetPath })
return
}
log(`Already linked: pear -> ${barePath}`)
return
}
// Remote exists but points elsewhere — update it
await gitExec(['remote', 'set-url', 'pear', barePath], targetPath)
if (flags.json) {
jsonOut({ status: 'updated', remote: 'pear', url: barePath, path: targetPath })
return
}
log(`Updated remote: pear -> ${barePath}`)
} catch {
// Remote doesn't exist — add it
await gitExec(['remote', 'add', 'pear', barePath], targetPath)
if (flags.json) {
jsonOut({ status: 'linked', remote: 'pear', url: barePath, path: targetPath })
return
}
log(`Linked: pear -> ${barePath}`)
}
log(`Push with: git push pear`)
}
// ---------------------------------------------------------------------------
// Autobase Collaboration Commands
// ---------------------------------------------------------------------------
async function repoCollabLink (client, name, flags) {
if (!name) {
error('Usage: pgh repo link <name>')
return
}
const data = await client.post(`/api/repos/${encodeURIComponent(name)}/link`)
if (flags.json) {
jsonOut(data)
return
}
if (data.error) {
error(data.error)
return
}
log(`\u2713 Linked: ${name}`)
log(` Bootstrap key: ${data.bootstrapKey}`)
log(` Role: ${data.role}`)
if (data.invite) {
log('')
log(` Invite (share with collaborators):`)
log(` ${data.invite}`)
log('')
log(` Join: pgh repo join ${data.invite}`)
log(` Mirror: pgh repo mirror ${data.invite}`)
}
}
async function repoCollabJoin (client, invite, flags) {
if (!invite) {
error('Usage: pgh repo join <invite>')
return
}
log(`Joining via blind-pairing invite...`)
log(`(Note: full P2P join requires server mode. Using API for local join.)`)
const data = await client.post('/api/repos/_join', { invite, role: 'writer' }).catch(() => null)
if (data && data.error) {
error(data.error)
return
}
if (flags.json) {
jsonOut(data || { status: 'pending', invite })
return
}
if (data && data.success) {
log(`\u2713 Joined: ${data.repoName}`)
log(` Bootstrap key: ${data.bootstrapKey}`)
log(` Role: ${data.role}`)
} else {
log(`Invite submitted. Join will complete when owner is online.`)
}
}
async function repoCollabMirror (client, invite, flags) {
if (!invite) {
error('Usage: pgh repo mirror <invite>')
return
}
log(`Mirroring via blind-pairing invite...`)
const data = await client.post('/api/repos/_join', { invite, role: 'mirror' }).catch(() => null)
if (flags.json) {
jsonOut(data || { status: 'pending', invite, role: 'mirror' })
return
}
if (data && data.success) {
log(`\u2713 Mirroring: ${data.repoName}`)
log(` Bootstrap key: ${data.bootstrapKey}`)
} else {
log(`Mirror invite submitted. Will complete when owner is online.`)
}
}
async function repoCollabPeers (client, name, flags) {
if (!name) {
error('Usage: pgh repo peers <name>')
return
}
const data = await client.get(`/api/repos/${encodeURIComponent(name)}/peers`)
if (flags.json) {
jsonOut(data)
return
}
if (data.error) {
error(data.error)
return
}
if (!data.writers || data.writers.length === 0) {
log(`No peers for ${name}`)
return
}
table(data.writers, [
{ key: 'userId', label: 'USER', width: 20 },
{ key: 'role', label: 'ROLE', width: 10 },
{ key: 'serverKey', label: 'SERVER KEY', width: 20 }
])
}
async function repoCollabUnlink (client, name, flags) {
if (!name) {
error('Usage: pgh repo unlink <name>')
return
}
const data = await client.del(`/api/repos/${encodeURIComponent(name)}/unlink`)
if (flags.json) {
jsonOut(data)
return
}
if (data.error) {
error(data.error)
return
}
log(`\u2713 Unlinked: ${name}`)
}
async function repoDelete (client, name, flags) {
if (!name) error('Usage: pgh repo delete <name>')
const data = await client.delete(`/api/admin/repos/${encodeURIComponent(name)}`)
if (flags.json) {
jsonOut(data)
return
}
log(`Deleted ${name} from registry`)
}
export function repoHelp () {
log(`Usage: pgh repo <subcommand> [args] [flags]
Subcommands:
list List all repositories
info <name> Show repository details
search <query> Search repositories
url <name> Get P2P URLs (git+pear://, punch://)
storage <name> Show storage stats
clone <url> Clone a repository
scan [path] Scan for repositories
import <name> Import a repository
import-all Import all git repos
create <name> Create repo via P2P (requires --server-key)
migrate <path> Migrate repos to new directory
delete <name> Remove repo from registry (admin only)
link <name> <path> Add 'pear' remote to working repo
link <name> Create Autobase overlay + display invite
join <invite> Join linked repo via blind-pairing invite
mirror <invite> Join as read-only mirror
peers <name> List writers/mirrors for linked repo
unlink <name> Remove Autobase overlay
Create Flags:
--server-key KEY Server Noise public key (hex)
--from-path PATH Clone from existing local path
--visibility VIS public (default), private, or internal
--category CAT Repository category
Other Flags:
--json Output as JSON
--server URL Server URL (for HTTP commands)`)
}