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>
This commit is contained in:
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/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"
|
||||
Reference in New Issue
Block a user