OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / ci / deploy-hook.js

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