diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c5f206 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude/ diff --git a/README.md b/README.md index e872615..695ff4c 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,15 @@ CLI tool for submitting posts to WordPress through its REST API using curl. ## Install ### WordPress -Install [Application Passwords](https://wordpress.org/plugins/application-passwords/) plugin and follow its installation steps (i.e. create a new passowrd for your user). +Application Passwords are built into WordPress core since 5.6 — no plugin needed. +Go to Users → Profile → Application Passwords on your site, generate a new +password for your user, and note it down. Your site must be served over +HTTPS for this to work. ### Local shell -Make sure you have `curl` installed. If you want to enter text in markdown format, install `python-markdown2`. +Make sure you have `curl` and `jq` installed. If you want to enter text in +markdown format, install `python-markdown2` (`pip install markdown2` or your +distro's `python3-markdown2` package). Create a config file `~/.config/wordpress-rest-curl/config.sh` from the `config.sh.template` file. @@ -19,3 +24,14 @@ Copy the posting script to e.g. `/usr/local/bin/wordpress-post` so that you can * Confirm the displayed post by pressing enter. * Done! +### Options +* `--file PATH` — use PATH as the post content instead of opening `$EDITOR`. +* `--publish` — shortcut for `STATUS=publish`, posts live immediately. +* `--edit POST_ID` — update an existing post instead of creating a new one. + +## Testing +`tests/run_tests.sh` runs post.sh against a local mock of the WordPress REST +API (`tests/mock_wp_server.py`) — no real WordPress install or network access +needed. Requires the same dependencies as the script itself (`curl`, `jq`, +`python3-markdown2`). + diff --git a/post.sh b/post.sh index 31ea4d0..e7009b8 100755 --- a/post.sh +++ b/post.sh @@ -14,15 +14,39 @@ TMPFILE=/tmp/wordpress-post.txt # location of a temporary file with the post tex source ~/.config/wordpress-rest-curl/config.sh -# let the user create the post -$EDITOR $TMPFILE || exit 1 +# CLI options (override config): +# --edit POST_ID update an existing post instead of creating a new one +# --publish shortcut for STATUS=publish +# --file PATH use PATH as the post source instead of opening $EDITOR +POST_ID="" +SOURCE_FILE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --edit) + POST_ID="$2"; shift 2 ;; + --publish) + STATUS="publish"; shift ;; + --file) + SOURCE_FILE="$2"; shift 2 ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 ;; + esac +done + +# let the user provide the post content +if [[ -n "$SOURCE_FILE" ]]; then + cp "$SOURCE_FILE" "$TMPFILE" || exit 1 +else + $EDITOR $TMPFILE || exit 1 +fi [[ -e $TMPFILE ]] || exit 1 # transformations cp $TMPFILE $TMPFILE.trans for T in "${TRANSFORM[@]}"; do if [[ "$T" == "title" ]]; then - python >$TMPFILE.trans2 <$TMPFILE.trans2 <$TMPFILE.trans2 <$TMPFILE.trans2 < 0) | tonumber' | jq -s '.') +fi +TAGS_JSON="[]" +if [[ -n "$TAGS" ]]; then + TAGS_JSON=$(echo "$TAGS" | tr ',' '\n' | jq -R 'select(length > 0) | tonumber' | jq -s '.') +fi + +PAYLOAD=$(jq -n \ + --arg title "$TITLE" \ + --arg content "$CONTENT" \ + --arg status "$STATUS" \ + --argjson categories "$CATEGORIES_JSON" \ + --argjson tags "$TAGS_JSON" \ + '{title: $title, content: $content, status: $status, categories: $categories, tags: $tags}') + +BASE_URL="$SERVER" +[[ "$BASE_URL" != http://* && "$BASE_URL" != https://* ]] && BASE_URL="https://$BASE_URL" +URL="$BASE_URL/wp-json/wp/v2/posts/" +[[ -n "$POST_ID" ]] && URL="$BASE_URL/wp-json/wp/v2/posts/$POST_ID" + # push the post! -curl --user "$USER:$PASSWORD" -X POST \ - --data-urlencode "title=$TITLE" \ - --data-urlencode "content=$CONTENT" \ - --data-urlencode "status=$STATUS" \ - --data-urlencode "categories=$CATEGORIES" \ - --data-urlencode "tags=$TAGS" \ - "https://$SERVER/wp-json/wp/v2/posts/" || exit 1 +RESPONSE=$(curl --silent --show-error --user "$USER:$PASSWORD" -X POST \ + -H "Content-Type: application/json" \ + --data "$PAYLOAD" \ + -w '\n%{http_code}' \ + "$URL") +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | sed '$d') + +if [[ "$HTTP_CODE" -lt 200 || "$HTTP_CODE" -ge 300 ]]; then + echo "Error: WordPress API returned HTTP $HTTP_CODE" >&2 + echo "$BODY" >&2 + exit 1 +fi +echo "$BODY" # backup the posted data (temporarily until it is auto-removed) mv $TMPFILE $TMPFILE.posted - diff --git a/tests/mock_wp_server.py b/tests/mock_wp_server.py new file mode 100755 index 0000000..9940fa6 --- /dev/null +++ b/tests/mock_wp_server.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# Minimal mock of the WordPress REST API /wp/v2/posts endpoint, for testing +# post.sh without a real WordPress install. +# +# Behavior is controlled by two files (paths given via env vars): +# MOCK_WP_CONTROL_FILE - optional JSON {"status": int, "body": {...}} that +# determines the response to the next request. If absent, responds +# 201 with the submitted fields echoed back under an "id". +# MOCK_WP_REQUEST_FILE - overwritten on every request with +# {"path": ..., "payload": ...} so a test can assert on what was sent. +# +# Listens on 127.0.0.1:$MOCK_WP_PORT (default 8899). + +import http.server +import json +import os + +PORT = int(os.environ.get("MOCK_WP_PORT", "8899")) +CONTROL_FILE = os.environ["MOCK_WP_CONTROL_FILE"] +REQUEST_FILE = os.environ["MOCK_WP_REQUEST_FILE"] + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + try: + data = json.loads(body) if body else {} + except json.JSONDecodeError: + data = {"_raw": body.decode(errors="replace")} + + with open(REQUEST_FILE, "w") as f: + json.dump({"path": self.path, "payload": data}, f) + + status, resp_body = 201, {"id": 1, **data} + if os.path.exists(CONTROL_FILE): + with open(CONTROL_FILE) as f: + control = json.load(f) + status = control.get("status", status) + resp_body = control.get("body", resp_body) + + payload = json.dumps(resp_body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, format, *args): + pass + + +if __name__ == "__main__": + http.server.HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..152aa52 --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# Test suite for post.sh, run against tests/mock_wp_server.py so no real +# WordPress install is needed. Requires curl, jq, python3, python3-markdown2. +set -u +cd "$(dirname "$0")/.." + +for bin in curl jq python3; do + command -v "$bin" >/dev/null || { echo "missing dependency: $bin"; exit 1; } +done +python3 -c "import markdown2" 2>/dev/null || { echo "missing dependency: python3-markdown2"; exit 1; } + +PORT=8899 +WORKDIR=$(mktemp -d) +trap 'kill $SERVER_PID 2>/dev/null; rm -rf "$WORKDIR"' EXIT + +export MOCK_WP_PORT=$PORT +export MOCK_WP_CONTROL_FILE="$WORKDIR/control.json" +export MOCK_WP_REQUEST_FILE="$WORKDIR/request.json" + +python3 -u tests/mock_wp_server.py & +SERVER_PID=$! +for i in $(seq 1 20); do + curl -s -o /dev/null "http://127.0.0.1:$PORT/" && break + sleep 0.2 +done + +export HOME="$WORKDIR/home" +mkdir -p "$HOME/.config/wordpress-rest-curl" +cat > "$HOME/.config/wordpress-rest-curl/config.sh" < "$POST_FILE" <<'EOF2' +# My Test Title + +Some **markdown** content here. +EOF2 + +run_post_sh() { + printf "" | "$PWD/post.sh" "$@" +} + +FAIL=0 +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" != "$actual" ]]; then + echo "FAIL: $desc (expected [$expected], got [$actual])" + FAIL=1 + else + echo "PASS: $desc" + fi +} + +# --- create + publish --- +rm -f "$MOCK_WP_REQUEST_FILE" "$MOCK_WP_CONTROL_FILE" +run_post_sh --file "$POST_FILE" --publish >/dev/null +assert_eq "create: exit code" "0" "$?" +REQ=$(cat "$MOCK_WP_REQUEST_FILE") +assert_eq "create: path" "/wp-json/wp/v2/posts/" "$(jq -r '.path' <<<"$REQ")" +assert_eq "create: title extracted from heading" "My Test Title" "$(jq -r '.payload.title' <<<"$REQ")" +assert_eq "create: status" "publish" "$(jq -r '.payload.status' <<<"$REQ")" +assert_eq "create: categories sent as array" "[3,7]" "$(jq -c '.payload.categories' <<<"$REQ")" +assert_eq "create: tags sent as array" "[10]" "$(jq -c '.payload.tags' <<<"$REQ")" +assert_eq "create: content rendered as markdown" "true" "$(jq -r '.payload.content | test("markdown")' <<<"$REQ")" + +# --- edit an existing post --- +rm -f "$MOCK_WP_REQUEST_FILE" "$MOCK_WP_CONTROL_FILE" +run_post_sh --file "$POST_FILE" --edit 99 >/dev/null +assert_eq "edit: exit code" "0" "$?" +REQ=$(cat "$MOCK_WP_REQUEST_FILE") +assert_eq "edit: path includes post ID" "/wp-json/wp/v2/posts/99" "$(jq -r '.path' <<<"$REQ")" +assert_eq "edit: status defaults to draft" "draft" "$(jq -r '.payload.status' <<<"$REQ")" + +# --- non-2xx response is surfaced and script exits non-zero --- +rm -f "$MOCK_WP_REQUEST_FILE" +echo '{"status": 401, "body": {"code": "rest_forbidden", "message": "Sorry, you are not allowed to do that."}}' > "$MOCK_WP_CONTROL_FILE" +OUT=$(run_post_sh --file "$POST_FILE" 2>&1) +assert_eq "error: exit code" "1" "$?" +assert_eq "error: HTTP status surfaced" "true" "$(grep -q "HTTP 401" <<<"$OUT" && echo true || echo false)" +rm -f "$MOCK_WP_CONTROL_FILE" + +echo +if [[ $FAIL -eq 0 ]]; then + echo "All tests passed." +else + echo "Some tests FAILED." +fi +exit $FAIL