/**
* pgh remote commands
*
* Petname management for pear-git servers.
* Stored locally in ~/.config/pear-git/pgh.json under "remotes".
*
* Subcommands: add, rm, list, rename, migrate-origins
*/
import fs from 'bare-fs'
import path from 'bare-path'
import { table, jsonOut, log, error } from '../output.js'
import { loadConfig, saveConfig } from '../config.js'
import { gitExec } from '../git.js'
export async function remoteCommand (client, sub, args, flags) {
switch (sub) {
case 'add':
return remoteAdd(args[0], args[1], flags)
case 'rm':
case 'remove':
return remoteRm(args[0], flags)
case 'list':
case 'ls':
return remoteList(flags)
case 'rename':
return remoteRename(args[0], args[1], flags)
case 'migrate-origins':
return remoteMigrateOrigins(args[0], flags)
default:
remoteHelp()
}
}
function remoteAdd (name, key, flags) {
if (!name || !key) error('Usage: pgh remote add <name> <key>')
const cfg = loadConfig()
if (!cfg.remotes) cfg.remotes = {}
if (cfg.remotes[name]) {
error(`Remote "${name}" already exists. Use "pgh remote rm ${name}" first.`)
}
cfg.remotes[name] = key
saveConfig(cfg)
log(`Added remote "${name}" → ${key.slice(0, 16)}...`)
}
function remoteRm (name, flags) {
if (!name) error('Usage: pgh remote rm <name>')
const cfg = loadConfig()
if (!cfg.remotes || !cfg.remotes[name]) {
error(`Remote "${name}" not found.`)
}
delete cfg.remotes[name]
saveConfig(cfg)
log(`Removed remote "${name}".`)
}
function remoteList (flags) {
const cfg = loadConfig()
const remotes = cfg.remotes || {}
const entries = Object.entries(remotes)
if (flags.json) {
jsonOut(remotes)
return
}
if (entries.length === 0) {
log('No remotes configured.')
log('Add one: pgh remote add <name> <server-key>')
return
}
table(entries.map(([name, key]) => ({
name,
key: key.length > 40 ? key.slice(0, 16) + '...' + key.slice(-8) : key
})), [
{ key: 'name', label: 'NAME', width: 20 },
{ key: 'key', label: 'KEY', width: 30 }
])
}
function remoteRename (oldName, newName, flags) {
if (!oldName || !newName) error('Usage: pgh remote rename <old> <new>')
const cfg = loadConfig()
if (!cfg.remotes || !cfg.remotes[oldName]) {
error(`Remote "${oldName}" not found.`)
}
if (cfg.remotes[newName]) {
error(`Remote "${newName}" already exists.`)
}
cfg.remotes[newName] = cfg.remotes[oldName]
delete cfg.remotes[oldName]
saveConfig(cfg)
log(`Renamed remote "${oldName}" → "${newName}".`)
}
/**
* Bulk-update git remote URLs across working directories.
* Scans for .git repos, finds remotes matching --from prefix, replaces with --to prefix.
*
* Usage: pgh remote migrate-origins <dir> --from <prefix> --to <prefix> [--dry-run]
*/
async function remoteMigrateOrigins (dir, flags) {
const from = flags.from
const to = flags.to
const dryRun = flags['dry-run'] || flags.dryRun
if (!dir || !from || !to) {
error('Usage: pgh remote migrate-origins <dir> --from <prefix> --to <prefix> [--dry-run]')
return
}
dir = path.resolve(dir)
log(`Scanning ${dir} for git repos...`)
const gitDirs = await findGitRepos(fs, path, dir, 5)
let updated = 0
let skipped = 0
let errors = 0
for (const repoDir of gitDirs) {
try {
const output = await gitExec(['remote', '-v'], repoDir)
const lines = output.split('\n')
const seen = new Set() // avoid duplicating fetch/push remotes
let repoHadMatch = false
for (const line of lines) {
if (!line.trim()) continue
const parts = line.split(/\s+/)
const remoteName = parts[0]
const url = parts[1]
if (!url || !url.startsWith(from) || seen.has(remoteName)) continue
seen.add(remoteName)
// Compute new URL: replace prefix, keep the suffix (e.g., /repo-name)
const suffix = url.slice(from.length)
const newUrl = to + suffix
const relPath = path.relative(dir, repoDir) || '.'
if (dryRun) {
log(` ${relPath}: ${remoteName} ${url} → ${newUrl}`)
} else {
await gitExec(['remote', 'set-url', remoteName, newUrl], repoDir)
log(` ${relPath}: ${remoteName} ${url} → ${newUrl}`)
}
repoHadMatch = true
updated++
}
if (!repoHadMatch) skipped++
} catch (err) {
errors++
}
}
if (dryRun) {
log(`\nDry run: would update ${updated} remotes in ${gitDirs.length} repos (${skipped} skipped)`)
} else {
log(`\n\u2713 Updated ${updated} remotes in ${gitDirs.length - skipped} repos (${skipped} skipped${errors > 0 ? `, ${errors} errors` : ''})`)
}
}
/**
* Recursively find directories containing .git/ (max depth)
*/
async function findGitRepos (fs, path, dir, maxDepth, depth = 0) {
if (depth > maxDepth) return []
const results = []
let entries
try {
entries = await fs.promises.readdir(dir)
} catch {
return results
}
// Check if this dir is a git repo
if (entries.includes('.git')) {
results.push(dir)
return results // Don't recurse into nested git repos
}
// Recurse into subdirectories
for (const entry of entries) {
if (entry === 'node_modules' || entry === '.git' || entry.startsWith('.')) continue
const fullPath = path.join(dir, entry)
try {
const stat = await fs.promises.stat(fullPath)
if (stat.isDirectory()) {
const subResults = await findGitRepos(fs, path, fullPath, maxDepth, depth + 1)
results.push(...subResults)
}
} catch {
// Skip inaccessible dirs
}
}
return results
}
export function remoteHelp () {
log(`Usage: pgh remote <subcommand> [args]
Subcommands:
add <name> <key> Add a named remote (petname)
rm <name> Remove a remote
list List all remotes
rename <old> <new> Rename a remote
migrate-origins <dir> [flags] Bulk-update git remote URLs
Migrate-Origins Flags:
--from <prefix> URL prefix to match (required)
--to <prefix> Replacement URL prefix (required)
--dry-run Preview changes without applying
Examples:
pgh remote add tabertron abc123def456...
pgh remote list
pgh remote rename tabertron myserver
# Bulk-update origins after server key change
pgh remote migrate-origins ~/projects \\
--from punch://3cf3266a --to punch://abc123newkey
# Switch from raw key to petname
pgh remote migrate-origins ~/projects \\
--from punch://3cf3266a --to pear://tabertron
# Preview first
pgh remote migrate-origins ~/projects \\
--from punch://oldKey --to punch://newKey --dry-run`)
}