/** * 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} ctx */ function ctxEnv(ctx) { return ctx.env && typeof ctx.env === 'object' ? /** @type {Record} */ (ctx.env) : /** @type {Record} */ ({}) } /** * @param {Record} ctx * @param {Record} 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} 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} */ 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 } | { 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} */ 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} 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} */ (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} */ (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} */ (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 } }