/**
* Deploy Hook - Execution logic for deployments
*
* Called by CLI trigger, web UI button, or automatically after push.
*
* New flow (git-based):
* checkout → CI steps → commit to release branch → record in SharedDeployRegistry
*
* Legacy flow (Hyperdrive):
* checkout → ensure pear config → stage → release → seed → log
*
* The deploy mode is determined by repo.deploy.mode:
* 'git' (default) — new git-based release branch deploy
* 'legacy' — old Hyperdrive pear stage/release/seed
*/
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import subprocess from 'bare-subprocess'
import { loadCIConfig, runCISteps } from './ci-runner.js'
import { generateArchive } from '../archive.js'
import { readRefs } from '../git/ref-reader.js'
/**
* Broadcast ref-update for a repo over existing Protomux channels.
* Instant — no new P2P connection needed.
*/
export function broadcastRefs (server, repoName) {
_broadcastRefs(server, repoName)
}
function _broadcastRefs (server, repoName) {
if (!server.gitSyncManager) return
const serverKey = server.serverIdentity ? server.serverIdentity.getPublicKeyHex() : null
if (!serverKey) return
const repoPath = path.join(server.repoRegistry.gitReposPath, repoName + '.git')
readRefs(repoPath).then(refs => {
server.gitSyncManager.broadcastRefUpdate(repoName, refs, serverKey)
}).catch(() => {})
}
/**
* Run a command in a directory and wait for exit
*/
function execInDir (dir, cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
const proc = subprocess.spawn(cmd, args, {
cwd: dir,
stdio: ['ignore', 'pipe', 'pipe'],
env: opts.env || undefined
})
const chunks = []
const errChunks = []
const timeoutMs = opts.timeout || 120000
const timeout = setTimeout(() => {
proc.kill('SIGTERM')
reject(new Error(`Command timed out after ${timeoutMs / 1000}s: ${cmd} ${args.join(' ')}`))
}, timeoutMs)
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)
})
})
}
/**
* Execute a full deploy for a repo
* @param {PearGitServer} server - The server instance
* @param {string} repoName - Name of the repo to deploy
* @param {object} opts - { release, seed, legacy } overrides
* @returns {{ repoName, channel, commitHash, branch, timestamp, duration }}
*/
export async function executeDeploy (server, repoName, opts = {}) {
const repo = await server.repoRegistry.get(repoName)
if (!repo) throw new Error(`Repository "${repoName}" not found`)
if (!repo.deploy || !repo.deploy.enabled) {
throw new Error(`Deploy not configured for "${repoName}". Run: pgh deploy setup ${repoName} --channel <name>`)
}
// Deploy lock — prevent concurrent deploys of the same repo
if (!server._deployLocks) server._deployLocks = new Map()
if (server._deployLocks.has(repoName)) {
throw new Error(`Deploy already in progress for "${repoName}"`)
}
const config = repo.deploy
const timestamp = Date.now()
const useLegacy = opts.legacy || config.mode === 'legacy'
// Acquire lock before starting work (with 30-minute safety timeout)
const LOCK_TIMEOUT = 30 * 60 * 1000
const lockTimeout = setTimeout(() => {
console.error(`[Deploy] Lock timeout for ${repoName} after 30 minutes — releasing`)
server._deployLocks.delete(repoName)
}, LOCK_TIMEOUT)
const innerFn = useLegacy ? _executeLegacyDeploy : _executeGitDeploy
const lockPromise = innerFn(server, repo, repoName, config, timestamp, opts)
.finally(() => {
clearTimeout(lockTimeout)
server._deployLocks.delete(repoName)
})
server._deployLocks.set(repoName, lockPromise)
return lockPromise
}
// ========================================
// New Git-Based Deploy (release branch)
// ========================================
async function _executeGitDeploy (server, repo, repoName, config, timestamp, opts) {
console.log(`[Deploy] Starting git deploy for ${repoName} (channel: ${config.channel})`)
// Clone repo to working dir for CI steps
const storagePath = path.resolve(server.storage || './pear-git-storage')
const seedCheckoutsDir = path.join(storagePath, 'seed-checkouts')
try { await fs.promises.mkdir(seedCheckoutsDir, { recursive: true }) } catch {}
const checkoutDir = path.join(seedCheckoutsDir, repoName)
// Clean previous checkout if exists
try { await fs.promises.rm(checkoutDir, { recursive: true }) } catch {}
try {
await execInDir(storagePath, 'git', [
'clone', '--depth', '1', repo.path, checkoutDir
])
} catch (err) {
throw new Error(`Failed to checkout ${repoName}: ${err.message}`)
}
try {
// Build step: CI config or npm install fallback
let ciSteps = null
const ciConfig = loadCIConfig(checkoutDir)
if (ciConfig) {
console.log(`[Deploy] Found ci.yaml with ${ciConfig.steps.length} step(s) for ${repoName}`)
try {
ciSteps = await runCISteps(checkoutDir, ciConfig, { repoName, channel: config.channel })
console.log(`[Deploy] CI steps complete for ${repoName}`)
} catch (err) {
console.error(`[Deploy] CI steps FAILED for ${repoName}: ${err.message}`)
throw err
}
} else {
// Fallback: npm install --production
const pkgPath = path.join(checkoutDir, 'package.json')
let hasPackageJson = false
try {
await fs.promises.stat(pkgPath)
hasPackageJson = true
} catch {}
if (hasPackageJson) {
console.log(`[Deploy] Installing dependencies for ${repoName}...`)
try {
const npmOutput = await execInDir(checkoutDir, 'npm', ['install', '--production'])
if (npmOutput) console.log(`[Deploy] npm: ${npmOutput.trim().split('\n').pop()}`)
console.log(`[Deploy] Dependencies installed`)
} catch (err) {
console.error(`[Deploy] npm install FAILED for ${repoName}: ${err.message}`)
throw new Error(`npm install failed for ${repoName}: ${err.message}`)
}
}
}
// Commit built result to release branch in the bare repo
let commitHash
try {
console.log(`[Deploy] Committing to release branch...`)
const commitStart = Date.now()
commitHash = await _commitToReleaseBranch(repo.path, checkoutDir, repoName, config, timestamp)
const commitDuration = ((Date.now() - commitStart) / 1000).toFixed(1)
console.log(`[Deploy] Committed to release branch: ${commitHash.slice(0, 8)} (${commitDuration}s)`)
} catch (err) {
throw new Error(`Failed to commit release branch for ${repoName}: ${err.message}`)
}
// Pre-build release archives (tar.gz + zip)
try {
console.log(`[Deploy] Building archives...`)
const archiveStart = Date.now()
const archiveDir = path.join(storagePath, 'archives')
await generateArchive(repo.path, repoName, archiveDir, 'tar.gz')
await generateArchive(repo.path, repoName, archiveDir, 'zip')
const archiveDuration = ((Date.now() - archiveStart) / 1000).toFixed(1)
console.log(`[Deploy] Archives built for ${repoName} (${archiveDuration}s)`)
} catch (err) {
// Non-fatal — archives can be lazy-generated on first HTTP request
console.error(`[Deploy] Archive generation failed for ${repoName}:`, err.message)
}
// Record deploy in SharedDeployRegistry (if available)
if (server.deployRegistry) {
try {
const serverKey = server.serverIdentity
? server.serverIdentity.getPublicKeyHex()
: null
await server.deployRegistry.deployComplete(repoName, {
commitHash,
branch: 'release',
channel: config.channel,
serverKey
})
console.log(`[Deploy] Recorded in deploy registry`)
} catch (err) {
console.error(`[Deploy] Failed to record in deploy registry:`, err.message)
// Non-fatal — deploy succeeded even if registry write fails
}
}
const duration = Date.now() - timestamp
const result = {
repoName,
channel: config.channel,
commitHash,
branch: 'release',
mode: 'git',
ciSteps,
timestamp,
duration
}
// Log to Hyperbee (local deploy log, always available)
await server.registry.put(`deploy-log:${repoName}:${timestamp}`, result)
// Update repo metadata
repo.deploy.lastDeploy = timestamp
repo.deploy.lastCommitHash = commitHash
repo.deploy.lastBranch = 'release'
await server.repoRegistry.register(repo)
// Refresh in-memory repos
server.repos = await server.repoRegistry.listAll()
console.log(`[Deploy] Complete: ${repoName} ${commitHash.slice(0, 8)} in ${duration}ms`)
// Append ref-update to MetadataBase Autobase — replicated automatically
// to all federation peers. Monotonic version prevents stale reverts.
if (server.metadataBase && server.metadataBase.writable) {
try {
await server.metadataBase.append({
ns: 'deploy',
type: 'ref-update',
repoName,
ref: 'refs/heads/release',
sha: commitHash,
version,
serverKey: server.serverIdentity ? server.serverIdentity.getPublicKeyHex() : null,
timestamp
})
console.log(`[Deploy] Ref-update v${version} appended to Autobase`)
} catch (err) {
console.error(`[Deploy] Autobase ref-update failed: ${err.message}`)
}
}
// Broadcast ref-update over Protomux (fast-path notification)
_broadcastRefs(server, repoName)
// Also notify via browse protocol (fallback for peers not on driveSwarm)
notifyPeersOfDeploy(server, repoName).catch(err => {
console.error(`[Deploy] Peer notification error: ${err.message}`)
})
// If we just deployed ourselves (pear-git), graceful shutdown so sidecar restarts with new code
if (repoName === 'pear-git') {
console.log('[Deploy] Self-deploy detected — shutting down for sidecar restart')
setTimeout(async () => {
try {
if (server.close) await server.close()
} catch {}
process.exit(75)
}, 2000) // delay to let notification send
}
return result
} finally {
// Always cleanup checkout dir
try { await fs.promises.rm(checkoutDir, { recursive: true }) } catch {}
}
}
/**
* Commit the built checkout directory to the `release` branch in the bare repo.
*
* Uses a temporary work tree + GIT_DIR pointing to the bare repo.
* This avoids needing to push — we write directly to the bare repo.
*
* @param {string} bareRepoPath - Path to the bare git repo (e.g. ~/git-repos/app.git)
* @param {string} checkoutDir - Path to the built checkout (with node_modules etc.)
* @param {string} repoName - Repo name for commit message
* @param {object} config - Deploy config
* @param {number} timestamp - Deploy timestamp
* @returns {string} Commit hash
*/
async function _commitToReleaseBranch (bareRepoPath, checkoutDir, repoName, config, timestamp) {
const env = {
...process.env,
GIT_DIR: bareRepoPath,
GIT_WORK_TREE: checkoutDir,
GIT_AUTHOR_NAME: 'pear-git-deploy',
GIT_AUTHOR_EMAIL: 'deploy@pear-git',
GIT_COMMITTER_NAME: 'pear-git-deploy',
GIT_COMMITTER_EMAIL: 'deploy@pear-git'
}
// Get deploy version number from existing release branch
let version = 1
try {
const logOutput = await execInDir(checkoutDir, 'git', [
'--git-dir', bareRepoPath,
'log', '--oneline', '-1', 'release'
])
const match = logOutput.match(/deploy: v(\d+)/)
if (match) version = parseInt(match[1], 10) + 1
} catch {
// No release branch yet — starts at v1
}
// Check if release branch exists
let releaseBranchExists = false
try {
await execInDir(checkoutDir, 'git', [
'--git-dir', bareRepoPath,
'rev-parse', '--verify', 'refs/heads/release'
])
releaseBranchExists = true
} catch {}
// Create a new tree from the checkout contents
// Using git's index to build the tree, then creating the commit directly
const indexFile = path.join(bareRepoPath, `deploy-index-${repoName}`)
try { await fs.promises.unlink(indexFile) } catch {}
const indexEnv = {
...env,
GIT_INDEX_FILE: indexFile
}
// Add all files from the checkout to a temporary index
await execInDir(checkoutDir, 'git', [
'--git-dir', bareRepoPath,
'--work-tree', checkoutDir,
'add', '-A'
], { env: indexEnv, timeout: 300000 })
// Write tree from the index
const treeHash = (await execInDir(checkoutDir, 'git', [
'--git-dir', bareRepoPath,
'write-tree'
], { env: indexEnv })).trim()
// Build commit (with or without parent)
const hostname = os.hostname()
const commitMsg = `deploy: v${version} from ${hostname} at ${new Date(timestamp).toISOString()}`
const commitArgs = ['--git-dir', bareRepoPath, 'commit-tree', treeHash, '-m', commitMsg]
if (releaseBranchExists) {
// Parent is the current release branch tip
const parentHash = (await execInDir(checkoutDir, 'git', [
'--git-dir', bareRepoPath,
'rev-parse', 'refs/heads/release'
])).trim()
commitArgs.push('-p', parentHash)
}
const commitHash = (await execInDir(checkoutDir, 'git', commitArgs, { env })).trim()
// Update the release branch ref to point to the new commit
await execInDir(checkoutDir, 'git', [
'--git-dir', bareRepoPath,
'update-ref', 'refs/heads/release', commitHash
])
// Clean up temporary index
try { await fs.promises.unlink(indexFile) } catch {}
return commitHash
}
// ========================================
// Legacy Hyperdrive Deploy (pear stage/release/seed)
// ========================================
async function _executeLegacyDeploy (server, repo, repoName, config, timestamp, opts) {
console.log(`[Deploy] Starting legacy deploy for ${repoName} (channel: ${config.channel})`)
const wantSeed = config.autoSeed || opts.seed
// Clone repo to persistent dir for staging + seeding
const storagePath = path.resolve(server.storage || './pear-git-storage')
const seedCheckoutsDir = path.join(storagePath, 'seed-checkouts')
try { await fs.promises.mkdir(seedCheckoutsDir, { recursive: true }) } catch {}
const checkoutDir = path.join(seedCheckoutsDir, repoName)
// Clean previous checkout if exists
try { await fs.promises.rm(checkoutDir, { recursive: true }) } catch {}
try {
await execInDir(storagePath, 'git', [
'clone', '--depth', '1', repo.path, checkoutDir
])
} catch (err) {
throw new Error(`Failed to checkout ${repoName}: ${err.message}`)
}
// Ensure package.json has pear config (sidecar requires it for stage/seed)
await ensurePearConfig(fs, checkoutDir, config.channel)
// Build step: CI config or npm install fallback
let ciSteps = null
const ciConfig = loadCIConfig(checkoutDir)
if (ciConfig) {
console.log(`[Deploy] Found ci.yaml with ${ciConfig.steps.length} step(s) for ${repoName}`)
try {
ciSteps = await runCISteps(checkoutDir, ciConfig, { repoName, channel: config.channel })
console.log(`[Deploy] CI steps complete for ${repoName}`)
} catch (err) {
console.error(`[Deploy] CI steps FAILED for ${repoName}: ${err.message}`)
throw err
}
} else {
const pkgPath = path.join(checkoutDir, 'package.json')
let hasPackageJson = false
try {
await fs.promises.stat(pkgPath)
hasPackageJson = true
} catch {}
if (hasPackageJson) {
console.log(`[Deploy] Installing dependencies for ${repoName}...`)
try {
const npmOutput = await execInDir(checkoutDir, 'npm', ['install', '--production'])
if (npmOutput) console.log(`[Deploy] npm: ${npmOutput.trim().split('\n').pop()}`)
console.log(`[Deploy] Dependencies installed`)
} catch (err) {
console.error(`[Deploy] npm install FAILED for ${repoName}: ${err.message}`)
throw new Error(`npm install failed for ${repoName}: ${err.message}`)
}
}
}
try {
// Stage
console.log(`[Deploy] Staging ${repoName}...`)
const stageResult = await server.deployer.stage(checkoutDir, config.channel, { purge: true })
console.log(`[Deploy] Staged ${repoName} v${stageResult.version}`)
// Release (if configured or explicit)
let releaseResult = null
if (config.autoRelease || opts.release) {
console.log(`[Deploy] Releasing ${repoName}...`)
releaseResult = await server.deployer.release(config.channel)
console.log(`[Deploy] Released ${repoName} v${releaseResult.version}`)
}
// Seed (if configured or explicit)
let minimalSeedDir = null
if (wantSeed) {
console.log(`[Deploy] Seeding ${repoName}...`)
minimalSeedDir = await createMinimalSeedDir(fs, server.storage, repoName, config)
await server.deployer.unseed(config.channel)
await server.deployer.seed(config.channel, { dir: minimalSeedDir, key: stageResult.key })
console.log(`[Deploy] Seeding ${repoName}`)
}
const duration = Date.now() - timestamp
const result = {
repoName,
channel: config.channel,
version: releaseResult ? releaseResult.version : stageResult.version,
key: stageResult.key,
staged: stageResult,
released: releaseResult,
mode: 'legacy',
ciSteps,
seedDir: minimalSeedDir,
timestamp,
duration
}
// Log to Hyperbee
await server.registry.put(`deploy-log:${repoName}:${timestamp}`, result)
// Update repo metadata
repo.deploy.lastDeploy = timestamp
repo.deploy.lastVersion = result.version
repo.deploy.lastKey = stageResult.key
repo.deploy.seedDir = minimalSeedDir
await server.repoRegistry.register(repo)
// Refresh in-memory repos
server.repos = await server.repoRegistry.listAll()
console.log(`[Deploy] Complete: ${repoName} v${result.version} in ${duration}ms`)
return result
} finally {
// Always delete the full checkout — seed uses minimal dir instead
try {
await fs.promises.rm(checkoutDir, { recursive: true })
} catch {}
}
}
/**
* Create a minimal seed directory for long-lived seeding (legacy mode).
*/
async function createMinimalSeedDir (fs, storagePath, repoName, config) {
const deployDir = path.join(path.resolve(storagePath), 'deploy', repoName)
try { await fs.promises.mkdir(deployDir, { recursive: true }) } catch {}
const pkg = {
name: config.channel || repoName,
pear: { name: config.channel || repoName, type: config.pearType || 'terminal' }
}
await fs.promises.writeFile(
path.join(deployDir, 'package.json'),
JSON.stringify(pkg) + '\n'
)
return deployDir
}
/**
* Ensure package.json has pear config (legacy mode).
*/
async function ensurePearConfig (fs, dir, channel) {
const pkgPath = path.join(dir, 'package.json')
try {
const raw = await fs.promises.readFile(pkgPath, 'utf-8')
const pkg = JSON.parse(raw)
if (!pkg.pear) {
pkg.pear = { name: channel, type: 'terminal' }
await fs.promises.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
console.log(`[Deploy] Added pear config to package.json`)
}
} catch {
const pkg = {
name: channel,
version: '0.0.1',
pear: { name: channel, type: 'terminal' }
}
await fs.promises.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
console.log(`[Deploy] Created package.json with pear config`)
}
}
/**
* Trigger auto-deploy after a successful push (non-blocking, never throws).
*
* Checks repo.deploy.autoDeploy flag. Concurrent deploy protection
* is handled by executeDeploy itself via server._deployLocks.
*
* @param {PearGitServer} server
* @param {string} repoName
*/
export function autoDeployAfterPush (server, repoName) {
if (!server || !server.repoRegistry) return
// Fire-and-forget — check config then delegate to executeDeploy
server.repoRegistry.get(repoName).then(repo => {
if (!repo) return
// Always notify peers of the push (even if deploy not configured)
// Deploy-triggered notifications happen inside _executeGitDeploy;
// this covers repos that don't have deploy enabled.
if (!repo.deploy || !repo.deploy.enabled) {
_broadcastRefs(server, repoName)
notifyPeersOfDeploy(server, repoName).catch(() => {})
return
}
if (repo.deploy.autoDeploy === false) return
if (!repo.deploy.channel) return
console.log(`[AutoDeploy] Starting deploy for ${repoName}...`)
executeDeploy(server, repoName)
.then(result => {
const ver = result.commitHash ? result.commitHash.slice(0, 8) : result.version
console.log(`[AutoDeploy] ${repoName} ${ver} deployed in ${result.duration}ms`)
})
.catch(err => {
console.error(`[AutoDeploy] ${repoName} skipped:`, err.message)
})
}).catch(() => {})
}
/**
* Notify federation peers that a repo has new refs.
* Connects to each peer's __admin__ topic and sends sync-repo command.
* Fire-and-forget — failures are logged but don't block.
*/
export async function notifyPeersOfDeploy (server, repoName) {
if (!server.registry || !server.serverIdentity) return
// Build peer list from federation entries
const peers = []
const stream = server.registry.createReadStream({ gt: 'federation:', lt: 'federation:\xff' })
for await (const entry of stream) {
try {
const data = typeof entry.value === 'string' ? JSON.parse(entry.value) : entry.value
if (data.status === 'active' && data.serverKey) {
peers.push(data)
}
} catch {}
}
if (peers.length === 0) return
const selfKey = server.serverIdentity.getPublicKeyHex()
for (const peer of peers) {
if (peer.serverKey === selfKey) continue
_notifySinglePeer(peer, repoName, selfKey).catch(err => {
console.error(`[Deploy] Notify ${peer.serverKey.slice(0, 8)} failed: ${err.message}`)
})
}
}
async function _notifySinglePeer (peer, repoName, selfKey) {
const { TopicConnector } = await import('../p2p/discovery/topic-manager.js')
const connector = new TopicConnector({ timeout: 15000 })
await connector.init()
try {
console.log(`[Deploy] Connecting to ${peer.serverKey.slice(0, 8)} __admin__ topic...`)
const conn = await connector.connect(peer.serverKey, '__admin__')
console.log(`[Deploy] Connected to ${peer.serverKey.slice(0, 8)}, sending syncRepo...`)
try {
// Send browse header: pgh-browse\0__admin__\0
// Uses browse protocol (no auth) instead of admin (requires Noise key)
const header = Buffer.concat([
Buffer.from('pgh-browse', 'utf-8'),
Buffer.from([0]),
Buffer.from('__admin__', 'utf-8'),
Buffer.from([0])
])
conn.write(header)
// Send syncRepo browse command
const cmd = JSON.stringify({ command: 'syncRepo', repoName, serverKey: selfKey })
conn.write(Buffer.from(cmd + '\n'))
// Wait for response (with timeout)
const response = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('response timeout')), 30000)
let buf = Buffer.alloc(0)
conn.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk])
const nl = buf.indexOf(10)
if (nl !== -1) {
clearTimeout(timer)
try { resolve(JSON.parse(buf.slice(0, nl).toString())) }
catch { resolve({ error: 'invalid response' }) }
}
})
conn.on('error', (err) => { clearTimeout(timer); reject(err) })
conn.on('close', () => { clearTimeout(timer); reject(new Error('connection closed')) })
})
// Browse responses wrap result in { ok, data }; check inner result too
const innerResult = response.data || response
if (innerResult.ok || (innerResult.repoName && !innerResult.error)) {
console.log(`[Deploy] Notified ${peer.displayName || peer.serverKey.slice(0, 8)}: sync ${repoName} OK`)
} else {
const errMsg = innerResult.error || response.error || JSON.stringify(response)
console.error(`[Deploy] Notify ${peer.serverKey.slice(0, 8)} responded: ${errMsg}`)
}
} finally {
if (!conn.destroyed) conn.destroy()
}
} finally {
await connector.destroy()
}
}
/**
* Get deploy log entries for a repo.
* Checks SharedDeployRegistry first, falls back to local Hyperbee.
*/
export async function getDeployLog (server, repoName, opts = {}) {
// Try SharedDeployRegistry first (has cross-machine deploy history)
if (server.deployRegistry) {
try {
const entries = await server.deployRegistry.getDeployLog(repoName, opts)
if (entries.length > 0) return entries
} catch {}
}
// Fallback to local Hyperbee deploy log
const limit = opts.limit || 20
const entries = []
const prefix = `deploy-log:${repoName}:`
const stream = server.registry.createReadStream({
gte: prefix,
lte: prefix + '\xff',
reverse: true,
limit
})
for await (const entry of stream) {
entries.push(entry.value)
}
return entries
}