scripts/publish.sh
scripts/publish.shBrowse 3 files
5,015 tokens
15,546 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env bash2set -euo pipefail3 4BASE_URL="https://here.now"5CREDENTIALS_FILE="$HOME/.herenow/credentials"6API_KEY="${HERENOW_API_KEY:-}"7API_KEY_SOURCE="none"8if [[ -n "${HERENOW_API_KEY:-}" ]]; then9 API_KEY_SOURCE="env"10fi11ALLOW_NON_HERENOW_BASE_URL=012SLUG=""13CLAIM_TOKEN=""14TITLE=""15DESCRIPTION=""16TTL=""17CLIENT=""18TARGET=""19FORKABLE=""20SPA_MODE=""21FROM_DRIVE=""22DRIVE_VERSION=""23 24usage() {25 cat <<'USAGE'26Usage: publish.sh <file-or-dir> [options]27 28Options:29 --api-key <key> API key (or set $HERENOW_API_KEY)30 --slug <slug> Update existing publish31 --claim-token <token> Claim token for anonymous updates32 --title <text> Viewer title33 --description <text> Viewer description34 --ttl <seconds> Expiry (authenticated only)35 --client <name> Agent name for attribution (e.g. cursor, claude-code)36 --forkable Allow others to fork this site37 --spa Enable SPA routing38 --from-drive <drv_...> Publish a Drive snapshot instead of local files39 --version <dv_...> Drive version for --from-drive (default: current head)40 --base-url <url> API base (default: https://here.now)41 --allow-nonherenow-base-url42 Allow auth requests to non-default API base URL43USAGE44 exit 145}46 47die() { echo "error: $1" >&2; exit 1; }48 49SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"50SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"51BUNDLED_JQ="${SKILL_DIR}/bin/jq"52 53if [[ -x "$BUNDLED_JQ" ]]; then54 JQ_BIN="$BUNDLED_JQ"55elif command -v jq >/dev/null 2>&1; then56 JQ_BIN="$(command -v jq)"57else58 die "requires jq"59fi60 61for cmd in curl file; do62 command -v "$cmd" >/dev/null 2>&1 || die "requires $cmd"63done64 65while [[ $# -gt 0 ]]; do66 case "$1" in67 --api-key) API_KEY="$2"; API_KEY_SOURCE="flag"; shift 2 ;;68 --slug) SLUG="$2"; shift 2 ;;69 --claim-token) CLAIM_TOKEN="$2"; shift 2 ;;70 --title) TITLE="$2"; shift 2 ;;71 --description) DESCRIPTION="$2"; shift 2 ;;72 --ttl) TTL="$2"; shift 2 ;;73 --client) CLIENT="$2"; shift 2 ;;74 --base-url) BASE_URL="$2"; shift 2 ;;75 --allow-nonherenow-base-url) ALLOW_NON_HERENOW_BASE_URL=1; shift ;;76 --forkable) FORKABLE="true"; shift ;;77 --spa) SPA_MODE="true"; shift ;;78 --from-drive) FROM_DRIVE="$2"; shift 2 ;;79 --version) DRIVE_VERSION="$2"; shift 2 ;;80 --help|-h) usage ;;81 -*) die "unknown option: $1" ;;82 *) [[ -z "$TARGET" ]] && TARGET="$1" || die "unexpected argument: $1"; shift ;;83 esac84done85 86if [[ -n "$FROM_DRIVE" ]]; then87 [[ -z "$TARGET" ]] || die "--from-drive does not accept a local file-or-dir argument"88else89 [[ -n "$TARGET" ]] || usage90 [[ -e "$TARGET" ]] || die "path does not exist: $TARGET"91fi92 93# Load API key from credentials file if not provided via flag or env94if [[ -z "$API_KEY" && -f "$CREDENTIALS_FILE" ]]; then95 API_KEY=$(cat "$CREDENTIALS_FILE" | tr -d '[:space:]')96 [[ -n "$API_KEY" ]] && API_KEY_SOURCE="credentials"97fi98 99BASE_URL="${BASE_URL%/}"100STATE_DIR=".herenow"101STATE_FILE="$STATE_DIR/state.json"102 103# Safety guard: avoid accidentally sending bearer auth to arbitrary endpoints.104if [[ -n "$API_KEY" && "$BASE_URL" != "https://here.now" && "$ALLOW_NON_HERENOW_BASE_URL" -ne 1 ]]; then105 die "refusing to send API key to non-default base URL; pass --allow-nonherenow-base-url to override"106fi107 108# Auto-load claim token from state file for anonymous updates109if [[ -n "$SLUG" && -z "$CLAIM_TOKEN" && -z "$API_KEY" && -f "$STATE_FILE" ]]; then110 CLAIM_TOKEN=$("$JQ_BIN" -r --arg s "$SLUG" '.publishes[$s].claimToken // empty' "$STATE_FILE" 2>/dev/null || true)111fi112 113if [[ -n "$FROM_DRIVE" ]]; then114 [[ -n "$API_KEY" ]] || die "--from-drive requires an account API key"115 BODY=$("$JQ_BIN" -n --arg d "$FROM_DRIVE" '{driveId:$d}')116 [[ -n "$DRIVE_VERSION" ]] && BODY=$(echo "$BODY" | "$JQ_BIN" --arg v "$DRIVE_VERSION" '.versionId = $v')117 [[ -n "$SLUG" ]] && BODY=$(echo "$BODY" | "$JQ_BIN" --arg s "$SLUG" '.slug = $s')118 if [[ -n "$TITLE" || -n "$DESCRIPTION" ]]; then119 viewer="{}"120 [[ -n "$TITLE" ]] && viewer=$(echo "$viewer" | "$JQ_BIN" --arg t "$TITLE" '.title = $t')121 [[ -n "$DESCRIPTION" ]] && viewer=$(echo "$viewer" | "$JQ_BIN" --arg d "$DESCRIPTION" '.description = $d')122 BODY=$(echo "$BODY" | "$JQ_BIN" --argjson v "$viewer" '.viewer = $v')123 fi124 [[ "$FORKABLE" == "true" ]] && BODY=$(echo "$BODY" | "$JQ_BIN" '.forkable = true')125 [[ "$SPA_MODE" == "true" ]] && BODY=$(echo "$BODY" | "$JQ_BIN" '.spaMode = true')126 CLIENT_HEADER_VALUE="here-now-publish-sh"127 if [[ -n "$CLIENT" ]]; then128 normalized_client=$(echo "$CLIENT" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')129 normalized_client="${normalized_client#-}"130 normalized_client="${normalized_client%-}"131 if [[ -n "$normalized_client" ]]; then132 CLIENT_HEADER_VALUE="${normalized_client}/publish-sh"133 fi134 fi135 136 echo "publishing from Drive..." >&2137 RESPONSE=$(curl -sS -X POST "$BASE_URL/api/v1/publish/from-drive" \138 -H "authorization: Bearer $API_KEY" \139 -H "x-herenow-client: $CLIENT_HEADER_VALUE" \140 -H "content-type: application/json" \141 -d "$BODY")142 if echo "$RESPONSE" | "$JQ_BIN" -e '.error' >/dev/null 2>&1; then143 err=$(echo "$RESPONSE" | "$JQ_BIN" -r '.error')144 die "$err"145 fi146 SITE_URL=$(echo "$RESPONSE" | "$JQ_BIN" -r '.siteUrl')147 OUT_SLUG=$(echo "$RESPONSE" | "$JQ_BIN" -r '.slug')148 CURRENT_VERSION=$(echo "$RESPONSE" | "$JQ_BIN" -r '.currentVersionId')149 DRIVE_VERSION_OUT=$(echo "$RESPONSE" | "$JQ_BIN" -r '.driveVersionId')150 echo "$SITE_URL"151 echo "" >&2152 echo "publish_result.site_url=$SITE_URL" >&2153 echo "publish_result.slug=$OUT_SLUG" >&2154 echo "publish_result.action=from_drive" >&2155 echo "publish_result.auth_mode=authenticated" >&2156 echo "publish_result.api_key_source=$API_KEY_SOURCE" >&2157 echo "publish_result.persistence=permanent" >&2158 echo "publish_result.drive_id=$FROM_DRIVE" >&2159 echo "publish_result.drive_version_id=$DRIVE_VERSION_OUT" >&2160 echo "publish_result.current_version_id=$CURRENT_VERSION" >&2161 exit 0162fi163 164compute_sha256() {165 local f="$1"166 if command -v sha256sum >/dev/null 2>&1; then167 sha256sum "$f" | cut -d' ' -f1168 else169 shasum -a 256 "$f" | cut -d' ' -f1170 fi171}172 173guess_content_type() {174 local f="$1"175 case "${f##*.}" in176 html|htm) echo "text/html; charset=utf-8" ;;177 css) echo "text/css; charset=utf-8" ;;178 js|mjs) echo "text/javascript; charset=utf-8" ;;179 json) echo "application/json; charset=utf-8" ;;180 md|txt) echo "text/plain; charset=utf-8" ;;181 svg) echo "image/svg+xml" ;;182 png) echo "image/png" ;;183 jpg|jpeg) echo "image/jpeg" ;;184 gif) echo "image/gif" ;;185 webp) echo "image/webp" ;;186 pdf) echo "application/pdf" ;;187 mp4) echo "video/mp4" ;;188 mov) echo "video/quicktime" ;;189 mp3) echo "audio/mpeg" ;;190 wav) echo "audio/wav" ;;191 xml) echo "application/xml" ;;192 woff2) echo "font/woff2" ;;193 woff) echo "font/woff" ;;194 ttf) echo "font/ttf" ;;195 ico) echo "image/x-icon" ;;196 *)197 local detected198 detected=$(file --brief --mime-type "$f" 2>/dev/null || echo "application/octet-stream")199 echo "$detected"200 ;;201 esac202}203 204# Build file manifest as JSON array205FILES_JSON="[]"206 207if [[ -f "$TARGET" ]]; then208 sz=$(wc -c < "$TARGET" | tr -d ' ')209 ct=$(guess_content_type "$TARGET")210 bn=$(basename "$TARGET")211 h=$(compute_sha256 "$TARGET")212 FILES_JSON=$("$JQ_BIN" -n --arg p "$bn" --argjson s "$sz" --arg c "$ct" --arg h "$h" \213 '[{"path":$p,"size":$s,"contentType":$c,"hash":$h}]')214 FILE_MAP=$("$JQ_BIN" -n --arg p "$bn" --arg a "$(cd "$(dirname "$TARGET")" && pwd)/$(basename "$TARGET")" \215 '{($p):$a}')216elif [[ -d "$TARGET" ]]; then217 FILE_MAP="{}"218 while IFS= read -r -d '' f; do219 rel="${f#$TARGET/}"220 [[ "$rel" == ".DS_Store" ]] && continue221 [[ "$(basename "$rel")" == ".DS_Store" ]] && continue222 [[ "$rel" == ".herenow/fork-meta.json" ]] && continue223 sz=$(wc -c < "$f" | tr -d ' ')224 ct=$(guess_content_type "$f")225 h=$(compute_sha256 "$f")226 abs=$(cd "$(dirname "$f")" && pwd)/$(basename "$f")227 FILES_JSON=$(echo "$FILES_JSON" | "$JQ_BIN" --arg p "$rel" --argjson s "$sz" --arg c "$ct" --arg h "$h" \228 '. + [{"path":$p,"size":$s,"contentType":$c,"hash":$h}]')229 FILE_MAP=$(echo "$FILE_MAP" | "$JQ_BIN" --arg p "$rel" --arg a "$abs" '. + {($p):$a}')230 done < <(find "$TARGET" -type f -print0 | sort -z)231else232 die "not a file or directory: $TARGET"233fi234 235file_count=$(echo "$FILES_JSON" | "$JQ_BIN" 'length')236[[ "$file_count" -gt 0 ]] || die "no files found"237 238# Read fork-meta.json defaults if present and no explicit flags given239FORK_META=""240if [[ -d "$TARGET" ]]; then241 FORK_META_PATH="$TARGET/.herenow/fork-meta.json"242 if [[ -f "$FORK_META_PATH" ]]; then243 FORK_META=$(cat "$FORK_META_PATH")244 if [[ -z "$FORKABLE" ]]; then245 FORKABLE=$("$JQ_BIN" -r '.forkable // empty' <<< "$FORK_META" 2>/dev/null || true)246 fi247 fi248fi249 250# Build request body251BODY=$(echo "$FILES_JSON" | "$JQ_BIN" '{files: .}')252 253if [[ -n "$TTL" ]]; then254 BODY=$(echo "$BODY" | "$JQ_BIN" --argjson t "$TTL" '.ttlSeconds = $t')255fi256 257if [[ -n "$TITLE" || -n "$DESCRIPTION" ]]; then258 viewer="{}"259 [[ -n "$TITLE" ]] && viewer=$(echo "$viewer" | "$JQ_BIN" --arg t "$TITLE" '.title = $t')260 [[ -n "$DESCRIPTION" ]] && viewer=$(echo "$viewer" | "$JQ_BIN" --arg d "$DESCRIPTION" '.description = $d')261 BODY=$(echo "$BODY" | "$JQ_BIN" --argjson v "$viewer" '.viewer = $v')262fi263 264if [[ -n "$CLAIM_TOKEN" && -n "$SLUG" && -z "$API_KEY" ]]; then265 BODY=$(echo "$BODY" | "$JQ_BIN" --arg ct "$CLAIM_TOKEN" '.claimToken = $ct')266fi267 268if [[ "$FORKABLE" == "true" ]]; then269 BODY=$(echo "$BODY" | "$JQ_BIN" '.forkable = true')270fi271 272if [[ "$SPA_MODE" == "true" ]]; then273 BODY=$(echo "$BODY" | "$JQ_BIN" '.spaMode = true')274fi275 276# Determine endpoint and method277if [[ -n "$SLUG" ]]; then278 URL="$BASE_URL/api/v1/publish/$SLUG"279 METHOD="PUT"280else281 URL="$BASE_URL/api/v1/publish"282 METHOD="POST"283fi284 285# Build auth header286AUTH_ARGS=()287if [[ -n "$API_KEY" ]]; then288 AUTH_ARGS=(-H "authorization: Bearer $API_KEY")289fi290 291AUTH_MODE="anonymous"292if [[ -n "$API_KEY" ]]; then293 AUTH_MODE="authenticated"294fi295 296CLIENT_HEADER_VALUE="here-now-publish-sh"297if [[ -n "$CLIENT" ]]; then298 normalized_client=$(echo "$CLIENT" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')299 normalized_client="${normalized_client#-}"300 normalized_client="${normalized_client%-}"301 if [[ -n "$normalized_client" ]]; then302 CLIENT_HEADER_VALUE="${normalized_client}/publish-sh"303 fi304fi305CLIENT_ARGS=(-H "x-herenow-client: $CLIENT_HEADER_VALUE")306 307# Step 1: Create/update publish308echo "creating publish ($file_count files)..." >&2309RESPONSE=$(curl -sS -X "$METHOD" "$URL" \310 "${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" \311 "${CLIENT_ARGS[@]+"${CLIENT_ARGS[@]}"}" \312 -H "content-type: application/json" \313 -d "$BODY")314 315# Check for errors316if echo "$RESPONSE" | "$JQ_BIN" -e '.error' >/dev/null 2>&1; then317 err=$(echo "$RESPONSE" | "$JQ_BIN" -r '.error')318 details=$(echo "$RESPONSE" | "$JQ_BIN" -r '.details // empty')319 die "$err${details:+ ($details)}"320fi321 322OUT_SLUG=$(echo "$RESPONSE" | "$JQ_BIN" -r '.slug')323VERSION_ID=$(echo "$RESPONSE" | "$JQ_BIN" -r '.upload.versionId')324FINALIZE_URL=$(echo "$RESPONSE" | "$JQ_BIN" -r '.upload.finalizeUrl')325SITE_URL=$(echo "$RESPONSE" | "$JQ_BIN" -r '.siteUrl')326UPLOAD_COUNT=$(echo "$RESPONSE" | "$JQ_BIN" '.upload.uploads | length')327SKIPPED_COUNT=$(echo "$RESPONSE" | "$JQ_BIN" '.upload.skipped // [] | length')328 329[[ "$OUT_SLUG" != "null" ]] || die "unexpected response: $RESPONSE"330 331# Step 2: Upload files (skipped files are unchanged from previous version)332if [[ "$SKIPPED_COUNT" -gt 0 ]]; then333 echo "uploading $UPLOAD_COUNT files ($SKIPPED_COUNT unchanged, skipped)..." >&2334else335 echo "uploading $UPLOAD_COUNT files..." >&2336fi337upload_errors=0338 339for i in $(seq 0 $((UPLOAD_COUNT - 1))); do340 upload_path=$(echo "$RESPONSE" | "$JQ_BIN" -r ".upload.uploads[$i].path")341 upload_url=$(echo "$RESPONSE" | "$JQ_BIN" -r ".upload.uploads[$i].url")342 upload_ct=$(echo "$RESPONSE" | "$JQ_BIN" -r ".upload.uploads[$i].headers[\"Content-Type\"] // empty")343 344 if [[ -f "$TARGET" && ! -d "$TARGET" ]]; then345 local_file="$TARGET"346 else347 local_file=$(echo "$FILE_MAP" | "$JQ_BIN" -r --arg p "$upload_path" '.[$p]')348 fi349 350 if [[ ! -f "$local_file" ]]; then351 echo "warning: missing local file for $upload_path" >&2352 upload_errors=$((upload_errors + 1))353 continue354 fi355 356 ct_args=()357 [[ -n "$upload_ct" ]] && ct_args=(-H "Content-Type: $upload_ct")358 359 http_code=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT "$upload_url" \360 "${ct_args[@]+"${ct_args[@]}"}" \361 --data-binary "@$local_file")362 363 if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then364 echo "warning: upload failed for $upload_path (HTTP $http_code)" >&2365 upload_errors=$((upload_errors + 1))366 fi367done368 369[[ "$upload_errors" -eq 0 ]] || die "$upload_errors file(s) failed to upload"370 371# Step 3: Finalize372echo "finalizing..." >&2373FIN_RESPONSE=$(curl -sS -X POST "$FINALIZE_URL" \374 "${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" \375 "${CLIENT_ARGS[@]+"${CLIENT_ARGS[@]}"}" \376 -H "content-type: application/json" \377 -d "{\"versionId\":\"$VERSION_ID\"}")378 379if echo "$FIN_RESPONSE" | "$JQ_BIN" -e '.error' >/dev/null 2>&1; then380 err=$(echo "$FIN_RESPONSE" | "$JQ_BIN" -r '.error')381 die "finalize failed: $err"382fi383 384# Save state385mkdir -p "$STATE_DIR"386if [[ -f "$STATE_FILE" ]]; then387 STATE=$(cat "$STATE_FILE")388else389 STATE='{"publishes":{}}'390fi391 392entry=$("$JQ_BIN" -n --arg s "$SITE_URL" '{siteUrl: $s}')393 394RESPONSE_CLAIM_TOKEN=$(echo "$RESPONSE" | "$JQ_BIN" -r '.claimToken // empty')395RESPONSE_CLAIM_URL=$(echo "$RESPONSE" | "$JQ_BIN" -r '.claimUrl // empty')396RESPONSE_EXPIRES=$(echo "$RESPONSE" | "$JQ_BIN" -r '.expiresAt // empty')397 398[[ -n "$RESPONSE_CLAIM_TOKEN" ]] && entry=$(echo "$entry" | "$JQ_BIN" --arg v "$RESPONSE_CLAIM_TOKEN" '.claimToken = $v')399[[ -n "$RESPONSE_CLAIM_URL" ]] && entry=$(echo "$entry" | "$JQ_BIN" --arg v "$RESPONSE_CLAIM_URL" '.claimUrl = $v')400[[ -n "$RESPONSE_EXPIRES" ]] && entry=$(echo "$entry" | "$JQ_BIN" --arg v "$RESPONSE_EXPIRES" '.expiresAt = $v')401 402STATE=$(echo "$STATE" | "$JQ_BIN" --arg slug "$OUT_SLUG" --argjson e "$entry" '.publishes[$slug] = $e')403echo "$STATE" | "$JQ_BIN" '.' > "$STATE_FILE"404 405# Output406echo "$SITE_URL"407 408PERSISTENCE="permanent"409if [[ "$AUTH_MODE" == "anonymous" ]]; then410 PERSISTENCE="expires_24h"411elif [[ -n "$RESPONSE_EXPIRES" ]]; then412 PERSISTENCE="expires_at"413fi414 415SAFE_CLAIM_URL=""416if [[ -n "$RESPONSE_CLAIM_URL" && "$RESPONSE_CLAIM_URL" == https://* ]]; then417 SAFE_CLAIM_URL="$RESPONSE_CLAIM_URL"418fi419 420ACTION="create"421if [[ -n "$SLUG" ]]; then422 ACTION="update"423fi424 425echo "" >&2426echo "publish_result.site_url=$SITE_URL" >&2427echo "publish_result.slug=$OUT_SLUG" >&2428echo "publish_result.action=$ACTION" >&2429echo "publish_result.auth_mode=$AUTH_MODE" >&2430echo "publish_result.api_key_source=$API_KEY_SOURCE" >&2431echo "publish_result.persistence=$PERSISTENCE" >&2432echo "publish_result.expires_at=$RESPONSE_EXPIRES" >&2433echo "publish_result.claim_url=$SAFE_CLAIM_URL" >&2434 435if [[ "$AUTH_MODE" == "authenticated" ]]; then436 echo "authenticated publish (permanent, saved to your account)" >&2437else438 echo "anonymous publish (expires in 24h)" >&2439 if [[ -n "$SAFE_CLAIM_URL" ]]; then440 echo "claim URL: $SAFE_CLAIM_URL" >&2441 fi442 if [[ -n "$RESPONSE_CLAIM_TOKEN" ]]; then443 echo "claim token saved to $STATE_FILE" >&2444 fi445fi446