/**
* 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