scripts/publish.sh
scripts/publish.shBrowse 4 files
3,557 tokens
12,039 bytes
Token encoding: o200k_base
Snapshot 5b913e7
← Back to SKILL.md
1#!/usr/bin/env bash2 3set -euo pipefail4 5usage() {6 cat <<'EOF'7Usage:8 publish.sh <dir> [--slug slug] [--update] [--dry-run]9 10Publishes a static directory with a root index.html to the configured Paperclip11pages bucket and prints the public URL and S3 prefix.12 13Required environment for live publish:14 PAPERCLIP_PAGE_BUCKET, PAPERCLIP_PAGE_BASE_URL, AWS_REGION, AWS credentials15 16Optional environment:17 PAPERCLIP_PAGE_DEFAULT_PREFIX, PAPERCLIP_PAGE_AWS_PROFILE18 PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID, PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY,19 PAPERCLIP_PAGE_AWS_SESSION_TOKEN20 21Credential resolution for aws calls made by this helper:22 1. PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID + PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY23 (used only by this helper; ambient AWS_PROFILE/AWS_* identity is untouched)24 2. PAPERCLIP_PAGE_AWS_PROFILE (passed as --profile; ambient AWS_* identity25 variables are stripped from the helper's aws calls)26 3. Ambient AWS credential chain (env keys, profile, instance role)27 28Options:29 --slug SLUG Lowercase URL slug. Allowed: a-z, 0-9, hyphen.30 --update Additively overwrite an owned existing prefix. Never deletes.31 --dry-run Validate and print the planned target without AWS writes.32 --help, -h Show this help.33EOF34}35 36die() {37 printf 'paperclip-page: %s\n' "$*" >&238 exit 139}40 41require_command() {42 if ! command -v "$1" >/dev/null 2>&1; then43 die "missing required command: $1"44 fi45}46 47normalize_base_url() {48 local value="$1"49 value="${value%/}"50 [[ "$value" == https://* ]] || die "PAPERCLIP_PAGE_BASE_URL must be an https URL"51 [[ ! "$value" =~ [[:space:]] ]] || die "PAPERCLIP_PAGE_BASE_URL cannot contain whitespace"52 printf '%s\n' "$value"53}54 55validate_segment() {56 local value="$1"57 local label="$2"58 59 [[ -n "$value" ]] || die "$label cannot be empty"60 [[ "${#value}" -le 64 ]] || die "$label is too long; max length is 64 characters"61 [[ "$value" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || die "$label must use lowercase letters, digits, and hyphens only"62 [[ "$value" != "." && "$value" != ".." ]] || die "$label cannot be a dot segment"63}64 65normalize_slug() {66 local value="$1"67 68 value="${value#/}"69 value="${value%/}"70 [[ "$value" != *"/"* ]] || die "slug must be one path segment, not a nested path"71 validate_segment "$value" "slug"72 case "$value" in73 404|404-html|index|index-html|root|assets)74 die "slug '$value' is reserved"75 ;;76 esac77 printf '%s\n' "$value"78}79 80derive_slug() {81 local source_dir="$1"82 local base83 84 base="$(basename "$source_dir")"85 base="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')"86 if [[ -z "$base" ]]; then87 base="paperclip-page"88 fi89 printf '%.48s\n' "$base" | sed -E 's/-+$//'90}91 92random_suffix() {93 if command -v openssl >/dev/null 2>&1; then94 openssl rand -hex 395 else96 od -An -N3 -tx1 /dev/urandom | tr -d ' \n'97 fi98}99 100normalize_default_prefix() {101 local raw="${1:-}"102 local segment103 local normalized=""104 105 raw="${raw#/}"106 raw="${raw%/}"107 [[ "$raw" != *"//"* ]] || die "PAPERCLIP_PAGE_DEFAULT_PREFIX cannot contain empty path segments"108 if [[ -z "$raw" ]]; then109 printf '\n'110 return111 fi112 113 IFS='/' read -r -a segments <<<"$raw"114 for segment in "${segments[@]}"; do115 validate_segment "$segment" "prefix segment"116 if [[ -z "$normalized" ]]; then117 normalized="$segment"118 else119 normalized="$normalized/$segment"120 fi121 done122 printf '%s\n' "$normalized"123}124 125join_prefix() {126 local default_prefix="$1"127 local slug="$2"128 129 if [[ -n "$default_prefix" ]]; then130 printf '%s/%s/\n' "$default_prefix" "$slug"131 else132 printf '%s/\n' "$slug"133 fi134}135 136aws_base_args=()137aws_env_unset=()138aws_env_overrides=()139 140aws_cli() {141 local name pair142 if [[ ${#aws_env_unset[@]} -gt 0 || ${#aws_env_overrides[@]} -gt 0 ]]; then143 # Scope the page-uploader identity to this helper's aws calls only, and144 # drop the ambient identity variables that would otherwise mix with or145 # shadow the configured credential source. Apply the overrides with shell146 # builtins in a subshell — passing them to an external `env` command would147 # expose the credential values in its argv (world-readable via148 # /proc/<pid>/cmdline) while it runs.149 (150 for name in "${aws_env_unset[@]}"; do unset "$name"; done151 for pair in "${aws_env_overrides[@]}"; do export "$pair"; done152 exec aws "${aws_base_args[@]}" "$@"153 )154 else155 aws "${aws_base_args[@]}" "$@"156 fi157}158 159object_exists() {160 local bucket="$1"161 local prefix="$2"162 local key163 164 key="$(aws_cli s3api list-objects-v2 \165 --bucket "$bucket" \166 --prefix "$prefix" \167 --max-keys 1 \168 --query 'Contents[0].Key' \169 --output text)"170 [[ "$key" != "None" && -n "$key" ]]171}172 173assert_safe_source_tree() {174 local source_dir="$1"175 local found176 177 [[ ! -L "$source_dir" ]] || die "source directory must not be a symlink"178 [[ -f "$source_dir/index.html" ]] || die "source directory must contain root index.html"179 180 found="$(find "$source_dir" -type l -print -quit)"181 [[ -z "$found" ]] || die "found symlink in source tree: $found"182 183 found="$(184 cd "$source_dir"185 find . -mindepth 1 \186 \( -path './.paperclip-page' -o -path './.paperclip-page/*' \) -prune -o \187 \( -name '.*' -o -path '*/.*' \) -print -quit188 )"189 [[ -z "$found" ]] || die "hidden files and dot paths are not allowed in published content: $found"190}191 192read_state_value() {193 local state_file="$1"194 local expression="$2"195 jq -r "$expression // empty" "$state_file"196}197 198assert_update_ownership() {199 local source_dir="$1"200 local bucket="$2"201 local prefix="$3"202 local state_file="$source_dir/.paperclip-page/state.json"203 local state_bucket204 local state_prefix205 206 [[ -f "$state_file" ]] || die "update of an existing prefix requires ownership state at $state_file"207 state_bucket="$(read_state_value "$state_file" '.bucket')"208 state_prefix="$(read_state_value "$state_file" '.prefix')"209 210 [[ "$state_bucket" == "$bucket" ]] || die "state bucket does not match target bucket"211 [[ "$state_prefix" == "$prefix" ]] || die "state prefix does not match target prefix"212}213 214compute_source_hash() {215 local source_dir="$1"216 217 if ! command -v sha256sum >/dev/null 2>&1; then218 printf 'unavailable\n'219 return220 fi221 222 (223 cd "$source_dir"224 find . -type f ! -path './.paperclip-page/*' -print0 |225 LC_ALL=C sort -z |226 while IFS= read -r -d '' path; do227 sha256sum "$path"228 done229 ) | sha256sum | awk '{print $1}'230}231 232write_state() {233 local source_dir="$1"234 local bucket="$2"235 local prefix="$3"236 local slug="$4"237 local url="$5"238 local base_url="$6"239 local source_hash="$7"240 local state_dir="$source_dir/.paperclip-page"241 local state_file="$state_dir/state.json"242 local temp_file243 244 mkdir -p "$state_dir"245 temp_file="$(mktemp "$state_dir/state.json.tmp.XXXXXX")"246 jq -n \247 --arg bucket "$bucket" \248 --arg prefix "$prefix" \249 --arg slug "$slug" \250 --arg url "$url" \251 --arg baseUrl "$base_url" \252 --arg publishedAt "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \253 --arg sourceHash "$source_hash" \254 '{255 bucket: $bucket,256 prefix: $prefix,257 slug: $slug,258 url: $url,259 baseUrl: $baseUrl,260 publishedAt: $publishedAt,261 sourceHash: $sourceHash,262 version: 1263 }' >"$temp_file"264 mv "$temp_file" "$state_file"265}266 267source_arg=""268slug_arg=""269update=0270dry_run=0271 272while [[ $# -gt 0 ]]; do273 case "$1" in274 --slug)275 slug_arg="${2:-}"276 shift 2277 ;;278 --update)279 update=1280 shift281 ;;282 --dry-run)283 dry_run=1284 shift285 ;;286 --help|-h)287 usage288 exit 0289 ;;290 --*)291 die "unknown argument: $1"292 ;;293 *)294 if [[ -n "$source_arg" ]]; then295 die "unexpected positional argument: $1"296 fi297 source_arg="$1"298 shift299 ;;300 esac301done302 303[[ -n "$source_arg" ]] || {304 usage >&2305 exit 1306}307 308require_command jq309require_command find310require_command sed311 312[[ -d "$source_arg" ]] || die "source path is not a directory: $source_arg"313source_dir="$(cd "$source_arg" && pwd -P)"314assert_safe_source_tree "$source_dir"315 316bucket="${PAPERCLIP_PAGE_BUCKET:-}"317base_url="${PAPERCLIP_PAGE_BASE_URL:-}"318region="${AWS_REGION:-}"319default_prefix="$(normalize_default_prefix "${PAPERCLIP_PAGE_DEFAULT_PREFIX:-}")"320 321[[ -n "$bucket" ]] || die "PAPERCLIP_PAGE_BUCKET is required"322[[ "$bucket" =~ ^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$ ]] || die "PAPERCLIP_PAGE_BUCKET does not look like a valid S3 bucket name"323[[ -n "$base_url" ]] || die "PAPERCLIP_PAGE_BASE_URL is required"324base_url="$(normalize_base_url "$base_url")"325 326page_access_key_id="${PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID:-}"327page_secret_access_key="${PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY:-}"328if [[ -n "$page_access_key_id" || -n "$page_secret_access_key" ]]; then329 [[ -n "$page_access_key_id" && -n "$page_secret_access_key" ]] ||330 die "PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID and PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY must be set together"331 [[ -z "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]] ||332 die "set PAPERCLIP_PAGE_AWS_PROFILE or the PAPERCLIP_PAGE_AWS_* key pair, not both"333fi334if [[ -n "${PAPERCLIP_PAGE_AWS_SESSION_TOKEN:-}" && -z "$page_access_key_id" ]]; then335 die "PAPERCLIP_PAGE_AWS_SESSION_TOKEN requires the PAPERCLIP_PAGE_AWS_* key pair"336fi337 338explicit_slug=0339if [[ -n "$slug_arg" ]]; then340 explicit_slug=1341 slug="$(normalize_slug "$slug_arg")"342else343 slug="$(normalize_slug "$(derive_slug "$source_dir")")"344fi345 346if [[ "$dry_run" == "0" ]]; then347 require_command aws348 require_command curl349 [[ -n "$region" ]] || die "AWS_REGION is required for live publish"350 aws_base_args=(--region "$region")351 if [[ -n "$page_access_key_id" ]]; then352 aws_env_unset=(AWS_PROFILE AWS_SESSION_TOKEN)353 aws_env_overrides=(354 AWS_ACCESS_KEY_ID="$page_access_key_id"355 AWS_SECRET_ACCESS_KEY="$page_secret_access_key"356 )357 if [[ -n "${PAPERCLIP_PAGE_AWS_SESSION_TOKEN:-}" ]]; then358 aws_env_overrides+=(AWS_SESSION_TOKEN="$PAPERCLIP_PAGE_AWS_SESSION_TOKEN")359 fi360 elif [[ -n "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]]; then361 aws_base_args+=(--profile "$PAPERCLIP_PAGE_AWS_PROFILE")362 aws_env_unset=(AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE)363 fi364fi365 366prefix="$(join_prefix "$default_prefix" "$slug")"367target_exists=0368 369if [[ "$update" == "1" ]]; then370 assert_update_ownership "$source_dir" "$bucket" "$prefix"371fi372 373if [[ "$dry_run" == "0" ]]; then374 if object_exists "$bucket" "$prefix"; then375 target_exists=1376 fi377 378 if [[ "$target_exists" == "1" && "$update" == "0" ]]; then379 if [[ "$explicit_slug" == "1" ]]; then380 die "slug already exists: $slug. Use --update from the owning source directory or choose a new slug."381 fi382 383 for _ in 1 2 3 4 5; do384 candidate="${slug}-$(random_suffix)"385 candidate="$(printf '%.64s' "$candidate" | sed -E 's/-+$//')"386 validate_segment "$candidate" "generated slug"387 candidate_prefix="$(join_prefix "$default_prefix" "$candidate")"388 if ! object_exists "$bucket" "$candidate_prefix"; then389 slug="$candidate"390 prefix="$candidate_prefix"391 target_exists=0392 break393 fi394 done395 396 [[ "$target_exists" == "0" ]] || die "could not find an unused generated slug after 5 attempts"397 fi398fi399 400url="${base_url}/${prefix}"401mode="publish"402if [[ "$update" == "1" ]]; then403 mode="update"404fi405 406if [[ "$dry_run" == "1" ]]; then407 cat <<EOF408paperclip-page dry run409mode: $mode410source: $source_dir411bucket: $bucket412prefix: $prefix413url: $url414EOF415 exit 0416fi417 418aws_cli s3 sync "$source_dir/" "s3://$bucket/$prefix" \419 --no-follow-symlinks \420 --exclude '.paperclip-page/*' \421 --cache-control 'public,max-age=60' \422 --only-show-errors423 424source_hash="$(compute_source_hash "$source_dir")"425write_state "$source_dir" "$bucket" "$prefix" "$slug" "$url" "$base_url" "$source_hash"426 427curl -fsSIL --max-time 20 "$url" >/dev/null428 429cat <<EOF430paperclip-page published431mode: $mode432url: $url433bucket: $bucket434prefix: $prefix435state: $source_dir/.paperclip-page/state.json436EOF437