Fix Pear Holesail client createRequire crash; polish confirm modals
CI / test (push) Successful in 9m59s

Load holesail via dynamic ESM import (no createRequire) so Pear can connect local tunnels. Route confirmations through a designed modal with icons and animations instead of native dialogs.
This commit is contained in:
2026-07-10 23:03:21 -04:00
parent 244ff569f5
commit 9e39c27b88
5 changed files with 329 additions and 90 deletions
+73 -26
View File
@@ -2549,11 +2549,15 @@ function populateContainerDetails(config, container) {
if (typeof window.peardockOps?.loadTunnelsView === 'function') {
// warm tunnels view cache
}
// Offer local connect
// Offer local connect via designed modal
if (url && window.peardockOps?.connectLocalTunnel) {
const open = window.confirm(
'Tunnel created. Open a local Holesail client and browser now?'
);
const open = window.peardockOps.askUserConfirm
? await window.peardockOps.askUserConfirm(
'Open local tunnel?',
'Start a local Holesail client and open this tunnel in your browser?',
{ confirmLabel: 'Open tunnel', icon: 'fa-network-wired' }
)
: false;
if (open) {
await window.peardockOps.connectLocalTunnel(url, { openBrowser: true });
}
@@ -4200,10 +4204,16 @@ document.addEventListener('DOMContentLoaded', () => {
}
if (terminalClearBtn) {
terminalClearBtn.addEventListener('click', () => {
if (detailsTerminalSession && detailsTerminalSession.xterm && confirm('Clear terminal?')) {
detailsTerminalSession.xterm.clear();
}
terminalClearBtn.addEventListener('click', async () => {
if (!detailsTerminalSession?.xterm) return;
const ok = window.peardockOps?.askUserConfirm
? await window.peardockOps.askUserConfirm(
'Clear terminal?',
'Clear the visible terminal buffer for this session?',
{ confirmLabel: 'Clear', icon: 'fa-eraser' }
)
: true;
if (ok) detailsTerminalSession.xterm.clear();
});
}
@@ -4223,52 +4233,86 @@ document.addEventListener('DOMContentLoaded', () => {
});
/**
* Confirmation helper respects Settings confirm destructive actions.
* Async-friendly: if confirm is disabled, runs onConfirm immediately.
* Confirmation helper designed modal only (never native window.confirm).
* Respects Settings confirm destructive actions when opts.destructive !== false.
* @param {string} message
* @param {Function} onConfirm
* @param {{ title?: string, requireText?: string, danger?: boolean, info?: boolean, confirmLabel?: string, destructive?: boolean }} [opts]
*/
function showConfirmModal(message, onConfirm, opts = {}) {
const run = () => {
if (typeof onConfirm === 'function') onConfirm();
};
// Settings gate: skip dialog when user disabled confirms
if (window.peardockOps?.shouldConfirmDestructive && !window.peardockOps.shouldConfirmDestructive()) {
const destructive = opts.destructive !== false && opts.info !== true;
// Settings gate: skip dialog when user disabled destructive confirms
if (
destructive &&
window.peardockOps?.shouldConfirmDestructive &&
!window.peardockOps.shouldConfirmDestructive()
) {
run();
return;
}
// Prefer modern confirm dialog when available
if (window.peardockOps?.confirmDestructive) {
const title = opts.title || (destructive ? 'Confirm' : 'Continue');
if (window.peardockOps?.confirmDialog) {
window.peardockOps
.confirmDestructive(opts.title || 'Confirm', message, opts.requireText)
.confirmDialog({
title,
body: message,
danger: opts.danger ?? destructive,
info: opts.info,
requireText: opts.requireText,
confirmLabel: opts.confirmLabel || (destructive ? 'Confirm' : 'Continue'),
})
.then((ok) => {
if (ok) run();
})
.catch(() => {
// fall through to bootstrap modal
showBootstrapConfirm(message, onConfirm);
});
.catch(() => showBootstrapConfirm(message, onConfirm, opts));
return;
}
if (window.peardockOps?.askUserConfirm) {
window.peardockOps
.askUserConfirm(title, message, {
danger: opts.danger ?? destructive,
info: opts.info,
confirmLabel: opts.confirmLabel,
})
.then((ok) => {
if (ok) run();
})
.catch(() => showBootstrapConfirm(message, onConfirm, opts));
return;
}
showBootstrapConfirm(message, onConfirm);
showBootstrapConfirm(message, onConfirm, opts);
}
function showBootstrapConfirm(message, onConfirm) {
function showBootstrapConfirm(message, onConfirm, opts = {}) {
const modalEl = document.getElementById('confirmModal');
if (!modalEl || typeof bootstrap === 'undefined') {
if (window.confirm(message) && typeof onConfirm === 'function') onConfirm();
// Last resort: still avoid ugly defaults when possible
console.warn('[WARN] confirm modal unavailable; action cancelled for safety');
return;
}
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
const messageEl = document.getElementById('confirmModalMessage');
const titleEl = document.getElementById('confirmModalLabel');
const confirmBtn = document.getElementById('confirmModalBtn');
if (titleEl && opts.title) titleEl.textContent = opts.title;
if (messageEl) messageEl.textContent = message;
if (confirmBtn && confirmBtn.parentNode) {
const newConfirmBtn = confirmBtn.cloneNode(true);
confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn);
if (opts.confirmLabel) newConfirmBtn.textContent = opts.confirmLabel;
newConfirmBtn.className = opts.danger === false && opts.info
? 'btn btn-primary'
: 'btn btn-danger';
newConfirmBtn.addEventListener('click', () => {
modal.hide();
if (typeof onConfirm === 'function') onConfirm();
@@ -5827,15 +5871,18 @@ document.addEventListener('DOMContentLoaded', () => {
}
// Reset all peers lives in Settings (not the sidebar)
document.getElementById('settings-reset-peers-btn')?.addEventListener('click', () => {
document.getElementById('settings-reset-peers-btn')?.addEventListener('click', async () => {
const count = Object.keys(connections).length;
if (count === 0) {
showAlert('info', 'No saved peers to reset');
return;
}
const ok = window.confirm(
`Remove all ${count} saved peer(s) from this client? You will need to re-add their public keys.`
);
const ok = window.peardockOps?.confirmDestructive
? await window.peardockOps.confirmDestructive(
'Reset all peers?',
`Remove all ${count} saved peer(s) from this client? You will need to re-add their public keys.`
)
: false;
if (ok) resetAllPeers();
});
+69 -35
View File
@@ -1,37 +1,70 @@
/**
* Pear-side Holesail client: bind a local port that proxies to a remote hs:// tunnel.
* Optional — requires the `holesail` package (already a peardock dependency).
*
* Avoids Node's `createRequire` (unsupported in Pear's ESM loader).
* Uses dynamic import so CJS packages resolve in both Node and Pear/Bare.
*/
import { createRequire } from 'module'
import net from 'net'
const require = createRequire(import.meta.url)
/** @type {Map<string, { instance: any, info: object, localPort: number }>} */
const localClients = new Map()
let HolesailCtor = null
let holesailLoadError = null
function loadHolesail() {
/**
* Load the holesail constructor via ESM dynamic import (CJS interop).
* @returns {Promise<Function>}
*/
async function loadHolesail() {
if (HolesailCtor) return HolesailCtor
HolesailCtor = require('holesail')
return HolesailCtor
if (holesailLoadError) throw holesailLoadError
try {
const mod = await import('holesail')
// CJS default / named interop across Node, Pear, Bare
const Ctor =
(typeof mod === 'function' && mod) ||
mod?.default ||
mod?.Holesail ||
(mod?.default && mod.default.default) ||
null
if (typeof Ctor !== 'function') {
throw new Error(
'holesail package loaded but no constructor export was found'
)
}
HolesailCtor = Ctor
return HolesailCtor
} catch (err) {
holesailLoadError = err
const msg = err?.message || String(err)
throw new Error(
`Could not load holesail in this runtime (${msg}). ` +
`Copy the hs:// URL and run: npx holesail <url>`
)
}
}
/**
* Pick a free TCP port on 127.0.0.1
* Pick a free TCP port on 127.0.0.1 (falls back to random high port if net unavailable).
* @returns {Promise<number>}
*/
export function findFreePort() {
return new Promise((resolve, reject) => {
const s = net.createServer()
s.listen(0, '127.0.0.1', () => {
const addr = s.address()
const port = typeof addr === 'object' && addr ? addr.port : 0
s.close((err) => (err ? reject(err) : resolve(port)))
export async function findFreePort() {
try {
const net = await import('net')
const createServer = net.createServer || net.default?.createServer
if (typeof createServer !== 'function') throw new Error('net.createServer missing')
return await new Promise((resolve, reject) => {
const s = createServer()
s.listen(0, '127.0.0.1', () => {
const addr = s.address()
const port = typeof addr === 'object' && addr ? addr.port : 0
s.close((err) => (err ? reject(err) : resolve(port)))
})
s.on('error', reject)
})
s.on('error', reject)
})
} catch {
// Pear / restricted environments: best-effort ephemeral range
return 41000 + Math.floor(Math.random() * 10000)
}
}
/**
@@ -47,7 +80,7 @@ export async function connectLocalHolesail(urlOrKey, opts = {}) {
return localClients.get(url)
}
const Holesail = loadHolesail()
const Holesail = await loadHolesail()
const localPort = opts.localPort || (await findFreePort())
const host = opts.host || '127.0.0.1'
@@ -102,27 +135,28 @@ export function listLocalHolesail() {
function tryOpenBrowser(href) {
try {
// Pear / Electron
if (typeof window !== 'undefined' && window.open) {
if (typeof window !== 'undefined' && typeof window.open === 'function') {
window.open(href, '_blank')
return
}
} catch {
// ignore
}
try {
const { exec } = require('child_process')
const platform = process.platform
const cmd =
platform === 'darwin'
? `open "${href}"`
: platform === 'win32'
? `start "" "${href}"`
: `xdg-open "${href}"`
exec(cmd)
} catch {
// ignore
}
// Best-effort outside browser (Node only) — never use createRequire
import('child_process')
.then((cp) => {
const exec = cp.exec || cp.default?.exec
if (typeof exec !== 'function') return
const platform = globalThis.process?.platform || ''
const cmd =
platform === 'darwin'
? `open "${href}"`
: platform === 'win32'
? `start "" "${href}"`
: `xdg-open "${href}"`
exec(cmd)
})
.catch(() => {})
}
export default {
+84 -18
View File
@@ -64,55 +64,104 @@ export function suggestedMark(source = 'Suggested') {
}
/**
* Confirm dialog promise.
* @param {{ title: string, body: string, confirmLabel?: string, danger?: boolean, requireText?: string }} opts
* Confirm / info dialog (designed modal — never native window.confirm).
* @param {{
* title: string,
* body: string,
* confirmLabel?: string,
* cancelLabel?: string,
* danger?: boolean,
* info?: boolean,
* icon?: string,
* requireText?: string,
* hideCancel?: boolean,
* }} opts
* @returns {Promise<boolean>}
*/
export function confirmDialog(opts) {
return new Promise((resolve) => {
const previouslyFocused = document.activeElement
const backdrop = el('div', { className: 'pd-modal-backdrop', role: 'presentation' })
const danger = Boolean(opts.danger)
const info = Boolean(opts.info) && !danger
const icon =
opts.icon ||
(danger ? 'fa-triangle-exclamation' : info ? 'fa-circle-info' : 'fa-circle-question')
const tone = danger ? 'danger' : info ? 'info' : 'primary'
const backdrop = el('div', {
className: 'pd-modal-backdrop',
role: 'presentation',
})
const modal = el('div', {
className: 'pd-modal',
className: `pd-modal pd-modal--${tone}`,
role: 'dialog',
'aria-modal': 'true',
'aria-labelledby': 'pd-confirm-title',
})
modal.innerHTML = `
<div class="pd-modal-icon pd-modal-icon--${tone}" aria-hidden="true">
<i class="fas ${escapeHtml(icon)}"></i>
</div>
<h3 class="pd-modal-title" id="pd-confirm-title">${escapeHtml(opts.title)}</h3>
<p class="pd-modal-body">${escapeHtml(opts.body)}</p>
${opts.requireText ? `<input class="form-control bg-dark text-white mb-3 pd-confirm-input" placeholder="Type ${escapeHtml(opts.requireText)} to confirm">` : ''}
${
opts.requireText
? `<div class="pd-modal-confirm-field">
<label class="pd-modal-label">Type <strong>${escapeHtml(opts.requireText)}</strong> to confirm</label>
<input class="form-control bg-dark text-white pd-confirm-input" autocomplete="off" spellcheck="false" placeholder="${escapeHtml(opts.requireText)}">
</div>`
: ''
}
<div class="pd-modal-actions">
<button type="button" class="btn btn-outline-secondary pd-cancel">Cancel</button>
<button type="button" class="btn ${opts.danger ? 'btn-danger' : 'btn-primary'} pd-ok">${escapeHtml(opts.confirmLabel || 'Confirm')}</button>
${
opts.hideCancel
? ''
: `<button type="button" class="btn btn-outline-secondary pd-cancel">${escapeHtml(opts.cancelLabel || 'Cancel')}</button>`
}
<button type="button" class="btn ${danger ? 'btn-danger' : 'btn-primary'} pd-ok">
${escapeHtml(opts.confirmLabel || (info ? 'OK' : 'Confirm'))}
</button>
</div>`
backdrop.appendChild(modal)
document.body.appendChild(backdrop)
// enter animation
requestAnimationFrame(() => backdrop.classList.add('pd-modal-backdrop--visible'))
const input = modal.querySelector('.pd-confirm-input')
const ok = modal.querySelector('.pd-ok')
const cancel = modal.querySelector('.pd-cancel')
const close = (val) => {
backdrop.remove()
document.removeEventListener('keydown', onKey)
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
try {
previouslyFocused.focus()
} catch {
// ignore
backdrop.classList.remove('pd-modal-backdrop--visible')
backdrop.classList.add('pd-modal-backdrop--leaving')
setTimeout(() => {
backdrop.remove()
document.removeEventListener('keydown', onKey)
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
try {
previouslyFocused.focus()
} catch {
// ignore
}
}
}
resolve(val)
resolve(val)
}, 160)
}
const onKey = (e) => {
if (e.key === 'Escape') {
e.preventDefault()
close(false)
}
if (e.key === 'Enter' && document.activeElement !== input) {
e.preventDefault()
ok?.click()
}
}
document.addEventListener('keydown', onKey)
cancel.onclick = () => close(false)
if (cancel) cancel.onclick = () => close(false)
ok.onclick = () => {
if (opts.requireText && input && input.value !== opts.requireText) {
input.classList.add('is-invalid')
input.focus()
return
}
close(true)
@@ -120,7 +169,24 @@ export function confirmDialog(opts) {
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) close(false)
})
setTimeout(() => (input || ok)?.focus?.(), 0)
setTimeout(() => (input || ok)?.focus?.(), 30)
})
}
/**
* Non-destructive confirm (e.g. open tunnel, clear log).
* @param {string} title
* @param {string} body
* @param {{ confirmLabel?: string, info?: boolean, icon?: string }} [opts]
*/
export async function askConfirm(title, body, opts = {}) {
return confirmDialog({
title,
body,
confirmLabel: opts.confirmLabel || 'Continue',
info: opts.info !== false && !opts.danger,
danger: opts.danger,
icon: opts.icon,
})
}
+16 -2
View File
@@ -28,7 +28,7 @@ import {
suggestName,
suggestFromImage,
} from '../client/snapshot.js'
import { openCommandPalette, confirmDialog, statusBadge } from './components.js'
import { openCommandPalette, confirmDialog, askConfirm, statusBadge } from './components.js'
import notificationManager from '../libs/notifications.js'
import {
DEFAULT_TEMPLATE_LIST_URLS,
@@ -974,7 +974,19 @@ export function shouldConfirmDestructive() {
export async function confirmDestructive(title, body, requireText) {
if (!shouldConfirmDestructive()) return true
return confirmDialog({ title, body, danger: true, requireText, confirmLabel: 'Confirm' })
return confirmDialog({
title,
body,
danger: true,
requireText,
confirmLabel: 'Confirm',
icon: 'fa-triangle-exclamation',
})
}
/** Non-destructive designed modal confirm (replaces window.confirm). */
export async function askUserConfirm(title, body, opts = {}) {
return askConfirm(title, body, opts)
}
export function initOpsApp({ navigateToView, sendCommand }) {
@@ -1183,6 +1195,8 @@ export function initOpsApp({ navigateToView, sendCommand }) {
openSmartNetworkModal,
hydrateFormFromImage,
confirmDestructive,
askUserConfirm,
confirmDialog,
shouldConfirmDestructive,
loadSettings,
applySettings,
+87 -9
View File
@@ -130,24 +130,102 @@
position: fixed;
inset: 0;
background: rgba(2, 6, 23, 0.72);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 3000;
padding: 1rem;
opacity: 0;
transition: opacity 0.16s ease;
}
.pd-modal-backdrop--visible {
opacity: 1;
}
.pd-modal-backdrop--leaving {
opacity: 0;
}
.pd-modal {
background: #0f172a;
border: 1px solid #334155;
border-radius: 12px;
padding: 1.25rem 1.5rem;
max-width: 28rem;
background: linear-gradient(165deg, #152032 0%, #0f172a 55%, #0b1220 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 1.35rem 1.5rem 1.25rem;
max-width: 26rem;
width: 100%;
box-shadow: 0 20px 50px rgba(0,0,0,0.45);
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.55),
0 0 0 1px rgba(52, 211, 153, 0.06);
transform: translateY(8px) scale(0.98);
transition: transform 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
.pd-modal-backdrop--visible .pd-modal {
transform: translateY(0) scale(1);
}
.pd-modal--danger {
box-shadow:
0 24px 60px rgba(0, 0, 0, 0.55),
0 0 0 1px rgba(248, 113, 113, 0.12);
}
.pd-modal-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: grid;
place-items: center;
margin-bottom: 0.85rem;
font-size: 1.1rem;
}
.pd-modal-icon--danger {
background: rgba(248, 113, 113, 0.14);
color: #f87171;
}
.pd-modal-icon--info {
background: rgba(56, 189, 248, 0.14);
color: #38bdf8;
}
.pd-modal-icon--primary {
background: rgba(52, 211, 153, 0.14);
color: #34d399;
}
.pd-modal-title {
margin: 0 0 0.45rem;
color: var(--text-primary, #f4f7fb);
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
}
.pd-modal-body {
color: var(--text-secondary, #c8d1df);
margin: 0 0 1.15rem;
font-size: 0.92rem;
line-height: 1.55;
}
.pd-modal-confirm-field {
margin: 0 0 1rem;
}
.pd-modal-label {
display: block;
font-size: 0.8rem;
color: var(--text-muted, #9aa8bc);
margin-bottom: 0.35rem;
}
.pd-modal-actions {
display: flex;
gap: 0.55rem;
justify-content: flex-end;
flex-wrap: wrap;
}
.pd-modal-actions .btn {
border-radius: 10px;
min-width: 5.5rem;
font-weight: 600;
}
.pd-confirm-input.is-invalid {
border-color: #f87171 !important;
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.15);
}
.pd-modal-title { margin: 0 0 0.5rem; color: var(--text-primary, #f4f7fb); font-size: 1.1rem; }
.pd-modal-body { color: var(--text-secondary, #c8d1df); margin-bottom: 1rem; }
.pd-modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
.pd-palette {
width: min(32rem, 100%);