Complete roadmap optionals: Holesail, Swarm UI, GitOps, virtualization.
CI / test (push) Successful in 9m58s

Add tunnel persistence and container one-click tunnels with local
Holesail client bind, Swarm services/nodes/tasks view, GitOps stack
sync from git, deploy wizard step chrome, virtualized container lists,
and structure regression tests. Mark Tracks A–D and optional future done.
This commit is contained in:
2026-07-10 22:53:30 -04:00
parent 573298b836
commit 02f25e0981
26 changed files with 2458 additions and 121 deletions
+3
View File
@@ -23,7 +23,10 @@ const AUDIT_METHODS = new Set([
'removeImage',
'removeStack',
'deployStack',
'syncStackFromGit',
'deployContainer',
'createTunnel',
'closeTunnel',
'createContainer',
'buildImage',
'pushImage',
+43
View File
@@ -5,6 +5,8 @@ import * as composeManager from '../utils/composeManager.js'
import * as validation from '../utils/validation.js'
import { docker } from '../services/docker.js'
import { broadcastContainers } from './containers.js'
import { fetchComposeFromGit } from '../utils/gitops.js'
import logger from '../utils/logger.js'
export function registerStackHandlers(session) {
session.respond('deployStack', async (args) => {
@@ -70,4 +72,45 @@ export function registerStackHandlers(session) {
composeContent: args.composeContent,
})
})
/**
* GitOps: fetch compose from git and deploy.
* args: { stackName, repoUrl, ref?, composePath?, build?, rollback? }
*/
session.respond('syncStackFromGit', async (args = {}) => {
const stackName = validation.sanitizeString(args.stackName, 63)
if (!stackName || !validation.isValidContainerName(stackName)) {
throw new Error('Valid stackName required')
}
if (!args.repoUrl) throw new Error('repoUrl required')
logger.info('GitOps sync starting', {
stackName,
repo: String(args.repoUrl).slice(0, 80),
ref: args.ref || 'main',
})
const fetched = await fetchComposeFromGit({
repoUrl: args.repoUrl,
ref: args.ref,
composePath: args.composePath,
})
composeManager.validateComposeFile(fetched.composeContent)
const result = await composeManager.deployComposeStack(
docker,
fetched.composeContent,
stackName,
{
build: Boolean(args.build),
rollback: args.rollback !== false,
}
)
await broadcastContainers()
return {
success: true,
message: `Stack "${stackName}" deployed from git${fetched.commit ? ` @ ${fetched.commit.slice(0, 8)}` : ''}`,
commit: fetched.commit || null,
composePath: fetched.path,
...result,
}
})
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Holesail tunnel RPC handlers.
* Gated by ENABLE_HOLESAIL=1.
*/
import {
isHolesailEnabled,
isHolesailAvailable,
listTunnels,
getTunnel,
createTunnel,
closeTunnel,
holesailStatus,
} from '../services/holesail-tunnels.js'
import { docker } from '../services/docker.js'
import * as validation from '../utils/validation.js'
import logger from '../utils/logger.js'
function assertHolesail() {
if (!isHolesailEnabled()) {
const err = new Error('Holesail tunnels disabled. Set ENABLE_HOLESAIL=1 to enable.')
err.code = 'FEATURE_DISABLED'
throw err
}
if (!isHolesailAvailable()) {
const err = new Error(
'Holesail package is not available on this server. Install dependency "holesail".'
)
err.code = 'FEATURE_DISABLED'
throw err
}
}
/**
* Resolve a published container port to host bind address.
* @param {string} containerId
* @param {number} containerPort
* @param {'tcp'|'udp'} [protocol]
*/
async function resolveContainerPublish(containerId, containerPort, protocol = 'tcp') {
const id = validation.sanitizeString(containerId, 128)
if (!id) throw Object.assign(new Error('containerId required'), { code: 'INVALID_ARGS' })
const c = docker.getContainer(id)
const inspect = await c.inspect()
const ports = inspect?.NetworkSettings?.Ports || {}
const key = `${containerPort}/${protocol}`
const bindings = ports[key]
if (!bindings?.length) {
throw Object.assign(
new Error(
`Container has no published binding for ${key}. Publish the port (e.g. -p 8080:${containerPort}) first.`
),
{ code: 'INVALID_ARGS' }
)
}
const b = bindings[0]
let host = b.HostIp || '127.0.0.1'
// Docker often uses 0.0.0.0 for "all interfaces" — tunnel to loopback is safer/default
if (!host || host === '0.0.0.0' || host === '::') host = '127.0.0.1'
const hostPort = parseInt(b.HostPort, 10)
if (!hostPort) {
throw Object.assign(new Error(`Invalid host port mapping for ${key}`), {
code: 'INVALID_ARGS',
})
}
const name = (inspect.Name || '').replace(/^\//, '') || id.slice(0, 12)
return {
host,
port: hostPort,
containerId: inspect.Id,
containerName: name,
}
}
export function registerTunnelHandlers(session) {
// Always register so clients get FEATURE_DISABLED when off.
session.respond('listTunnels', async () => {
assertHolesail()
return {
success: true,
tunnels: listTunnels(),
status: holesailStatus(),
}
})
session.respond('getHolesailStatus', async () => {
return {
success: true,
status: holesailStatus(),
}
})
session.respond('createTunnel', async (args = {}) => {
assertHolesail()
const protocol = args.protocol === 'udp' ? 'udp' : 'tcp'
let host = args.host
let port = args.port
let containerId = args.containerId || null
let containerName = args.containerName || null
if (args.containerId && args.containerPort != null) {
const resolved = await resolveContainerPublish(
args.containerId,
Number(args.containerPort),
protocol
)
host = resolved.host
port = resolved.port
containerId = resolved.containerId
containerName = resolved.containerName
}
if (port == null) {
throw Object.assign(new Error('port is required (or containerId + containerPort)'), {
code: 'INVALID_ARGS',
})
}
const tunnel = await createTunnel({
name: args.name,
host,
port: Number(port),
protocol,
secure: args.secure !== false,
containerId,
containerName,
})
logger.info('Tunnel created via RPC', {
id: tunnel.id,
peerId: session.id?.slice?.(0, 12),
name: tunnel.name,
})
return { success: true, tunnel }
})
session.respond('closeTunnel', async (args = {}) => {
assertHolesail()
const id = validation.sanitizeString(args.id || args.tunnelId, 64)
if (!id) throw Object.assign(new Error('id required'), { code: 'INVALID_ARGS' })
const existing = getTunnel(id)
if (!existing) {
throw Object.assign(new Error(`Tunnel not found: ${id}`), { code: 'INVALID_ARGS' })
}
await closeTunnel(id)
return { success: true, id, closed: true }
})
session.respond('getTunnel', async (args = {}) => {
assertHolesail()
const id = validation.sanitizeString(args.id || args.tunnelId, 64)
if (!id) throw Object.assign(new Error('id required'), { code: 'INVALID_ARGS' })
const tunnel = getTunnel(id)
if (!tunnel) {
throw Object.assign(new Error(`Tunnel not found: ${id}`), { code: 'INVALID_ARGS' })
}
return { success: true, tunnel }
})
}
+2
View File
@@ -18,6 +18,7 @@ import { registerPeerHandlers } from '../handlers/peers.js'
import { registerVaultHandlers } from '../handlers/vault.js'
import { registerBinaryStreamHandlers } from './binary-stream.js'
import { registerSuggestionHandlers } from '../handlers/suggestions.js'
import { registerTunnelHandlers } from '../handlers/tunnels.js'
/**
* @param {import('./session.js').PeerSession} session
@@ -40,6 +41,7 @@ export function registerAllHandlers(session) {
registerVaultHandlers(session)
registerBinaryStreamHandlers(session)
registerSuggestionHandlers(session)
registerTunnelHandlers(session)
}
/**
+26
View File
@@ -16,6 +16,12 @@ import { startDockerEventStream, stopDockerEventStream } from './services/events
import { startStatsBroadcast, stopStatsBroadcast } from './services/stats.js'
import { isPeerRevoked } from './core/peer-policy.js'
import { recordPeerConnect, recordPeerDisconnect } from './services/metrics.js'
import {
closeAllTunnels,
isHolesailEnabled,
holesailStatus,
restoreTunnelsFromDisk,
} from './services/holesail-tunnels.js'
import logger from './utils/logger.js'
const { keyPair, publicKeyHex } = loadOrCreateKeyPair()
@@ -64,16 +70,36 @@ console.log('══════════════════════
console.log(' peardock server ready')
console.log(` Public key (paste into the client):`)
console.log(` ${publicKeyHex}`)
if (isHolesailEnabled()) {
const hs = holesailStatus()
console.log(
` Holesail tunnels: ${hs.available ? 'enabled' : 'enabled but package missing'} (max ${hs.max})`
)
} else {
console.log(' Holesail tunnels: off (set ENABLE_HOLESAIL=1 to enable)')
}
console.log('═══════════════════════════════════════════════════════════')
console.log('')
startDockerEventStream()
startStatsBroadcast()
// Recreate persisted Holesail tunnels (same hs:// keys when possible)
if (isHolesailEnabled()) {
restoreTunnelsFromDisk().catch((err) => {
logger.warn('Tunnel restore failed', { error: err.message })
})
}
async function shutdown() {
console.log('[INFO] Server shutting down…')
stopStatsBroadcast()
stopDockerEventStream()
try {
await closeAllTunnels()
} catch {
// ignore
}
peers.clear()
try {
await server.close()
+430
View File
@@ -0,0 +1,430 @@
/**
* Holesail tunnel manager with optional disk persistence.
*
* peardock control plane: HyperDHT + protomux-rpc
* Data plane tunnels: Holesail L4 TCP/UDP reverse proxy (hs:// keys)
*
* Gated by ENABLE_HOLESAIL=1. Package: holesail (AGPL-3.0).
*
* @see https://github.com/holesail/holesail
* @see docs/HOLESAIL.md
*/
import { createRequire } from 'module'
import { randomBytes } from 'crypto'
import fs from 'fs'
import path from 'path'
import logger from '../utils/logger.js'
const require = createRequire(import.meta.url)
/** @typedef {{
* id: string,
* name: string,
* host: string,
* port: number,
* protocol: 'tcp'|'udp',
* secure: boolean,
* url: string,
* key: string,
* publicKey?: string,
* containerId?: string|null,
* containerName?: string|null,
* createdAt: string,
* state: string,
* persist?: boolean,
* }} TunnelInfo */
const MAX_TUNNELS = Math.max(1, Number(process.env.PEARDOCK_MAX_TUNNELS || 20))
const DEFAULT_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '0.0.0.0'])
function persistPath() {
return (
process.env.PEARDOCK_TUNNELS_PATH || path.join(process.cwd(), 'peardock-tunnels.json')
)
}
/** @type {Map<string, { instance: any, info: TunnelInfo }>} */
const tunnels = new Map()
let HolesailCtor = null
let holesailLoadError = null
let restoreDone = false
function loadHolesail() {
if (HolesailCtor) return HolesailCtor
if (holesailLoadError) throw holesailLoadError
try {
HolesailCtor = require('holesail')
return HolesailCtor
} catch (err) {
holesailLoadError = err
throw err
}
}
export function isHolesailEnabled() {
return process.env.ENABLE_HOLESAIL === '1' || process.env.ENABLE_HOLESAIL === 'true'
}
export function isHolesailAvailable() {
try {
loadHolesail()
return true
} catch {
return false
}
}
export function getTunnelsPersistPath() {
return persistPath()
}
/**
* @returns {Set<string>}
*/
export function allowedTunnelHosts() {
const set = new Set(DEFAULT_HOSTS)
const extra = process.env.PEARDOCK_TUNNEL_HOSTS || ''
for (const part of extra.split(/[,\s]+/)) {
const h = part.trim().toLowerCase()
if (h) set.add(h)
}
return set
}
/**
* @param {string} host
* @param {number} port
*/
export function assertTunnelTarget(host, port) {
const h = String(host || '127.0.0.1').trim().toLowerCase()
const p = Number(port)
if (!Number.isInteger(p) || p < 1 || p > 65535) {
throw Object.assign(new Error('port must be an integer 165535'), { code: 'INVALID_ARGS' })
}
const allowed = allowedTunnelHosts()
if (!allowed.has(h)) {
throw Object.assign(
new Error(
`Host "${host}" is not allowed for tunnels. Allowed: ${[...allowed].join(', ')}. ` +
'Extend with PEARDOCK_TUNNEL_HOSTS=host1,host2'
),
{ code: 'PERMISSION_DENIED' }
)
}
return { host: h === 'localhost' ? '127.0.0.1' : host, port: p }
}
function newId() {
return `tnl_${Date.now().toString(36)}_${randomBytes(4).toString('hex')}`
}
/**
* Persistable definitions (no live sockets). Keys are capabilities — file mode 600.
* @returns {object[]}
*/
function buildPersistPayload() {
return [...tunnels.values()]
.filter((t) => t.info.persist !== false)
.map((t) => ({
id: t.info.id,
name: t.info.name,
host: t.info.host,
port: t.info.port,
protocol: t.info.protocol,
secure: t.info.secure,
key: t.info.key,
containerId: t.info.containerId || null,
containerName: t.info.containerName || null,
createdAt: t.info.createdAt,
persist: true,
}))
}
export function saveTunnelsToDisk() {
if (!isHolesailEnabled()) return false
try {
const payload = {
version: 1,
updatedAt: new Date().toISOString(),
tunnels: buildPersistPayload(),
}
const file = persistPath()
const tmp = `${file}.${process.pid}.tmp`
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { encoding: 'utf8', mode: 0o600 })
fs.renameSync(tmp, file)
try {
fs.chmodSync(file, 0o600)
} catch {
// ignore
}
return true
} catch (err) {
logger.warn('Failed to persist tunnels', { error: err.message })
return false
}
}
/**
* Read definitions from disk (does not start tunnels).
* @returns {object[]}
*/
export function loadTunnelDefsFromDisk() {
try {
const file = persistPath()
if (!fs.existsSync(file)) return []
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
const list = Array.isArray(raw?.tunnels) ? raw.tunnels : Array.isArray(raw) ? raw : []
return list.filter((t) => t && t.port)
} catch (err) {
logger.warn('Failed to read tunnel persist file', { error: err.message })
return []
}
}
/**
* Public list shape (includes connection URL — treat as secret capability).
* @returns {TunnelInfo[]}
*/
export function listTunnels() {
return [...tunnels.values()].map((t) => ({ ...t.info }))
}
/**
* @param {string} id
* @returns {TunnelInfo|null}
*/
export function getTunnel(id) {
const t = tunnels.get(id)
return t ? { ...t.info } : null
}
/**
* Start a Holesail server tunnel to host:port.
* @param {{
* id?: string,
* name?: string,
* host?: string,
* port: number,
* protocol?: 'tcp'|'udp',
* secure?: boolean,
* key?: string,
* containerId?: string|null,
* containerName?: string|null,
* persist?: boolean,
* createdAt?: string,
* skipPersistWrite?: boolean,
* }} opts
* @returns {Promise<TunnelInfo>}
*/
export async function createTunnel(opts = {}) {
if (!isHolesailEnabled()) {
const err = new Error('Holesail tunnels disabled. Set ENABLE_HOLESAIL=1 to enable.')
err.code = 'FEATURE_DISABLED'
throw err
}
if (tunnels.size >= MAX_TUNNELS) {
throw Object.assign(new Error(`Maximum concurrent tunnels (${MAX_TUNNELS}) reached`), {
code: 'RATE_LIMIT_EXCEEDED',
})
}
const Holesail = loadHolesail()
const protocol = opts.protocol === 'udp' ? 'udp' : 'tcp'
const secure = opts.secure !== false
const { host, port } = assertTunnelTarget(opts.host || '127.0.0.1', opts.port)
const name =
String(opts.name || `${protocol}-${host}:${port}`).slice(0, 80) || `tunnel-${port}`
const persist = opts.persist !== false
// Avoid duplicate host:port:protocol
for (const t of tunnels.values()) {
if (
t.info.host === host &&
t.info.port === port &&
t.info.protocol === protocol &&
t.info.state === 'listening'
) {
throw Object.assign(
new Error(`Tunnel already active for ${protocol}://${host}:${port} (${t.info.id})`),
{ code: 'DOCKER_CONFLICT' }
)
}
}
const id = opts.id && String(opts.id).startsWith('tnl_') ? String(opts.id) : newId()
logger.info('Starting Holesail tunnel', { id, host, port, protocol, secure, persist })
const ctorOpts = {
server: true,
secure,
port,
host,
udp: protocol === 'udp',
log: false,
}
// Reuse connection key so restarts keep the same hs:// URL
if (opts.key) ctorOpts.key = String(opts.key)
const instance = new Holesail(ctorOpts)
try {
await instance.ready()
} catch (err) {
try {
await instance.close()
} catch {
// ignore
}
throw Object.assign(new Error(`Failed to start Holesail tunnel: ${err.message || err}`), {
code: 'DOCKER_ERROR',
cause: err,
})
}
const raw = instance.info || {}
/** @type {TunnelInfo} */
const info = {
id,
name,
host,
port,
protocol,
secure: Boolean(raw.secure ?? secure),
url: String(raw.url || ''),
key: String(raw.key || opts.key || ''),
publicKey: raw.publicKey ? String(raw.publicKey) : undefined,
containerId: opts.containerId || null,
containerName: opts.containerName || null,
createdAt: opts.createdAt || new Date().toISOString(),
state: String(raw.state || 'listening'),
persist,
}
if (!info.url) {
await safeClose(instance)
throw new Error('Holesail started but returned no connection URL')
}
tunnels.set(id, { instance, info })
if (!opts.skipPersistWrite) saveTunnelsToDisk()
logger.info('Holesail tunnel ready', {
id,
name: info.name,
target: `${protocol}://${host}:${port}`,
urlPrefix: info.url.slice(0, 12) + '…',
})
return { ...info }
}
/**
* @param {string} id
* @returns {Promise<boolean>}
*/
export async function closeTunnel(id) {
const entry = tunnels.get(id)
if (!entry) return false
tunnels.delete(id)
await safeClose(entry.instance)
saveTunnelsToDisk()
logger.info('Holesail tunnel closed', { id, name: entry.info.name })
return true
}
export async function closeAllTunnels() {
const ids = [...tunnels.keys()]
for (const id of ids) {
try {
const entry = tunnels.get(id)
tunnels.delete(id)
if (entry) await safeClose(entry.instance)
} catch (err) {
logger.warn('Failed to close tunnel on shutdown', { id, error: err.message })
}
}
// Keep persist file so next boot restores
}
/**
* Recreate tunnels from peardock-tunnels.json (call once at server boot).
* @returns {Promise<{ restored: number, failed: number }>}
*/
export async function restoreTunnelsFromDisk() {
if (restoreDone) return { restored: 0, failed: 0 }
restoreDone = true
if (!isHolesailEnabled()) return { restored: 0, failed: 0 }
const defs = loadTunnelDefsFromDisk()
if (!defs.length) return { restored: 0, failed: 0 }
let restored = 0
let failed = 0
for (const def of defs) {
try {
await createTunnel({
id: def.id,
name: def.name,
host: def.host,
port: def.port,
protocol: def.protocol,
secure: def.secure !== false,
key: def.key,
containerId: def.containerId,
containerName: def.containerName,
createdAt: def.createdAt,
persist: true,
skipPersistWrite: true,
})
restored += 1
} catch (err) {
failed += 1
logger.warn('Failed to restore tunnel', {
id: def.id,
port: def.port,
error: err.message,
})
}
}
saveTunnelsToDisk()
if (restored || failed) {
logger.info('Holesail tunnel restore complete', { restored, failed })
}
return { restored, failed }
}
async function safeClose(instance) {
if (!instance) return
try {
if (typeof instance.close === 'function') await instance.close()
else if (typeof instance.destroy === 'function') await instance.destroy()
} catch {
// ignore
}
}
export function holesailStatus() {
return {
enabled: isHolesailEnabled(),
available: isHolesailAvailable(),
active: tunnels.size,
max: MAX_TUNNELS,
allowedHosts: [...allowedTunnelHosts()],
persistPath: persistPath(),
}
}
export default {
isHolesailEnabled,
isHolesailAvailable,
listTunnels,
getTunnel,
createTunnel,
closeTunnel,
closeAllTunnels,
restoreTunnelsFromDisk,
saveTunnelsToDisk,
loadTunnelDefsFromDisk,
holesailStatus,
assertTunnelTarget,
getTunnelsPersistPath,
}
+2
View File
@@ -89,6 +89,8 @@ export function getMetricsSnapshot(extra = {}) {
features: {
swarm: process.env.ENABLE_SWARM === '1' || process.env.ENABLE_SWARM === 'true',
plugins: process.env.ENABLE_PLUGINS === '1' || process.env.ENABLE_PLUGINS === 'true',
holesail:
process.env.ENABLE_HOLESAIL === '1' || process.env.ENABLE_HOLESAIL === 'true',
peerAllowlist:
process.env.PEARDOCK_PEER_ALLOWLIST === '1' ||
process.env.PEARDOCK_PEER_ALLOWLIST === 'true',
+129
View File
@@ -0,0 +1,129 @@
/**
* Minimal GitOps helper: shallow-clone a repo and read a compose file.
* Requires `git` on PATH on the peardock server host.
*/
import { spawn } from 'child_process'
import fs from 'fs'
import path from 'path'
import os from 'os'
import { randomBytes } from 'crypto'
/**
* @param {string} cmd
* @param {string[]} args
* @param {{ cwd?: string, timeoutMs?: number }} [opts]
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
*/
function run(cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd: opts.cwd || process.cwd(),
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
})
let stdout = ''
let stderr = ''
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`Command timed out: ${cmd} ${args.join(' ')}`))
}, opts.timeoutMs || 120_000)
child.stdout?.on('data', (d) => {
stdout += d.toString()
})
child.stderr?.on('data', (d) => {
stderr += d.toString()
})
child.on('error', (err) => {
clearTimeout(timer)
reject(err)
})
child.on('close', (code) => {
clearTimeout(timer)
resolve({ code: code ?? 1, stdout, stderr })
})
})
}
/**
* Shallow clone and read compose YAML.
* @param {{
* repoUrl: string,
* ref?: string,
* composePath?: string,
* }} opts
* @returns {Promise<{ composeContent: string, commit?: string, path: string }>}
*/
export async function fetchComposeFromGit(opts) {
const repoUrl = String(opts.repoUrl || '').trim()
if (!repoUrl) throw Object.assign(new Error('repoUrl required'), { code: 'INVALID_ARGS' })
if (!/^https?:\/\//i.test(repoUrl) && !/^git@/i.test(repoUrl)) {
throw Object.assign(
new Error('repoUrl must be http(s) or git@ URL'),
{ code: 'INVALID_ARGS' }
)
}
const ref = String(opts.ref || 'main').trim() || 'main'
let composePath = String(opts.composePath || 'docker-compose.yml').trim() || 'docker-compose.yml'
// Path traversal guard
if (composePath.includes('..') || path.isAbsolute(composePath)) {
throw Object.assign(new Error('composePath must be a relative path without ..'), {
code: 'INVALID_ARGS',
})
}
const tmpRoot = path.join(os.tmpdir(), `peardock-gitops-${randomBytes(6).toString('hex')}`)
fs.mkdirSync(tmpRoot, { recursive: true })
try {
const clone = await run(
'git',
['clone', '--depth', '1', '--branch', ref, '--single-branch', repoUrl, tmpRoot],
{ timeoutMs: 180_000 }
)
if (clone.code !== 0) {
// Retry without branch if ref is a tag/sha that needs full history hint
const clone2 = await run('git', ['clone', '--depth', '1', repoUrl, tmpRoot], {
timeoutMs: 180_000,
})
if (clone2.code !== 0) {
throw new Error(
`git clone failed: ${(clone.stderr || clone2.stderr || clone.stdout).slice(0, 400)}`
)
}
if (ref && ref !== 'main' && ref !== 'master') {
await run('git', ['checkout', ref], { cwd: tmpRoot, timeoutMs: 60_000 })
}
}
const full = path.join(tmpRoot, composePath)
if (!fs.existsSync(full)) {
// try compose.yaml
const alt = path.join(tmpRoot, 'compose.yaml')
if (composePath === 'docker-compose.yml' && fs.existsSync(alt)) {
composePath = 'compose.yaml'
} else {
throw new Error(`Compose file not found in repo: ${composePath}`)
}
}
const filePath = path.join(tmpRoot, composePath)
const composeContent = fs.readFileSync(filePath, 'utf8')
if (!composeContent.trim()) throw new Error('Compose file is empty')
let commit = ''
try {
const rev = await run('git', ['rev-parse', 'HEAD'], { cwd: tmpRoot, timeoutMs: 10_000 })
if (rev.code === 0) commit = rev.stdout.trim()
} catch {
// ignore
}
return { composeContent, commit, path: composePath }
} finally {
try {
fs.rmSync(tmpRoot, { recursive: true, force: true })
} catch {
// ignore
}
}
}
export default { fetchComposeFromGit }