OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / packages / git-remote-pear / lib / petnames.js

191 lines · javascript ref:
/**
 * 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