529 lines
19 KiB
JavaScript
529 lines
19 KiB
JavaScript
/**
|
|
* Drive-resident /bin/holesail → ctx.bareOsRunHolesailCli (booter).
|
|
* Manages persisted tunnels under **`BARE_OS_HOLESAIL_STATE`** or **`~/.holesail/state.json`** (per **`$HOME`**).
|
|
* Live start/stop uses the **bare-holesail** initd unit after login (managed mode is the stock default).
|
|
*/
|
|
import {
|
|
bareHolesailManagedEnvForConnectionId,
|
|
bareHolesailManagedNormalizeEntry,
|
|
bareHolesailManagedReadState,
|
|
bareHolesailManagedRuntimeRunning,
|
|
bareHolesailManagedRuntimeUrl,
|
|
bareHolesailManagedServiceIsRunning,
|
|
bareHolesailManagedStartOne,
|
|
bareHolesailManagedStatePath,
|
|
bareHolesailManagedStopOne,
|
|
bareHolesailManagedSyncPersistedServerKey,
|
|
bareHolesailManagedValidateId,
|
|
bareHolesailManagedWriteState
|
|
} from './bare-holesail-managed.js'
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
*/
|
|
function ctxEnv(ctx) {
|
|
return ctx.env && typeof ctx.env === 'object'
|
|
? /** @type {Record<string, string | undefined>} */ (ctx.env)
|
|
: /** @type {Record<string, string | undefined>} */ ({})
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {Record<string, string | undefined>} env
|
|
*/
|
|
async function holesailCliReadStateMergedForList(ctx, env) {
|
|
return bareHolesailManagedReadState(ctx, env)
|
|
}
|
|
|
|
function printHelp(ctx, prog) {
|
|
ctx.console.log(
|
|
`Usage: ${prog} help|path|list|status
|
|
${prog} show ID
|
|
${prog} add ID --server|--client [--key KEY] [--port N] [--host H] [--udp] [--secure] [--no-secure] [--log N|true|false]
|
|
${prog} edit ID [--port N] [--host H] [--clear-host] [--udp|--no-udp|--tcp] [--secure|--no-secure] [--key KEY] [--log N|true|false]
|
|
${prog} remove ID
|
|
${prog} start ID | stop ID | restart ID
|
|
${prog} enable ID | disable ID
|
|
|
|
State file: BARE_OS_HOLESAIL_STATE or ${bareHolesailManagedStatePath({})}
|
|
Stock boot enables bare-holesail in managed mode (BARE_OS_HOLESAIL_INITD/MANAGED default on).
|
|
Disable with BARE_OS_HOLESAIL_INITD=0 or systemctl disable bare-holesail.
|
|
|
|
show never prints seed material (ctor secret). list/show print the shareable hs:// URL.
|
|
edit updates fields in place (does not change server/client or seed). Live tunnels restart.
|
|
|
|
Upstream holesail is AGPL-3.0; see handbook/04-the-booter-runtime.md.`
|
|
)
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} entry
|
|
* @param {string} id
|
|
* @param {boolean} live
|
|
* @param {string} runtimeUrl
|
|
*/
|
|
export function holesailCliFormatListRow(id, entry, live, runtimeUrl) {
|
|
const mode = entry.server ? 'server' : entry.client ? 'client' : '?'
|
|
const persistedUrl = String(entry.key ?? '').trim()
|
|
const url = (live ? String(runtimeUrl || '').trim() : '') || persistedUrl
|
|
const port = entry.port != null && entry.port !== '' ? String(entry.port) : ''
|
|
const host = String(entry.host ?? '').trim()
|
|
const udp = entry.udp != null ? String(!!entry.udp) : ''
|
|
const secure = entry.secure != null ? String(!!entry.secure) : ''
|
|
const bits = [
|
|
id,
|
|
mode,
|
|
`enabled=${String(!!entry.enabled)}`,
|
|
`live=${String(!!live)}`
|
|
]
|
|
if (port) bits.push(`port=${port}`)
|
|
if (host) bits.push(`host=${host}`)
|
|
if (udp) bits.push(`udp=${udp}`)
|
|
if (secure) bits.push(`secure=${secure}`)
|
|
if (url) bits.push(`url=${url.slice(0, 120)}`)
|
|
return bits.join('\t')
|
|
}
|
|
|
|
/**
|
|
* Accept `hs://…` without `--key` (otherwise parsed as an unknown flag).
|
|
* @param {string} a
|
|
*/
|
|
function holesailCliTokenLooksLikeHolesailConnectionString(a) {
|
|
return typeof a === 'string' && /^hs:\/\//i.test(a)
|
|
}
|
|
|
|
export function holesailCliParseAdd(flagArgs) {
|
|
if (flagArgs.length < 1) {
|
|
return { ok: false, err: 'holesail add: missing ID' }
|
|
}
|
|
const idOk = bareHolesailManagedValidateId(flagArgs[0])
|
|
if (!idOk.ok) return { ok: false, err: `holesail add: ${idOk.err}` }
|
|
/** @type {Record<string, unknown>} */
|
|
const raw = {}
|
|
for (let i = 1; i < flagArgs.length; i++) {
|
|
const a = flagArgs[i]
|
|
if (a === '--server') raw.server = true
|
|
else if (a === '--client') raw.client = true
|
|
else if (holesailCliTokenLooksLikeHolesailConnectionString(a)) {
|
|
if (raw.key) {
|
|
return { ok: false, err: 'holesail add: duplicate key' }
|
|
}
|
|
raw.key = a
|
|
} else if (a === '--key' && flagArgs[i + 1]) raw.key = flagArgs[++i]
|
|
else if (a === '--port' && flagArgs[i + 1]) raw.port = flagArgs[++i]
|
|
else if (a === '--host' && flagArgs[i + 1]) raw.host = flagArgs[++i]
|
|
else if (a === '--udp') raw.udp = true
|
|
else if (a === '--secure') raw.secure = true
|
|
else if (a === '--no-secure') raw.secure = false
|
|
else if (a === '--log' && flagArgs[i + 1]) {
|
|
const v = flagArgs[++i]
|
|
if (v === 'true' || v === 'false') raw.log = v
|
|
else raw.log = v
|
|
} else {
|
|
return { ok: false, err: `holesail add: unknown or incomplete flag: ${a}` }
|
|
}
|
|
}
|
|
const norm = bareHolesailManagedNormalizeEntry(raw)
|
|
if (!norm.ok) return { ok: false, err: `holesail add: ${norm.err}` }
|
|
return { ok: true, id: idOk.id, entry: norm.entry }
|
|
}
|
|
|
|
/**
|
|
* @param {string[]} flagArgs tokens including ID for "edit"
|
|
* @returns {{ ok: true, id: string, patch: Record<string, unknown> } | { ok: false, err: string }}
|
|
*/
|
|
export function holesailCliParseEdit(flagArgs) {
|
|
if (flagArgs.length < 1) {
|
|
return { ok: false, err: 'holesail edit: missing ID' }
|
|
}
|
|
const idOk = bareHolesailManagedValidateId(flagArgs[0])
|
|
if (!idOk.ok) return { ok: false, err: `holesail edit: ${idOk.err}` }
|
|
/** @type {Record<string, unknown>} */
|
|
const patch = {}
|
|
let touched = false
|
|
for (let i = 1; i < flagArgs.length; i++) {
|
|
const a = flagArgs[i]
|
|
if (holesailCliTokenLooksLikeHolesailConnectionString(a)) {
|
|
if (patch.key) return { ok: false, err: 'holesail edit: duplicate key' }
|
|
patch.key = a
|
|
touched = true
|
|
} else if (a === '--key' && flagArgs[i + 1]) {
|
|
patch.key = flagArgs[++i]
|
|
touched = true
|
|
} else if (a === '--port' && flagArgs[i + 1]) {
|
|
patch.port = flagArgs[++i]
|
|
touched = true
|
|
} else if (a === '--host' && flagArgs[i + 1]) {
|
|
patch.host = flagArgs[++i]
|
|
touched = true
|
|
} else if (a === '--clear-host') {
|
|
patch.host = ''
|
|
touched = true
|
|
} else if (a === '--udp') {
|
|
patch.udp = true
|
|
touched = true
|
|
} else if (a === '--no-udp' || a === '--tcp') {
|
|
patch.udp = false
|
|
touched = true
|
|
} else if (a === '--secure') {
|
|
patch.secure = true
|
|
touched = true
|
|
} else if (a === '--no-secure') {
|
|
patch.secure = false
|
|
touched = true
|
|
} else if (a === '--log' && flagArgs[i + 1]) {
|
|
patch.log = flagArgs[++i]
|
|
touched = true
|
|
} else if (a === '--server' || a === '--client') {
|
|
return { ok: false, err: 'holesail edit: cannot change server/client (remove and add)' }
|
|
} else {
|
|
return { ok: false, err: `holesail edit: unknown or incomplete flag: ${a}` }
|
|
}
|
|
}
|
|
if (!touched) return { ok: false, err: 'holesail edit: no fields to change' }
|
|
return { ok: true, id: idOk.id, patch }
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string[]} argv argv[0] is holesail
|
|
*/
|
|
export async function runHolesailCli(ctx, argv) {
|
|
const env = ctxEnv(ctx)
|
|
const args = argv.slice(1)
|
|
const prog = 'holesail'
|
|
|
|
if (
|
|
args.length === 0 ||
|
|
args[0] === 'help' ||
|
|
args[0] === '--help' ||
|
|
args[0] === '-h'
|
|
) {
|
|
printHelp(ctx, prog)
|
|
ctx.exitCode = 0
|
|
return
|
|
}
|
|
|
|
const sub = args[0]
|
|
const rest = args.slice(1)
|
|
|
|
if (sub === 'path') {
|
|
ctx.console.log(bareHolesailManagedStatePath(env))
|
|
ctx.exitCode = 0
|
|
return
|
|
}
|
|
|
|
const vfs = ctx.vfs
|
|
if (!vfs || typeof vfs.readFile !== 'function' || typeof vfs.writeFile !== 'function') {
|
|
ctx.console.error('holesail: ctx.vfs read/write unavailable')
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
|
|
if (!ctx.b4a || typeof ctx.b4a.from !== 'function') {
|
|
ctx.console.error('holesail: ctx.b4a.from unavailable')
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
|
|
try {
|
|
if (sub === 'list') {
|
|
let { path, state } = await holesailCliReadStateMergedForList(ctx, env)
|
|
const idsSorted = Object.keys(state.connections).sort()
|
|
let resynced = false
|
|
for (const id of idsSorted) {
|
|
if (!bareHolesailManagedRuntimeRunning(id)) continue
|
|
const raw0 = state.connections[id]
|
|
const norm0 = bareHolesailManagedNormalizeEntry(raw0)
|
|
if (norm0.ok && norm0.entry.server) {
|
|
const w = await bareHolesailManagedSyncPersistedServerKey(
|
|
ctx,
|
|
bareHolesailManagedEnvForConnectionId(env, id),
|
|
id
|
|
)
|
|
if (w) resynced = true
|
|
}
|
|
}
|
|
if (resynced) {
|
|
;({ path, state } = await holesailCliReadStateMergedForList(ctx, env))
|
|
}
|
|
const daemon = bareHolesailManagedServiceIsRunning()
|
|
ctx.console.log(`state: ${path}`)
|
|
ctx.console.log(`daemon: ${daemon ? 'managed bare-holesail running' : 'no managed daemon (live ops limited)'}`)
|
|
const ids = Object.keys(state.connections).sort()
|
|
if (ids.length === 0) {
|
|
ctx.console.log('(no connections)')
|
|
ctx.exitCode = 0
|
|
return
|
|
}
|
|
for (const id of ids) {
|
|
const raw = state.connections[id]
|
|
const norm = bareHolesailManagedNormalizeEntry(raw)
|
|
const live = bareHolesailManagedRuntimeRunning(id)
|
|
const runtimeUrl = live ? bareHolesailManagedRuntimeUrl(id).trim() : ''
|
|
if (norm.ok) {
|
|
ctx.console.log(holesailCliFormatListRow(id, norm.entry, live, runtimeUrl))
|
|
} else {
|
|
ctx.console.log(`${id}\t?\tenabled=?\tlive=${live}\tINVALID: ${norm.err}`)
|
|
}
|
|
}
|
|
ctx.exitCode = 0
|
|
return
|
|
}
|
|
|
|
if (sub === 'status') {
|
|
const { path, state } = await holesailCliReadStateMergedForList(ctx, env)
|
|
const ids = Object.keys(state.connections).sort()
|
|
let enabled = 0
|
|
let live = 0
|
|
let servers = 0
|
|
let clients = 0
|
|
for (const id of ids) {
|
|
const norm = bareHolesailManagedNormalizeEntry(state.connections[id])
|
|
if (!norm.ok) continue
|
|
if (norm.entry.enabled) enabled++
|
|
if (norm.entry.server) servers++
|
|
if (norm.entry.client) clients++
|
|
if (bareHolesailManagedRuntimeRunning(id)) live++
|
|
}
|
|
const daemon = bareHolesailManagedServiceIsRunning()
|
|
ctx.console.log(`state: ${path}`)
|
|
ctx.console.log(`daemon: ${daemon ? 'managed bare-holesail running' : 'no managed daemon (live ops limited)'}`)
|
|
ctx.console.log(
|
|
`connections: ${ids.length}\tenabled=${enabled}\tlive=${live}\tserver=${servers}\tclient=${clients}`
|
|
)
|
|
ctx.exitCode = 0
|
|
return
|
|
}
|
|
|
|
if (sub === 'show') {
|
|
const idOk = bareHolesailManagedValidateId(rest[0] || '')
|
|
if (!idOk.ok) {
|
|
ctx.console.error(`holesail show: ${idOk.err}`)
|
|
ctx.exitCode = 2
|
|
return
|
|
}
|
|
const envW = bareHolesailManagedEnvForConnectionId(env, idOk.id)
|
|
const { path, state } = await bareHolesailManagedReadState(ctx, envW)
|
|
const raw = state.connections[idOk.id]
|
|
if (!raw || typeof raw !== 'object') {
|
|
ctx.console.error(`holesail show: unknown ${idOk.id}`)
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
const norm = bareHolesailManagedNormalizeEntry(raw)
|
|
if (!norm.ok) {
|
|
ctx.console.error(`holesail show: ${norm.err}`)
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
const live = bareHolesailManagedRuntimeRunning(idOk.id)
|
|
const persistedUrl = String(norm.entry.key ?? '').trim()
|
|
const runtimeUrl = live ? bareHolesailManagedRuntimeUrl(idOk.id).trim() : ''
|
|
const url = runtimeUrl || persistedUrl
|
|
const seedSet = String(norm.entry.seed ?? '').trim() ? 'yes' : 'no'
|
|
const daemon = bareHolesailManagedServiceIsRunning()
|
|
ctx.console.log(`id\t${idOk.id}`)
|
|
ctx.console.log(`mode\t${norm.entry.server ? 'server' : 'client'}`)
|
|
ctx.console.log(`enabled\t${String(!!norm.entry.enabled)}`)
|
|
ctx.console.log(`live\t${String(!!live)}`)
|
|
if (norm.entry.port != null) ctx.console.log(`port\t${norm.entry.port}`)
|
|
if (norm.entry.host) ctx.console.log(`host\t${norm.entry.host}`)
|
|
if (norm.entry.udp != null) ctx.console.log(`udp\t${String(!!norm.entry.udp)}`)
|
|
if (norm.entry.secure != null) ctx.console.log(`secure\t${String(!!norm.entry.secure)}`)
|
|
if (norm.entry.log != null) ctx.console.log(`log\t${norm.entry.log}`)
|
|
ctx.console.log(`seed\t${seedSet}`)
|
|
if (url) ctx.console.log(`url\t${url}`)
|
|
ctx.console.log(`state\t${path}`)
|
|
ctx.console.log(`daemon\t${daemon ? 'running' : 'stopped'}`)
|
|
ctx.exitCode = 0
|
|
return
|
|
}
|
|
|
|
if (sub === 'add') {
|
|
const parsed = holesailCliParseAdd(rest)
|
|
if (!parsed.ok) {
|
|
ctx.console.error(parsed.err)
|
|
ctx.exitCode = 2
|
|
return
|
|
}
|
|
const envW = bareHolesailManagedEnvForConnectionId(env, parsed.id)
|
|
const { path, state } = await bareHolesailManagedReadState(ctx, envW)
|
|
if (state.connections[parsed.id]) {
|
|
ctx.console.error(`holesail add: ${parsed.id} already exists (use remove first)`)
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
state.connections[parsed.id] = parsed.entry
|
|
await bareHolesailManagedWriteState(ctx, envW, state)
|
|
ctx.console.log(`wrote ${path}`)
|
|
if (bareHolesailManagedServiceIsRunning()) {
|
|
try {
|
|
await bareHolesailManagedStartOne(ctx, envW, parsed.id)
|
|
ctx.console.log(`started ${parsed.id}`)
|
|
} catch (e) {
|
|
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
|
ctx.console.error(`holesail: persisted but start failed: ${msg}`)
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
}
|
|
ctx.exitCode = 0
|
|
return
|
|
}
|
|
|
|
if (sub === 'remove') {
|
|
const idOk = bareHolesailManagedValidateId(rest[0] || '')
|
|
if (!idOk.ok) {
|
|
ctx.console.error(`holesail remove: ${idOk.err}`)
|
|
ctx.exitCode = 2
|
|
return
|
|
}
|
|
const envW = bareHolesailManagedEnvForConnectionId(env, idOk.id)
|
|
const { path, state } = await bareHolesailManagedReadState(ctx, envW)
|
|
if (!state.connections[idOk.id]) {
|
|
ctx.console.error(`holesail remove: unknown ${idOk.id}`)
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
if (bareHolesailManagedRuntimeRunning(idOk.id)) {
|
|
await bareHolesailManagedStopOne(ctx, idOk.id)
|
|
}
|
|
delete state.connections[idOk.id]
|
|
await bareHolesailManagedWriteState(ctx, envW, state)
|
|
ctx.console.log(`removed ${idOk.id} (${path})`)
|
|
ctx.exitCode = 0
|
|
return
|
|
}
|
|
|
|
if (sub === 'start' || sub === 'stop' || sub === 'restart') {
|
|
const idOk = bareHolesailManagedValidateId(rest[0] || '')
|
|
if (!idOk.ok) {
|
|
ctx.console.error(`holesail ${sub}: ${idOk.err}`)
|
|
ctx.exitCode = 2
|
|
return
|
|
}
|
|
if (sub === 'restart' || sub === 'stop') {
|
|
if (bareHolesailManagedRuntimeRunning(idOk.id)) {
|
|
await bareHolesailManagedStopOne(ctx, idOk.id)
|
|
}
|
|
}
|
|
if (sub === 'stop') {
|
|
ctx.console.log(`stopped ${idOk.id} (still in state; will restart on boot if enabled)`)
|
|
ctx.exitCode = 0
|
|
return
|
|
}
|
|
await bareHolesailManagedStartOne(
|
|
ctx,
|
|
bareHolesailManagedEnvForConnectionId(env, idOk.id),
|
|
idOk.id
|
|
)
|
|
ctx.console.log(`started ${idOk.id}`)
|
|
ctx.exitCode = 0
|
|
return
|
|
}
|
|
|
|
if (sub === 'edit') {
|
|
const parsed = holesailCliParseEdit(rest)
|
|
if (!parsed.ok) {
|
|
ctx.console.error(parsed.err)
|
|
ctx.exitCode = 2
|
|
return
|
|
}
|
|
const envW = bareHolesailManagedEnvForConnectionId(env, parsed.id)
|
|
const { path, state } = await bareHolesailManagedReadState(ctx, envW)
|
|
const raw = state.connections[parsed.id]
|
|
if (!raw || typeof raw !== 'object') {
|
|
ctx.console.error(`holesail edit: unknown ${parsed.id}`)
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
const merged = { .../** @type {Record<string, unknown>} */ (raw), ...parsed.patch }
|
|
if (Object.prototype.hasOwnProperty.call(parsed.patch, 'host') && parsed.patch.host === '') {
|
|
delete merged.host
|
|
}
|
|
const norm = bareHolesailManagedNormalizeEntry(merged)
|
|
if (!norm.ok) {
|
|
ctx.console.error(`holesail edit: ${norm.err}`)
|
|
ctx.exitCode = 2
|
|
return
|
|
}
|
|
const prev = /** @type {Record<string, unknown>} */ (raw)
|
|
if (prev.seed) norm.entry.seed = prev.seed
|
|
if (prev.server) {
|
|
norm.entry.server = true
|
|
norm.entry.client = false
|
|
} else if (prev.client) {
|
|
norm.entry.client = true
|
|
norm.entry.server = false
|
|
}
|
|
if (prev.enabled !== undefined && parsed.patch.enabled === undefined) {
|
|
norm.entry.enabled = prev.enabled
|
|
}
|
|
state.connections[parsed.id] = norm.entry
|
|
await bareHolesailManagedWriteState(ctx, envW, state)
|
|
ctx.console.log(`wrote ${path}`)
|
|
const wasLive = bareHolesailManagedRuntimeRunning(parsed.id)
|
|
if (wasLive) {
|
|
await bareHolesailManagedStopOne(ctx, parsed.id)
|
|
if (norm.entry.enabled && bareHolesailManagedServiceIsRunning()) {
|
|
try {
|
|
await bareHolesailManagedStartOne(ctx, envW, parsed.id)
|
|
ctx.console.log(`restarted ${parsed.id}`)
|
|
} catch (e) {
|
|
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
|
ctx.console.error(`holesail: saved but restart failed: ${msg}`)
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
}
|
|
}
|
|
ctx.exitCode = 0
|
|
return
|
|
}
|
|
|
|
if (sub === 'enable' || sub === 'disable') {
|
|
const idOk = bareHolesailManagedValidateId(rest[0] || '')
|
|
if (!idOk.ok) {
|
|
ctx.console.error(`holesail ${sub}: ${idOk.err}`)
|
|
ctx.exitCode = 2
|
|
return
|
|
}
|
|
const envW = bareHolesailManagedEnvForConnectionId(env, idOk.id)
|
|
const { path, state } = await bareHolesailManagedReadState(ctx, envW)
|
|
const raw = state.connections[idOk.id]
|
|
if (!raw || typeof raw !== 'object') {
|
|
ctx.console.error(`holesail ${sub}: unknown ${idOk.id}`)
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
const o = /** @type {Record<string, unknown>} */ (raw)
|
|
o.enabled = sub === 'enable'
|
|
await bareHolesailManagedWriteState(ctx, envW, state)
|
|
ctx.console.log(`${sub}d ${idOk.id} (${path})`)
|
|
if (sub === 'disable' && bareHolesailManagedRuntimeRunning(idOk.id)) {
|
|
await bareHolesailManagedStopOne(ctx, idOk.id)
|
|
}
|
|
if (sub === 'enable' && bareHolesailManagedServiceIsRunning()) {
|
|
try {
|
|
await bareHolesailManagedStartOne(ctx, envW, idOk.id)
|
|
} catch (e) {
|
|
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
|
ctx.console.error(`holesail: enable saved but start failed: ${msg}`)
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
}
|
|
ctx.exitCode = 0
|
|
return
|
|
}
|
|
|
|
ctx.console.error(`holesail: unknown command ${sub} (try ${prog} help)`)
|
|
ctx.exitCode = 2
|
|
} catch (e) {
|
|
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
|
ctx.console.error(`holesail: ${msg}`)
|
|
ctx.exitCode = 1
|
|
}
|
|
}
|