mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
feat(skills): add built-in here.now skill
Add the here.now productivity skill with a bundled publish runtime so Hermes can publish files and folders to live URLs. Keep the skill thin and docs-first while fixing script path resolution and upload failure handling. Made-with: Cursor
This commit is contained in:
parent
2110a3a0c4
commit
f7dfd4ae36
2 changed files with 574 additions and 0 deletions
190
skills/productivity/here-now/SKILL.md
Normal file
190
skills/productivity/here-now/SKILL.md
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
---
|
||||||
|
name: here-now
|
||||||
|
description: >
|
||||||
|
Publish files and folders to the web instantly. Static hosting for HTML sites,
|
||||||
|
images, PDFs, and any file type. Sites can connect to external APIs (LLMs,
|
||||||
|
databases, email, payments) via proxy routes with server-side credential
|
||||||
|
injection. Use when asked to "publish this", "host this", "deploy this",
|
||||||
|
"share this on the web", "make a website", "put this online", "upload to
|
||||||
|
the web", "create a webpage", "share a link", "serve this site", "generate
|
||||||
|
a URL", or "build a chatbot". Outputs a live, shareable URL at {slug}.here.now.
|
||||||
|
version: 1.14.0
|
||||||
|
author: here.now
|
||||||
|
license: MIT
|
||||||
|
prerequisites:
|
||||||
|
commands: [curl, file, jq]
|
||||||
|
platforms: [macos, linux]
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [here.now, herenow, publish, deploy, hosting, static-site, web, share, URL]
|
||||||
|
homepage: https://here.now
|
||||||
|
requires_toolsets: [terminal]
|
||||||
|
---
|
||||||
|
|
||||||
|
# here.now
|
||||||
|
|
||||||
|
Create a live URL from any file or folder. Static hosting with optional proxy routes for calling external APIs server-side.
|
||||||
|
|
||||||
|
## Current docs
|
||||||
|
|
||||||
|
**Before answering questions about here.now capabilities, features, or workflows, read the current docs:**
|
||||||
|
|
||||||
|
→ **https://here.now/docs**
|
||||||
|
|
||||||
|
Read the docs:
|
||||||
|
|
||||||
|
- at the first here.now-related interaction in a conversation
|
||||||
|
- any time the user asks how to do something
|
||||||
|
- any time the user asks what is possible, supported, or recommended
|
||||||
|
- before telling the user a feature is unsupported
|
||||||
|
|
||||||
|
Topics that require current docs (do not rely on local skill text alone):
|
||||||
|
|
||||||
|
- custom domains
|
||||||
|
- payments and payment gating
|
||||||
|
- forking
|
||||||
|
- proxy routes and service variables
|
||||||
|
- handles and links
|
||||||
|
- limits and quotas
|
||||||
|
- SPA routing
|
||||||
|
- error handling and remediation
|
||||||
|
- feature availability
|
||||||
|
|
||||||
|
**If docs and live API behavior disagree, trust the live API behavior.**
|
||||||
|
|
||||||
|
If the docs fetch fails or times out, continue with the local skill and live API/script output. Prefer live API behavior for active operations.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Required binaries: `curl`, `file`, `jq`
|
||||||
|
- Optional environment variable: `$HERENOW_API_KEY`
|
||||||
|
- Optional credentials file: `~/.herenow/credentials`
|
||||||
|
- Skill script path: `${HERMES_SKILL_DIR}/scripts/publish.sh`
|
||||||
|
|
||||||
|
## Create a site
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PUBLISH="${HERMES_SKILL_DIR}/scripts/publish.sh"
|
||||||
|
bash "$PUBLISH" {file-or-dir} --client hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs the live URL (e.g. `https://bright-canvas-a7k2.here.now/`).
|
||||||
|
|
||||||
|
Under the hood this is a three-step flow: create/update -> upload files -> finalize. A site is not live until finalize succeeds.
|
||||||
|
|
||||||
|
Without an API key this creates an **anonymous site** that expires in 24 hours.
|
||||||
|
With a saved API key, the site is permanent.
|
||||||
|
|
||||||
|
**File structure:** For HTML sites, place `index.html` at the root of the directory you publish, not inside a subdirectory. The directory's contents become the site root. For example, publish `my-site/` where `my-site/index.html` exists — don't publish a parent folder that contains `my-site/`.
|
||||||
|
|
||||||
|
You can also publish raw files without any HTML. Single files get a rich auto-viewer (images, PDF, video, audio). Multiple files get an auto-generated directory listing with folder navigation and an image gallery.
|
||||||
|
|
||||||
|
## Update an existing site
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PUBLISH="${HERMES_SKILL_DIR}/scripts/publish.sh"
|
||||||
|
bash "$PUBLISH" {file-or-dir} --slug {slug} --client hermes
|
||||||
|
```
|
||||||
|
|
||||||
|
The script auto-loads the `claimToken` from `.herenow/state.json` when updating anonymous sites. Pass `--claim-token {token}` to override.
|
||||||
|
|
||||||
|
Authenticated updates require a saved API key.
|
||||||
|
|
||||||
|
## API key storage
|
||||||
|
|
||||||
|
The publish script reads the API key from these sources (first match wins):
|
||||||
|
|
||||||
|
1. `--api-key {key}` flag (CI/scripting only — avoid in interactive use)
|
||||||
|
2. `$HERENOW_API_KEY` environment variable
|
||||||
|
3. `~/.herenow/credentials` file (recommended for agents)
|
||||||
|
|
||||||
|
To store a key, write it to the credentials file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.herenow && echo "{API_KEY}" > ~/.herenow/credentials && chmod 600 ~/.herenow/credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
**IMPORTANT**: After receiving an API key, save it immediately — run the command above yourself. Do not ask the user to run it manually. Avoid passing the key via CLI flags (e.g. `--api-key`) in interactive sessions; the credentials file is the preferred storage method.
|
||||||
|
|
||||||
|
Never commit credentials or local state files (`~/.herenow/credentials`, `.herenow/state.json`) to source control.
|
||||||
|
|
||||||
|
## Getting an API key
|
||||||
|
|
||||||
|
To upgrade from anonymous (24h) to permanent sites:
|
||||||
|
|
||||||
|
1. Ask the user for their email address.
|
||||||
|
2. Request a one-time sign-in code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS https://here.now/api/auth/agent/request-code \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
-d '{"email": "user@example.com"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Tell the user: "Check your inbox for a sign-in code from here.now and paste it here."
|
||||||
|
4. Verify the code and get the API key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS https://here.now/api/auth/agent/verify-code \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
-d '{"email":"user@example.com","code":"ABCD-2345"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Save the returned `apiKey` yourself (do not ask the user to do this):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.herenow && echo "{API_KEY}" > ~/.herenow/credentials && chmod 600 ~/.herenow/credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
## State file
|
||||||
|
|
||||||
|
After every site create/update, the script writes to `.herenow/state.json` in the working directory:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"publishes": {
|
||||||
|
"bright-canvas-a7k2": {
|
||||||
|
"siteUrl": "https://bright-canvas-a7k2.here.now/",
|
||||||
|
"claimToken": "abc123",
|
||||||
|
"claimUrl": "https://here.now/claim?slug=bright-canvas-a7k2&token=abc123",
|
||||||
|
"expiresAt": "2026-02-18T01:00:00.000Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Before creating or updating sites, you may check this file to find prior slugs.
|
||||||
|
Treat `.herenow/state.json` as internal cache only.
|
||||||
|
Never present this local file path as a URL, and never use it as source of truth for auth mode, expiry, or claim URL.
|
||||||
|
|
||||||
|
## What to tell the user
|
||||||
|
|
||||||
|
- Always share the `siteUrl` from the current script run.
|
||||||
|
- Read and follow `publish_result.*` lines from script stderr to determine auth mode.
|
||||||
|
- When `publish_result.auth_mode=authenticated`: tell the user the site is **permanent** and saved to their account. No claim URL is needed.
|
||||||
|
- When `publish_result.auth_mode=anonymous`: tell the user the site **expires in 24 hours**. Share the claim URL (if `publish_result.claim_url` is non-empty and starts with `https://`) so they can keep it permanently. Warn that claim tokens are only returned once and cannot be recovered.
|
||||||
|
- Never tell the user to inspect `.herenow/state.json` for claim URLs or auth status.
|
||||||
|
|
||||||
|
## Script options
|
||||||
|
|
||||||
|
| Flag | Description |
|
||||||
|
| ---------------------- | -------------------------------------------- |
|
||||||
|
| `--slug {slug}` | Update an existing site instead of creating |
|
||||||
|
| `--claim-token {token}`| Override claim token for anonymous updates |
|
||||||
|
| `--title {text}` | Viewer title (non-HTML sites) |
|
||||||
|
| `--description {text}` | Viewer description |
|
||||||
|
| `--ttl {seconds}` | Set expiry (authenticated only) |
|
||||||
|
| `--client {name}` | Agent name for attribution (e.g. `hermes`) |
|
||||||
|
| `--base-url {url}` | API base URL (default: `https://here.now`) |
|
||||||
|
| `--allow-nonherenow-base-url` | Allow sending auth to non-default `--base-url` |
|
||||||
|
| `--api-key {key}` | API key override (prefer credentials file) |
|
||||||
|
| `--spa` | Enable SPA routing (serve index.html for unknown paths) |
|
||||||
|
| `--forkable` | Allow others to fork this site |
|
||||||
|
|
||||||
|
## Beyond the script
|
||||||
|
|
||||||
|
For all other operations — delete, metadata, passwords, payments, domains, handles, links, variables, proxy routes, forking, duplication, and more — see the current docs:
|
||||||
|
|
||||||
|
→ **https://here.now/docs**
|
||||||
|
|
||||||
|
Full docs: https://here.now/docs
|
||||||
384
skills/productivity/here-now/scripts/publish.sh
Executable file
384
skills/productivity/here-now/scripts/publish.sh
Executable file
|
|
@ -0,0 +1,384 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="https://here.now"
|
||||||
|
CREDENTIALS_FILE="$HOME/.herenow/credentials"
|
||||||
|
API_KEY="${HERENOW_API_KEY:-}"
|
||||||
|
API_KEY_SOURCE="none"
|
||||||
|
if [[ -n "${HERENOW_API_KEY:-}" ]]; then
|
||||||
|
API_KEY_SOURCE="env"
|
||||||
|
fi
|
||||||
|
ALLOW_NON_HERENOW_BASE_URL=0
|
||||||
|
SLUG=""
|
||||||
|
CLAIM_TOKEN=""
|
||||||
|
TITLE=""
|
||||||
|
DESCRIPTION=""
|
||||||
|
TTL=""
|
||||||
|
CLIENT=""
|
||||||
|
TARGET=""
|
||||||
|
FORKABLE=""
|
||||||
|
SPA_MODE=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage: publish.sh <file-or-dir> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--api-key <key> API key (or set $HERENOW_API_KEY)
|
||||||
|
--slug <slug> Update existing publish
|
||||||
|
--claim-token <token> Claim token for anonymous updates
|
||||||
|
--title <text> Viewer title
|
||||||
|
--description <text> Viewer description
|
||||||
|
--ttl <seconds> Expiry (authenticated only)
|
||||||
|
--client <name> Agent name for attribution (e.g. cursor, claude-code)
|
||||||
|
--forkable Allow others to fork this site
|
||||||
|
--spa Enable SPA routing
|
||||||
|
--base-url <url> API base (default: https://here.now)
|
||||||
|
--allow-nonherenow-base-url
|
||||||
|
Allow auth requests to non-default API base URL
|
||||||
|
USAGE
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
die() { echo "error: $1" >&2; exit 1; }
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
BUNDLED_JQ="${SKILL_DIR}/bin/jq"
|
||||||
|
|
||||||
|
if [[ -x "$BUNDLED_JQ" ]]; then
|
||||||
|
JQ_BIN="$BUNDLED_JQ"
|
||||||
|
elif command -v jq >/dev/null 2>&1; then
|
||||||
|
JQ_BIN="$(command -v jq)"
|
||||||
|
else
|
||||||
|
die "requires jq"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for cmd in curl file; do
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || die "requires $cmd"
|
||||||
|
done
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--api-key) API_KEY="$2"; API_KEY_SOURCE="flag"; shift 2 ;;
|
||||||
|
--slug) SLUG="$2"; shift 2 ;;
|
||||||
|
--claim-token) CLAIM_TOKEN="$2"; shift 2 ;;
|
||||||
|
--title) TITLE="$2"; shift 2 ;;
|
||||||
|
--description) DESCRIPTION="$2"; shift 2 ;;
|
||||||
|
--ttl) TTL="$2"; shift 2 ;;
|
||||||
|
--client) CLIENT="$2"; shift 2 ;;
|
||||||
|
--base-url) BASE_URL="$2"; shift 2 ;;
|
||||||
|
--allow-nonherenow-base-url) ALLOW_NON_HERENOW_BASE_URL=1; shift ;;
|
||||||
|
--forkable) FORKABLE="true"; shift ;;
|
||||||
|
--spa) SPA_MODE="true"; shift ;;
|
||||||
|
--help|-h) usage ;;
|
||||||
|
-*) die "unknown option: $1" ;;
|
||||||
|
*) [[ -z "$TARGET" ]] && TARGET="$1" || die "unexpected argument: $1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "$TARGET" ]] || usage
|
||||||
|
[[ -e "$TARGET" ]] || die "path does not exist: $TARGET"
|
||||||
|
|
||||||
|
# Load API key from credentials file if not provided via flag or env
|
||||||
|
if [[ -z "$API_KEY" && -f "$CREDENTIALS_FILE" ]]; then
|
||||||
|
API_KEY=$(cat "$CREDENTIALS_FILE" | tr -d '[:space:]')
|
||||||
|
[[ -n "$API_KEY" ]] && API_KEY_SOURCE="credentials"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASE_URL="${BASE_URL%/}"
|
||||||
|
STATE_DIR=".herenow"
|
||||||
|
STATE_FILE="$STATE_DIR/state.json"
|
||||||
|
|
||||||
|
# Safety guard: avoid accidentally sending bearer auth to arbitrary endpoints.
|
||||||
|
if [[ -n "$API_KEY" && "$BASE_URL" != "https://here.now" && "$ALLOW_NON_HERENOW_BASE_URL" -ne 1 ]]; then
|
||||||
|
die "refusing to send API key to non-default base URL; pass --allow-nonherenow-base-url to override"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Auto-load claim token from state file for anonymous updates
|
||||||
|
if [[ -n "$SLUG" && -z "$CLAIM_TOKEN" && -z "$API_KEY" && -f "$STATE_FILE" ]]; then
|
||||||
|
CLAIM_TOKEN=$("$JQ_BIN" -r --arg s "$SLUG" '.publishes[$s].claimToken // empty' "$STATE_FILE" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
compute_sha256() {
|
||||||
|
local f="$1"
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum "$f" | cut -d' ' -f1
|
||||||
|
else
|
||||||
|
shasum -a 256 "$f" | cut -d' ' -f1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
guess_content_type() {
|
||||||
|
local f="$1"
|
||||||
|
case "${f##*.}" in
|
||||||
|
html|htm) echo "text/html; charset=utf-8" ;;
|
||||||
|
css) echo "text/css; charset=utf-8" ;;
|
||||||
|
js|mjs) echo "text/javascript; charset=utf-8" ;;
|
||||||
|
json) echo "application/json; charset=utf-8" ;;
|
||||||
|
md|txt) echo "text/plain; charset=utf-8" ;;
|
||||||
|
svg) echo "image/svg+xml" ;;
|
||||||
|
png) echo "image/png" ;;
|
||||||
|
jpg|jpeg) echo "image/jpeg" ;;
|
||||||
|
gif) echo "image/gif" ;;
|
||||||
|
webp) echo "image/webp" ;;
|
||||||
|
pdf) echo "application/pdf" ;;
|
||||||
|
mp4) echo "video/mp4" ;;
|
||||||
|
mov) echo "video/quicktime" ;;
|
||||||
|
mp3) echo "audio/mpeg" ;;
|
||||||
|
wav) echo "audio/wav" ;;
|
||||||
|
xml) echo "application/xml" ;;
|
||||||
|
woff2) echo "font/woff2" ;;
|
||||||
|
woff) echo "font/woff" ;;
|
||||||
|
ttf) echo "font/ttf" ;;
|
||||||
|
ico) echo "image/x-icon" ;;
|
||||||
|
*)
|
||||||
|
local detected
|
||||||
|
detected=$(file --brief --mime-type "$f" 2>/dev/null || echo "application/octet-stream")
|
||||||
|
echo "$detected"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build file manifest as JSON array
|
||||||
|
FILES_JSON="[]"
|
||||||
|
|
||||||
|
if [[ -f "$TARGET" ]]; then
|
||||||
|
sz=$(wc -c < "$TARGET" | tr -d ' ')
|
||||||
|
ct=$(guess_content_type "$TARGET")
|
||||||
|
bn=$(basename "$TARGET")
|
||||||
|
h=$(compute_sha256 "$TARGET")
|
||||||
|
FILES_JSON=$("$JQ_BIN" -n --arg p "$bn" --argjson s "$sz" --arg c "$ct" --arg h "$h" \
|
||||||
|
'[{"path":$p,"size":$s,"contentType":$c,"hash":$h}]')
|
||||||
|
FILE_MAP=$("$JQ_BIN" -n --arg p "$bn" --arg a "$(cd "$(dirname "$TARGET")" && pwd)/$(basename "$TARGET")" \
|
||||||
|
'{($p):$a}')
|
||||||
|
elif [[ -d "$TARGET" ]]; then
|
||||||
|
FILE_MAP="{}"
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
rel="${f#$TARGET/}"
|
||||||
|
[[ "$rel" == ".DS_Store" ]] && continue
|
||||||
|
[[ "$(basename "$rel")" == ".DS_Store" ]] && continue
|
||||||
|
[[ "$rel" == ".herenow/fork-meta.json" ]] && continue
|
||||||
|
sz=$(wc -c < "$f" | tr -d ' ')
|
||||||
|
ct=$(guess_content_type "$f")
|
||||||
|
h=$(compute_sha256 "$f")
|
||||||
|
abs=$(cd "$(dirname "$f")" && pwd)/$(basename "$f")
|
||||||
|
FILES_JSON=$(echo "$FILES_JSON" | "$JQ_BIN" --arg p "$rel" --argjson s "$sz" --arg c "$ct" --arg h "$h" \
|
||||||
|
'. + [{"path":$p,"size":$s,"contentType":$c,"hash":$h}]')
|
||||||
|
FILE_MAP=$(echo "$FILE_MAP" | "$JQ_BIN" --arg p "$rel" --arg a "$abs" '. + {($p):$a}')
|
||||||
|
done < <(find "$TARGET" -type f -print0 | sort -z)
|
||||||
|
else
|
||||||
|
die "not a file or directory: $TARGET"
|
||||||
|
fi
|
||||||
|
|
||||||
|
file_count=$(echo "$FILES_JSON" | "$JQ_BIN" 'length')
|
||||||
|
[[ "$file_count" -gt 0 ]] || die "no files found"
|
||||||
|
|
||||||
|
# Read fork-meta.json defaults if present and no explicit flags given
|
||||||
|
FORK_META=""
|
||||||
|
if [[ -d "$TARGET" ]]; then
|
||||||
|
FORK_META_PATH="$TARGET/.herenow/fork-meta.json"
|
||||||
|
if [[ -f "$FORK_META_PATH" ]]; then
|
||||||
|
FORK_META=$(cat "$FORK_META_PATH")
|
||||||
|
if [[ -z "$FORKABLE" ]]; then
|
||||||
|
FORKABLE=$("$JQ_BIN" -r '.forkable // empty' <<< "$FORK_META" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build request body
|
||||||
|
BODY=$(echo "$FILES_JSON" | "$JQ_BIN" '{files: .}')
|
||||||
|
|
||||||
|
if [[ -n "$TTL" ]]; then
|
||||||
|
BODY=$(echo "$BODY" | "$JQ_BIN" --argjson t "$TTL" '.ttlSeconds = $t')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$TITLE" || -n "$DESCRIPTION" ]]; then
|
||||||
|
viewer="{}"
|
||||||
|
[[ -n "$TITLE" ]] && viewer=$(echo "$viewer" | "$JQ_BIN" --arg t "$TITLE" '.title = $t')
|
||||||
|
[[ -n "$DESCRIPTION" ]] && viewer=$(echo "$viewer" | "$JQ_BIN" --arg d "$DESCRIPTION" '.description = $d')
|
||||||
|
BODY=$(echo "$BODY" | "$JQ_BIN" --argjson v "$viewer" '.viewer = $v')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$CLAIM_TOKEN" && -n "$SLUG" && -z "$API_KEY" ]]; then
|
||||||
|
BODY=$(echo "$BODY" | "$JQ_BIN" --arg ct "$CLAIM_TOKEN" '.claimToken = $ct')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$FORKABLE" == "true" ]]; then
|
||||||
|
BODY=$(echo "$BODY" | "$JQ_BIN" '.forkable = true')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SPA_MODE" == "true" ]]; then
|
||||||
|
BODY=$(echo "$BODY" | "$JQ_BIN" '.spaMode = true')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine endpoint and method
|
||||||
|
if [[ -n "$SLUG" ]]; then
|
||||||
|
URL="$BASE_URL/api/v1/publish/$SLUG"
|
||||||
|
METHOD="PUT"
|
||||||
|
else
|
||||||
|
URL="$BASE_URL/api/v1/publish"
|
||||||
|
METHOD="POST"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build auth header
|
||||||
|
AUTH_ARGS=()
|
||||||
|
if [[ -n "$API_KEY" ]]; then
|
||||||
|
AUTH_ARGS=(-H "authorization: Bearer $API_KEY")
|
||||||
|
fi
|
||||||
|
|
||||||
|
AUTH_MODE="anonymous"
|
||||||
|
if [[ -n "$API_KEY" ]]; then
|
||||||
|
AUTH_MODE="authenticated"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CLIENT_HEADER_VALUE="here-now-publish-sh"
|
||||||
|
if [[ -n "$CLIENT" ]]; then
|
||||||
|
normalized_client=$(echo "$CLIENT" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-')
|
||||||
|
normalized_client="${normalized_client#-}"
|
||||||
|
normalized_client="${normalized_client%-}"
|
||||||
|
if [[ -n "$normalized_client" ]]; then
|
||||||
|
CLIENT_HEADER_VALUE="${normalized_client}/publish-sh"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
CLIENT_ARGS=(-H "x-herenow-client: $CLIENT_HEADER_VALUE")
|
||||||
|
|
||||||
|
# Step 1: Create/update publish
|
||||||
|
echo "creating publish ($file_count files)..." >&2
|
||||||
|
RESPONSE=$(curl -sS -X "$METHOD" "$URL" \
|
||||||
|
"${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" \
|
||||||
|
"${CLIENT_ARGS[@]+"${CLIENT_ARGS[@]}"}" \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
-d "$BODY")
|
||||||
|
|
||||||
|
# Check for errors
|
||||||
|
if echo "$RESPONSE" | "$JQ_BIN" -e '.error' >/dev/null 2>&1; then
|
||||||
|
err=$(echo "$RESPONSE" | "$JQ_BIN" -r '.error')
|
||||||
|
details=$(echo "$RESPONSE" | "$JQ_BIN" -r '.details // empty')
|
||||||
|
die "$err${details:+ ($details)}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUT_SLUG=$(echo "$RESPONSE" | "$JQ_BIN" -r '.slug')
|
||||||
|
VERSION_ID=$(echo "$RESPONSE" | "$JQ_BIN" -r '.upload.versionId')
|
||||||
|
FINALIZE_URL=$(echo "$RESPONSE" | "$JQ_BIN" -r '.upload.finalizeUrl')
|
||||||
|
SITE_URL=$(echo "$RESPONSE" | "$JQ_BIN" -r '.siteUrl')
|
||||||
|
UPLOAD_COUNT=$(echo "$RESPONSE" | "$JQ_BIN" '.upload.uploads | length')
|
||||||
|
SKIPPED_COUNT=$(echo "$RESPONSE" | "$JQ_BIN" '.upload.skipped // [] | length')
|
||||||
|
|
||||||
|
[[ "$OUT_SLUG" != "null" ]] || die "unexpected response: $RESPONSE"
|
||||||
|
|
||||||
|
# Step 2: Upload files (skipped files are unchanged from previous version)
|
||||||
|
if [[ "$SKIPPED_COUNT" -gt 0 ]]; then
|
||||||
|
echo "uploading $UPLOAD_COUNT files ($SKIPPED_COUNT unchanged, skipped)..." >&2
|
||||||
|
else
|
||||||
|
echo "uploading $UPLOAD_COUNT files..." >&2
|
||||||
|
fi
|
||||||
|
upload_errors=0
|
||||||
|
|
||||||
|
for i in $(seq 0 $((UPLOAD_COUNT - 1))); do
|
||||||
|
upload_path=$(echo "$RESPONSE" | "$JQ_BIN" -r ".upload.uploads[$i].path")
|
||||||
|
upload_url=$(echo "$RESPONSE" | "$JQ_BIN" -r ".upload.uploads[$i].url")
|
||||||
|
upload_ct=$(echo "$RESPONSE" | "$JQ_BIN" -r ".upload.uploads[$i].headers[\"Content-Type\"] // empty")
|
||||||
|
|
||||||
|
if [[ -f "$TARGET" && ! -d "$TARGET" ]]; then
|
||||||
|
local_file="$TARGET"
|
||||||
|
else
|
||||||
|
local_file=$(echo "$FILE_MAP" | "$JQ_BIN" -r --arg p "$upload_path" '.[$p]')
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$local_file" ]]; then
|
||||||
|
echo "warning: missing local file for $upload_path" >&2
|
||||||
|
upload_errors=$((upload_errors + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
ct_args=()
|
||||||
|
[[ -n "$upload_ct" ]] && ct_args=(-H "Content-Type: $upload_ct")
|
||||||
|
|
||||||
|
http_code=$(curl -sS -o /dev/null -w "%{http_code}" -X PUT "$upload_url" \
|
||||||
|
"${ct_args[@]+"${ct_args[@]}"}" \
|
||||||
|
--data-binary "@$local_file")
|
||||||
|
|
||||||
|
if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then
|
||||||
|
echo "warning: upload failed for $upload_path (HTTP $http_code)" >&2
|
||||||
|
upload_errors=$((upload_errors + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ "$upload_errors" -eq 0 ]] || die "$upload_errors file(s) failed to upload"
|
||||||
|
|
||||||
|
# Step 3: Finalize
|
||||||
|
echo "finalizing..." >&2
|
||||||
|
FIN_RESPONSE=$(curl -sS -X POST "$FINALIZE_URL" \
|
||||||
|
"${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" \
|
||||||
|
"${CLIENT_ARGS[@]+"${CLIENT_ARGS[@]}"}" \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
-d "{\"versionId\":\"$VERSION_ID\"}")
|
||||||
|
|
||||||
|
if echo "$FIN_RESPONSE" | "$JQ_BIN" -e '.error' >/dev/null 2>&1; then
|
||||||
|
err=$(echo "$FIN_RESPONSE" | "$JQ_BIN" -r '.error')
|
||||||
|
die "finalize failed: $err"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save state
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
if [[ -f "$STATE_FILE" ]]; then
|
||||||
|
STATE=$(cat "$STATE_FILE")
|
||||||
|
else
|
||||||
|
STATE='{"publishes":{}}'
|
||||||
|
fi
|
||||||
|
|
||||||
|
entry=$("$JQ_BIN" -n --arg s "$SITE_URL" '{siteUrl: $s}')
|
||||||
|
|
||||||
|
RESPONSE_CLAIM_TOKEN=$(echo "$RESPONSE" | "$JQ_BIN" -r '.claimToken // empty')
|
||||||
|
RESPONSE_CLAIM_URL=$(echo "$RESPONSE" | "$JQ_BIN" -r '.claimUrl // empty')
|
||||||
|
RESPONSE_EXPIRES=$(echo "$RESPONSE" | "$JQ_BIN" -r '.expiresAt // empty')
|
||||||
|
|
||||||
|
[[ -n "$RESPONSE_CLAIM_TOKEN" ]] && entry=$(echo "$entry" | "$JQ_BIN" --arg v "$RESPONSE_CLAIM_TOKEN" '.claimToken = $v')
|
||||||
|
[[ -n "$RESPONSE_CLAIM_URL" ]] && entry=$(echo "$entry" | "$JQ_BIN" --arg v "$RESPONSE_CLAIM_URL" '.claimUrl = $v')
|
||||||
|
[[ -n "$RESPONSE_EXPIRES" ]] && entry=$(echo "$entry" | "$JQ_BIN" --arg v "$RESPONSE_EXPIRES" '.expiresAt = $v')
|
||||||
|
|
||||||
|
STATE=$(echo "$STATE" | "$JQ_BIN" --arg slug "$OUT_SLUG" --argjson e "$entry" '.publishes[$slug] = $e')
|
||||||
|
echo "$STATE" | "$JQ_BIN" '.' > "$STATE_FILE"
|
||||||
|
|
||||||
|
# Output
|
||||||
|
echo "$SITE_URL"
|
||||||
|
|
||||||
|
PERSISTENCE="permanent"
|
||||||
|
if [[ "$AUTH_MODE" == "anonymous" ]]; then
|
||||||
|
PERSISTENCE="expires_24h"
|
||||||
|
elif [[ -n "$RESPONSE_EXPIRES" ]]; then
|
||||||
|
PERSISTENCE="expires_at"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SAFE_CLAIM_URL=""
|
||||||
|
if [[ -n "$RESPONSE_CLAIM_URL" && "$RESPONSE_CLAIM_URL" == https://* ]]; then
|
||||||
|
SAFE_CLAIM_URL="$RESPONSE_CLAIM_URL"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACTION="create"
|
||||||
|
if [[ -n "$SLUG" ]]; then
|
||||||
|
ACTION="update"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >&2
|
||||||
|
echo "publish_result.site_url=$SITE_URL" >&2
|
||||||
|
echo "publish_result.slug=$OUT_SLUG" >&2
|
||||||
|
echo "publish_result.action=$ACTION" >&2
|
||||||
|
echo "publish_result.auth_mode=$AUTH_MODE" >&2
|
||||||
|
echo "publish_result.api_key_source=$API_KEY_SOURCE" >&2
|
||||||
|
echo "publish_result.persistence=$PERSISTENCE" >&2
|
||||||
|
echo "publish_result.expires_at=$RESPONSE_EXPIRES" >&2
|
||||||
|
echo "publish_result.claim_url=$SAFE_CLAIM_URL" >&2
|
||||||
|
|
||||||
|
if [[ "$AUTH_MODE" == "authenticated" ]]; then
|
||||||
|
echo "authenticated publish (permanent, saved to your account)" >&2
|
||||||
|
else
|
||||||
|
echo "anonymous publish (expires in 24h)" >&2
|
||||||
|
if [[ -n "$SAFE_CLAIM_URL" ]]; then
|
||||||
|
echo "claim URL: $SAFE_CLAIM_URL" >&2
|
||||||
|
fi
|
||||||
|
if [[ -n "$RESPONSE_CLAIM_TOKEN" ]]; then
|
||||||
|
echo "claim token saved to $STATE_FILE" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
Loading…
Add table
Add a link
Reference in a new issue