OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / ci / pear-git-updater.js

224 lines · javascript ref:
/**
 * PearGitUpdater — OTA update library for apps deployed via pear-git
 *
 * Drop-in replacement for pear-runtime-updater. Instead of polling a
 * Hyperdrive for new releases, it:
 *   1. Periodically runs `git fetch origin release` to check for new commits
 *   2. Compares local HEAD with origin/release
 *   3. If changed: resets to origin/release (fast-forward pull)
 *   4. Emits 'updated' → app restarts (same pattern as pear-runtime)
 *
 * The app directory must be a git clone of the release branch from a
 * pear-git server. ServerSync ensures release branches propagate across
 * all linked pear-git instances, so any server can serve updates.
 *
 * Usage:
 *   import { PearGitUpdater } from 'pear-git-updater'
 *
 *   const updater = new PearGitUpdater({
 *     appDir: __dirname,
 *     repoName: 'hyper-note',
 *     checkInterval: 60000
 *   })
 *
 *   updater.on('update-available', ({ commitHash }) => { ... })
 *   updater.on('updated', () => { process.exit(0) })
 *   updater.on('error', (err) => { ... })
 *
 *   await updater.start()
 */

import { EventEmitter } from 'bare-events'
import fs from 'bare-fs'
import path from 'bare-path'
import subprocess from 'bare-subprocess'

export class PearGitUpdater extends EventEmitter {
  /**
   * @param {Object} opts
   * @param {string} opts.appDir - Local app directory (must be a git checkout)
   * @param {string} opts.repoName - App name in deploy registry
   * @param {number} [opts.checkInterval=60000] - Poll interval in ms
   * @param {boolean} [opts.autoApply=false] - Auto-apply updates without waiting for apply() call
   */
  constructor (opts = {}) {
    super()

    this.appDir = opts.appDir
    this.repoName = opts.repoName
    this.checkInterval = opts.checkInterval || 60000
    this.autoApply = opts.autoApply || false

    this._currentHash = null
    this._timer = null
    this._checking = false
    this._started = false
  }

  /**
   * Start checking for updates
   */
  async start () {
    if (this._started) return
    this._started = true

    // Read current commit hash from the git repo
    try {
      this._currentHash = await this._getLocalHash()
    } catch {
      // Not a git repo or no commits — will update on first check
    }

    // Also try reading from .pear-git-updater.json
    if (!this._currentHash) {
      try {
        const configPath = path.join(this.appDir, '.pear-git-updater.json')
        const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
        this._currentHash = config.lastCommitHash || null
      } catch {}
    }

    // Start periodic checks
    this._timer = setInterval(() => this._check(), this.checkInterval)

    // First check after short delay
    setTimeout(() => this._check(), 5000)
  }

  /**
   * Stop checking for updates
   */
  stop () {
    if (this._timer) {
      clearInterval(this._timer)
      this._timer = null
    }
    this._started = false
  }

  /**
   * Apply a pending update (git pull release branch)
   */
  async apply () {
    this.emit('updating')

    try {
      await this._gitPull()

      // Update stored hash
      const newHash = await this._getLocalHash()
      this._currentHash = newHash

      // Update config file
      try {
        const configPath = path.join(this.appDir, '.pear-git-updater.json')
        let config = {}
        try {
          config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
        } catch {}
        config.lastCommitHash = newHash
        config.lastUpdated = Date.now()
        fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n')
      } catch {}

      this.emit('updated')
    } catch (err) {
      this.emit('error', err)
    }
  }

  // ========================================
  // Internal
  // ========================================

  async _check () {
    if (this._checking) return
    this._checking = true

    try {
      // Fetch latest from remote (non-destructive, just updates refs)
      await this._gitFetch()

      // Compare local HEAD with remote release branch
      const remoteHash = await this._getRemoteHash()
      if (!remoteHash) return

      if (this._currentHash && remoteHash !== this._currentHash) {
        this.emit('update-available', {
          commitHash: remoteHash,
          currentHash: this._currentHash
        })

        if (this.autoApply) {
          await this.apply()
        }
      } else if (!this._currentHash) {
        // First run, set current hash
        this._currentHash = remoteHash
      }
    } catch (err) {
      this.emit('error', err)
    } finally {
      this._checking = false
    }
  }

  async _getLocalHash () {
    const output = await this._exec('git', ['rev-parse', 'HEAD'])
    return output.trim()
  }

  async _getRemoteHash () {
    try {
      const output = await this._exec('git', ['rev-parse', 'origin/release'])
      return output.trim()
    } catch {
      return null
    }
  }

  async _gitFetch () {
    await this._exec('git', ['fetch', 'origin', 'release', '--depth', '1'])
  }

  async _gitPull () {
    // Reset to remote release branch (handles force-pushed release branches)
    await this._exec('git', ['reset', '--hard', 'origin/release'])
  }

  _exec (cmd, args) {
    return new Promise((resolve, reject) => {
      const proc = subprocess.spawn(cmd, args, {
        cwd: this.appDir,
        stdio: ['ignore', 'pipe', 'pipe']
      })

      const chunks = []
      const errChunks = []

      const timeout = setTimeout(() => {
        proc.kill('SIGTERM')
        reject(new Error(`${cmd} timed out after 60s`))
      }, 60000)

      proc.stdout.on('data', (c) => chunks.push(c))
      proc.stderr.on('data', (c) => errChunks.push(c))

      proc.on('exit', (code) => {
        clearTimeout(timeout)
        if (code === 0) {
          resolve(Buffer.concat(chunks).toString('utf-8'))
        } else {
          reject(new Error(Buffer.concat(errChunks).toString('utf-8') || `${cmd} exited with code ${code}`))
        }
      })
      proc.on('error', (err) => {
        clearTimeout(timeout)
        reject(err)
      })
    })
  }
}

export default PearGitUpdater