feat(distribution): Phase 2 — artifact apply/revert pipeline + ops interpreter

The mutating side of the unified distribution primitive (spec §8.3). Hotfixes
can now be applied and reverted, first-party, through the task system.

New scripts/cli/commands/artifact/cli_artifact_apply.sh:
- artifactApply <id>: resolve+gate (applies_when / min_lp / max_lp /
  max_footprint / publishers-map role) → fetch+verify payload (sha256 pinned by
  the signed index + minisig) → dry-precheck ALL ops (all-or-nothing) → best-
  effort snapshot → apply each op recording a precise inverse → bring app up →
  auto-rollback (replay undo LIFO, snapshot fallback) → applied-record + History.
- artifactRevert <id>: replay the applied-record's undo log (LIFO).
- Bounded, CLOSED op vocabulary (no run-script/exec, ever): set-config-key,
  set-compose-image, patch-file-if-checksum-matches, set-data-file. An
  unsupported op rejects the whole artifact at precheck (fail-closed).
- Write-target firewall: scope:app → containers/<app>/ only; scope:system →
  configs/ only; the install tree (our code) is off-limits to hotfixes (fork 1).
  Drift guards (expect_current / checksum) skip cleanly rather than clobber.
- Two-tier trust: index minisig-verified vs the footprint key (lpFetchIndex)
  covers the envelope; payload sha256-pinned + minisig-verified; publishers-map
  role gate (a non-official publisher can't claim official). Community per-
  artifact-key sigs are gated off until that tier is enabled.

cli_artifact_commands.sh: apply/revert via the task system (artifact_apply /
artifact_revert types — no allowlist needed), + read-only `applied` list.

cli_updater_commands.sh:
- FIX verified safety bug: updaterApplyApp/RollbackApp called `libreportal backup
  app "$app"` and `... restore latest`, which parse the app name as the ACTION,
  hit the dispatcher's `*)` default (exits 0) — so updates ran with NO snapshot
  and rollback was a silent no-op. Call backupAppStart / restoreAppStart directly.
- FIX updaterRecordHistory jq-silent-skip: was `command -v jq || return 0`
  (silently dropped the audit entry). Now fail-closed with a brace-agnostic
  bash-native prepend fallback; extended with artifact_id/serial/undo_id.

fetch.sh: add _lpJsonEsc (shared JSON-escape for the jq-free fallbacks).
Regenerated source arrays + lazy-load manifest for the new file/functions.

Unit-tested 31/31: every op apply+precheck+undo round-trip, the path-allowlist
firewall (incl. .. traversal + install-tree + cross-app rejection), all-or-
nothing abort, unsupported-op rejection, and the History bash-native fallback
(records + preserves prior entries without jq). A full signed-apply e2e needs
minisign + the signing key (Phase 5 make_hotfix.sh).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-31 20:01:11 +01:00
parent a18d34fcfb
commit 2df4e28a85
7 changed files with 604 additions and 24 deletions

View File

@ -0,0 +1,441 @@
#!/bin/bash
#
# Artifact APPLY pipeline — Phase 2 of the unified distribution primitive
# (docs/roadmap/updates-and-distribution.md §8.3). The MUTATING side: it takes a
# verified artifact from the signed index and applies it reversibly, then can
# revert it. Runs ONLY under the task system (cli_artifact_commands.sh enqueues;
# the processor re-invokes with LIBREPORTAL_TASK_EXEC=1, which is when these run).
#
# Design contracts (all enforced below, fail-closed):
# * The trust core is the same anchor as releases — the index is minisign-
# verified against the root-owned footprint key by lpFetchIndex (source/
# artifacts.sh); the payload is sha256-pinned by that signed index and itself
# minisign-verified. The publishers map + role gate makes a community key
# unable to masquerade as official (registry-ready; first-party-only today).
# * The op vocabulary is a CLOSED allowlist (no run-script/exec/shell, ever).
# An unsupported op rejects the WHOLE artifact at validation, before any write.
# * ALL-OR-NOTHING: every op is dry-prechecked first; one failed precondition
# skips the whole artifact untouched (recorded as a skip, so coverage gaps
# are visible — a customised box may legitimately miss a fix).
# * Two-tier reversibility: every op records a precise inverse into a per-id
# applied-record (the revert path); a best-effort recovery snapshot is also
# taken when a backup location exists (the dirty-op fallback).
# * Mutations write only through the de-sudo funnels (runFileOp / runInstallOp /
# updateConfigOption), never raw sudo. The install tree (our own code) is
# off-limits to hotfixes — code fixes ride signed releases (fork 1).
# --- paths -------------------------------------------------------------------
_artifactGenDir() { echo "${containers_dir%/}/libreportal/frontend/data/updater/generated"; }
_artifactAppliedDir() { echo "$(_artifactGenDir)/applied"; }
_artifactRecordFile() { echo "$(_artifactAppliedDir)/$1.json"; } # $1=id
# Require jq for the apply path. The TRUST core (index sig, payload sha256+sig) is
# jq-free; only walking the bounded op list / envelope fields needs structured
# parsing, and apply is a heavy, rare, privileged path where requiring jq is fine.
_artifactNeedJq() {
command -v jq >/dev/null 2>&1 && return 0
isError "artifact: jq is required to apply/revert artifacts (the op interpreter needs it) — refusing."
return 1
}
# ----------------------------------------------------------------------------
# RESOLVE — fetch+verify the index, pull out one artifact, check the gates.
# Echoes the artifact JSON on success; non-zero (nothing usable) otherwise.
# Sets globals: _ART_INDEX (verified index json), _ART_SCOPE, _ART_APP.
# ----------------------------------------------------------------------------
_artifactResolve() {
local id="$1"
_ART_INDEX="$(lpFetchIndex)" || { isError "artifact: could not fetch/verify the index."; return 1; }
local art; art="$(printf '%s' "$_ART_INDEX" | jq -ce --arg id "$id" '.artifacts[]? | select(.id==$id)' 2>/dev/null)"
[[ -n "$art" ]] || { isError "artifact: id '$id' not found in the signed index."; return 1; }
local type; type="$(jq -r '.type // empty' <<<"$art")"
if [[ "$type" != "hotfix" ]]; then
isError "artifact: type '$type' is not supported by this build (only 'hotfix')."; return 1
fi
# --- trust: publishers map + role gate ---
local pub trust role
pub="$(jq -r '.publisher // empty' <<<"$art")"
trust="$(jq -r '.trust // "official"' <<<"$art")"
role="$(printf '%s' "$_ART_INDEX" | jq -r --arg p "$pub" '.publishers[$p].role // empty' 2>/dev/null)"
if [[ -z "$pub" || -z "$role" ]]; then
isError "artifact: publisher '$pub' is not in the index publishers map — refusing."; return 1
fi
if [[ "$trust" == "official" && "$role" != "official" ]]; then
isError "artifact: '$id' claims trust=official but publisher '$pub' has role '$role' — refusing."; return 1
fi
if [[ "$role" != "official" ]]; then
# Community/custom would require the artifact's own canonical-bytes signature
# against the publisher's key. Not enabled in this first-party-only build.
isError "artifact: publisher role '$role' (non-official) is not enabled yet — refusing."; return 1
fi
# --- gates (applies_when) ---
_ART_APP="$(jq -r '.applies_when.app // empty' <<<"$art")"
_ART_SCOPE="system"; [[ -n "$_ART_APP" ]] && _ART_SCOPE="app"
if [[ "$_ART_SCOPE" == "app" && ! -d "${containers_dir%/}/$_ART_APP" ]]; then
isNotice "artifact: '$id' targets '$_ART_APP' which is not installed — not applicable."; return 2
fi
local minlp maxlp maxfp curlp curfp
minlp="$(jq -r '.applies_when.min_lp // empty' <<<"$art")"
maxlp="$(jq -r '.applies_when.max_lp // empty' <<<"$art")"
maxfp="$(jq -r '.applies_when.max_footprint // empty' <<<"$art")"
curlp="${CFG_LIBREPORTAL_VERSION:-$(cat "$script_dir/version" 2>/dev/null | tr -dc '0-9.')}"
if [[ -n "$minlp" && -n "$curlp" ]] && lpVersionGt "$minlp" "$curlp"; then
isNotice "artifact: '$id' needs LibrePortal >= $minlp (have ${curlp:-?}) — not applicable."; return 2
fi
if [[ -n "$maxlp" && -n "$curlp" ]] && lpVersionGt "$curlp" "$maxlp"; then
isNotice "artifact: '$id' applies only up to LibrePortal $maxlp (have $curlp) — not applicable."; return 2
fi
if [[ -n "$maxfp" ]]; then
curfp="$(lpInstalledFootprintVersion)"
if (( curfp > maxfp )); then
isNotice "artifact: '$id' applies only up to footprint $maxfp (have $curfp) — not applicable."; return 2
fi
fi
printf '%s' "$art"
}
# ----------------------------------------------------------------------------
# PAYLOAD — download, sha256-pin (from the signed index), minisig-verify.
# Echoes the verified payload JSON; non-zero on any failure.
# ----------------------------------------------------------------------------
_artifactFetchPayload() {
local art="$1" base channel url want_sha sig_url tmp pf sf got kind
base="$(lpReleaseBaseUrl)"; channel="$(lpReleaseChannel)"
kind="$(jq -r '.payload.kind // empty' <<<"$art")"
if [[ "$kind" != "ops" ]]; then
isError "artifact: payload.kind '$kind' is not supported by this build (only 'ops')."; return 1
fi
url="$(jq -r '.payload.url // empty' <<<"$art")"
want_sha="$(jq -r '.payload.sha256 // empty' <<<"$art")"
sig_url="$(jq -r '.payload.sig // empty' <<<"$art")"
[[ -n "$url" && -n "$want_sha" ]] || { isError "artifact: payload missing url/sha256 — refusing."; return 1; }
tmp="$(mktemp -d)"; pf="$tmp/payload.json"; sf="$tmp/payload.sig"
# url/sig may be channel-relative ("stable/payloads/..") or absolute.
case "$url" in http*://*) : ;; *) url="$base/$url" ;; esac
if ! _lpDownload "$url" "$pf" 2>/dev/null; then isError "artifact: payload download failed."; rm -rf "$tmp"; return 1; fi
got="$(_lpSha256 "$pf")"
if [[ "$got" != "$want_sha" ]]; then isError "artifact: payload CHECKSUM MISMATCH — refusing."; rm -rf "$tmp"; return 1; fi
if [[ -n "$sig_url" ]]; then
case "$sig_url" in http*://*) : ;; *) sig_url="$base/$sig_url" ;; esac
_lpDownload "$sig_url" "$sf" 2>/dev/null || true
fi
if ! lpVerifyMinisig "$pf" "$sf" >/dev/null; then rm -rf "$tmp"; return 1; fi
cat "$pf"; rm -rf "$tmp"
}
# --- path allowlist (the write-target firewall) -----------------------------
# scope:app -> only under $containers_dir/<app>/
# scope:system -> only under $configs_dir/ (the install/code tree is OFF-LIMITS
# to hotfixes — code rides signed releases; fork 1)
_artifactPathAllowed() {
local path="$1" scope="$2" app="$3" real root
[[ "$path" == *".."* ]] && return 1
real="$(realpath -m -- "$path" 2>/dev/null)"; [[ -n "$real" ]] || return 1
if [[ "$scope" == "app" ]]; then
root="$(realpath -m -- "${containers_dir%/}/$app" 2>/dev/null)"
[[ "$real" == "$root/"* ]] && return 0
return 1
fi
root="$(realpath -m -- "${configs_dir%/}" 2>/dev/null)"
[[ -n "$root" && "$real" == "$root/"* ]] && return 0
return 1
}
# current image of an app's compose (first image: line), quotes stripped.
_artifactComposeImage() {
local app="$1" f="${containers_dir%/}/$1/docker-compose.yml"
[[ -f "$f" ]] || return 1
grep -m1 -E '^\s*image:' "$f" 2>/dev/null | sed -E 's/^\s*image:\s*//; s/["'"'"']//g; s/\s+#.*$//; s/\s+$//'
}
# ----------------------------------------------------------------------------
# OP PRECHECK — pure read; returns 0 if the op can be applied as-is, else 1 with
# a reason on stderr. This is the dry-run that enforces all-or-nothing.
# ----------------------------------------------------------------------------
_artifactOpPrecheck() {
local op_json="$1" scope="$2" app="$3" op
op="$(jq -r '.op // empty' <<<"$op_json")"
case "$op" in
set-config-key)
local key expect cur
key="$(jq -r '.key // empty' <<<"$op_json")"
[[ "$key" =~ ^CFG_[A-Z0-9_]+$ ]] || { isError "op set-config-key: bad key '$key'"; return 1; }
expect="$(jq -r 'if has("expect_current") then .expect_current else "NONE" end' <<<"$op_json")"
if [[ "$expect" != $'NONE' ]]; then
cur="$(grep -m1 "^$key=" "$(findConfigFileForOption "$key" 2>/dev/null)" 2>/dev/null | sed -E "s/^$key=//; s/^\"//; s/\".*$//; s/\s+#.*$//")"
[[ "$cur" == "$expect" ]] || { isError "op set-config-key: $key is '$cur', expected '$expect' (drift)"; return 1; }
fi
;;
set-compose-image)
local from cur
from="$(jq -r '.from // empty' <<<"$op_json")"
[[ -d "${containers_dir%/}/$app" ]] || { isError "op set-compose-image: app '$app' not installed"; return 1; }
cur="$(_artifactComposeImage "$app")"
[[ -n "$from" && "$cur" == "$from" ]] || { isError "op set-compose-image: image is '$cur', expected '$from' (drift)"; return 1; }
;;
patch-file-if-checksum-matches)
local path want got
path="$(jq -r '.path // empty' <<<"$op_json")"
_artifactPathAllowed "$path" "$scope" "$app" || { isError "op patch-file: path '$path' not in the allowlist"; return 1; }
[[ -f "$path" ]] || { isError "op patch-file: '$path' does not exist"; return 1; }
want="$(jq -r '.expect_sha256 // empty' <<<"$op_json")"
got="$(_lpSha256 "$path")"
[[ -n "$want" && "$got" == "$want" ]] || { isError "op patch-file: '$path' sha mismatch (drift) — skipping"; return 1; }
;;
set-data-file)
local path
path="$(jq -r '.path // empty' <<<"$op_json")"
_artifactPathAllowed "$path" "$scope" "$app" || { isError "op set-data-file: path '$path' not in the allowlist"; return 1; }
;;
*)
isError "op '$op' is not supported by this build — rejecting the whole artifact (fail-closed)."
return 1
;;
esac
return 0
}
# ----------------------------------------------------------------------------
# OP APPLY — mutate, and APPEND the precise inverse op (as compact JSON) to the
# file named by $4 (the undo log). Returns non-zero on failure.
# ----------------------------------------------------------------------------
_artifactOpApply() {
local op_json="$1" scope="$2" app="$3" undo_file="$4" op
op="$(jq -r '.op // empty' <<<"$op_json")"
case "$op" in
set-config-key)
local key value cur f
key="$(jq -r '.key' <<<"$op_json")"; value="$(jq -r '.value' <<<"$op_json")"
f="$(findConfigFileForOption "$key" 2>/dev/null)"
cur="$(grep -m1 "^$key=" "$f" 2>/dev/null | sed -E "s/^$key=//; s/^\"//; s/\".*$//; s/\s+#.*$//")"
updateConfigOption "$key" "$value" || return 1
jq -cn --arg k "$key" --arg v "$cur" '{op:"set-config-key", key:$k, value:$v}' >> "$undo_file"
;;
set-compose-image)
local image cur f esc_cur esc_img
image="$(jq -r '.image' <<<"$op_json")"
f="${containers_dir%/}/$app/docker-compose.yml"
cur="$(_artifactComposeImage "$app")"
esc_cur="$(printf '%s' "$cur" | sed -e 's/[\/&]/\\&/g')"
esc_img="$(printf '%s' "$image" | sed -e 's/[\/&]/\\&/g')"
# Replace the first image: line's value, preserving indentation.
runFileOp sed -i "0,/^\([[:space:]]*\)image:.*/s//\1image: $esc_img/" "$f" || return 1
jq -cn --arg img "$cur" '{op:"set-compose-image", image:$img}' >> "$undo_file"
;;
patch-file-if-checksum-matches|set-data-file)
local path existed prior_b64 content_b64
path="$(jq -r '.path' <<<"$op_json")"
content_b64="$(jq -r '.content_b64 // empty' <<<"$op_json")"
[[ -n "$content_b64" ]] || { isError "op $op: missing content_b64"; return 1; }
if [[ -f "$path" ]]; then existed=true; prior_b64="$(base64 -w0 < "$path" 2>/dev/null)"; else existed=false; prior_b64=""; fi
if ! printf '%s' "$content_b64" | base64 -d 2>/dev/null | runFileWrite "$path"; then
isError "op $op: write to '$path' failed"; return 1
fi
jq -cn --arg p "$path" --arg e "$existed" --arg b "$prior_b64" \
'{op:"restore-file", path:$p, existed:($e=="true"), content_b64:$b}' >> "$undo_file"
;;
*)
isError "op '$op': no apply handler (should have been rejected at precheck)"; return 1
;;
esac
return 0
}
# ----------------------------------------------------------------------------
# OP UNDO — apply one inverse op recorded by _artifactOpApply (the revert path).
# ----------------------------------------------------------------------------
_artifactOpUndo() {
local op_json="$1" app="$2" op
op="$(jq -r '.op // empty' <<<"$op_json")"
case "$op" in
set-config-key)
local key value; key="$(jq -r '.key' <<<"$op_json")"; value="$(jq -r '.value' <<<"$op_json")"
updateConfigOption "$key" "$value" || return 1
;;
set-compose-image)
local image f esc_img; image="$(jq -r '.image' <<<"$op_json")"
f="${containers_dir%/}/$app/docker-compose.yml"
esc_img="$(printf '%s' "$image" | sed -e 's/[\/&]/\\&/g')"
runFileOp sed -i "0,/^\([[:space:]]*\)image:.*/s//\1image: $esc_img/" "$f" || return 1
;;
restore-file)
local path existed content_b64
path="$(jq -r '.path' <<<"$op_json")"; existed="$(jq -r '.existed' <<<"$op_json")"
content_b64="$(jq -r '.content_b64 // empty' <<<"$op_json")"
if [[ "$existed" == "true" ]]; then
printf '%s' "$content_b64" | base64 -d 2>/dev/null | runFileWrite "$path" || return 1
else
runFileOp rm -f "$path" || return 1
fi
;;
*) isError "undo: unknown inverse op '$op'"; return 1 ;;
esac
return 0
}
# ----------------------------------------------------------------------------
# artifactApply <id> — the full pipeline (steps 0-9). Idempotent-ish: a
# re-apply re-prechecks (drift guards skip already-applied ops cleanly).
# ----------------------------------------------------------------------------
artifactApply() {
local id="$1"
_artifactNeedJq || return 1
[[ -n "$id" ]] || { isError "artifactApply: no id"; return 1; }
isHeader "Applying hotfix: $id"
# 0. RESOLVE + gates
local art rc
art="$(_artifactResolve "$id")"; rc=$?
if [[ $rc -eq 2 ]]; then updaterRecordHistory "${_ART_APP:-}" "hotfix" "" "$id" "not-applicable" "$id" "$(_lpJsonNum "$_ART_INDEX" index_serial)" ""; return 0; fi
[[ $rc -eq 0 && -n "$art" ]] || { updaterRecordHistory "" "hotfix" "" "$id" "rejected" "$id" "" ""; return 1; }
local app="$_ART_APP" scope="$_ART_SCOPE" serial title
serial="$(_lpJsonNum "$_ART_INDEX" index_serial)"
title="$(jq -r '.title // empty' <<<"$art")"
isNotice "$title (scope=$scope${app:+, app=$app})"
# 4. PAYLOAD (fetch+verify)
local payload; payload="$(_artifactFetchPayload "$art")" || { updaterRecordHistory "$app" "hotfix" "" "$id" "verify-failed" "$id" "$serial" ""; return 1; }
# collect ops
local ops_count; ops_count="$(jq '.ops | length' <<<"$payload" 2>/dev/null || echo 0)"
if ! [[ "$ops_count" =~ ^[0-9]+$ ]] || (( ops_count == 0 )); then
isError "artifact: payload has no ops."; updaterRecordHistory "$app" "hotfix" "" "$id" "empty" "$id" "$serial" ""; return 1
fi
# 6a. DRY-PRECHECK ALL (all-or-nothing)
local i op_json
for (( i=0; i<ops_count; i++ )); do
op_json="$(jq -c ".ops[$i]" <<<"$payload")"
if ! _artifactOpPrecheck "$op_json" "$scope" "$app"; then
isNotice "Precheck failed — skipping the whole hotfix untouched (no changes made)."
updaterRecordHistory "$app" "hotfix" "" "$id" "skipped-precheck" "$id" "$serial" ""
return 0
fi
done
# 5. SNAPSHOT (best-effort safety net; undo log is the guaranteed precise path)
if [[ "$scope" == "app" ]]; then
isNotice "Recovery snapshot of $app"
backupAppStart "$app" >/dev/null 2>&1 || isNotice "No snapshot taken (no backup location?) — relying on the precise undo log."
else
backupSystemConfig >/dev/null 2>&1 || isNotice "No system-config snapshot taken — relying on the precise undo log."
fi
# 6b. APPLY (collect inverse ops into the undo log)
local undo_file; undo_file="$(mktemp)"
local applied_ok=1
for (( i=0; i<ops_count; i++ )); do
op_json="$(jq -c ".ops[$i]" <<<"$payload")"
if ! _artifactOpApply "$op_json" "$scope" "$app" "$undo_file"; then applied_ok=0; break; fi
done
# bring the app up / regenerate system config so changes take effect
if [[ "$scope" == "app" ]]; then
dockerComposeUp "$app" >/dev/null 2>&1 || applied_ok=0
else
declare -F webuiGenerateSystemConfigs >/dev/null 2>&1 && webuiGenerateSystemConfigs >/dev/null 2>&1 || true
fi
if [[ "$applied_ok" != "1" ]]; then
# 8. AUTO-ROLLBACK — replay the undo log LIFO, then the snapshot as fallback.
isNotice "Apply failed — rolling back…"
_artifactReplayUndoFile "$undo_file" "$app" "reverse"
[[ "$scope" == "app" ]] && { restoreAppStart "$app" latest "" >/dev/null 2>&1 || true; dockerComposeUp "$app" >/dev/null 2>&1 || true; }
rm -f "$undo_file"
updaterRecordHistory "$app" "hotfix" "" "$id" "rolled-back" "$id" "$serial" ""
isError "Hotfix $id failed and was rolled back."
return 1
fi
# 9. RECORD (applied-record with the undo log) + History + serial high-water
_artifactWriteRecord "$id" "$art" "$serial" "$undo_file"
rm -f "$undo_file"
_artifactRegenAppliedManifest
updaterRecordHistory "$app" "hotfix" "" "$id" "applied" "$id" "$serial" "$id"
isSuccessful "Hotfix $id applied. Reversible via: libreportal artifact revert $id"
return 0
}
# Replay every inverse op in $1; "reverse" applies them LIFO (for rollback).
_artifactReplayUndoFile() {
local file="$1" app="$2" order="$3"
[[ -s "$file" ]] || return 0
local lines; mapfile -t lines < "$file"
if [[ "$order" == "reverse" ]]; then
local n=${#lines[@]} j
for (( j=n-1; j>=0; j-- )); do [[ -n "${lines[j]}" ]] && _artifactOpUndo "${lines[j]}" "$app" || true; done
else
local l; for l in "${lines[@]}"; do [[ -n "$l" ]] && _artifactOpUndo "$l" "$app" || true; done
fi
}
# Persist the applied-record (metadata + the undo log as a JSON array).
_artifactWriteRecord() {
local id="$1" art="$2" serial="$3" undo_file="$4"
local dir; dir="$(_artifactAppliedDir)"
runFileOp mkdir -p "$dir" 2>/dev/null || true
local ts; ts="$(date -Iseconds 2>/dev/null || date)"
local undo_arr="[]"
[[ -s "$undo_file" ]] && undo_arr="$(jq -cs '.' < "$undo_file" 2>/dev/null || echo '[]')"
local rec
rec="$(jq -cn --argjson art "$art" --arg ts "$ts" --arg serial "$serial" --argjson undo "$undo_arr" \
'{id:$art.id, type:$art.type, version:($art.version//1), app:($art.applies_when.app//null),
severity:($art.severity//"tweak"), title:($art.title//""), why:($art.why//""),
applied_at:$ts, serial:$serial, undo:$undo}')"
printf '%s' "$rec" | runFileWrite "$(_artifactRecordFile "$id")"
}
# Rebuild artifacts_applied.json (the WebUI-read manifest) from applied/*.json.
_artifactRegenAppliedManifest() {
local dir; dir="$(_artifactAppliedDir)"
local out; out="$(_artifactGenDir)/artifacts_applied.json"
local body="[]"
if compgen -G "$dir/*.json" >/dev/null 2>&1; then
body="$(jq -cs '[.[] | {id,type,app,severity,title,why,applied_at,serial,version}]' "$dir"/*.json 2>/dev/null || echo '[]')"
fi
local ts; ts="$(date -Iseconds 2>/dev/null || date)"
printf '%s' "$(jq -cn --argjson a "$body" --arg ts "$ts" '{generated_at:$ts, applied:$a}')" | runFileWrite "$out"
}
# ----------------------------------------------------------------------------
# artifactRevert <id> — replay the applied-record's undo log (LIFO), then bring
# the app up / regen config. Removes the record + logs to History.
# ----------------------------------------------------------------------------
artifactRevert() {
local id="$1"
_artifactNeedJq || return 1
[[ -n "$id" ]] || { isError "artifactRevert: no id"; return 1; }
local rf; rf="$(_artifactRecordFile "$id")"
[[ -f "$rf" ]] || { isError "artifact: '$id' is not recorded as applied — nothing to revert."; return 1; }
isHeader "Reverting hotfix: $id"
local rec; rec="$(cat "$rf")"
local app; app="$(jq -r '.app // empty' <<<"$rec")"
local serial; serial="$(jq -r '.serial // empty' <<<"$rec")"
# Replay the undo ops in reverse order (LIFO).
local undo_file; undo_file="$(mktemp)"
jq -c '.undo[]?' <<<"$rec" > "$undo_file" 2>/dev/null
_artifactReplayUndoFile "$undo_file" "$app" "reverse"
rm -f "$undo_file"
if [[ -n "$app" ]]; then dockerComposeUp "$app" >/dev/null 2>&1 || true
else declare -F webuiGenerateSystemConfigs >/dev/null 2>&1 && webuiGenerateSystemConfigs >/dev/null 2>&1 || true; fi
runFileOp rm -f "$rf" 2>/dev/null || true
_artifactRegenAppliedManifest
updaterRecordHistory "$app" "hotfix" "$id" "" "reverted" "$id" "$serial" ""
isSuccessful "Hotfix $id reverted."
return 0
}

View File

@ -4,17 +4,18 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Dispatched automatically by cli_initialize.sh (category -> cliHandleArtifactCommands). # Dispatched automatically by cli_initialize.sh (category -> cliHandleArtifactCommands).
# #
# This is PHASE 1 of the unified distribution primitive: the READ side. It fetches # The unified distribution primitive. The READ side (`index`/`applied`) fetches +
# and verifies the team-signed artifact index (hotfixes today; apps/themes/ # verifies the team-signed artifact index (hotfixes today; apps/themes/components
# components later — all the same envelope) and lists what's available. It makes # later — all one envelope) and lists what's available/applied; it changes nothing,
# NO changes to the system, so — like `updater check` — it runs directly rather # so — like `updater check` — it runs directly. The state-changing `apply`/`revert`
# than through the task system. The state-changing `apply`/`rollback` verbs (which # verbs route through the TASK system (snapshot → bounded declarative ops →
# DO route through tasks → snapshot → declarative ops → rollback → History) arrive # auto-rollback → History), never a mutating API. See docs/roadmap/updates-and-
# in Phase 2. See docs/roadmap/updates-and-distribution.md. # distribution.md and cli_artifact_apply.sh.
cliHandleArtifactCommands() cliHandleArtifactCommands()
{ {
local sub="$initial_command2" local sub="$initial_command2"
local id="$initial_command3"
# Lazy-loader gap: ensure the read primitives are defined. These are new # Lazy-loader gap: ensure the read primitives are defined. These are new
# files; the array/manifest regen self-heals them on deploy, but this covers # files; the array/manifest regen self-heals them on deploy, but this covers
@ -24,17 +25,62 @@ cliHandleArtifactCommands()
source "$install_scripts_dir/source/fetch.sh" 2>/dev/null source "$install_scripts_dir/source/fetch.sh" 2>/dev/null
source "$install_scripts_dir/source/artifacts.sh" 2>/dev/null source "$install_scripts_dir/source/artifacts.sh" 2>/dev/null
fi fi
# The apply pipeline lives in a sibling file — source it on demand too.
if ! declare -F artifactApply >/dev/null 2>&1; then
source "$install_scripts_dir/cli/commands/artifact/cli_artifact_apply.sh" 2>/dev/null
fi
case "$sub" in case "$sub" in
""|"index"|"list") ""|"index"|"list")
artifactListIndex artifactListIndex
;; ;;
"applied")
artifactListApplied
;;
"apply")
if [[ -z "$id" ]]; then isError "Usage: libreportal artifact apply <id>"; return 1; fi
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
artifactApply "$id"
else
cliTaskRun "libreportal artifact apply $id" "artifact_apply" "$id" ""
fi
;;
"revert"|"rollback")
if [[ -z "$id" ]]; then isError "Usage: libreportal artifact revert <id>"; return 1; fi
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
artifactRevert "$id"
else
cliTaskRun "libreportal artifact revert $id" "artifact_revert" "$id" ""
fi
;;
*) *)
cliShowArtifactHelp cliShowArtifactHelp
;; ;;
esac esac
} }
# List the applied hotfixes (read-only) from the per-id applied records.
artifactListApplied()
{
isHeader "Applied hotfixes"
local dir; dir="${containers_dir%/}/libreportal/frontend/data/updater/generated/applied"
if ! compgen -G "$dir/*.json" >/dev/null 2>&1; then
isSuccessful "0 hotfixes applied."
return 0
fi
local n=0 f id title app
for f in "$dir"/*.json; do
n=$((n+1))
if command -v jq >/dev/null 2>&1; then
id="$(jq -r '.id' "$f" 2>/dev/null)"; title="$(jq -r '.title // ""' "$f" 2>/dev/null)"; app="$(jq -r '.app // ""' "$f" 2>/dev/null)"
echo "$id${app:+ [$app]}$title"
else
echo "$(basename "$f" .json)"
fi
done
isSuccessful "$n hotfix(es) applied. Revert with: libreportal artifact revert <id>"
}
# Fetch + verify the signed index and print a human summary. Read-only. # Fetch + verify the signed index and print a human summary. Read-only.
artifactListIndex() artifactListIndex()
{ {

View File

@ -8,13 +8,17 @@ cliShowArtifactHelp()
echo "" echo ""
echo "Available Artifact Commands:" echo "Available Artifact Commands:"
echo "" echo ""
echo " libreportal artifact index - Fetch + verify the signed artifact index and list what's available" echo " libreportal artifact index - Fetch + verify the signed index; list what's available"
echo " libreportal artifact applied - List the hotfixes currently applied to this install"
echo " libreportal artifact apply <id> - Apply a hotfix (snapshot + reversible declarative ops)"
echo " libreportal artifact revert <id> - Revert a previously-applied hotfix (replays its undo log)"
echo "" echo ""
echo "An 'artifact' is anything LibrePortal pulls from the outside and applies" echo "An 'artifact' is anything LibrePortal pulls from the outside and applies"
echo "reversibly — a hotfix today; apps / themes / components later. They share" echo "reversibly — a hotfix today; apps / themes / components later. They share"
echo "one team-signed catalog (index.json) on the same channel as the version" echo "one team-signed catalog (index.json) on the same channel as the version"
echo "check. This read side verifies the catalog against the root-owned signing" echo "check, verified against the root-owned signing key. apply/revert run through"
echo "key; the apply pipeline (snapshot → declarative ops → rollback → History)" echo "the task system (never a mutating API): each apply dry-prechecks a bounded,"
echo "lands in a later phase. See docs/roadmap/updates-and-distribution.md." echo "declarative op list (no scripts), snapshots, applies, records a precise undo,"
echo "and auto-rolls-back on failure. See docs/roadmap/updates-and-distribution.md."
echo "" echo ""
} }

View File

@ -76,9 +76,14 @@ updaterApplyApp()
isHeader "Updating $app (a recovery snapshot is taken first)" isHeader "Updating $app (a recovery snapshot is taken first)"
# 1. DISASTER RECOVERY — snapshot before touching anything. # 1. DISASTER RECOVERY — snapshot before touching anything. Call the backup
# function directly (we already run under LIBREPORTAL_TASK_EXEC): the CLI form
# `backup app "$app"` parsed the app name as the ACTION, hit the dispatcher's
# `*)` default (a notice that exits 0), so the `if !` guard passed and the app
# was updated with NO snapshot — and rollback below was a no-op that reported
# success. backupAppStart is the real entry point and returns 0/1 honestly.
isNotice "Snapshotting $app before update…" isNotice "Snapshotting $app before update…"
if ! libreportal backup app "$app" >/dev/null 2>&1; then if ! backupAppStart "$app" >/dev/null 2>&1; then
isNotice "Pre-update snapshot did not complete cleanly — continuing is risky; aborting $app update." isNotice "Pre-update snapshot did not complete cleanly — continuing is risky; aborting $app update."
updaterRecordHistory "$app" "update" "" "" "aborted-no-snapshot" updaterRecordHistory "$app" "update" "" "" "aborted-no-snapshot"
return 1 return 1
@ -125,8 +130,10 @@ updaterRollbackApp()
{ {
local app="$1" mode="$2" local app="$1" mode="$2"
[[ "$mode" != "auto" ]] && isHeader "Rolling $app back to its pre-update snapshot" [[ "$mode" != "auto" ]] && isHeader "Rolling $app back to its pre-update snapshot"
# Delegate to the backup engine's restore (latest snapshot for this app). # Delegate to the restore engine (latest snapshot for this app). Call the
if libreportal backup app "$app" restore latest >/dev/null 2>&1; then # function directly — the old `backup app "$app" restore latest` CLI form was
# malformed (parsed as action="$app") so it silently did nothing yet exited 0.
if restoreAppStart "$app" latest "" >/dev/null 2>&1; then
dockerComposeUp "$app" >/dev/null 2>&1 || true dockerComposeUp "$app" >/dev/null 2>&1 || true
[[ "$mode" != "auto" ]] && updaterRecordHistory "$app" "rollback" "" "" "rolled-back" [[ "$mode" != "auto" ]] && updaterRecordHistory "$app" "rollback" "" "" "rolled-back"
isSuccessful "$app restored from its pre-update snapshot." isSuccessful "$app restored from its pre-update snapshot."
@ -150,19 +157,43 @@ updaterComposePull()
fi fi
} }
# Append an entry to history.json (best-effort; needs jq, skips silently if absent). # Append an entry to history.json. The "nothing silent" guarantee depends on this
# actually recording, so it is FAIL-CLOSED, not best-effort: with jq we prepend +
# cap to 200; WITHOUT jq we fall back to a brace-agnostic bash-native prepend
# (no 200-cap, the one thing jq bought) rather than silently dropping the entry.
# Args 6-8 are optional and carry the artifact channel's metadata.
updaterRecordHistory() updaterRecordHistory()
{ {
local app="$1" action="$2" from="$3" to="$4" result="$5" local app="$1" action="$2" from="$3" to="$4" result="$5"
local artifact_id="${6:-}" serial="${7:-}" undo_id="${8:-}"
local f="$containers_dir/libreportal/frontend/data/updater/generated/history.json" local f="$containers_dir/libreportal/frontend/data/updater/generated/history.json"
command -v jq >/dev/null 2>&1 || return 0
[ -f "$f" ] || printf '{ "entries": [] }\n' | runFileWrite "$f"
local ts; ts="$(date -Iseconds 2>/dev/null || date)" local ts; ts="$(date -Iseconds 2>/dev/null || date)"
local tmp; tmp="$(mktemp)" [ -f "$f" ] || printf '{ "entries": [] }\n' | runFileWrite "$f"
if jq --arg ts "$ts" --arg app "$app" --arg action "$action" --arg from "$from" --arg to "$to" --arg result "$result" \
'.entries = ([{ts:$ts, app:$app, action:$action, from:$from, to:$to, result:$result}] + (.entries // []))[0:200]' \ if command -v jq >/dev/null 2>&1; then
"$f" > "$tmp" 2>/dev/null; then local tmp; tmp="$(mktemp)"
runFileWrite "$f" < "$tmp" if jq --arg ts "$ts" --arg app "$app" --arg action "$action" --arg from "$from" --arg to "$to" \
--arg result "$result" --arg aid "$artifact_id" --arg serial "$serial" --arg undo "$undo_id" \
'.entries = ([{ts:$ts, app:$app, action:$action, from:$from, to:$to, result:$result, artifact_id:$aid, serial:$serial, undo_id:$undo}] + (.entries // []))[0:200]' \
"$f" > "$tmp" 2>/dev/null; then
runFileWrite "$f" < "$tmp"; rm -f "$tmp"; return 0
fi
rm -f "$tmp"
isError "updaterRecordHistory: jq write failed for $f — using bash fallback"
fi fi
rm -f "$tmp"
# jq absent or failed — bash-native, brace-agnostic prepend. History entries
# are flat (scalar fields only), so splicing on the outer [ ... ] is safe.
local entry
entry="{\"ts\":\"$(_lpJsonEsc "$ts")\",\"app\":\"$(_lpJsonEsc "$app")\",\"action\":\"$(_lpJsonEsc "$action")\",\"from\":\"$(_lpJsonEsc "$from")\",\"to\":\"$(_lpJsonEsc "$to")\",\"result\":\"$(_lpJsonEsc "$result")\",\"artifact_id\":\"$(_lpJsonEsc "$artifact_id")\",\"serial\":\"$(_lpJsonEsc "$serial")\",\"undo_id\":\"$(_lpJsonEsc "$undo_id")\"}"
local cur inner
cur="$(cat "$f" 2>/dev/null)"
inner="${cur#*[}"; inner="${inner%]*}"
inner="$(printf '%s' "$inner" | tr -d '\n' | sed -E 's/^[[:space:]]*//; s/[[:space:]]*$//')"
local newcontent
if [[ -z "$inner" ]]; then newcontent="{ \"entries\": [$entry] }"
else newcontent="{ \"entries\": [$entry, $inner] }"; fi
local tmp2; tmp2="$(mktemp)"; printf '%s\n' "$newcontent" > "$tmp2"
runFileWrite "$f" < "$tmp2"; rm -f "$tmp2"
return 0
} }

View File

@ -30,6 +30,9 @@ _lpDownload() { # _lpDownload <url> <outfile|->
_lpSha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1; fi; } _lpSha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1; fi; }
_lpJsonStr() { printf '%s' "$1" | grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed -E 's/.*"([^"]*)"$/\1/'; } _lpJsonStr() { printf '%s' "$1" | grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed -E 's/.*"([^"]*)"$/\1/'; }
_lpJsonNum() { printf '%s' "$1" | grep -oE "\"$2\"[[:space:]]*:[[:space:]]*[0-9]+" | head -1 | grep -oE '[0-9]+$'; } _lpJsonNum() { printf '%s' "$1" | grep -oE "\"$2\"[[:space:]]*:[[:space:]]*[0-9]+" | head -1 | grep -oE '[0-9]+$'; }
# JSON-escape a scalar for safe inline embedding (backslash first, then quote).
# Used by the jq-free fallbacks (history fallback, artifact applied-records).
_lpJsonEsc() { local s="${1//\\/\\\\}"; s="${s//\"/\\\"}"; printf '%s' "$s"; }
# Verify a downloaded file against a detached minisig using the ROOT-OWNED # Verify a downloaded file against a detached minisig using the ROOT-OWNED
# footprint public key (/usr/local/lib/libreportal/libreportal.pub). The key is # footprint public key (/usr/local/lib/libreportal/libreportal.pub). The key is

View File

@ -10,6 +10,7 @@ cli_scripts=(
"cli/commands/app/cli_app_header.sh" "cli/commands/app/cli_app_header.sh"
"cli/commands/app/cli_app_restore.sh" "cli/commands/app/cli_app_restore.sh"
"cli/commands/app/cli_app_tool_list.sh" "cli/commands/app/cli_app_tool_list.sh"
"cli/commands/artifact/cli_artifact_apply.sh"
"cli/commands/artifact/cli_artifact_commands.sh" "cli/commands/artifact/cli_artifact_commands.sh"
"cli/commands/artifact/cli_artifact_header.sh" "cli/commands/artifact/cli_artifact_header.sh"
"cli/commands/backup/cli_backup_commands.sh" "cli/commands/backup/cli_backup_commands.sh"

View File

@ -101,7 +101,24 @@ declare -gA LP_FN_MAP=(
[appUpdateSpecifics_nextcloud]="nextcloud/scripts/nextcloud_update_specifics.sh" [appUpdateSpecifics_nextcloud]="nextcloud/scripts/nextcloud_update_specifics.sh"
[appUpdateSpecifics_pihole]="pihole/scripts/pihole_update_specifics.sh" [appUpdateSpecifics_pihole]="pihole/scripts/pihole_update_specifics.sh"
[appWebuiRefresh_gluetun]="gluetun/scripts/gluetun_providers.sh" [appWebuiRefresh_gluetun]="gluetun/scripts/gluetun_providers.sh"
[_artifactAppliedDir]="cli/commands/artifact/cli_artifact_apply.sh"
[artifactApply]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactComposeImage]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactFetchPayload]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactGenDir]="cli/commands/artifact/cli_artifact_apply.sh"
[artifactListApplied]="cli/commands/artifact/cli_artifact_commands.sh"
[artifactListIndex]="cli/commands/artifact/cli_artifact_commands.sh" [artifactListIndex]="cli/commands/artifact/cli_artifact_commands.sh"
[_artifactNeedJq]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactOpApply]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactOpPrecheck]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactOpUndo]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactPathAllowed]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactRecordFile]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactRegenAppliedManifest]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactReplayUndoFile]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactResolve]="cli/commands/artifact/cli_artifact_apply.sh"
[artifactRevert]="cli/commands/artifact/cli_artifact_apply.sh"
[_artifactWriteRecord]="cli/commands/artifact/cli_artifact_apply.sh"
[atomicWriteWebUI]="webui/data/utils/webui_atomic_write.sh" [atomicWriteWebUI]="webui/data/utils/webui_atomic_write.sh"
[authAdapter_adguard_setPassword]="adguard/scripts/adguard_auth.sh" [authAdapter_adguard_setPassword]="adguard/scripts/adguard_auth.sh"
[authAdapter_bookstack_createUser]="bookstack/scripts/bookstack_auth.sh" [authAdapter_bookstack_createUser]="bookstack/scripts/bookstack_auth.sh"
@ -569,6 +586,7 @@ declare -gA LP_FN_MAP=(
[lpIndexArtifactIds]="source/artifacts.sh" [lpIndexArtifactIds]="source/artifacts.sh"
[lpIndexTop]="source/artifacts.sh" [lpIndexTop]="source/artifacts.sh"
[lpInstalledFootprintVersion]="source/fetch.sh" [lpInstalledFootprintVersion]="source/fetch.sh"
[_lpJsonEsc]="source/fetch.sh"
[_lpJsonNum]="source/fetch.sh" [_lpJsonNum]="source/fetch.sh"
[_lpJsonStr]="source/fetch.sh" [_lpJsonStr]="source/fetch.sh"
[lpRegen]="webui/webui_regen.sh" [lpRegen]="webui/webui_regen.sh"
@ -1020,7 +1038,24 @@ declare -gA LP_FN_ROOT=(
[appUpdateSpecifics_nextcloud]="containers" [appUpdateSpecifics_nextcloud]="containers"
[appUpdateSpecifics_pihole]="containers" [appUpdateSpecifics_pihole]="containers"
[appWebuiRefresh_gluetun]="containers" [appWebuiRefresh_gluetun]="containers"
[_artifactAppliedDir]="scripts"
[artifactApply]="scripts"
[_artifactComposeImage]="scripts"
[_artifactFetchPayload]="scripts"
[_artifactGenDir]="scripts"
[artifactListApplied]="scripts"
[artifactListIndex]="scripts" [artifactListIndex]="scripts"
[_artifactNeedJq]="scripts"
[_artifactOpApply]="scripts"
[_artifactOpPrecheck]="scripts"
[_artifactOpUndo]="scripts"
[_artifactPathAllowed]="scripts"
[_artifactRecordFile]="scripts"
[_artifactRegenAppliedManifest]="scripts"
[_artifactReplayUndoFile]="scripts"
[_artifactResolve]="scripts"
[artifactRevert]="scripts"
[_artifactWriteRecord]="scripts"
[atomicWriteWebUI]="scripts" [atomicWriteWebUI]="scripts"
[authAdapter_adguard_setPassword]="containers" [authAdapter_adguard_setPassword]="containers"
[authAdapter_bookstack_createUser]="containers" [authAdapter_bookstack_createUser]="containers"
@ -1488,6 +1523,7 @@ declare -gA LP_FN_ROOT=(
[lpIndexArtifactIds]="scripts" [lpIndexArtifactIds]="scripts"
[lpIndexTop]="scripts" [lpIndexTop]="scripts"
[lpInstalledFootprintVersion]="scripts" [lpInstalledFootprintVersion]="scripts"
[_lpJsonEsc]="scripts"
[_lpJsonNum]="scripts" [_lpJsonNum]="scripts"
[_lpJsonStr]="scripts" [_lpJsonStr]="scripts"
[lpRegen]="scripts" [lpRegen]="scripts"
@ -1959,7 +1995,24 @@ appUpdateSpecifics_libreportal() { source "${install_containers_dir}libreportal/
appUpdateSpecifics_nextcloud() { source "${install_containers_dir}nextcloud/scripts/nextcloud_update_specifics.sh"; appUpdateSpecifics_nextcloud "$@"; } appUpdateSpecifics_nextcloud() { source "${install_containers_dir}nextcloud/scripts/nextcloud_update_specifics.sh"; appUpdateSpecifics_nextcloud "$@"; }
appUpdateSpecifics_pihole() { source "${install_containers_dir}pihole/scripts/pihole_update_specifics.sh"; appUpdateSpecifics_pihole "$@"; } appUpdateSpecifics_pihole() { source "${install_containers_dir}pihole/scripts/pihole_update_specifics.sh"; appUpdateSpecifics_pihole "$@"; }
appWebuiRefresh_gluetun() { source "${install_containers_dir}gluetun/scripts/gluetun_providers.sh"; appWebuiRefresh_gluetun "$@"; } appWebuiRefresh_gluetun() { source "${install_containers_dir}gluetun/scripts/gluetun_providers.sh"; appWebuiRefresh_gluetun "$@"; }
_artifactAppliedDir() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactAppliedDir "$@"; }
artifactApply() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; artifactApply "$@"; }
_artifactComposeImage() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactComposeImage "$@"; }
_artifactFetchPayload() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactFetchPayload "$@"; }
_artifactGenDir() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactGenDir "$@"; }
artifactListApplied() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_commands.sh"; artifactListApplied "$@"; }
artifactListIndex() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_commands.sh"; artifactListIndex "$@"; } artifactListIndex() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_commands.sh"; artifactListIndex "$@"; }
_artifactNeedJq() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactNeedJq "$@"; }
_artifactOpApply() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactOpApply "$@"; }
_artifactOpPrecheck() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactOpPrecheck "$@"; }
_artifactOpUndo() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactOpUndo "$@"; }
_artifactPathAllowed() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactPathAllowed "$@"; }
_artifactRecordFile() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactRecordFile "$@"; }
_artifactRegenAppliedManifest() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactRegenAppliedManifest "$@"; }
_artifactReplayUndoFile() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactReplayUndoFile "$@"; }
_artifactResolve() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactResolve "$@"; }
artifactRevert() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; artifactRevert "$@"; }
_artifactWriteRecord() { source "${install_scripts_dir}cli/commands/artifact/cli_artifact_apply.sh"; _artifactWriteRecord "$@"; }
atomicWriteWebUI() { source "${install_scripts_dir}webui/data/utils/webui_atomic_write.sh"; atomicWriteWebUI "$@"; } atomicWriteWebUI() { source "${install_scripts_dir}webui/data/utils/webui_atomic_write.sh"; atomicWriteWebUI "$@"; }
authAdapter_adguard_setPassword() { source "${install_containers_dir}adguard/scripts/adguard_auth.sh"; authAdapter_adguard_setPassword "$@"; } authAdapter_adguard_setPassword() { source "${install_containers_dir}adguard/scripts/adguard_auth.sh"; authAdapter_adguard_setPassword "$@"; }
authAdapter_bookstack_createUser() { source "${install_containers_dir}bookstack/scripts/bookstack_auth.sh"; authAdapter_bookstack_createUser "$@"; } authAdapter_bookstack_createUser() { source "${install_containers_dir}bookstack/scripts/bookstack_auth.sh"; authAdapter_bookstack_createUser "$@"; }
@ -2427,6 +2480,7 @@ _lpFetchTool() { source "${install_scripts_dir}source/fetch.sh"; _lpFetchTool "$
lpIndexArtifactIds() { source "${install_scripts_dir}source/artifacts.sh"; lpIndexArtifactIds "$@"; } lpIndexArtifactIds() { source "${install_scripts_dir}source/artifacts.sh"; lpIndexArtifactIds "$@"; }
lpIndexTop() { source "${install_scripts_dir}source/artifacts.sh"; lpIndexTop "$@"; } lpIndexTop() { source "${install_scripts_dir}source/artifacts.sh"; lpIndexTop "$@"; }
lpInstalledFootprintVersion() { source "${install_scripts_dir}source/fetch.sh"; lpInstalledFootprintVersion "$@"; } lpInstalledFootprintVersion() { source "${install_scripts_dir}source/fetch.sh"; lpInstalledFootprintVersion "$@"; }
_lpJsonEsc() { source "${install_scripts_dir}source/fetch.sh"; _lpJsonEsc "$@"; }
_lpJsonNum() { source "${install_scripts_dir}source/fetch.sh"; _lpJsonNum "$@"; } _lpJsonNum() { source "${install_scripts_dir}source/fetch.sh"; _lpJsonNum "$@"; }
_lpJsonStr() { source "${install_scripts_dir}source/fetch.sh"; _lpJsonStr "$@"; } _lpJsonStr() { source "${install_scripts_dir}source/fetch.sh"; _lpJsonStr "$@"; }
lpRegen() { source "${install_scripts_dir}webui/webui_regen.sh"; lpRegen "$@"; } lpRegen() { source "${install_scripts_dir}webui/webui_regen.sh"; lpRegen "$@"; }