mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
fix(ghost): send source=html on html writes and repair scrutiny round-1 findings
Admin API writes carrying an html payload (create-post, update-post, create-page) now attach the docs-required ?source=html query flag; _post/_put gained params plumbing, and the create-post dry-run plan includes params for plan/request parity. Regression tests pin the param whenever an html payload is present (mocked request capture and dry-run plans) and assert its absence on mobiledoc/lexical writes. Docs: fix the jq interpolation typo in worked-recipes recipe 2 (missing backslash made the scheduled-posts line exit 5), replace the blanket "exit code 2" claim in admin-auth-and-basics with the script's actual 2-5 failure-class mapping, and state the source=html requirement in the SKILL.md --html gotcha. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
83e07b9ac2
commit
a20b66e6c1
+1
-1
@@ -141,7 +141,7 @@ JSON shapes worth knowing:
|
||||
- **HS256 only, decoded-secret keying.** Tokens signed with HS512 are refused ("invalid algorithm"); signing without first hex-decoding the secret half produces "valid-looking" garbage that 401s. The CLI handles both rules.
|
||||
- **`Authorization: Ghost`, not Bearer.** `Bearer` scheme answers 401 `INVALID_AUTH_HEADER`.
|
||||
- **Edits require collision guards.** PUTs without the post's current `updated_at` fail with 409; relation arrays (`tags`, `authors`) replace wholesale rather than merge.
|
||||
- **HTML ingestion is lossy without cards.** Send proper Lexical, or wrap fixed markup in HTML card comments when using `--html`.
|
||||
- **`--html` requires `source=html` and stays lossy.** Every write carrying an `html` payload (create-post, update-post, create-page) must send the `?source=html` query flag — the CLI attaches it automatically, and dry-run plans show it in `params` — or Ghost parses the body as mobiledoc/lexical. Even with the flag, conversion is lossy: send proper Lexical, or wrap fixed markup in HTML card comments.
|
||||
- **Pagination caps at 100** since Ghost 6 removed `limit=all`; oversized limits silently return ≤100 rows, so always loop by `next`.
|
||||
- **Deletion is permanent** and takes effect on the public site immediately.
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ Ghost returns JSON errors shaped like `{"errors": [{"message", "context", "type"
|
||||
| Malformed token JSON/base64, `INVALID_JWT` | 400 | Structurally undecodable token |
|
||||
| No auth at all → `Authorization failed`, type `NoPermissionError` | 403 | Missing `Authorization` header entirely |
|
||||
|
||||
The CLI maps each of these to exit code 2 with the server message plus a hint.
|
||||
The CLI surfaces each of these on stderr with the server message plus a hint, mapped to exit codes by failure class: `401` and `403` exit `2` (auth/permission), `404` exits `3` (missing resource), `409` exits `4` (update collision), and `429` exits `5` (rate limited).
|
||||
|
||||
## Request conventions
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ Drafts, scheduled, and published live on different filters; one call per status:
|
||||
|
||||
```bash
|
||||
ghost posts --status draft --limit 50 --json | jq -r '.posts[] | "\(.title)\t\(.slug)"'
|
||||
ghost posts --status scheduled --json | jq -r '.posts[] | "\(.title)\t(.published_at // "-")"'
|
||||
ghost posts --status scheduled --json | jq -r '.posts[] | "\(.title)\t\(.published_at // "-")"'
|
||||
```
|
||||
|
||||
Pair it with an authoring-wide sanity check via jq types before feeding slugs onward:
|
||||
|
||||
+19
-9
@@ -210,11 +210,11 @@ class GhostClient:
|
||||
def _get(self, path, params=None):
|
||||
return self._request("get", path, params=params)
|
||||
|
||||
def _post(self, path, json_data):
|
||||
return self._request("post", path, json_data=json_data)
|
||||
def _post(self, path, json_data, params=None):
|
||||
return self._request("post", path, params=params, json_data=json_data)
|
||||
|
||||
def _put(self, path, json_data):
|
||||
return self._request("put", path, json_data=json_data)
|
||||
def _put(self, path, json_data, params=None):
|
||||
return self._request("put", path, params=params, json_data=json_data)
|
||||
|
||||
def _delete(self, path):
|
||||
return self._request("delete", path)
|
||||
@@ -241,10 +241,16 @@ class GhostClient:
|
||||
post["slug"] = slug
|
||||
if published_at:
|
||||
post["published_at"] = published_at
|
||||
return self._post("/posts", {"posts": [post]})
|
||||
# html payloads need the docs-required source=html flag; Ghost otherwise
|
||||
# parses the body as mobiledoc/lexical and rejects or mangles it.
|
||||
params = {"source": "html"} if html else None
|
||||
return self._post("/posts", {"posts": [post]}, params=params)
|
||||
|
||||
def update_post(self, post_id, **kwargs):
|
||||
return self._put(f"/posts/{post_id}", {"posts": [kwargs]})
|
||||
# html payloads need the docs-required source=html flag; Ghost otherwise
|
||||
# parses the body as mobiledoc/lexical and rejects or mangles it.
|
||||
params = {"source": "html"} if kwargs.get("html") else None
|
||||
return self._put(f"/posts/{post_id}", {"posts": [kwargs]}, params=params)
|
||||
|
||||
def delete_post(self, post_id):
|
||||
return self._delete(f"/posts/{post_id}")
|
||||
@@ -261,7 +267,10 @@ class GhostClient:
|
||||
page["html"] = html
|
||||
if slug:
|
||||
page["slug"] = slug
|
||||
return self._post("/pages", {"pages": [page]})
|
||||
# html payloads need the docs-required source=html flag; Ghost otherwise
|
||||
# parses the body as mobiledoc/lexical and rejects or mangles it.
|
||||
params = {"source": "html"} if html else None
|
||||
return self._post("/pages", {"pages": [page]}, params=params)
|
||||
|
||||
def get_tags(self, limit=50, page=None):
|
||||
params: Dict[str, Any] = {"limit": limit, "include": "count.posts"}
|
||||
@@ -339,9 +348,10 @@ def cmd_create_post(client, args):
|
||||
slug=parsed.slug, published_at=parsed.published_at) or {}
|
||||
if data.get("dry_run"):
|
||||
# Plan and real request share one code path, so the previewed URL,
|
||||
# method, and JSON envelope are exactly what execution would send.
|
||||
# method, params, and JSON envelope are exactly what execution would send.
|
||||
plan = {"dry_run": True, "method": data.get("method"),
|
||||
"url": data.get("url"), "json": data.get("json"), **vars(parsed)}
|
||||
"url": data.get("url"), "params": data.get("params"),
|
||||
"json": data.get("json"), **vars(parsed)}
|
||||
return emit(f"[dry-run] Create post '{parsed.title}' "
|
||||
f"-> {data.get('method', 'POST').upper()} {data.get('url')}", plan)
|
||||
posts = data.get("posts", [])
|
||||
|
||||
@@ -123,6 +123,59 @@ class GhostCliTests(unittest.TestCase):
|
||||
self.assertEqual(fields["updated_at"], "2026-08-26T12:00:00.000Z")
|
||||
self.assertEqual(fields["status"], "published")
|
||||
|
||||
def test_create_post_dry_run_sends_source_html_only_with_html_payload(self):
|
||||
# Ghost parses html write payloads as mobiledoc/lexical unless the
|
||||
# docs-required ?source=html query flag rides along; the plan must
|
||||
# preview exactly that request.
|
||||
result = self.run_cli("--dry-run", "--json", "create-post",
|
||||
"--title", "Doc", "--html", "<p>Hi</p>")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["params"], {"source": "html"})
|
||||
self.assertIn("html", payload["json"]["posts"][0])
|
||||
|
||||
no_html = self.run_cli("--dry-run", "--json", "create-post",
|
||||
"--title", "Doc")
|
||||
self.assertEqual(no_html.returncode, 0)
|
||||
no_html_payload = json.loads(no_html.stdout)
|
||||
self.assertNotIn("html", no_html_payload["json"]["posts"][0])
|
||||
self.assertNotIn("source=html", no_html_payload["url"])
|
||||
self.assertIsNone(no_html_payload["params"])
|
||||
|
||||
def test_update_post_dry_run_sends_source_html_only_with_html_payload(self):
|
||||
result = self.run_cli("--dry-run", "--json", "update-post", "abc123",
|
||||
"--html", "<p>Edited</p>",
|
||||
"--updated-at", "2026-08-26T12:00:00.000Z")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["params"], {"source": "html"})
|
||||
self.assertIn("html", payload["fields"])
|
||||
|
||||
no_html = self.run_cli("--dry-run", "--json", "update-post", "abc123",
|
||||
"--status", "draft",
|
||||
"--updated-at", "2026-08-26T12:00:00.000Z")
|
||||
self.assertEqual(no_html.returncode, 0)
|
||||
no_html_payload = json.loads(no_html.stdout)
|
||||
self.assertNotIn("html", no_html_payload["fields"])
|
||||
self.assertNotIn("source=html", no_html_payload["url"])
|
||||
self.assertIsNone(no_html_payload["params"])
|
||||
|
||||
def test_create_page_dry_run_sends_source_html_only_with_html_payload(self):
|
||||
result = self.run_cli("--dry-run", "--json", "create-page",
|
||||
"--title", "About", "--html", "<p>About us</p>")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["params"], {"source": "html"})
|
||||
self.assertIn("html", payload["json"]["pages"][0])
|
||||
|
||||
no_html = self.run_cli("--dry-run", "--json", "create-page",
|
||||
"--title", "About")
|
||||
self.assertEqual(no_html.returncode, 0)
|
||||
no_html_payload = json.loads(no_html.stdout)
|
||||
self.assertNotIn("html", no_html_payload["json"]["pages"][0])
|
||||
self.assertNotIn("source=html", no_html_payload["url"])
|
||||
self.assertIsNone(no_html_payload["params"])
|
||||
|
||||
def test_scheduled_post_requires_published_at_even_in_dry_run(self):
|
||||
result = self.run_cli("--dry-run", "--json", "create-post",
|
||||
"--title", "Later", "--status", "scheduled")
|
||||
@@ -442,5 +495,66 @@ class MockedClientTests(unittest.TestCase):
|
||||
self.assertEqual(called_url, "https://example.com/ghost/api/admin/site")
|
||||
|
||||
|
||||
class HtmlSourceFlagTests(unittest.TestCase):
|
||||
"""Regression: html write payloads must carry the docs-required
|
||||
?source=html query flag; mobiledoc/lexical writes must not send it."""
|
||||
|
||||
def setUp(self):
|
||||
self.cli = load_cli()
|
||||
self.cli.GLOBAL_FLAGS = {"json": True, "dry_run": False,
|
||||
"quiet": False, "verbose": False}
|
||||
ok = Mock(status_code=201)
|
||||
ok.json = Mock(return_value={"posts": []})
|
||||
self.cli.requests.post = Mock(return_value=ok)
|
||||
self.client = self.cli.GhostClient(url="https://example.com", key=FIXED_KEY)
|
||||
|
||||
def _put_ok(self):
|
||||
ok = Mock(status_code=200)
|
||||
ok.json = Mock(return_value={"posts": []})
|
||||
self.cli.requests.put = Mock(return_value=ok)
|
||||
return self.cli.requests.put
|
||||
|
||||
def test_create_post_with_html_sends_source_html_query_param(self):
|
||||
self.client.create_post("Doc", html="<p>Hi</p>")
|
||||
call = self.cli.requests.post.call_args
|
||||
self.assertEqual(call.kwargs["params"], {"source": "html"})
|
||||
self.assertIn("html", call.kwargs["json"]["posts"][0])
|
||||
|
||||
def test_create_post_without_html_omits_source_flag(self):
|
||||
self.client.create_post("Doc")
|
||||
call = self.cli.requests.post.call_args
|
||||
self.assertNotIn("source", (call.kwargs.get("params") or {}))
|
||||
self.assertNotIn("html", call.kwargs["json"]["posts"][0])
|
||||
|
||||
def test_update_post_with_html_sends_source_html_query_param(self):
|
||||
put = self._put_ok()
|
||||
self.client.update_post("abc123", html="<p>Edited</p>",
|
||||
updated_at="2026-08-26T12:00:00.000Z")
|
||||
call = put.call_args
|
||||
self.assertEqual(call.kwargs["params"], {"source": "html"})
|
||||
self.assertIn("html", call.kwargs["json"]["posts"][0])
|
||||
|
||||
def test_update_post_without_html_omits_source_flag(self):
|
||||
put = self._put_ok()
|
||||
self.client.update_post("abc123", status="published",
|
||||
updated_at="2026-08-26T12:00:00.000Z")
|
||||
call = put.call_args
|
||||
self.assertNotIn("source", (call.kwargs.get("params") or {}))
|
||||
self.assertNotIn("html", call.kwargs["json"]["posts"][0])
|
||||
|
||||
def test_create_page_with_html_sends_source_html_query_param(self):
|
||||
self.client.create_page("About", html="<p>About us</p>")
|
||||
call = self.cli.requests.post.call_args
|
||||
self.assertEqual(call.kwargs["params"], {"source": "html"})
|
||||
self.assertEqual(call.args[0], "https://example.com/ghost/api/admin/pages")
|
||||
self.assertIn("html", call.kwargs["json"]["pages"][0])
|
||||
|
||||
def test_create_page_without_html_omits_source_flag(self):
|
||||
self.client.create_page("About")
|
||||
call = self.cli.requests.post.call_args
|
||||
self.assertNotIn("source", (call.kwargs.get("params") or {}))
|
||||
self.assertNotIn("html", call.kwargs["json"]["pages"][0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user