/**
* Petname Manager for Pear-Git P2P URLs
*
* Provides local resolution of human-memorable names to server public keys.
* Petnames are NOT globally portable - they are local mappings that users
* create after receiving canonical key-based URLs.
*
* Example:
* Canonical: pear://zupx3qzpwj61hdt1bxanabctcyxqqu3arogctwim4qt3wfbzn4bo/wirecore
* Local: pear://tabertron/wirecore (after adding petname)
*/
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
export class PetnameManager {
constructor(options = {}) {
const homeDir = os.homedir()
this.configDir = options.configDir || path.join(homeDir, '.pear-git')
this.remotesFile = path.join(this.configDir, 'remotes.json')
this.remotes = {}
}
/**
* Initialize the petname manager
* Creates config directory and loads existing petnames
*/
async init() {
// Ensure config directory exists
try {
await fs.promises.mkdir(this.configDir, { recursive: true })
} catch (err) {
if (err.code !== 'EEXIST') throw err
}
// Load existing remotes
try {
const data = await fs.promises.readFile(this.remotesFile, 'utf-8')
this.remotes = JSON.parse(data)
} catch (err) {
if (err.code !== 'ENOENT') throw err
this.remotes = {}
}
}
/**
* Add a petname for a server public key
* @param {string} name - Human-memorable name (e.g., "tabertron")
* @param {string} key - Server public key (64 char hex)
* @param {Object} metadata - Optional metadata (description, etc.)
*/
async add(name, key, metadata = {}) {
if (!this._isValidPetname(name)) {
throw new Error(`Invalid petname: "${name}". Use lowercase letters, numbers, and hyphens.`)
}
if (!this._isValidKey(key)) {
throw new Error(`Invalid key: "${key}". Must be 64 character hex string.`)
}
this.remotes[name] = {
key: key.toLowerCase(),
addedAt: Date.now(),
...metadata
}
await this._save()
return { name, key: this.remotes[name].key }
}
/**
* Remove a petname
* @param {string} name - Petname to remove
*/
async remove(name) {
if (!this.remotes[name]) {
throw new Error(`Petname "${name}" not found`)
}
delete this.remotes[name]
await this._save()
}
/**
* Resolve a petname to its public key
* @param {string} name - Petname to resolve
* @returns {string|null} - Public key or null if not found
*/
resolve(name) {
const entry = this.remotes[name]
return entry ? entry.key : null
}
/**
* List all petnames
* @returns {Array} - Array of { name, key, addedAt, ... }
*/
list() {
return Object.entries(this.remotes).map(([name, data]) => ({
name,
...data
}))
}
/**
* Parse a pear:// URL and resolve petnames
* @param {string} url - pear://key/repo or pear://petname/repo
* @returns {Object} - { key, repo, isPetname }
*/
parseUrl(url) {
if (!url.startsWith('pear://')) {
throw new Error(`Invalid pear URL: "${url}". Must start with pear://`)
}
const withoutProtocol = url.slice(7) // Remove "pear://"
const slashIndex = withoutProtocol.indexOf('/')
if (slashIndex === -1) {
throw new Error(`Invalid pear URL: "${url}". Must include repo name (pear://key/repo)`)
}
const keyOrPetname = withoutProtocol.slice(0, slashIndex)
const repo = withoutProtocol.slice(slashIndex + 1)
if (!repo) {
throw new Error(`Invalid pear URL: "${url}". Missing repo name`)
}
// Check if it's a full key (64 hex chars) or a petname
if (this._isValidKey(keyOrPetname)) {
return {
key: keyOrPetname.toLowerCase(),
repo,
isPetname: false
}
}
// Try to resolve as petname
const resolvedKey = this.resolve(keyOrPetname)
if (!resolvedKey) {
throw new Error(`Unknown petname: "${keyOrPetname}". Add it with: pear-git remote add ${keyOrPetname} <key>`)
}
return {
key: resolvedKey,
repo,
isPetname: true,
petname: keyOrPetname
}
}
/**
* Format a canonical pear URL from key and repo
* @param {string} key - Server public key
* @param {string} repo - Repository name
* @returns {string} - pear://key/repo
*/
formatUrl(key, repo) {
return `pear://${key}/${repo}`
}
/**
* Format a short display URL (truncated key)
* @param {string} key - Server public key
* @param {string} repo - Repository name
* @returns {string} - pear://abc123.../repo
*/
formatShortUrl(key, repo) {
const shortKey = key.slice(0, 8) + '...'
return `pear://${shortKey}/${repo}`
}
// === Private Methods ===
_isValidPetname(name) {
return /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/.test(name) || /^[a-z0-9]$/.test(name)
}
_isValidKey(key) {
return /^[a-f0-9]{64}$/i.test(key)
}
async _save() {
const data = JSON.stringify(this.remotes, null, 2)
await fs.promises.writeFile(this.remotesFile, data, 'utf-8')
}
}
export default PetnameManager