Compare commits

...

3 Commits

Author SHA1 Message Date
archipelago
340b981b79 chore: release v1.7.89-alpha 2026-06-13 01:34:11 -04:00
archipelago
c49e8fcacd fix: harden OTA updates, AIUI desktop gap, LND no-proxy
- update.rs: post-OTA probe falls back to http://127.0.0.1/ on connect
  error (nginx binds :80, not :443) so good updates are no longer rolled
  back; recover stuck update_in_progress; avoid ETXTBSY on running binary
- LND: REST client bypasses proxy, GET newaddress p2wkh, wallet
  readiness/unlock after restart
- Dashboard.vue: chat route back to plain h-full (desktop bottom-gap fix)
- vite.config.ts: dev-only /aiui proxy
- tests/release/run.sh: release gate harness (static+frontend+backend)
- CHANGELOG: v1.7.89-alpha notes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:23:32 -04:00
archipelago
495b90782a fix: restore AIUI mobile layout 2026-06-12 06:01:24 -04:00
18 changed files with 230 additions and 73 deletions

View File

@ -1,5 +1,13 @@
# Changelog
## v1.7.89-alpha (2026-06-12)
- The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom.
- System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates.
- After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone.
- Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way.
- The Lightning wallet now recovers and unlocks itself properly after restarts.
## v1.7.88-alpha (2026-06-12)
- AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.88-alpha"
version = "1.7.89-alpha"
dependencies = [
"anyhow",
"archipelago-container",

View File

@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.88-alpha"
version = "1.7.89-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]

View File

@ -38,6 +38,7 @@ impl RpcHandler {
let macaroon_hex = hex::encode(&macaroon_bytes);
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(10))
.danger_accept_invalid_certs(true)
.build()
@ -180,6 +181,7 @@ impl RpcHandler {
let macaroon_hex = hex::encode(&macaroon_bytes);
let client = reqwest::Client::builder()
.no_proxy()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(10))
.build()

View File

@ -63,6 +63,7 @@ impl RpcHandler {
let macaroon_bytes = read_lnd_admin_macaroon().await?;
let macaroon_hex = hex::encode(&macaroon_bytes);
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.danger_accept_invalid_certs(true)
.build()

View File

@ -530,6 +530,7 @@ impl RpcHandler {
// Call LND REST API to initialize wallet with derived entropy.
// LND must be running but NOT yet initialized (no existing wallet).
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(30))
.danger_accept_invalid_certs(true)
.build()

View File

@ -76,7 +76,7 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
if file_exists_as_root(wallet_db).await {
if file_exists_as_root(admin_macaroon).await {
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
return Ok(());
}
unlock_existing_wallet().await?;
@ -127,6 +127,7 @@ async fn unlock_existing_wallet() -> Result<()> {
async fn unlock_existing_wallet_via_rest() -> Result<()> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
@ -204,6 +205,7 @@ struct InitWalletRequest {
async fn init_wallet_via_rest() -> Result<()> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
@ -305,12 +307,12 @@ async fn decode_lnd_unlocker_response<T: for<'de> Deserialize<'de>>(
anyhow::bail!("LND REST {path} returned {status}: {text}")
}
#[allow(dead_code)]
async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
let Ok(macaroon) = read_file_as_root(admin_macaroon).await else {
return false;
};
let Ok(client) = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(5))
.danger_accept_invalid_certs(true)
.build()

View File

@ -3220,11 +3220,11 @@ app:
- /data/.filebrowser.json
volumes:
- type: bind
source: /tmp/filebrowser-srv
source: /var/lib/archipelago/filebrowser-srv
target: /srv
options: [rw]
- type: bind
source: /tmp/filebrowser-data
source: /var/lib/archipelago/filebrowser-data
target: /data
options: [rw]
"#;
@ -3244,7 +3244,7 @@ app:
secret_file: bitcoin-rpc-password
volumes:
- type: bind
source: /tmp/lnd
source: /var/lib/archipelago/lnd
target: /root/.lnd
"#;
AppManifest::parse(yaml).unwrap()

View File

@ -367,12 +367,20 @@ async fn probe_frontend_once() -> Result<()> {
.context("build probe client")?;
// Prefer HTTPS since that's the failure mode we're catching (nginx
// 500 on the PWA). HTTP usually redirects to HTTPS and would mask
// the bug.
let resp = client
.get("https://127.0.0.1/")
.send()
.await
.context("probe GET https://127.0.0.1/")?;
// the bug. BUT not every node binds 443 on loopback (.116 serves
// plain HTTP; 443 there belongs to tailscale) — on a *connect*
// error, fall back to HTTP so a healthy node isn't "verified" into
// a rollback. An HTTP error status stays fatal on whichever scheme
// answered.
let resp = match client.get("https://127.0.0.1/").send().await {
Ok(resp) => resp,
Err(e) if e.is_connect() => client
.get("http://127.0.0.1/")
.send()
.await
.context("probe GET http://127.0.0.1/ (https not bound on loopback)")?,
Err(e) => return Err(e).context("probe GET https://127.0.0.1/"),
};
let status = resp.status();
if status.is_success() || status.is_redirection() {
return Ok(());
@ -1078,6 +1086,17 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
let current_binary = Path::new("/usr/local/bin/archipelago");
if current_binary.exists() {
let backup_path = backup_dir.join("archipelago");
// A leftover backup from an earlier rollback can be root-owned
// (rollback used to chown it in place), and fs::copy O_TRUNCs the
// existing file — EACCES as the service user, wedging every apply
// (seen on .116, v1.7.86 OTA). Unlink first; the dir is
// service-owned so unlink works even when the file isn't ours.
if backup_path.exists() {
if let Err(e) = fs::remove_file(&backup_path).await {
tracing::warn!(error = %e, "unlink of stale binary backup failed, retrying via host_sudo");
let _ = host_sudo(&["rm", "-f", &backup_path.to_string_lossy()]).await;
}
}
fs::copy(current_binary, &backup_path)
.await
.context("Failed to backup current binary")?;
@ -1415,21 +1434,38 @@ pub async fn rollback_update(data_dir: &Path) -> Result<()> {
let backup_binary = backup_dir.join("archipelago");
if backup_binary.exists() {
// Use host_sudo + mv so we escape the archipelago service's
// ProtectSystem=strict mount namespace. A plain fs::copy or
// `sudo cp` from inside the service hits EROFS on /usr/local/bin,
// which would silently orphan the rollback — exactly the
// opposite of what auto-rollback is for. Pattern matches
// apply_update()'s binary swap above.
// Same two namespace gotchas as apply_update()'s binary swap:
// `cp` straight onto the running binary is O_TRUNC and fails
// ETXTBSY (exit 1 — exactly what broke the .116 rollback), and
// plain sudo inherits ProtectSystem=strict, so everything goes
// through host_sudo. Copy to a temp name on the same filesystem,
// fix ownership on the TEMP file (never the stored backup — an
// in-place chown is what later wedged apply_update), then mv,
// which is an atomic rename and tolerates a busy destination.
let backup_str = backup_binary.to_string_lossy().to_string();
let _ = host_sudo(&["chmod", "0755", &backup_str]).await;
let _ = host_sudo(&["chown", "root:root", &backup_str]).await;
let status = host_sudo(&["cp", &backup_str, "/usr/local/bin/archipelago"])
let tmp = format!(
"/usr/local/bin/.archipelago.rollback.{}",
chrono::Utc::now().timestamp_millis()
);
let copy = host_sudo(&["cp", &backup_str, &tmp])
.await
.context("Failed to stage backup binary via host_sudo")?;
if !copy.success() {
anyhow::bail!(
"cp backup binary to {} failed (exit {:?})",
tmp,
copy.code()
);
}
let _ = host_sudo(&["chmod", "0755", &tmp]).await;
let _ = host_sudo(&["chown", "root:root", &tmp]).await;
let status = host_sudo(&["mv", &tmp, "/usr/local/bin/archipelago"])
.await
.context("Failed to restore backup binary via host_sudo")?;
if !status.success() {
let _ = host_sudo(&["rm", "-f", &tmp]).await;
anyhow::bail!(
"cp backup binary into /usr/local/bin failed (exit {:?})",
"mv backup binary into /usr/local/bin failed (exit {:?})",
status.code()
);
}

View File

@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.88-alpha",
"version": "1.7.89-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.88-alpha",
"version": "1.7.89-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",

View File

@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.88-alpha",
"version": "1.7.89-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",

View File

@ -556,8 +556,8 @@ input[type="radio"]:active + * {
context) stay above the tab bar instead of sliding underneath it. */
@media (max-width: 767px) {
.chat-iframe-mobile {
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px))) !important;
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px))) !important;
height: calc(100vh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
height: calc(100dvh - var(--mobile-tab-bar-height, 72px) - var(--safe-area-top, env(safe-area-inset-top, 0px)) - 16px) !important;
flex: none;
}
}

View File

@ -1,7 +1,7 @@
<template>
<div class="chat-fullscreen">
<!-- Close button + connection indicator (desktop: top-right pill) -->
<div class="chat-mode-pill flex">
<div class="chat-mode-pill hidden md:flex">
<button class="chat-close-btn" :aria-label="t('chat.closeAssistant')" @click="closeChat">
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@ -13,14 +13,7 @@
class="w-2 h-2 rounded-full bg-green-400 ml-2 shadow-[0_0_6px_rgba(74,222,128,0.5)]"
:title="t('chat.aiuiConnected')"
/>
</div>
<!-- Mobile back button -->
<button class="chat-mobile-back md:hidden" :aria-label="t('common.goBack')" @click="closeChat">
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
<!-- Loading indicator while iframe loads -->
<Transition name="fade">
@ -79,8 +72,8 @@ let broker: ContextBroker | null = null
const aiuiUrl = computed(() => {
const envUrl = import.meta.env.VITE_AIUI_URL
if (envUrl) return `${envUrl}?embedded=true`
if (import.meta.env.PROD) return '/aiui/?embedded=true'
if (envUrl) return `${envUrl}?embedded=true&hideClose=true`
if (import.meta.env.PROD) return '/aiui/?embedded=true&hideClose=true'
return ''
})

View File

@ -86,8 +86,8 @@
<div
v-if="route.path === '/dashboard/chat' || route.path === '/dashboard/mesh'"
:class="[
'h-full dashboard-scroll-panel mobile-scroll-pad',
route.path === '/dashboard/mesh' ? 'mesh-dashboard-panel' : '',
'h-full',
route.path === '/dashboard/mesh' ? 'dashboard-scroll-panel mobile-scroll-pad mesh-dashboard-panel' : '',
mobileTabPaddingTop ? 'overflow-y-auto' : ''
]"
:style="{ paddingTop: mobileTabPaddingTop ? (mobileTabPaddingTop + 16) + 'px' : undefined }"

View File

@ -150,6 +150,12 @@ export default defineConfig({
changeOrigin: true,
secure: false,
},
// Serve the node's deployed AIUI same-origin like production (set VITE_AIUI_URL=/aiui/)
'/aiui': {
target: process.env.AIUI_PROXY_TARGET || 'http://127.0.0.1:80',
changeOrigin: true,
secure: false,
},
},
},
build: {

View File

@ -1,27 +1,29 @@
{
"version": "1.7.88-alpha",
"release_date": "2026-06-12",
"version": "1.7.89-alpha",
"release_date": "2026-06-13",
"changelog": [
"AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.",
"Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the `501 Method Not Allowed` response from the previous POST attempt.",
"Validation pending on the AIUI rollback; the rest of the release train remains unchanged."
"The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom.",
"System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates.",
"After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone.",
"Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way.",
"The Lightning wallet now recovers and unlocks itself properly after restarts."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.88-alpha",
"new_version": "1.7.88-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.88-alpha/archipelago",
"sha256": "5948655918c76f7a3e53e2c61c3e5662b4035eb4692545546b17d7f2d4d782c3",
"size_bytes": 44057520
"current_version": "1.7.89-alpha",
"new_version": "1.7.89-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.89-alpha/archipelago",
"sha256": "ecf8b0e5ad4cb0f30e0da85241cf56937502d1d77594b4a7fedafde4e0e8908a",
"size_bytes": 44260776
},
{
"name": "archipelago-frontend-1.7.88-alpha.tar.gz",
"current_version": "1.7.88-alpha",
"new_version": "1.7.88-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.88-alpha/archipelago-frontend-1.7.88-alpha.tar.gz",
"sha256": "158be23c11d3a5d007e2a99d70eaafce2f287c5d3c240c4ab37945245f006a13",
"size_bytes": 184056182
"name": "archipelago-frontend-1.7.89-alpha.tar.gz",
"current_version": "1.7.89-alpha",
"new_version": "1.7.89-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.89-alpha/archipelago-frontend-1.7.89-alpha.tar.gz",
"sha256": "b8b282a55a29661c4bb62702f4cdbbbe0bf4716dacb800199ff99f415a69d840",
"size_bytes": 184055902
}
]
}

View File

@ -1,27 +1,29 @@
{
"version": "1.7.88-alpha",
"release_date": "2026-06-12",
"version": "1.7.89-alpha",
"release_date": "2026-06-13",
"changelog": [
"AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.",
"Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the `501 Method Not Allowed` response from the previous POST attempt.",
"Validation pending on the AIUI rollback; the rest of the release train remains unchanged."
"The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom.",
"System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates.",
"After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone.",
"Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way.",
"The Lightning wallet now recovers and unlocks itself properly after restarts."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.88-alpha",
"new_version": "1.7.88-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.88-alpha/archipelago",
"sha256": "5948655918c76f7a3e53e2c61c3e5662b4035eb4692545546b17d7f2d4d782c3",
"size_bytes": 44057520
"current_version": "1.7.89-alpha",
"new_version": "1.7.89-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.89-alpha/archipelago",
"sha256": "ecf8b0e5ad4cb0f30e0da85241cf56937502d1d77594b4a7fedafde4e0e8908a",
"size_bytes": 44260776
},
{
"name": "archipelago-frontend-1.7.88-alpha.tar.gz",
"current_version": "1.7.88-alpha",
"new_version": "1.7.88-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.88-alpha/archipelago-frontend-1.7.88-alpha.tar.gz",
"sha256": "158be23c11d3a5d007e2a99d70eaafce2f287c5d3c240c4ab37945245f006a13",
"size_bytes": 184056182
"name": "archipelago-frontend-1.7.89-alpha.tar.gz",
"current_version": "1.7.89-alpha",
"new_version": "1.7.89-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.89-alpha/archipelago-frontend-1.7.89-alpha.tar.gz",
"sha256": "b8b282a55a29661c4bb62702f4cdbbbe0bf4716dacb800199ff99f415a69d840",
"size_bytes": 184055902
}
]
}

104
tests/release/run.sh Executable file
View File

@ -0,0 +1,104 @@
#!/bin/bash
# Release gate harness — seed of the full-system test harness.
#
# Ties together the checks that already exist in this repo (catalog drift,
# release manifest, lifecycle bats, vitest, cargo tests) plus live-node
# smoke probes, so "is this release OK?" is one command instead of folklore.
#
# Usage:
# tests/release/run.sh # static + frontend + backend stages
# tests/release/run.sh --quick # static + frontend unit only
# tests/release/run.sh --with-build # also production-build the frontend
# # and verify the dist version changed
# tests/release/run.sh --manifest # also validate releases/manifest.json
# # (run AFTER create-release staged it)
# tests/release/run.sh --live [URL] # also smoke-probe a running node
# # (default http://127.0.0.1)
#
# Flags compose. Exits non-zero on the first failing stage.
#
# CAUTION (.116 and other dev nodes): full `cargo test -p archipelago` has
# hung tool PTYs here before — every cargo invocation below is wrapped in
# `timeout` and scoped to focused module filters.
set -u
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$REPO"
QUICK=0 WITH_BUILD=0 MANIFEST=0 LIVE=0 LIVE_URL="http://127.0.0.1"
while [[ $# -gt 0 ]]; do
case "$1" in
--quick) QUICK=1 ;;
--with-build) WITH_BUILD=1 ;;
--manifest) MANIFEST=1 ;;
--live) LIVE=1; [[ "${2:-}" == http* ]] && { LIVE_URL="$2"; shift; } ;;
*) echo "unknown flag: $1" >&2; exit 2 ;;
esac
shift
done
PASS=() FAIL=()
stage() { # stage <name> <cmd...>
local name="$1"; shift
echo
echo "=== [$name] $*"
if "$@"; then
echo "=== [$name] PASS"
PASS+=("$name")
else
echo "=== [$name] FAIL (exit $?)"
FAIL+=("$name")
summary 1
fi
}
summary() {
echo
echo "──────── release gate summary ────────"
printf 'PASS: %s\n' "${PASS[@]:-none}"
[[ ${#FAIL[@]} -gt 0 ]] && printf 'FAIL: %s\n' "${FAIL[@]}"
exit "${1:-0}"
}
# ── Stage 1: static ──────────────────────────────────────────────────
stage "git-diff-check" git diff --check
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py
if [[ $MANIFEST -eq 1 ]]; then
stage "release-manifest" scripts/check-release-manifest.sh
fi
# ── Stage 2: frontend ────────────────────────────────────────────────
stage "ui-type-check" bash -c 'cd neode-ui && npm run --silent type-check'
stage "ui-unit-tests" bash -c 'cd neode-ui && npx vitest run --silent 2>&1 | tail -4; exit ${PIPESTATUS[0]}'
if [[ $WITH_BUILD -eq 1 ]]; then
# npm run build can fail silently (vue-tsc EACCES burned us before) —
# require the packaged output to actually contain the current version.
VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | cut -d'"' -f2)
stage "ui-build" bash -c 'cd neode-ui && npm run build'
stage "ui-dist-version" bash -c "grep -rqo '${VERSION}' web/dist/neode-ui/assets/*.js"
fi
[[ $QUICK -eq 1 ]] && summary 0
# ── Stage 3: backend ─────────────────────────────────────────────────
stage "cargo-check" timeout 580 cargo check --manifest-path core/Cargo.toml -p archipelago
# Focused suites for the subsystems this release train touched:
# update:: — OTA download/apply/rollback/probe (v1.7.89 hardening)
# lnd — receive address + wallet readiness (v1.7.85.89)
# container::image_versions — image pinning / false-update detection
# scanner — RAII in-flight guard (v1.7.84)
# 1500s: the non-incremental test-profile compile alone takes ~9 min on the
# .116 ThinkPad; 580s expires mid-compile (exit 124) before a single test runs.
stage "cargo-test-weekly" timeout 1500 env CARGO_INCREMENTAL=0 \
cargo test --manifest-path core/Cargo.toml -p archipelago -- \
update:: lnd container::image_versions scanner
# ── Stage 4: live node smoke ─────────────────────────────────────────
if [[ $LIVE -eq 1 ]]; then
stage "live-frontend" bash -c "curl -skf -o /dev/null '$LIVE_URL/' || curl -skf -o /dev/null '${LIVE_URL/http:/https:}/'"
stage "live-aiui" curl -sf -o /dev/null "$LIVE_URL/aiui/"
stage "live-rpc" bash -c "curl -s -X POST '$LIVE_URL/rpc/v1' -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"update.status\",\"params\":{}}' | grep -qE '\"(result|error)\"'"
fi
summary 0