release / tag (push) Failing after 1s
Consolidating the divergence between this repo and local-dev's embedded ~/dev/.skills/ (the two had drifted; the 19 overlapping skills were byte-identical). - Import the 17 skills that existed only in local-dev: discovery-framing, stage-gate-review, web-shot, and the 14 superpowers-* skills. This repo is now the superset / single source of truth. - SKILLS_INDEX.md: add rows for the 17. - install.sh + Taskfile.yml: git.d-ma.be host (was stale gitea.d-ma.be, which broke the one-line curl|bash installer post-rename). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
182 lines
6.0 KiB
Bash
Executable File
182 lines
6.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# web-shot/shot.sh — screenshot a running web UI via a k3s playwright Job.
|
|
#
|
|
# Reusable across any Tailscale-connected host whose kubeconfig points at the
|
|
# koala k3s cluster. See SKILL.md alongside this file for prereqs.
|
|
set -euo pipefail
|
|
|
|
# --- defaults --------------------------------------------------------------
|
|
PORT=""
|
|
PATH_=""
|
|
HOST=""
|
|
URL=""
|
|
SEARCH=""
|
|
OUT_DIR=""
|
|
JOB_PREFIX="web-shot"
|
|
PLAYWRIGHT_TAG="v1.49.0-jammy"
|
|
PLAYWRIGHT_NPM="1.49.0"
|
|
NAMESPACE="default"
|
|
|
|
usage() {
|
|
sed -n '1,/^# ---/p' "$0" | sed 's/^# \{0,1\}//;1,/^!/d' >&2
|
|
cat >&2 <<EOF
|
|
usage: shot.sh [--port N] [--path P] [--host H] [--url U]
|
|
[--search Q] [--out DIR] [--name PREFIX]
|
|
See SKILL.md for prerequisites and examples.
|
|
EOF
|
|
exit 2
|
|
}
|
|
|
|
# --- parse args ------------------------------------------------------------
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--port) PORT="$2"; shift 2 ;;
|
|
--path) PATH_="$2"; shift 2 ;;
|
|
--host) HOST="$2"; shift 2 ;;
|
|
--url) URL="$2"; shift 2 ;;
|
|
--search) SEARCH="$2"; shift 2 ;;
|
|
--out) OUT_DIR="$2"; shift 2 ;;
|
|
--name) JOB_PREFIX="$2"; shift 2 ;;
|
|
-h|--help) usage ;;
|
|
*) echo "unknown flag: $1" >&2; usage ;;
|
|
esac
|
|
done
|
|
|
|
# --- resolve URL -----------------------------------------------------------
|
|
if [ -z "$URL" ]; then
|
|
if [ -z "$PORT" ]; then
|
|
echo "✗ --port or --url is required" >&2
|
|
usage
|
|
fi
|
|
if [ -z "$HOST" ]; then
|
|
if command -v tailscale >/dev/null 2>&1; then
|
|
HOST="$(tailscale status --self --json 2>/dev/null | jq -r '.Self.HostName' 2>/dev/null || true)"
|
|
fi
|
|
if [ -z "$HOST" ] && [ -r /etc/hostname ]; then
|
|
HOST="$(tr -d '\n' </etc/hostname)"
|
|
fi
|
|
if [ -z "$HOST" ]; then
|
|
echo "✗ could not auto-detect Tailscale hostname — pass --host" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
PATH_="${PATH_:-/}"
|
|
case "$PATH_" in /*) ;; *) PATH_="/$PATH_" ;; esac
|
|
URL="http://${HOST}:${PORT}${PATH_}"
|
|
fi
|
|
|
|
OUT_DIR="${OUT_DIR:-$(pwd)/screenshots}"
|
|
mkdir -p "$OUT_DIR"
|
|
|
|
JOB_NAME="${JOB_PREFIX}-$(date +%s)"
|
|
echo "→ host=${HOST:-—} url=${URL} out=${OUT_DIR} job=${JOB_NAME}"
|
|
|
|
# --- preflight: dev server must be reachable -------------------------------
|
|
# (Optional but cheap. Run from the caller, not the pod.)
|
|
if ! curl -fsS --max-time 3 "${URL%/}/healthz" >/dev/null 2>&1 \
|
|
&& ! curl -fsS --max-time 3 "$URL" >/dev/null 2>&1; then
|
|
echo "⚠ ${URL} not reachable from this host. Pod will retry; if it fails, the dev server may be bound to 127.0.0.1 only." >&2
|
|
fi
|
|
|
|
# --- emit + apply Job ------------------------------------------------------
|
|
TAKE_SEARCH=""
|
|
[ -n "$SEARCH" ] && TAKE_SEARCH="yes"
|
|
|
|
cleanup() {
|
|
kubectl -n "$NAMESPACE" delete job "$JOB_NAME" --ignore-not-found=true >/dev/null 2>&1 || true
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
kubectl -n "$NAMESPACE" apply -f - >/dev/null <<YAML
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: ${JOB_NAME}
|
|
spec:
|
|
ttlSecondsAfterFinished: 120
|
|
backoffLimit: 0
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
containers:
|
|
- name: shot
|
|
image: mcr.microsoft.com/playwright:${PLAYWRIGHT_TAG}
|
|
env:
|
|
- { name: URL, value: "${URL}" }
|
|
- { name: SEARCH, value: "${SEARCH}" }
|
|
- { name: DO_SEARCH, value: "${TAKE_SEARCH}" }
|
|
command: ["bash", "-lc"]
|
|
args:
|
|
- |
|
|
set -e
|
|
cd /tmp
|
|
npm init -y >/dev/null 2>&1
|
|
npm install playwright@${PLAYWRIGHT_NPM} --no-fund --no-audit --silent 2>&1 | tail -1
|
|
mkdir -p /shot
|
|
cat > /tmp/s.mjs <<'JS'
|
|
import { chromium } from 'playwright';
|
|
const url = process.env.URL;
|
|
const out = process.env.OUT;
|
|
const action = process.env.ACTION || '';
|
|
const b = await chromium.launch();
|
|
const ctx = await b.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 });
|
|
const p = await ctx.newPage();
|
|
await p.goto(url, { waitUntil: 'networkidle' });
|
|
if (action.startsWith('search:')) {
|
|
const q = action.slice('search:'.length);
|
|
await p.fill('input[name="q"]', q);
|
|
await p.waitForResponse(r => r.url().includes('/search'), { timeout: 5000 }).catch(() => {});
|
|
await p.waitForTimeout(500);
|
|
}
|
|
await p.screenshot({ path: out, fullPage: true });
|
|
await b.close();
|
|
console.log('wrote', out);
|
|
JS
|
|
URL="\$URL" OUT=/shot/home.png ACTION= node /tmp/s.mjs
|
|
if [ "\$DO_SEARCH" = "yes" ]; then
|
|
URL="\$URL" OUT=/shot/search.png ACTION="search:\$SEARCH" node /tmp/s.mjs
|
|
fi
|
|
chmod 644 /shot/*.png
|
|
echo "READY"
|
|
# Window for kubectl cp; deleted by trap after we've grabbed files.
|
|
sleep 90
|
|
YAML
|
|
|
|
# --- wait for pod + READY marker ------------------------------------------
|
|
echo "→ waiting for pod scheduling…"
|
|
for _ in $(seq 1 60); do
|
|
POD="$(kubectl -n "$NAMESPACE" get pods -l "batch.kubernetes.io/job-name=$JOB_NAME" \
|
|
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)"
|
|
[ -n "$POD" ] && break
|
|
sleep 1
|
|
done
|
|
if [ -z "$POD" ]; then
|
|
echo "✗ pod not scheduled" >&2; exit 1
|
|
fi
|
|
echo "→ pod $POD"
|
|
|
|
echo "→ waiting for READY (pulling image + rendering)…"
|
|
for _ in $(seq 1 60); do
|
|
if kubectl -n "$NAMESPACE" logs "$POD" 2>/dev/null | grep -q '^READY$'; then
|
|
break
|
|
fi
|
|
PHASE="$(kubectl -n "$NAMESPACE" get pod "$POD" -o jsonpath='{.status.phase}' 2>/dev/null || true)"
|
|
if [ "$PHASE" = "Failed" ]; then
|
|
echo "✗ pod Failed — logs:" >&2
|
|
kubectl -n "$NAMESPACE" logs "$POD" 2>&1 | tail -30 >&2
|
|
exit 1
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
# --- copy files out --------------------------------------------------------
|
|
kubectl -n "$NAMESPACE" cp "${NAMESPACE}/${POD}:/shot/home.png" "$OUT_DIR/home.png" 2>/dev/null \
|
|
|| { echo "✗ kubectl cp home.png failed" >&2; exit 1; }
|
|
if [ "$TAKE_SEARCH" = "yes" ]; then
|
|
kubectl -n "$NAMESPACE" cp "${NAMESPACE}/${POD}:/shot/search.png" "$OUT_DIR/search.png" 2>/dev/null \
|
|
|| { echo "✗ kubectl cp search.png failed" >&2; exit 1; }
|
|
fi
|
|
|
|
ls -la "$OUT_DIR"/*.png
|
|
echo "✓ screenshots in $OUT_DIR"
|