mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 18:07:06 +01:00
Some checks failed
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
billsplit CI / quality (push) Has been cancelled
reverse-survivors CI / check (push) Has been cancelled
The shellcheck hook ran with --severity=warning, so info- and style-level
findings never failed a commit. 222 had accumulated across 94 of 244 shell
files. They were not all style noise -- six were bugs:
- Five scripts in single_use/misc/testsAndMisc-bash/ sourced
$SCRIPT_DIR/../../lib/common.sh, one directory too shallow. Nothing exists
at that path, so all five failed at runtime.
- setup_personal_website.sh and setup_kcd2_dice_solver.sh called usage()
(which ends in `exit 0`) and then `exit 1` for an unknown command. The
exit 1 was unreachable, so a typo'd subcommand reported SUCCESS.
- Six i3blocks scripts had a `# shellcheck source=` directive missing the
scripts/periodic_background/ path segment. The stale path had silently
disabled source-following for all of them.
- diagnose_arch_performance.sh word-split a newline-joined file list; when
the find came back empty, awk got no file operands, fell through to
reading stdin, and the diagnostic hung.
- lib/common.sh's handle_arg_help_or_unknown ended in an unreachable
`return 0` while documenting "Returns: 0 if handled" -- a contract no code
path could satisfy.
- deploy.sh's SCRIPT_DIR is reassigned by config.sh, which it sources at top
level, and config.sh can repoint it at the on-device path
/data/local/tmp/focus_mode. Renamed to DEPLOY_DIR.
The SC1091 bulk (81 findings) was resolved rather than silenced: .shellcheckrc
sets external-sources + source-path=SCRIPTDIR so sourced files are actually
followed and analysed. That is what surfaced the i3blocks and deploy.sh bugs.
17 `# shellcheck disable=` directives remain, each with a written reason, and
only where the finding is provably wrong: injection-test payloads that must not
expand, literal source text being grepped for, POSIX sh scripts on the phone
where arrays do not exist, and source paths that only exist at runtime.
Everything else was fixed in code.
Verified in a CLEAN worktree, not just my own tree. config.sh sources the
gitignored config_secrets.sh, which exists locally but not in a fresh checkout;
a working-tree-only check passed while ci_mirror.sh -- which lints HEAD in a
clean worktree, exactly as CI does -- failed on it.
The gate is verified to bite: a file whose only finding is SC2016 (info) fails
the hook, while the old --severity=warning gate exits 0 on it.
third_party/ is excluded -- vendored upstream, matching the prettier hook.
Not fixed, reported: check_polling_antipatterns.sh carries a pre-existing
SC2034 disable that hides a real bug -- its `patterns` array is never used, so
the checker detects nothing. Confirmed unchanged by this commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
397 lines
13 KiB
Bash
Executable File
397 lines
13 KiB
Bash
Executable File
#!/bin/bash
|
|
#
|
|
# setup_personal_website.sh — self-hosted personal website at kuhy.duckdns.org
|
|
#
|
|
# Builds the React/TypeScript personal website (projects showcase + CV) and
|
|
# serves its static build behind the existing shared Caddy edge (gitea-caddy),
|
|
# taking the bare root domain kuhy.duckdns.org. Gitea, which used to own that
|
|
# root, is moved to gitea.kuhy.duckdns.org.
|
|
#
|
|
# What it does (idempotent — safe to re-run):
|
|
# - Installs build/runtime deps (node, pnpm, docker, ...).
|
|
# - Migrates the shared Caddyfile to a per-service snippet directory so no
|
|
# service block ever clobbers another (fixes a latent bug where re-running
|
|
# setup_gitea.sh dropped the dufs/syncyomi blocks).
|
|
# - Moves Gitea to gitea.kuhy.duckdns.org via setup_gitea.sh (single source).
|
|
# - Builds the website and serves dist/ from a loopback caddy container on
|
|
# 127.0.0.1:8088, fronted by a reverse-proxy snippet on the root domain.
|
|
# - Reuses the existing DuckDNS updater (wildcard already resolves) and opens
|
|
# the firewall via setup_wireguard_ssh.sh.
|
|
#
|
|
# Reboot survival: the website container uses restart: unless-stopped and
|
|
# docker.service is enabled — no systemd unit is needed.
|
|
#
|
|
# Usage:
|
|
# setup_personal_website.sh [setup] Build + deploy (default).
|
|
# setup_personal_website.sh status Self-diagnose the deployment.
|
|
# setup_personal_website.sh help Show this help.
|
|
#
|
|
# Prerequisite: setup_gitea.sh must have been run at least once (the gitea-caddy
|
|
# edge must exist). Run as your normal user, not root.
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
|
readonly SCRIPT_DIR
|
|
# shellcheck source=../../lib/common.sh
|
|
source "${SCRIPT_DIR}/../../lib/common.sh"
|
|
|
|
# --- Configuration ----------------------------------------------------------
|
|
readonly WEBSITE_DOMAIN="kuhy.duckdns.org"
|
|
readonly GITEA_DOMAIN="gitea.kuhy.duckdns.org"
|
|
readonly WEBSITE_PORT="8088"
|
|
# React/TS source lives in its own standalone repo (github.com/kuhyx/personal-website),
|
|
# cloned at ~/personal-website; the runtime data dir holds the generated compose +
|
|
# inner Caddyfile.
|
|
readonly WEBSITE_SRC="${HOME}/personal-website"
|
|
readonly WEBSITE_DATA_DIR="${HOME}/personal-website-serve"
|
|
readonly WEBSITE_COMPOSE="${WEBSITE_DATA_DIR}/docker-compose.yml"
|
|
readonly WEBSITE_INNER_CADDY="${WEBSITE_DATA_DIR}/Caddyfile"
|
|
# Shared edge owned by setup_gitea.sh.
|
|
readonly GITEA_DATA_DIR="${HOME}/gitea"
|
|
readonly CADDYFILE="${GITEA_DATA_DIR}/Caddyfile"
|
|
readonly SITES_DIR="${GITEA_DATA_DIR}/sites"
|
|
readonly WEBSITE_SNIPPET="${SITES_DIR}/website.caddy"
|
|
readonly CADDY_CONTAINER="gitea-caddy"
|
|
# Sibling scripts (single sources of truth for their concerns).
|
|
readonly GITEA_SCRIPT="${SCRIPT_DIR}/setup_gitea.sh"
|
|
readonly WG_SCRIPT="${SCRIPT_DIR}/setup_wireguard_ssh.sh"
|
|
# Canonical CV data (rendered by the CV repo's build_cv.py); copied into the
|
|
# website build. A committed copy in the repo keeps the build standalone.
|
|
readonly CV_JSON_SRC="${HOME}/CV/generic/cv.json"
|
|
readonly CV_PDF_SRC="${HOME}/CV/generic/cv-en.pdf"
|
|
|
|
die() {
|
|
log_error "$1"
|
|
exit 1
|
|
}
|
|
|
|
# Takes the exit status to leave with, so the error path can print the same help
|
|
# and still fail. It used to hardcode `exit 0`, which made the `exit 1` after the
|
|
# unknown-command branch unreachable -- so `setup_personal_website.sh bogus`
|
|
# printed an error and then reported SUCCESS to its caller.
|
|
usage() {
|
|
grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
|
|
exit "${1:-0}"
|
|
}
|
|
|
|
# --- Phase 1: preflight -----------------------------------------------------
|
|
preflight() {
|
|
[[ ${EUID} -ne 0 ]] || die "Run as your normal user, not root."
|
|
[[ -d $WEBSITE_SRC ]] || die "Website source not found at ${WEBSITE_SRC}."
|
|
|
|
install_missing_pacman_packages nodejs pnpm docker docker-compose jq curl
|
|
|
|
if ! is_service_active docker; then
|
|
log_info "Starting and enabling docker.service…"
|
|
sudo systemctl enable --now docker
|
|
fi
|
|
|
|
[[ -f $CADDYFILE ]] ||
|
|
die "Shared Caddyfile ${CADDYFILE} missing — run setup_gitea.sh first."
|
|
if ! docker ps --format '{{.Names}}' | grep -qx "$CADDY_CONTAINER"; then
|
|
die "Container ${CADDY_CONTAINER} is not running — run setup_gitea.sh first."
|
|
fi
|
|
log_ok "Preflight checks passed."
|
|
}
|
|
|
|
# --- Phase 2: migrate the shared Caddyfile into per-service snippets ---------
|
|
# Reads the CURRENT monolithic Caddyfile and writes every top-level block to
|
|
# sites/<label>.caddy, EXCEPT the old root block (kuhy.duckdns.org, previously
|
|
# gitea — now repurposed for the website) and the gitea block (setup_gitea.sh
|
|
# rewrites its own). This preserves dufs (kuhy-cloud) and syncyomi (kuhy-sync)
|
|
# before setup_gitea.sh switches the root Caddyfile to `import`. Idempotent.
|
|
migrate_caddy_snippets() {
|
|
ensure_dir "$SITES_DIR"
|
|
if grep -qE '^[[:space:]]*import[[:space:]]+/etc/caddy/sites' "$CADDYFILE"; then
|
|
log_info "Caddyfile already uses per-service snippets; nothing to migrate."
|
|
return 0
|
|
fi
|
|
|
|
local line label body in_block=0 migrated=0
|
|
label=""
|
|
body=""
|
|
while IFS= read -r line || [[ -n $line ]]; do
|
|
if [[ $in_block -eq 0 && $line =~ ^([^[:space:]#].*)\{[[:space:]]*$ ]]; then
|
|
label="${BASH_REMATCH[1]}"
|
|
# Trim trailing whitespace from the captured label.
|
|
label="${label%"${label##*[![:space:]]}"}"
|
|
body="${line}"$'\n'
|
|
in_block=1
|
|
elif [[ $in_block -eq 1 ]]; then
|
|
body+="${line}"$'\n'
|
|
if [[ $line =~ ^\}[[:space:]]*$ ]]; then
|
|
in_block=0
|
|
if [[ $label == "$WEBSITE_DOMAIN" || $label == "$GITEA_DOMAIN" ]]; then
|
|
label=""
|
|
body=""
|
|
continue
|
|
fi
|
|
local snippet="${SITES_DIR}/${label}.caddy"
|
|
if [[ ! -f $snippet ]]; then
|
|
printf '# Migrated from Caddyfile by setup_personal_website.sh\n%s' \
|
|
"$body" >"$snippet"
|
|
log_ok "Migrated ${label} → ${snippet}"
|
|
migrated=$((migrated + 1))
|
|
fi
|
|
label=""
|
|
body=""
|
|
fi
|
|
fi
|
|
done <"$CADDYFILE"
|
|
log_info "Migrated ${migrated} existing site block(s) into ${SITES_DIR}."
|
|
}
|
|
|
|
# --- Phase 3: relocate Gitea to its subdomain -------------------------------
|
|
relocate_gitea() {
|
|
log_info "Applying Gitea relocation to ${GITEA_DOMAIN} via setup_gitea.sh…"
|
|
bash "$GITEA_SCRIPT" setup
|
|
log_ok "Gitea now served at https://${GITEA_DOMAIN}/."
|
|
}
|
|
|
|
# --- Phase 4: refresh CV data ----------------------------------------------
|
|
build_cv_json() {
|
|
if [[ -f $CV_JSON_SRC ]]; then
|
|
cp "$CV_JSON_SRC" "${WEBSITE_SRC}/src/data/cv.json"
|
|
log_ok "Copied CV data from ${CV_JSON_SRC}."
|
|
else
|
|
log_warn "CV data ${CV_JSON_SRC} not found — using the copy committed in the website repo."
|
|
fi
|
|
# Stage the rendered PDF for the CV download button (served from /cv.pdf).
|
|
if [[ -f $CV_PDF_SRC ]]; then
|
|
ensure_dir "${WEBSITE_SRC}/public"
|
|
cp "$CV_PDF_SRC" "${WEBSITE_SRC}/public/cv.pdf"
|
|
log_ok "Copied CV PDF from ${CV_PDF_SRC}."
|
|
else
|
|
log_warn "CV PDF ${CV_PDF_SRC} not found — the Download PDF link will 404 until built."
|
|
fi
|
|
}
|
|
|
|
# --- Phase 5: build the website --------------------------------------------
|
|
build_website() {
|
|
log_info "Installing website dependencies and building…"
|
|
(
|
|
cd "$WEBSITE_SRC"
|
|
pnpm install --frozen-lockfile
|
|
pnpm run build
|
|
)
|
|
[[ -f "${WEBSITE_SRC}/dist/index.html" ]] ||
|
|
die "Build did not produce ${WEBSITE_SRC}/dist/index.html."
|
|
log_ok "Website built to ${WEBSITE_SRC}/dist."
|
|
}
|
|
|
|
# --- Phase 6: write the website's static-serve stack ------------------------
|
|
write_website_stack() {
|
|
ensure_dir "$WEBSITE_DATA_DIR"
|
|
cat >"$WEBSITE_COMPOSE" <<EOF
|
|
# Managed by setup_personal_website.sh — do not edit by hand.
|
|
services:
|
|
personal-website:
|
|
image: caddy:2.8
|
|
container_name: personal-website
|
|
restart: unless-stopped
|
|
network_mode: host
|
|
volumes:
|
|
- ${WEBSITE_SRC}/dist:/srv:ro
|
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
EOF
|
|
|
|
cat >"$WEBSITE_INNER_CADDY" <<EOF
|
|
# Managed by setup_personal_website.sh — do not edit by hand.
|
|
# Static file server on loopback; fronted by gitea-caddy on the public domain.
|
|
#
|
|
# admin off is REQUIRED: this container shares the host network namespace with
|
|
# gitea-caddy, so without it both Caddies would listen on the admin API (:2019)
|
|
# and a 'caddy reload' aimed at gitea-caddy could be applied to this one instead
|
|
# — which would make it grab 80/443 and run ACME. Disabling admin keeps this
|
|
# process strictly a loopback static server on ${WEBSITE_PORT}.
|
|
{
|
|
admin off
|
|
}
|
|
|
|
:${WEBSITE_PORT} {
|
|
root * /srv
|
|
try_files {path} /index.html
|
|
file_server
|
|
encode gzip
|
|
}
|
|
EOF
|
|
log_ok "Wrote ${WEBSITE_COMPOSE} and ${WEBSITE_INNER_CADDY}."
|
|
}
|
|
|
|
# --- Phase 7: front the website on the root domain --------------------------
|
|
write_website_snippet() {
|
|
cat >"$WEBSITE_SNIPPET" <<EOF
|
|
# Managed by setup_personal_website.sh — do not edit by hand.
|
|
${WEBSITE_DOMAIN} {
|
|
reverse_proxy 127.0.0.1:${WEBSITE_PORT}
|
|
}
|
|
EOF
|
|
log_ok "Wrote ${WEBSITE_SNIPPET}."
|
|
}
|
|
|
|
# --- Phase 8: start the website container -----------------------------------
|
|
wait_for_website() {
|
|
local _
|
|
for _ in $(seq 1 30); do
|
|
if curl -sf -o /dev/null "http://127.0.0.1:${WEBSITE_PORT}/"; then
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
return 1
|
|
}
|
|
|
|
start_website() {
|
|
log_info "Starting the personal-website container…"
|
|
docker compose -f "$WEBSITE_COMPOSE" up -d
|
|
if wait_for_website; then
|
|
log_ok "Website answering on http://127.0.0.1:${WEBSITE_PORT}/."
|
|
else
|
|
die "Website did not become reachable on 127.0.0.1:${WEBSITE_PORT}."
|
|
fi
|
|
}
|
|
|
|
# --- Phase 9: reload the edge -----------------------------------------------
|
|
reload_caddy() {
|
|
if docker exec "$CADDY_CONTAINER" caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1; then
|
|
docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile
|
|
log_ok "Reloaded ${CADDY_CONTAINER}."
|
|
else
|
|
die "Caddy config invalid — aborting reload. Check ${SITES_DIR}/*.caddy."
|
|
fi
|
|
}
|
|
|
|
# --- Phase 10: firewall -----------------------------------------------------
|
|
ensure_firewall() {
|
|
log_info "Ensuring ports 80/443 are open (idempotent)…"
|
|
sudo bash "$WG_SCRIPT" allow-web
|
|
}
|
|
|
|
# --- Phase 11: report -------------------------------------------------------
|
|
print_report() {
|
|
cat <<EOF
|
|
|
|
============================================================================
|
|
Personal website deployed.
|
|
============================================================================
|
|
Website : https://${WEBSITE_DOMAIN}/
|
|
Gitea : https://${GITEA_DOMAIN}/ (moved from the root)
|
|
|
|
Gitea moved to a subdomain. Re-point any local clones/mirrors:
|
|
git remote set-url <name> https://${GITEA_DOMAIN}/<owner>/<repo>.git
|
|
|
|
Acceptance test (do this on your phone):
|
|
Turn Wi-Fi OFF (use cellular) and open https://${WEBSITE_DOMAIN}/ —
|
|
it should load over HTTPS with the hero, live GitHub projects, and CV,
|
|
and be usable at phone width.
|
|
============================================================================
|
|
EOF
|
|
}
|
|
|
|
setup_cmd() {
|
|
print_setup_header "Personal website setup"
|
|
preflight
|
|
migrate_caddy_snippets
|
|
relocate_gitea
|
|
build_cv_json
|
|
build_website
|
|
write_website_stack
|
|
write_website_snippet
|
|
start_website
|
|
reload_caddy
|
|
ensure_firewall
|
|
print_report
|
|
log_ok "Personal website setup complete."
|
|
}
|
|
|
|
# --- status: self-diagnose --------------------------------------------------
|
|
status_line() {
|
|
if [[ $1 -eq 0 ]]; then
|
|
log_ok "$2"
|
|
else
|
|
log_warn "$2"
|
|
fi
|
|
}
|
|
|
|
check_http() {
|
|
# Prints 0 to stdout if the URL returns 200, else 1.
|
|
local url="$1" code
|
|
code="$(curl -sk -o /dev/null -w '%{http_code}' --max-time 10 "$url" 2>/dev/null || echo 000)"
|
|
[[ $code == "200" ]] && echo 0 || echo 1
|
|
}
|
|
|
|
status_cmd() {
|
|
print_setup_header "Personal website status"
|
|
|
|
if has_cmd node; then
|
|
status_line 0 "node present"
|
|
else
|
|
status_line 1 "node missing"
|
|
fi
|
|
if has_cmd pnpm; then
|
|
status_line 0 "pnpm present"
|
|
else
|
|
status_line 1 "pnpm missing"
|
|
fi
|
|
if [[ -f "${WEBSITE_SRC}/dist/index.html" ]]; then
|
|
status_line 0 "build present (dist/index.html)"
|
|
else
|
|
status_line 1 "build missing — run setup"
|
|
fi
|
|
if docker ps --format '{{.Names}}' | grep -qx personal-website; then
|
|
status_line 0 "personal-website container running"
|
|
else
|
|
status_line 1 "personal-website container not running"
|
|
fi
|
|
|
|
status_line "$(check_http "http://127.0.0.1:${WEBSITE_PORT}/")" \
|
|
"local static server (127.0.0.1:${WEBSITE_PORT})"
|
|
|
|
if [[ -f $WEBSITE_SNIPPET ]]; then
|
|
status_line 0 "Caddy website snippet present"
|
|
else
|
|
status_line 1 "Caddy website snippet missing"
|
|
fi
|
|
if grep -qE '^[[:space:]]*import[[:space:]]+/etc/caddy/sites' "$CADDYFILE" 2>/dev/null; then
|
|
status_line 0 "root Caddyfile uses snippet imports"
|
|
else
|
|
status_line 1 "root Caddyfile not migrated to imports"
|
|
fi
|
|
if getent hosts "$WEBSITE_DOMAIN" >/dev/null; then
|
|
status_line 0 "${WEBSITE_DOMAIN} resolves"
|
|
else
|
|
status_line 1 "${WEBSITE_DOMAIN} does not resolve"
|
|
fi
|
|
if getent hosts "$GITEA_DOMAIN" >/dev/null; then
|
|
status_line 0 "${GITEA_DOMAIN} resolves"
|
|
else
|
|
status_line 1 "${GITEA_DOMAIN} does not resolve"
|
|
fi
|
|
status_line "$(check_http "https://${WEBSITE_DOMAIN}/")" \
|
|
"external https://${WEBSITE_DOMAIN}/"
|
|
status_line "$(check_http "https://${GITEA_DOMAIN}/")" \
|
|
"external https://${GITEA_DOMAIN}/"
|
|
}
|
|
|
|
main() {
|
|
local cmd="${1:-setup}"
|
|
case "$cmd" in
|
|
setup)
|
|
setup_cmd
|
|
;;
|
|
status)
|
|
status_cmd
|
|
;;
|
|
help | -h | --help)
|
|
usage
|
|
;;
|
|
*)
|
|
log_error "Unknown command: $cmd"
|
|
usage 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|