@hostinger/mcp 3.2.2 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/headless/SKILL.md +247 -36
- package/skills/headless/connect/SKILL.md +103 -0
- package/skills/headless/{entry → connect}/bootstrap.mjs +8 -3
- package/skills/headless/connect/tools/antigravity.md +54 -0
- package/skills/headless/connect/tools/claude-code.md +46 -0
- package/skills/headless/connect/tools/codex.md +48 -0
- package/skills/headless/connect/tools/cursor.md +109 -0
- package/skills/headless/connect/tools/devin.md +50 -0
- package/skills/headless/connect/tools/vscode.md +57 -0
- package/skills/headless/report.schema.json +149 -0
- package/skills/headless/run-log.mjs +662 -0
- package/skills/headless/steps/01-check-state/SKILL.md +193 -0
- package/skills/headless/steps/02-refine/SKILL.md +206 -0
- package/skills/headless/steps/03-provision/SKILL.md +62 -0
- package/skills/headless/steps/03-provision/ecommerce/SKILL.md +133 -0
- package/skills/headless/steps/03-provision/webapp/SKILL.md +154 -0
- package/skills/headless/steps/03-provision/wordpress/SKILL.md +100 -0
- package/skills/headless/steps/04-content-model/SKILL.md +150 -0
- package/skills/headless/steps/04-content-model/ecommerce/SKILL.md +75 -0
- package/skills/headless/steps/04-content-model/wordpress/SKILL.md +178 -0
- package/skills/headless/steps/05-design/SKILL.md +455 -0
- package/skills/headless/steps/05-design/logo-kit.mjs +201 -0
- package/skills/headless/steps/06-frontend/SKILL.md +131 -0
- package/skills/headless/steps/07-validate/SKILL.md +53 -0
- package/skills/headless/steps/08-deploy/SKILL.md +171 -0
- package/skills/headless/steps/09-done/SKILL.md +151 -0
- package/src/core/skills.js +164 -24
- package/src/core/skills.ts +164 -24
- package/src/servers/agency-hosting.js +1 -1
- package/src/servers/agency-hosting.ts +1 -1
- package/src/servers/agent-readiness.js +1 -1
- package/src/servers/agent-readiness.ts +1 -1
- package/src/servers/all.js +1 -1
- package/src/servers/all.ts +1 -1
- package/src/servers/billing.js +1 -1
- package/src/servers/billing.ts +1 -1
- package/src/servers/dns.js +1 -1
- package/src/servers/dns.ts +1 -1
- package/src/servers/domains.js +1 -1
- package/src/servers/domains.ts +1 -1
- package/src/servers/ecommerce.js +1 -1
- package/src/servers/ecommerce.ts +1 -1
- package/src/servers/horizons.js +1 -1
- package/src/servers/horizons.ts +1 -1
- package/src/servers/hosting.js +1 -1
- package/src/servers/hosting.ts +1 -1
- package/src/servers/mail.js +1 -1
- package/src/servers/mail.ts +1 -1
- package/src/servers/reach.js +1 -1
- package/src/servers/reach.ts +1 -1
- package/src/servers/vps.js +1 -1
- package/src/servers/vps.ts +1 -1
- package/src/servers/wordpress.js +1 -1
- package/src/servers/wordpress.ts +1 -1
- package/skills/headless/entry/skill.md +0 -79
- package/skills/headless/references/DATABASE.md +0 -49
- package/skills/headless/references/DEPLOYMENT.md +0 -34
- package/skills/headless/references/SETUP.md +0 -35
- package/skills/headless/references/STORE.md +0 -52
- package/skills/headless/references/WORDPRESS.md +0 -51
package/src/core/skills.ts
CHANGED
|
@@ -66,50 +66,190 @@ const skills: Skill[] = [
|
|
|
66
66
|
"uri": "skill://hostinger-headless/SKILL.md",
|
|
67
67
|
"frontmatter": {
|
|
68
68
|
"name": "hostinger-headless",
|
|
69
|
-
"description": "
|
|
69
|
+
"description": "Orchestrator for the Hostinger Headless build flow — build and deploy a real website on the user's Hostinger account from a single prompt that names a domain. Holds the stage flow and the state contract, and drives each stage in sequence: connect, check state, refine, provision, content model, design, frontend, validate, deploy, done. Triggers: build a headless site for <domain>, build me a site, create a website, make me a website, deploy this to Hostinger, host this on Hostinger, connect this project to Hostinger."
|
|
70
70
|
},
|
|
71
71
|
"resources": [
|
|
72
72
|
{
|
|
73
73
|
"uri": "skill://hostinger-headless/SKILL.md",
|
|
74
|
-
"digest": "sha256:
|
|
75
|
-
"size":
|
|
74
|
+
"digest": "sha256:e993d4278571cd812353dd633596b928eb78de3a7e347b4adfc543191dc3c686",
|
|
75
|
+
"size": 18469,
|
|
76
76
|
"mimeType": "text/markdown",
|
|
77
|
-
"text": "---\nname: hostinger-headless\ndescription: \"Build, connect, or iterate on a website hosted on Hostinger — provision hosting and a domain, optionally seed an ecommerce store with a real hosted checkout or a WordPress content backend (headless CMS/blog), build the frontend, deploy, and verify. Requires an authenticated Hostinger MCP session (see entry/skill.md). Triggers: build me a site on Hostinger, deploy this to Hostinger, connect this project to Hostinger, add a store to my Hostinger site, add a blog to my Hostinger site, update my Hostinger site.\"\n---\n\n# Hostinger Headless\n\nThis skill turns a prompt into a live website on Hostinger. Its job is to own the full run: check the account, provision hosting and a domain, seed the backend (an ecommerce store when the intent calls for selling), build the frontend, deploy it, and verify the result.\n\nThe frontend is built ad-hoc to the user's intent — there is no template library. The backend pieces are real Hostinger products: web hosting (static or Node.js), domains and DNS, Hostinger Ecommerce with its public Storefront API and hosted checkout, and WordPress as a headless CMS/blog backend read through the public WP REST API.\n\n## Preconditions\n\n1. An authenticated Hostinger MCP session — the entry skill's bootstrap handled this. If MCP tools fail with auth errors, send the user back through `entry/skill.md`.\n2. The Hostinger MCP operations for **hosting** (plus **ecommerce** when the run involves a store, and **wordpress** when it involves a content backend). Operation availability varies per user — product groups can be toggled in the Hostinger Connector. When search cannot find a needed operation, ask the user to enable that product group (or configure the scoped binary, e.g. `hostinger-hosting-mcp`) rather than improvising around it.\n3. An active hosting plan (checked in Setup §1). Hostinger hosting is a paid product: if the account has no usable plan, inform the user plainly that a subscription is required, link them to https://www.hostinger.com/web-hosting, and pause until they confirm the purchase. Do not treat this as an error — it is a normal step for new accounts. Everything that doesn't need hosting (planning, building the frontend locally) can proceed while they decide.\n\n## Resolving the operation\n\nResolve by intent first, disk second — never let an empty directory override what the user is asking for. Check `iterate` first (it's decided by an unambiguous on-disk signal):\n\n- `iterate` — a `.hostinger/site.json` is present in the project: the site is already deployed through this skill and the user wants changes. Reuse the recorded domain/site details; apply only the delta the new intent needs (edit frontend → redeploy; add a store → run `references/STORE.md` then redeploy). Never re-provision an existing site.\n- `connect` — a frontend project already on disk (or brought in as a zip/export/URL) that is not yet on Hostinger, with language like \"deploy this / host this on Hostinger / connect this project\". Emptiness of the CWD at trigger time is not a create signal when a design is brought in from elsewhere.\n- `create` — a new site from a prompt with nothing brought in: \"build me a store / portfolio / site…\".\n\nIf signals conflict, ask the user — don't guess.\n\n## The run\n\n1. **Discovery** — from the prompt (and the project on disk for connect/iterate), infer: does this run need a store (any buy/sell/product intent → yes)? Does it need owner-managed content — a blog, news, or anything the owner edits without a redeploy (→ WordPress backend; static copy that rarely changes does not qualify)? What brand, copy, and structure does the site need? What stack fits — plain static HTML/CSS/JS for simple sites (default), a static-output framework build, or a Node.js app when SSR or server code is genuinely required? Prefer static: it deploys fastest and has no build-failure surface. Ask the user only what you cannot infer.\n2. **Setup** (`references/SETUP.md`) — plan check, then provision the website on a free subdomain (or the user's own domain) and wait until it's ready.\n3. **Store** (`references/STORE.md`, only when commerce is needed) — resolve or create the store and its `custom` sales channel, seed products/shipping/payment, and note the `sales_channel_id` for the frontend.\n4. **Content backend** (`references/WORDPRESS.md`, only when owner-managed content is needed) — install WordPress on a dedicated subdomain, wait until it's ready, and hand the owner a wp-admin login link. The frontend reads it through the public WP REST API.\n5. **Build** — create or wire the frontend. For store runs, follow the frontend contract in `references/STORE.md` (catalog fetched at runtime from the public Storefront API, cart in `localStorage`, client-side checkout — never embed an API token in the site). For content runs, follow the frontend contract in `references/WORDPRESS.md` (posts fetched at runtime from the WP REST API, rendered HTML bodies, graceful empty states). When the app needs MySQL, follow `references/DATABASE.md` (which host each runtime connects to, credentials via env vars or a server-side config file, never in the frontend).\n6. **Deploy** (`references/DEPLOYMENT.md`) — archive and deploy via the matching hosting operation, polling build logs for Node.js runs.\n7. **Verify** — curl the live URL for a 200 and a piece of real page copy; for store runs, confirm a checkout POST returns a redirect URL; for content runs, confirm the WP REST API returns 200 and the frontend renders posts. Show the user the live URL and where to manage things (hPanel: https://hpanel.hostinger.com, the store dashboard for commerce, wp-admin for content).\n8. **Record** — write `.hostinger/site.json` into the project: `{ \"domain\", \"username\", \"type\": \"static\"|\"nodejs\", \"sales_channel_id\"?, \"store_id\"?, \"cms_domain\"? }`. This is what makes future runs resolve as `iterate`.\n\nRun non-interactively wherever possible. The exceptions that must involve the user: the paid-plan confirmation, anything that costs money (a domain purchase, a new subscription), and picking between genuinely equal options the prompt doesn't decide.\n\n## Paths\n\n| What | Path |\n| --- | --- |\n| Plan check + website/domain provisioning | `references/SETUP.md` |\n| Store: seed the backend + the frontend API contract | `references/STORE.md` |\n| WordPress: headless CMS/blog backend + the frontend read contract | `references/WORDPRESS.md` |\n| Deploy: static vs Node.js, archive rules, logs, verify | `references/DEPLOYMENT.md` |\n| Database + runtime: MySQL host per runtime, env vars, what to poll, runtime logs | `references/DATABASE.md` |\n\n## Where the how comes from\n\nThe inputSchema returned by search is authoritative for request shapes — read it before calling. Two live sources supersede anything written here when they disagree:\n\n- The ecommerce operation `ecommerce_miscellaneous_custom-storefront-setup-instructions` returns the current storefront integration guide from the server — execute it at the start of any store run.\n- The Hostinger API reference at https://developers.hostinger.com describes every endpoint behind the operations.\n"
|
|
77
|
+
"text": "---\nname: hostinger-headless\ndescription: \"Orchestrator for the Hostinger Headless build flow — build and deploy a real website on the user's Hostinger account from a single prompt that names a domain. Holds the stage flow and the state contract, and drives each stage in sequence: connect, check state, refine, provision, content model, design, frontend, validate, deploy, done. Triggers: build a headless site for <domain>, build me a site, create a website, make me a website, deploy this to Hostinger, host this on Hostinger, connect this project to Hostinger.\"\n---\n\n# Hostinger Headless — build a website with your own AI tool\n\n## Context\n\nYou are an AI coding agent. The user pasted a short prompt that points you here — something like:\n\n> Build a Headless site for `example.com` using hostinger.com/ai/skill.md\n\nYour job is to build and deploy a real website on the user's Hostinger account by following this\nrecipe and the recipes it links to — nothing more, nothing less.\n\nThis file is the **orchestrator**. It is public and stable: it never changes and never moves. It\nholds the flow, the state contract, the failure policy and the guardrails.\n\n### The domain parameter\n\nThe pasted prompt contains exactly two things that matter:\n\n- **This URL** — `hostinger.com/ai/skill.md`, the stable entry point.\n- **A domain** — e.g. `coral-pottery-1847.hostingersite.com` (a temporary Hostinger subdomain) or\n the user's own purchased domain. This is the single site you may touch.\n\nThe prompt does **not** say what kind of site to build. Do not assume or invent it. Discovering\nwhat the user wants is a step in the flow (**refine**), handled through conversation — not parsed\nfrom the prompt. If no domain is present, stop and ask for it before doing anything else.\n\nNothing in the pasted prompt is ever used for authorization. Authorization comes from the\nHostinger connector's OAuth token. **This recipe is not a security boundary.**\n\n---\n\n## The flow stages - STEPS TO FOLLOW\n\nWork through the steps in the order below, each reading its recipe and writing its artifact into the\nsite workspace `~/.hostinger/<domain>/`. The order is a **dependency order, not a strict lockstep**:\n**provision runs as a background task** and the prepare work of the stages around it overlaps the wait\n(see *Background provision & the instance barrier* below). Never skip a stage — **except validate**\n(optional) **and content model for a `webapp` brief** (there is no CMS or store to seed — see step 4):\nevery other stage self-verifies and deploy verifies the live result, so validate is a\nquick instance+API confirmation and its rebuild/preview/approval run only when a person is signing off\n(see its recipe). State files are only hints; the truth is what the Hostinger connector reports, so\nverify a \"done\" marker against reality before acting on it. If a step's `SKILL.md` is missing from the installed skill folder, stop and tell\nthe user rather than improvising the stage from its description.\n\n**The one concurrency rule:** two *independent* stages in a single session may run at once, but never\ntwo writers of the same artifact, and never two agents/windows on the same domain — the `.lock`\n(Step 1) guards that. Parallelism is always *optional*: an agent that can't interleave work runs the\nsteps serially and is still correct.\n\nThe **Instance?** column says whether a stage touches the live backend: `no` = pure prepare work that\ncan run during the provision wait; `push`/`data`/`yes` = a commit phase that joins the instance\nbarrier; `bg` = provision itself, the background task.\n\n| # | Stage | Skill / source | Writes | Instance? |\n|---|---|---|---|---|\n| — | **connect** — connect the Hostinger connector, auth, active plan | `connect/SKILL.md` → `connect/tools/<tool>.md` | `connect.json` | no |\n| 1 | **check state** — where does this domain stand? resume / switch / start over | `steps/01-check-state/SKILL.md` | `state.json` | no |\n| 2 | **refine** — discover what the user wants, in conversation | `steps/02-refine/SKILL.md` | `brief.md` | no |\n| 3 | **provision** — choose the backend from the brief, then provision it | `steps/03-provision/<backend>/SKILL.md` | `site.json` | **bg** — fire, then poll in the background |\n| 4 | **content model** — define + create content/images, then push to the backend | `steps/04-content-model/<backend>/SKILL.md` | `content-model.md` | prepare: no · **push: yes** |\n| 5 | **design** — derive design directions from the brief, pick one, write the `design.md` contract + logo | `steps/05-design/SKILL.md` | `design.md`, `brand/` | no |\n| 6 | **frontend** — pick/confirm the framework (default Next.js SSR), implement `design.md` against the backend | `steps/06-frontend/SKILL.md` | source in `<workspace>/src/` | shell: no · **data: yes** |\n| 7 | **validate** *(optional)* — quick instance+API confirmation; rebuild/preview/approval only when a person signs off | `steps/07-validate/SKILL.md` | — | no (local) |\n| 8 | **deploy** — upload source, trigger the build | `steps/08-deploy/SKILL.md` | `deploy.json` | **yes** |\n| 9 | **done** — hand over URLs, dashboard, summary, next steps (the **final report**) | `steps/09-done/SKILL.md` | — | no |\n\n**connect** runs first — it's fetched from the public URL and runs *before* the Hostinger connector\nexists. The nine numbered steps live under `steps/` and are read from the installed skill folder.\n**provision** and **content model** route to a per-backend subfolder (`<backend>` = `wordpress` |\n`ecommerce` | `webapp`), chosen from the brief. **`webapp`** is a plain Node.js app host (an empty\nwebsite — no CMS, no store, no `/cms`) for any app/SPA that brings its own code and data; it **skips the\ncontent-model stage entirely** (there is nothing to seed — the app owns its own data) and never installs\nWordPress. **design** turns the brief into a chosen look and writes the `design.md` contract;\n**frontend** is a single generic step that picks the framework (guess → default Next.js SSR → confirm)\nand implements `design.md`.\n\n**Always end with the final report.** Whenever a run ends, give the user the final report defined\nin `steps/09-done/SKILL.md`: an outcome paragraph, a *Steps completed* table (one row per stage that\nran), the links, where state lives, and any gaps. That includes runs that stop early at a dummy or\nmissing step, a failed provision, or a user pause. Every run also saves a **build report**\n(`run-log.mjs report`, see *Run log and build report* below), on those early stops too.\n\n**Log the run as you go.** Wrap every stage (and each phase of a split stage) in\n`run-log.mjs stage-start` / `stage-end`, and log an event the moment something goes wrong, the user\ncorrects you, or a skill is missing or wrong — before you continue. See *Run log and build report*.\n\n**Why this order.** connect first, so account/plan failures surface before the user spends time\ntalking. Check-state right after, so a resumed, duplicate, or brand-new run is sorted out — and the\nuser is offered the choice to continue, switch domains, or start over — before any new work begins.\nRefinement before provisioning, because the backend cannot be chosen until the brief exists.\nValidation before deploy, always — nothing reaches the live domain unseen.\n\n### Background provision & the instance barrier\n\nProvisioning is mostly *waiting*: `hosting_websites_create` returns \"queued\" immediately, then it's a\n`hosting_websites_list-setups` poll every ~15 s for a few minutes. Don't block on that poll. The\nsingle rule that makes the overlap safe:\n\n> **Fire provision as a background task, then do every stage's prepare work while it settles. The\n> moment a step must write to the live instance, it waits for provision to report `completed`\n> (verified against `hosting_websites_list-setups`). That wait is the *instance barrier*.**\n\nOnce `brief.md` exists (it already fixes the backend choice), the lanes are:\n\n```\nrefine ──▶ brief.md\n ├─▶ provision: FIRE create, record provision.in_progress ─────────┐ (bg poll)\n ├─▶ content-model PREPARE: define entities, write copy, │\n │ generate images │\n ├─▶ design: directions → design.md + logo (from the brief) │\n └─▶ frontend SHELL: pick framework, scaffold, implement design.md │\n │\n ══════════════════════ instance barrier: provision.status == completed ═════╪═════\n ▼\n ├─▶ content-model PUSH: send content/media via the connector (needs the instance)\n └─▶ frontend DATA: wire fetch/bind against the backend API URL from site.json\n │\n ▼\n validate ──▶ deploy (needs the provisioned target) ──▶ done\n```\n\nNothing before the barrier touches the backend, so it's safe to run in any order the agent can manage\n— sequentially if it can't interleave. Nothing after the barrier may start until provision is\nverifiably `completed`. If provision **fails** at the barrier (quota, `422`, a changed free\nsubdomain), the prepare artifacts (drafted content, generated images, the `src/` shell) stay valid —\nonly the commit phases wait or retry; re-run provision, then cross the barrier.\n\n---\n\n## State and artifacts\n\nEverything for a domain — its state **and** its generated source — lives under the **site workspace\n`~/.hostinger/<domain>/`** (resolve `~` to the home directory: `$HOME` in a shell, `os.homedir()` in\nNode — `~/.hostinger` means `$HOME/.hostinger`). This is a **global, per-user** location, not a\nproject folder, so a build is discoverable and resumable from **any** directory — the current working\ndirectory does not matter. Create the workspace if it's missing and do all of a stage's work inside\nit (including the frontend `src/`, built and deployed from `~/.hostinger/<domain>/src/`).\n\n**The directory name is the domain**, so the files inside it never repeat it, and `~/.hostinger/`\nholds every site you've built — one subdirectory per domain, no collisions.\n\n| File (under `~/.hostinger/<domain>/`) | Written by | Contains |\n|---|---|---|\n| `state.json` | every stage | tool, next stage, Hostinger connector version, timestamps |\n| `connect.json` | connect | tool, account/plan readiness |\n| `brief.md` | refine | refined brief, audience, tone, pages, must-haves |\n| `site.json` | provision | chosen backend, username, install id, all URLs |\n| `content-model.md` | content model | entities, fields, pages, navigation |\n| `design.md` (+ `brand/` logo files) | design | chosen design direction: tokens, type, logo, components, per-page composition, accessibility |\n| `src/` | frontend | the website source implementing `design.md`; framework choice is recorded in `state.json → frontend` |\n| `deploy.json` | deploy | build id, deployed at, live URLs |\n| `events.jsonl` | every stage, via `run-log.mjs` | append-only log of issues, user feedback, skill gaps and wins |\n| `reports/<run>.json` + `.html`, `report.html` | done (or any early stop), via `run-log.mjs` | the build report for each run; `report.html` is the latest |\n\n`state.json` is the minimum you must keep current. **`stage` is the *next* stage to run** (not the\nlast completed one), and it does **not** store the domain — the directory is the key. Because\nprovision runs in the background, its status lives in its own `provision` field rather than in\n`stage`, so prepare work can advance `stage` while provision is still settling:\n\n```json\n{\n \"tool\": \"claude-code\",\n \"stage\": \"content_model\",\n \"provision\": {\n \"status\": \"in_progress\",\n \"domain\": \"example.com\",\n \"started_at\": \"2026-10-08T10:00:00Z\"\n },\n \"updated_at\": \"2026-10-08T10:00:00Z\"\n}\n```\n\n- `provision` is **absent** until provision fires, `{\"status\":\"in_progress\", …}` while the\n background poll runs, and `{\"status\":\"completed\", …}` once `hosting_websites_list-setups` confirms\n it. `domain` is the poll target.\n- Any **instance-barrier** step (content-model push, frontend data, deploy) must read\n `provision.status == \"completed\"` — and re-verify it against the connector — before it runs. A\n prepare step ignores `provision` entirely.\n- On **resume**, `provision.status` is a hint only: re-poll `hosting_websites_list-setups` to learn\n the truth, then update the field.\n- `run` (the current run), `history` (one entry per stage or phase, with real start and end\n times), and `reports_sent` / `reports_rejected` (which copy of each report reached Hostinger) are\n written **only** by `run-log.mjs`. Leave them alone when you update `state.json` by hand.\n\n---\n\n## Run log and build report\n\n`run-log.mjs` (next to this file) records the run while it happens and turns it into a report at the\nend. Timestamps come from the script, so they're measured, not remembered. It needs only Node.\nEvery command is `node <skill>/run-log.mjs <domain> …`, where `<skill>` is the folder this file is\nin. Write it out in full each time (a shell variable holding the command breaks in zsh):\n\n```bash\nnode <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version> # check-state, first thing\nnode <skill>/run-log.mjs <domain> run-update --kind resume # check-state, once the route is agreed (or redesign / iterate)\nnode <skill>/run-log.mjs <domain> stage-start frontend # before a stage\nnode <skill>/run-log.mjs <domain> stage-end frontend ok_after_retries --note \"second framework tried\"\nnode <skill>/run-log.mjs <domain> stage-start frontend --phase shell # split stages log each phase\nnode <skill>/run-log.mjs <domain> event issue '{\"stage\":\"content_model\",\"symptom\":\"…\",\"cause\":\"…\",\"workaround\":\"…\",\"severity\":\"medium\",\"resolved\":true}'\nnode <skill>/run-log.mjs <domain> report --outcome live # done, or wherever the run stops\nnode <skill>/run-log.mjs <domain> report-sent <run> # after the report was sent (see below)\nnode <skill>/run-log.mjs <domain> report-rejected <run> # the send returned 422\nnode <skill>/run-log.mjs <domain> unsent # check-state: reports still to send\nnode <skill>/run-log.mjs <domain> run-resume --reason \"about/contact layout fix\" # follow-up in the same session\n```\n\n**Follow-ups continue the run.** When the user asks for a change after the final report in the\nsame session (a layout fix, a copy tweak, a redeploy), that work belongs to the run it follows:\ncall `run-resume`, log the feedback and the stages it re-runs as usual, then `report` again. That\nrewrites the same `reports/<run>.json` and `.html`, so you get one report that includes the follow-up,\nnot a second report that replaces `report.html`. Use `run-start` only in a new session; check-state\ndoes that.\n\n`stage-end` results: `ok`, `ok_after_retries`, `ok_with_fixups`, `failed`, `skipped`, `stopped`.\nStages: `check_state`, `refine`, `provision`, `content_model`, `design`, `frontend`, `validate`,\n`deploy`, `done` (connect has its own `connect.json`). Split stages use `--phase`: provision `fire` /\n`join`, content model `prepare` / `push`, frontend `shell` / `data`.\n\n**Forgot a `stage-start`?** Log it as soon as you notice, with roughly when the stage began:\n`stage-start frontend --started-ago 6m` (or `--started-at <ISO time>`). The same flags work on a\n`stage-end` that has no start. With neither, the script assumes the stage began when the previous one\nended. Either way the start is marked as estimated in the report. Never log a start and an end\nback to back after the work is done: that records a stage that took no time. If you already did,\ncorrect it with `stage-fix frontend --started-ago 6m`.\n\n**Log an event the moment it happens**, not at the end — details are gone by then:\n\n| Event | Log it when | Fields (required in bold) |\n|---|---|---|\n| `issue` | something fails, misbehaves, or needs a workaround — including your own mistakes | **stage**, **symptom**, cause, workaround, severity (`high`/`medium`/`low`), resolved, user_had_to_intervene |\n| `feedback` | the user corrects you, rejects something, or changes scope | **stage**, **category** (e.g. `design_rejected`, `layout_bug`, `scope_add`, `wrong_assumption`), **paraphrase** |\n| `skill_gap` | a step is dummy, missing, wrong or silent on what you needed | **skill** (path), **gap** |\n| `good` | something worked notably well or fast | **text** |\n\nParaphrase the user; don't paste long quotes, file contents, credentials or personal data. Set\n`user_had_to_intervene` when the user had to point the problem out.\n\n**Tokens** go on `report --tokens '<json>'` only if your tool tells you your own usage directly\n(`{\"source\":\"…\",\"requests\":…,\"input\":…,\"output\":…,\"cache_read\":…,\"cache_write\":…,\"est_cost_usd\":…}`).\nOtherwise leave it out and the report says `{\"source\":\"not reported\"}` (not null: the connector rejects a null `tokens`). Never read session transcripts or log\nfiles to get it.\n\nThe report is saved to `reports/<run>.json` and `.html`, and `report.html` in the workspace is the\nlatest. If a run never reaches its report (the window closed), the next `run-start` closes it as\n`abandoned`, so its log still becomes a report.\n\n**Send every report to Hostinger.** Sending is part of the flow, so don't ask first; tell the user in\none line right after the send (see `09-done`). Right after each\n`report` (follow-ups included), read `reports/<run>.json` and pass its contents, unchanged, as the\narguments of the Hostinger connector tool `hosting_headless_submit-build-report`. Then record the\nresult:\n\n- **Success:** `report-sent <run>`.\n- **422** (the report doesn't match the schema the endpoint holds): `report-rejected <run>`. Don't\n resend it.\n- **Anything else** (network error, 5xx, the tool isn't in your connector): do nothing. The next\n check-state sends it again.\n\nNever edit the JSON before sending, never add account details (the connector's login identifies the\naccount), and never let a failed send block, delay or change the run. Check-state sends any\nreports still unsent (abandoned runs, earlier failures) with `unsent`.\n"
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
|
-
"uri": "skill://hostinger-headless/
|
|
81
|
-
"digest": "sha256:
|
|
82
|
-
"size":
|
|
80
|
+
"uri": "skill://hostinger-headless/connect/SKILL.md",
|
|
81
|
+
"digest": "sha256:8f3df0603e4de5d0d042920e39c73b4b2fa83e294e7684fc743d96e900657af2",
|
|
82
|
+
"size": 6142,
|
|
83
83
|
"mimeType": "text/markdown",
|
|
84
|
-
"text": "
|
|
84
|
+
"text": "---\nname: hostinger-headless-connect\ndescription: \"Connect step for the Hostinger Headless flow — verify Node, run the bootstrap (reach the Hostinger MCP server + OAuth sign-in), then route to the matching coding-tool skill under tools/ so MCP tools and skills are available before the build steps run. Triggers: connect to Hostinger, set up the Hostinger connector, start a headless build.\"\n---\n\n# Hostinger Headless Skill — connect\n\nYou are a coding agent, and this is a skill for you to execute. Follow the phases below in order.\n\nThis skill goal is to help you to connect to Hostinger via MCP. That can be done via Hostinger Connector or adding the MCP via MCP Server.\n\nWe support different coding Agentic coding tools such as Claude Code, Codex, Cursor, VS Code...and so on. \n\nYour goal is to route those tools to their specific skill inside the tools folder ensuring the tool is fully connected.\n\n\n## Phase 0 — Node (the single one prerequisite)\n\nThe Hostinger MCP server requires Node ≥ 20. Check it:\n\n```bash\nnode -v\n```\n\nIf that errors (Node not installed) or prints a version below 20, install or upgrade Node and re-check — do not try to work around it:\n\n- macOS: `brew install node` (or `nvm install 22 && nvm use 22`)\n- Linux: `nvm install 22 && nvm use 22` (or your distro's Node 20+ package)\n- Windows: `winget install OpenJS.NodeJS.LTS` (or download from nodejs.org)\n\n## Phase 1 — Run the bootstrap (deterministic)\n\nDownload the bootstrap script, then run it. It verifies the MCP server and handles sign-in, emitting one JSON event per line on stdout. Run it as a background/streaming process and relay its events to the user.\n\nThe script is safe and inspectable: it only checks the MCP server via `npx` and drives the OAuth sign-in (a browser flow with a localhost callback) — no other network calls, no filesystem writes. Read it first if your sandbox flags externally-downloaded code.\n\n```bash\n# macOS/Linux:\ncurl -fsSL -O https://raw.githubusercontent.com/hostinger/api-mcp-server/main/skills/headless/connect/bootstrap.mjs\n# Windows PowerShell:\niwr https://raw.githubusercontent.com/hostinger/api-mcp-server/main/skills/headless/connect/bootstrap.mjs -OutFile bootstrap.mjs\n\nnode bootstrap.mjs\n```\n\n### Relay these events\n\nThe script emits one JSON object per line:\n\n| Event | What to do |\n| --- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `mcp_ok` | MCP server reachable — continue. |\n| `logged_in` | Already authenticated (env token or a previous sign-in) — continue. |\n| `awaiting_user` (`verificationUri`) | A browser window should have opened on the user's machine. Show the URL in plain prose and wait for the user to finish signing in. The OAuth callback lands on a localhost port, so the sign-in must be completed on this same machine. |\n| `success` | Bootstrap done — continue to Phase 2. |\n| `mcp_unreachable` / `login_failed` (with `detail`) | Stop and show the user the `detail`. Do not improvise a parallel setup by hand. |\n\nIf the environment has a `HOSTINGER_API_TOKEN` set (for example CI), the bootstrap reports `logged_in` immediately — no browser needed.\n\n### Sign in or out manually\n\nThe bootstrap runs the sign-in for you. To run it directly, or to switch accounts, use the MCP's own commands. `--login` opens the browser to the Hostinger sign-in page, waits for the localhost callback, stores the credentials and exits. `--logout` revokes the stored credentials, so the next sign-in opens the browser again.\n\n```bash\n# With a global install (npm install -g @hostinger/mcp):\nhostinger-api-mcp --login\nhostinger-api-mcp --logout\n\n# Without a global install:\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --login\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --logout\n```\n\nCredentials live in `~/.config/hostinger-mcp/credentials.json` (macOS/Linux) or `%APPDATA%\\hostinger-mcp\\credentials.json` (Windows) and are shared by every Hostinger MCP binary. OAuth works only in stdio mode; `--http` needs `HOSTINGER_API_TOKEN`.\n\n## Phase 2 - Route to the specific tool instructions\n\n| Tool | Route |\n|-------------|---------------------------------|\n| Claude Code | `connect/tools/claude-code.md` |\n| Codex | `connect/tools/codex.md` |\n| Cursor | `connect/tools/cursor.md` |\n| VS Code | `connect/tools/vscode.md` |\n| Antigravity | `connect/tools/antigravity.md` |\n| Devin | `connect/tools/devin.md` |\n\nIf the user's tool isn't listed yet, connect it the generic way (register the Hostinger MCP server\nfor that tool, or drive `npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp` over stdio) and continue.\n\n## Phase 3 - Continue to Headless Hosting steps\n\nAfter evrything is verified then you can go back to `headless/SKILL.md` and follow the steps section to start the process.\n\n> **Backend-specific MCP groups.** The base connector covers hosting + WordPress. An **Ecommerce** build\n> also needs the separate **`hostinger-ecommerce`** MCP group (`ecommerce_*` ops) — it is not loaded by\n> default. The provision step adds it when the brief chooses Ecommerce (see\n> `steps/03-provision/ecommerce/SKILL.md`); adding an MCP server needs a session restart/reconnect to\n> take effect.\n\n## Reference Sources\n\n- https://mcpservers.org/servers/hostinger/api-mcp-server\n- https://www.hostinger.com/connector\n"
|
|
85
85
|
},
|
|
86
86
|
{
|
|
87
|
-
"uri": "skill://hostinger-headless/
|
|
88
|
-
"digest": "sha256:
|
|
89
|
-
"size":
|
|
87
|
+
"uri": "skill://hostinger-headless/connect/bootstrap.mjs",
|
|
88
|
+
"digest": "sha256:091047b2359291d718641060e703150d2bd2e0103dff3e82f81553bbe3682eb6",
|
|
89
|
+
"size": 5377,
|
|
90
|
+
"mimeType": "text/plain",
|
|
91
|
+
"text": "#!/usr/bin/env node\n// Hostinger Headless — bootstrap.\n// Verifies the Hostinger MCP server is reachable and drives the OAuth sign-in,\n// emitting one JSON event per line on stdout so a coding agent can relay\n// progress to the user. No filesystem writes besides what the MCP itself\n// stores (OAuth credentials); no network calls of its own.\nimport { spawn, spawnSync } from 'node:child_process';\nimport { existsSync, readFileSync } from 'node:fs';\nimport path from 'node:path';\nimport os from 'node:os';\n\n// ── tiny event protocol (one JSON object per line) ───────────────────────────\nconst emit = (event, extra = {}) =>\n process.stdout.write(JSON.stringify({ event, ...extra }) + '\\n');\nconst fail = (event, extra = {}) => {\n emit(event, { ok: false, ...extra });\n process.exit(1);\n};\n\n// ── platform-safe binary names (npm/npx are .cmd on Windows) ─────────────────\nconst isWin = process.platform === 'win32';\nconst bin = (name) => (isWin ? `${name}.cmd` : name);\n// Run the MCP via npx — no global install, always the latest published version.\n// Use --package so npx resolves the published package, then invoke the unified binary.\nconst MCP = [bin('npx'), '-y', '--package=@hostinger/mcp@latest', 'hostinger-api-mcp'];\n// Node >= 18.20.2 / 20.12.2 refuses to spawn a .cmd/.bat without a shell\n// (CVE-2024-27980), so npx.cmd needs shell:true on Windows. Our args are all\n// fixed literals, so enabling the shell introduces no injection risk.\nconst spawnShell = isWin;\n\nfunction capture(cmd, args) {\n const r = spawnSync(cmd, args, { encoding: 'utf8', shell: spawnShell });\n return { status: r.status ?? 1, out: `${r.stdout || ''}${r.stderr || ''}`, error: r.error };\n}\n\n// ── 1. MCP server reachable ──────────────────────────────────────────────────\nfunction checkMcp() {\n const r = capture(MCP[0], [...MCP.slice(1), '--version']);\n if (r.status !== 0) fail('mcp_unreachable', { detail: r.out.trim().slice(0, 400) });\n // npx interleaves \"npm notice …\" lines, so pick the first semver-looking token.\n const version = (r.out.match(/\\d+\\.\\d+\\.\\d+[^\\s]*/) || [])[0] || r.out.trim().split('\\n').pop();\n emit('mcp_ok', { version });\n}\n\n// ── 2. Existing auth? ────────────────────────────────────────────────────────\n// An env token always wins (the MCP bypasses OAuth entirely when it is set).\n// Otherwise stored OAuth credentials mean a previous sign-in can be reused —\n// the MCP refreshes expired tokens automatically on the first tool call.\nfunction existingAuth() {\n if (process.env.HOSTINGER_API_TOKEN || process.env.API_TOKEN) {\n return 'api_token';\n }\n const base = isWin ? (process.env.APPDATA || os.homedir()) : path.join(os.homedir(), '.config');\n const credsPath = path.join(base, 'hostinger-mcp', 'credentials.json');\n if (existsSync(credsPath)) {\n try {\n const creds = JSON.parse(readFileSync(credsPath, 'utf8'));\n if (creds.refresh_token || (creds.access_token && creds.expires_at > Date.now())) {\n return 'oauth_cached';\n }\n } catch {\n /* unreadable credentials — fall through to a fresh login */\n }\n }\n return null;\n}\n\n// ── 3. Login (human-in-the-loop browser flow) ────────────────────────────────\n// `hostinger-api-mcp --login` opens the Hostinger sign-in page and listens on\n// an ephemeral localhost port for the OAuth callback, so the browser must run\n// on this same machine. It prints the sign-in URL to stderr with a stable\n// prefix — forward it so the agent can show it if the browser didn't open.\nfunction login() {\n return new Promise((resolve) => {\n const child = spawn(MCP[0], [...MCP.slice(1), '--login'], { shell: spawnShell });\n let diag = '';\n let urlSent = false;\n const collect = (chunk) => {\n diag = (diag + chunk).slice(-2000);\n if (!urlSent) {\n const m = diag.match(/Opening browser for sign-in:\\s*\\n\\s*(https?:\\/\\/\\S+)/);\n if (m) {\n urlSent = true;\n emit('awaiting_user', {\n verificationUri: m[1],\n note: 'A browser window should have opened. If not, open this URL manually on this machine and sign in.',\n });\n }\n }\n };\n child.stderr?.on('data', collect);\n child.stdout?.on('data', collect);\n child.on('close', (code) => {\n if (code === 0 && /Sign-in successful/i.test(diag)) {\n emit('logged_in', { method: 'oauth' });\n resolve();\n return;\n }\n const detail = diag.trim().slice(-1500) || `--login exited (code ${code}) with no output.`;\n fail('login_failed', { code, detail });\n });\n child.on('error', (e) => fail('login_failed', { detail: String(e) }));\n });\n}\n\n// ── main ─────────────────────────────────────────────────────────────────────\ncheckMcp();\nconst method = existingAuth();\nif (method) {\n emit('logged_in', { method });\n} else {\n await login();\n}\nemit('success');\n"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"uri": "skill://hostinger-headless/connect/tools/antigravity.md",
|
|
95
|
+
"digest": "sha256:464a093874d849927eada7e6857bb50a9be497414dac1359bd3fde0194914a7f",
|
|
96
|
+
"size": 2656,
|
|
97
|
+
"mimeType": "text/markdown",
|
|
98
|
+
"text": "---\nname: headless-connect-antigravity\ndescription: \"Specific Antigravity Hostinger MCP Server connection instructions for the Hostinger Headless flow.\"\n---\n\n## Goals\n\n- Make Hostinger MCP tools and Skills available in Google Antigravity.\n- Don't provision hosting, create stores, install WordPress, or deploy by hand here — the skill does all of that. This entry stops at signed in and checking that Hostinger MCP and skills are reachable.\n\n## Step 1 — Make the connector callable (no restart needed)\n\nThe later stages drive the Hostinger connector. If its Hostinger tools already respond in this\nsession, use them and move on.\n\nIf they don't, **don't rely on registering the server and restarting** — Antigravity loads MCP servers\nwhen the editor starts, and you can't restart it mid-run. The Hostinger MCP runs as a local **stdio**\nprocess and the bootstrap already signed you in, so talk to it directly for this run: spawn\n`npx -y @hostinger/mcp@latest` and speak newline-delimited JSON-RPC 2.0 over its stdin/stdout\n(`initialize` → `notifications/initialized` → `tools/call`). It reuses your cached sign-in automatically\n(`~/.config/hostinger-mcp/credentials.json`, or `HOSTINGER_API_TOKEN`) — no tokens to handle. It\nexposes `search`, `execute`, `multi-execute`; everything the stages need goes through `execute`, and a\nresult is in `reply.result.content[0].text`. Write the little client yourself (~20 lines), keep one\nprocess alive for the whole run, and use it wherever a stage calls the connector.\n\n**To persist it for future Antigravity sessions** (optional), add the server to\n`~/.gemini/antigravity/mcp_config.json` (Windows: `C:\\Users\\<USERNAME>\\.gemini\\antigravity\\mcp_config.json`) —\nreachable from the Agent panel's \"...\" → MCP Servers → Manage MCP Servers → View raw config — then fully\nquit and reopen Antigravity once so it loads:\n\n```json\n{\n \"mcpServers\": {\n \"hostinger\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@hostinger/mcp@latest\"]\n }\n }\n}\n```\n\n## Step 2 — Install the skills and hand off\n\nDownload the Hostinger skills locally so your agent reads the files directly with no further network\ncalls. The repo is **public**, so fetch just the skill folder over HTTPS — no git, no credentials (see\n`connect/tools/claude-code.md` for the full tarball command):\n\n```bash\n# macOS/Linux — downloads only skills/headless from the public repo\ncurl -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main \\\n | tar -xz --strip-components=2 'api-mcp-server-main/skills/headless'\n```\n\nThen open `headless/SKILL.md` and follow its steps. That skill owns the rest of the run.\n"
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"uri": "skill://hostinger-headless/connect/tools/claude-code.md",
|
|
102
|
+
"digest": "sha256:f26a77d3d3583dd2fef27a056c619fc399ee7cbb49b8e221b3a16a79289a11ea",
|
|
103
|
+
"size": 2676,
|
|
104
|
+
"mimeType": "text/markdown",
|
|
105
|
+
"text": "---\nname: headless-connect-claude-code\ndescription: \"Specific Claude Code Hostinger MCP Server connection instructions for the Hostinger Headless flow.\"\n---\n\n## Goals\n\n- Your goal is to make Hostinger MCP tools and Skills available in Claude Code. \n- Don't provision hosting, create stores, install WordPress, or deploy by hand here — the skill does all of that. This entry stops at signed in and checking that Hostinger MCP and skills are available.\n\n\n## Step 1 — Make the MCP callable (no restart needed)\n\nStages 2–10 drive the Hostinger connector. If its tools are already in this session\n(`mcp__hostinger__*`, or the scoped `mcp__hostinger-*__*`), use them and move on.\n\nIf they aren't, **don't register the server and restart** — Claude Code only loads MCP servers at\nstartup, but you don't need it to. The Hostinger MCP runs as a local **stdio** process and the\nbootstrap already signed you in, so talk to it directly: spawn `npx -y @hostinger/mcp@latest` and\nspeak newline-delimited JSON-RPC 2.0 over its stdin/stdout (`initialize` →\n`notifications/initialized` → `tools/call`). It reuses your cached sign-in automatically\n(`~/.config/hostinger-mcp/credentials.json`, or `HOSTINGER_API_TOKEN`) — no tokens to handle. It\nexposes `search`, `execute`, `multi-execute`; everything the stages need goes through `execute`, and\na result is in `reply.result.content[0].text`. Write the little client yourself (~20 lines), keep one\nprocess alive for the whole run, and use it wherever a stage calls the connector.\n\n## Step 2 — Install the skills and hand off\n\nDownload the Hostinger skills locally so your agent reads the files directly with no further network calls. The repo is **public**, so fetch just the skill folder over HTTPS — no git, no credentials:\n\n```bash\n# macOS/Linux — downloads only skills/headless from the public repo\ncurl -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main \\\n | tar -xz --strip-components=2 'api-mcp-server-main/skills/headless'\n```\n\n```powershell\n# Windows PowerShell\ncurl.exe -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main -o headless-skill.tar.gz\ntar -xf headless-skill.tar.gz --strip-components=2 'api-mcp-server-main/skills/headless'\n```\n\nThis leaves a local `headless/` folder with the full skill set. If the tarball is ever blocked, browse the public folder at `https://github.com/hostinger/api-mcp-server/tree/main/skills/headless` and save each file under a local `headless/` directory, preserving the paths.\n\nThen open `headless/SKILL.md` and follow the steps on it. That skill owns the rest of the run — it resolves the operations sequence.\n\n"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"uri": "skill://hostinger-headless/connect/tools/codex.md",
|
|
109
|
+
"digest": "sha256:36f294dbbbaf33bf4e3dcc5d52afef3d9a889a10f1962e3bb7e7a8796b0e796e",
|
|
110
|
+
"size": 2432,
|
|
111
|
+
"mimeType": "text/markdown",
|
|
112
|
+
"text": "---\nname: headless-connect-codex\ndescription: \"Specific Codex Hostinger MCP Server connection instructions for the Hostinger Headless flow.\"\n---\n\n## Goals\n\n- Make Hostinger MCP tools and Skills available in Codex.\n- Don't provision hosting, create stores, install WordPress, or deploy by hand here — the skill does all of that. This entry stops at signed in and checking that Hostinger MCP and skills are reachable.\n\n## Step 1 — Make the connector callable (no restart needed)\n\nThe later stages drive the Hostinger connector. If its Hostinger tools already respond in this\nsession, use them and move on.\n\nIf they don't, **don't rely on registering the server and restarting** — Codex loads MCP servers when\nit starts, and you can't restart it mid-run. The Hostinger MCP runs as a local **stdio** process and the\nbootstrap already signed you in, so talk to it directly for this run: spawn `npx -y @hostinger/mcp@latest`\nand speak newline-delimited JSON-RPC 2.0 over its stdin/stdout (`initialize` →\n`notifications/initialized` → `tools/call`). It reuses your cached sign-in automatically\n(`~/.config/hostinger-mcp/credentials.json`, or `HOSTINGER_API_TOKEN`) — no tokens to handle. It\nexposes `search`, `execute`, `multi-execute`; everything the stages need goes through `execute`, and a\nresult is in `reply.result.content[0].text`. Write the little client yourself (~20 lines), keep one\nprocess alive for the whole run, and use it wherever a stage calls the connector.\n\n**To persist it for future Codex sessions** (optional), register the server with\n`codex mcp add hostinger -- npx -y @hostinger/mcp@latest`, or add it to `~/.codex/config.toml`, then\nrestart Codex so it loads:\n\n```toml\n[mcp_servers.hostinger]\ncommand = \"npx\"\nargs = [\"-y\", \"@hostinger/mcp@latest\"]\n```\n\n## Step 2 — Install the skills and hand off\n\nDownload the Hostinger skills locally so your agent reads the files directly with no further network\ncalls. The repo is **public**, so fetch just the skill folder over HTTPS — no git, no credentials (see\n`connect/tools/claude-code.md` for the full tarball command):\n\n```bash\n# macOS/Linux — downloads only skills/headless from the public repo\ncurl -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main \\\n | tar -xz --strip-components=2 'api-mcp-server-main/skills/headless'\n```\n\nThen open `headless/SKILL.md` and follow its steps. That skill owns the rest of the run.\n"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"uri": "skill://hostinger-headless/connect/tools/cursor.md",
|
|
116
|
+
"digest": "sha256:d07d8df136dcaf2e3acc231c6726842e50263182cca866ef716a68dca8bb8c1d",
|
|
117
|
+
"size": 5190,
|
|
118
|
+
"mimeType": "text/markdown",
|
|
119
|
+
"text": "---\nname: headless-connect-cursor\ndescription: \"Specific Cursor Hostinger MCP Server connection instructions for the Hostinger Headless flow.\"\n---\n\n## Goals\n\n- Install (or reach) the Hostinger MCP in Cursor.\n- Sign the user in with OAuth 2.0 PKCE — the MCP opens a browser so they only need to log in.\n- Don't provision hosting, create stores, install WordPress, or deploy by hand here — the skill does all of that. This entry stops at signed in and checking that Hostinger MCP and skills are reachable.\n\n## Step 1 — Install / reach Hostinger MCP\n\n**Prerequisite:** Node.js 20 or newer on `PATH` (`node -v`). The published package engines require `>=20`; use Node 24 if you follow the package README.\n\nThe Hostinger MCP is the npm package `@hostinger/mcp`. You do **not** need a global install — Cursor (and this session) can run it through `npx`. Prefer the unified binary `hostinger-api-mcp` (every operation). Scoped binaries such as `hostinger-hosting-mcp` are optional.\n\n### Option A — no global install (recommended)\n\n```bash\n# Verify the package/binary is reachable\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --version\n```\n\n### Option B — global install\n\n```bash\nnpm install -g @hostinger/mcp\n# or: yarn global add @hostinger/mcp\n# or: pnpm add -g @hostinger/mcp\n\nhostinger-api-mcp --version\n```\n\n### Persist for future Cursor sessions (optional)\n\nRegister the server in `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global), then fully quit and reopen Cursor once so it loads. **Do not** put `HOSTINGER_API_TOKEN` in this file when you want OAuth — leave the token unset so stdio OAuth can run:\n\n```json\n{\n \"mcpServers\": {\n \"hostinger\": {\n \"command\": \"npx\",\n \"args\": [\"--yes\", \"--package=@hostinger/mcp@latest\", \"hostinger-api-mcp\"]\n }\n }\n}\n```\n\nIf Hostinger MCP tools are already in this session (`mcp__hostinger__*` / `mcp__hostinger-*__*`), skip ahead to Step 2 only if you still need sign-in; otherwise go to Step 3.\n\n## Step 2 — Authenticate with OAuth (browser opens)\n\nPrefer OAuth for interactive Cursor use. OAuth works only in **stdio** mode (Cursor's default for this local server). Skip this step if `HOSTINGER_API_TOKEN` is already set in the environment (that bypasses OAuth entirely).\n\nIf the connect bootstrap already emitted `logged_in` / `success`, credentials are already on disk — continue to Step 3.\n\nOtherwise run the login command. It registers a dynamic OAuth client (once per machine), **opens the user's browser** to Hostinger's authorization page, listens on a localhost callback, stores tokens, and exits:\n\n```bash\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --login\n```\n\n(With a global install: `hostinger-api-mcp --login`.)\n\nWhat to tell the user:\n\n1. A browser window should open automatically for Hostinger sign-in.\n2. If it does not, the process prints `[OAuth] Opening browser for sign-in:` plus the URL on stderr — show that URL and ask them to open it on **this same machine** (the callback is `http://127.0.0.1:<ephemeral-port>/oauth/callback`).\n3. Wait until the command prints that sign-in succeeded and exits 0.\n\nCredentials are stored and reused automatically:\n\n- macOS / Linux: `~/.config/hostinger-mcp/credentials.json` (mode 0600)\n- Windows: `%APPDATA%\\hostinger-mcp\\credentials.json`\n\nThey are shared across every Hostinger MCP binary. Expired access tokens refresh on their own; a revoked refresh token re-triggers the browser flow.\n\nTo sign out later:\n\n```bash\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --logout\n```\n\n**API token (optional, CI / scripts):** set `HOSTINGER_API_TOKEN` in the environment before launching Cursor. When present it always wins and no OAuth code runs. Do not commit the token.\n\n## Step 3 — Use the connector in this run (no Cursor restart)\n\nCursor only loads registered MCP servers when the editor starts, so a mid-run `.cursor/mcp.json` change will not attach tools to **this** session. For the rest of this run, talk to the local stdio server directly: spawn\n\n```bash\nnpx --yes --package=@hostinger/mcp@latest hostinger-api-mcp\n```\n\nand speak newline-delimited JSON-RPC 2.0 over its stdin/stdout (`initialize` → `notifications/initialized` → `tools/call`). It reuses the cached sign-in from Step 2 (or `HOSTINGER_API_TOKEN`) — no tokens to handle in chat. It exposes `search`, `execute`, and `multi-execute`; everything the stages need goes through `execute`, and a result is in `reply.result.content[0].text`. Keep one process alive for the whole run.\n\n## Step 4 — Install the skills and hand off\n\nDownload the Hostinger skills locally so your agent reads the files directly with no further network\ncalls. The repo is **public**, so fetch just the skill folder over HTTPS — no git, no credentials (see\n`connect/tools/claude-code.md` for the full tarball command):\n\n```bash\n# macOS/Linux — downloads only skills/headless from the public repo\ncurl -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main \\\n | tar -xz --strip-components=2 'api-mcp-server-main/skills/headless'\n```\n\nThen open `headless/SKILL.md` and follow its steps. That skill owns the rest of the run.\n"
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
"uri": "skill://hostinger-headless/connect/tools/devin.md",
|
|
123
|
+
"digest": "sha256:2f9214fb189f3120a262da7282795438185df7dab311e2fef547462a814022bf",
|
|
124
|
+
"size": 2288,
|
|
125
|
+
"mimeType": "text/markdown",
|
|
126
|
+
"text": "---\nname: headless-connect-devin\ndescription: \"Specific Devin Hostinger MCP Server connection instructions for the Hostinger Headless flow.\"\n---\n\n## Goals\n\n- Make Hostinger MCP tools and Skills available in Devin.\n- Don't provision hosting, create stores, install WordPress, or deploy by hand here — the skill does all of that. This entry stops at signed in and checking that Hostinger MCP and skills are reachable.\n\n## Step 1 — Make the connector callable\n\nThe later stages drive the Hostinger connector. If its Hostinger tools already respond in this\nsession, use them and move on.\n\nIf they don't, register the Hostinger MCP server for Devin. The local MCP runs as a **stdio** process\nand the bootstrap already signed you in, so it reuses your cached sign-in automatically\n(`~/.config/hostinger-mcp/credentials.json`, or `HOSTINGER_API_TOKEN`) — no tokens to handle.\n\n- **Devin CLI:** `devin mcp add hostinger -- npx -y @hostinger/mcp@latest`\n- **Devin app:** Settings → MCP Marketplace → **Add Your Own**, transport **STDIO**, with:\n\n ```json\n {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@hostinger/mcp@latest\"]\n }\n ```\n\nOn Devin Desktop, fully quit and reopen the app once after adding the server so it loads.\n\nIf you can't reach the registered server from this run, drive `npx -y @hostinger/mcp@latest` directly\nover stdio instead: spawn it and speak newline-delimited JSON-RPC 2.0 over its stdin/stdout\n(`initialize` → `notifications/initialized` → `tools/call`). It exposes `search`, `execute`,\n`multi-execute`; everything the stages need goes through `execute`, and a result is in\n`reply.result.content[0].text`.\n\n## Step 2 — Install the skills and hand off\n\nDownload the Hostinger skills locally so your agent reads the files directly with no further network\ncalls. The repo is **public**, so fetch just the skill folder over HTTPS — no git, no credentials (see\n`connect/tools/claude-code.md` for the full tarball command):\n\n```bash\n# macOS/Linux — downloads only skills/headless from the public repo\ncurl -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main \\\n | tar -xz --strip-components=2 'api-mcp-server-main/skills/headless'\n```\n\nThen open `headless/SKILL.md` and follow its steps. That skill owns the rest of the run.\n"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"uri": "skill://hostinger-headless/connect/tools/vscode.md",
|
|
130
|
+
"digest": "sha256:eec180a0e2899340f32e35fa2f479427253d85f190da76cd6a69a7c7a4d1d2c1",
|
|
131
|
+
"size": 2605,
|
|
132
|
+
"mimeType": "text/markdown",
|
|
133
|
+
"text": "---\nname: headless-connect-vscode\ndescription: \"Specific VS Code Hostinger MCP Server connection instructions for the Hostinger Headless flow.\"\n---\n\n## Goals\n\n- Make Hostinger MCP tools and Skills available in VS Code (GitHub Copilot / agent mode).\n- Don't provision hosting, create stores, install WordPress, or deploy by hand here — the skill does all of that. This entry stops at signed in and checking that Hostinger MCP and skills are reachable.\n\n## Step 1 — Make the connector callable\n\nThe later stages drive the Hostinger connector. If its Hostinger tools already respond in this\nsession, use them and move on.\n\nIf they don't, register the Hostinger MCP server for VS Code. The local MCP runs as a **stdio**\nprocess and the bootstrap already signed you in, so it reuses your cached sign-in automatically\n(`~/.config/hostinger-mcp/credentials.json`, or `HOSTINGER_API_TOKEN`) — no tokens to handle. Add it to\n`.vscode/mcp.json` (workspace) or your user `settings.json` under `mcp`:\n\n```json\n// .vscode/mcp.json\n{\n \"servers\": {\n \"hostinger\": {\n \"type\": \"stdio\",\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@hostinger/mcp@latest\"]\n }\n }\n}\n```\n\nOr add it in one command: `code --add-mcp '{\"name\":\"hostinger\",\"command\":\"npx\",\"args\":[\"-y\",\"@hostinger/mcp@latest\"]}'`.\nVS Code picks up the server and its tools become available immediately — no editor restart needed.\n(Alternatively, the official Hostinger Connector extension installs the same server:\n`code --install-extension hostinger-official.hostinger-connector`.)\n\nIf you can't reach the registered server from this run, drive `npx -y @hostinger/mcp@latest` directly\nover stdio instead: spawn it and speak newline-delimited JSON-RPC 2.0 over its stdin/stdout\n(`initialize` → `notifications/initialized` → `tools/call`). It exposes `search`, `execute`,\n`multi-execute`; everything the stages need goes through `execute`, and a result is in\n`reply.result.content[0].text`.\n\n## Step 2 — Install the skills and hand off\n\nDownload the Hostinger skills locally so your agent reads the files directly with no further network\ncalls. The repo is **public**, so fetch just the skill folder over HTTPS — no git, no credentials (see\n`connect/tools/claude-code.md` for the full tarball command):\n\n```bash\n# macOS/Linux — downloads only skills/headless from the public repo\ncurl -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main \\\n | tar -xz --strip-components=2 'api-mcp-server-main/skills/headless'\n```\n\nThen open `headless/SKILL.md` and follow its steps. That skill owns the rest of the run.\n"
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
"uri": "skill://hostinger-headless/report.schema.json",
|
|
137
|
+
"digest": "sha256:ace685d311608e37b33b171a13ab87b2d5d53d38be2d38be9c49d6be5decf805",
|
|
138
|
+
"size": 6091,
|
|
139
|
+
"mimeType": "text/plain",
|
|
140
|
+
"text": "{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"title\": \"Hostinger Headless build report\",\n \"description\": \"Written by run-log.mjs at the end of every run (including early stops) to ~/.hostinger/<domain>/reports/<run>.json.\",\n \"type\": \"object\",\n \"required\": [\"schema_version\", \"generated_at\", \"consent\", \"run\", \"info\", \"timing\", \"tokens\", \"issues\", \"user_feedback\", \"skill_gaps\", \"what_went_well\", \"previous_runs\"],\n \"properties\": {\n \"schema_version\": { \"type\": \"string\", \"enum\": [\"1\"] },\n \"generated_at\": { \"type\": \"string\" },\n \"consent\": {\n \"type\": \"object\",\n \"required\": [\"sent\"],\n \"properties\": { \"sent\": { \"type\": \"boolean\" }, \"note\": { \"type\": \"string\" } }\n },\n \"run\": {\n \"type\": \"object\",\n \"required\": [\"id\", \"kind\"],\n \"properties\": {\n \"id\": { \"type\": \"string\" },\n \"kind\": { \"type\": \"string\", \"enum\": [\"build\", \"resume\", \"redesign\", \"iterate\"] },\n \"skill\": { \"type\": [\"string\", \"null\"] },\n \"follow_ups\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"resumed_at\"],\n \"properties\": { \"resumed_at\": { \"type\": \"string\" }, \"after_report_at\": { \"type\": [\"string\", \"null\"] }, \"reason\": { \"type\": [\"string\", \"null\"] } }\n }\n }\n }\n },\n \"info\": {\n \"type\": \"object\",\n \"required\": [\"domain\", \"tool\", \"outcome\"],\n \"properties\": {\n \"domain\": { \"type\": \"string\" },\n \"tool\": { \"type\": [\"string\", \"null\"] },\n \"model\": { \"type\": [\"string\", \"null\"] },\n \"os\": { \"type\": \"string\" },\n \"node\": { \"type\": \"string\" },\n \"mcp\": { \"type\": [\"string\", \"null\"] },\n \"backend\": { \"type\": [\"string\", \"null\"] },\n \"frontend\": { \"type\": [\"string\", \"null\"] },\n \"outcome\": { \"type\": \"string\", \"enum\": [\"live\", \"stopped\", \"failed\", \"abandoned\"] },\n \"stopped_at\": { \"type\": [\"string\", \"null\"] },\n \"live_url\": { \"type\": [\"string\", \"null\"] },\n \"admin_url\": { \"type\": [\"string\", \"null\"] }\n }\n },\n \"timing\": {\n \"type\": \"object\",\n \"required\": [\"started_at\", \"ended_at\", \"minutes_total\", \"stages\"],\n \"properties\": {\n \"started_at\": { \"type\": \"string\" },\n \"ended_at\": { \"type\": \"string\" },\n \"live_at\": { \"type\": [\"string\", \"null\"] },\n \"minutes_total\": { \"type\": [\"number\", \"null\"] },\n \"minutes_to_live\": { \"type\": [\"number\", \"null\"] },\n \"stages\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"stage\", \"started_at\", \"ended_at\", \"result\"],\n \"properties\": {\n \"stage\": { \"type\": \"string\", \"enum\": [\"connect\", \"check_state\", \"refine\", \"provision\", \"content_model\", \"design\", \"frontend\", \"validate\", \"deploy\", \"done\"] },\n \"phase\": { \"type\": [\"string\", \"null\"] },\n \"started_at\": { \"type\": \"string\" },\n \"ended_at\": { \"type\": [\"string\", \"null\"] },\n \"result\": { \"type\": [\"string\", \"null\"], \"enum\": [\"ok\", \"ok_after_retries\", \"ok_with_fixups\", \"failed\", \"skipped\", \"stopped\", null] },\n \"note\": { \"type\": [\"string\", \"null\"] },\n \"minutes\": { \"type\": [\"number\", \"null\"] },\n \"start_estimated\": { \"type\": \"boolean\" }\n }\n }\n }\n }\n },\n \"tokens\": {\n \"description\": \"Only when the tool reports its own usage. Never read the user's transcripts to get it.\",\n \"type\": [\"object\", \"null\"],\n \"properties\": {\n \"source\": { \"type\": \"string\" },\n \"requests\": { \"type\": \"integer\" },\n \"input\": { \"type\": \"integer\" },\n \"output\": { \"type\": \"integer\" },\n \"cache_read\": { \"type\": \"integer\" },\n \"cache_write\": { \"type\": \"integer\" },\n \"est_cost_usd\": { \"type\": \"number\" }\n }\n },\n \"issues\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"stage\", \"symptom\", \"severity\", \"resolved\", \"user_had_to_intervene\"],\n \"properties\": {\n \"stage\": { \"type\": \"string\" },\n \"symptom\": { \"type\": \"string\" },\n \"cause\": { \"type\": \"string\" },\n \"workaround\": { \"type\": \"string\" },\n \"severity\": { \"type\": \"string\", \"enum\": [\"high\", \"medium\", \"low\"] },\n \"resolved\": { \"type\": \"boolean\" },\n \"user_had_to_intervene\": { \"type\": \"boolean\" },\n \"logged_at\": { \"type\": \"string\" }\n }\n }\n },\n \"user_feedback\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"stage\", \"category\", \"paraphrase\"],\n \"properties\": { \"stage\": { \"type\": \"string\" }, \"category\": { \"type\": \"string\" }, \"paraphrase\": { \"type\": \"string\" }, \"logged_at\": { \"type\": \"string\" } }\n }\n },\n \"skill_gaps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"skill\", \"gap\"],\n \"properties\": { \"skill\": { \"type\": \"string\" }, \"gap\": { \"type\": \"string\" }, \"stage\": { \"type\": \"string\" }, \"logged_at\": { \"type\": \"string\" } }\n }\n },\n \"what_went_well\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"text\"],\n \"properties\": { \"text\": { \"type\": \"string\" }, \"stage\": { \"type\": \"string\" }, \"logged_at\": { \"type\": \"string\" } }\n }\n },\n \"logging_gaps\": {\n \"description\": \"Where the run log is missing or unsure: unfinished stages, estimated starts, zero-length entries, stages never logged on a live build.\",\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"required\": [\"stage\", \"problem\"],\n \"properties\": { \"stage\": { \"type\": \"string\" }, \"phase\": { \"type\": [\"string\", \"null\"] }, \"problem\": { \"type\": \"string\" } }\n }\n },\n \"previous_runs\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": { \"run\": { \"type\": [\"string\", \"null\"] }, \"kind\": { \"type\": [\"string\", \"null\"] }, \"outcome\": { \"type\": [\"string\", \"null\"] }, \"minutes\": { \"type\": [\"number\", \"null\"] }, \"html\": { \"type\": \"string\" } }\n }\n }\n }\n}\n"
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"uri": "skill://hostinger-headless/run-log.mjs",
|
|
144
|
+
"digest": "sha256:83ac43a6f414f431276ee3caa53eba68f983730d2b1095e1882acc665a7ba920",
|
|
145
|
+
"size": 38936,
|
|
146
|
+
"mimeType": "text/plain",
|
|
147
|
+
"text": "#!/usr/bin/env node\n// Hostinger Headless run log: records a build as it happens and writes reports/<run>.json + .html.\n//\n// node run-log.mjs <domain> run-start [--kind build|resume|redesign|iterate] [--tool <tool>] [--model <model>] [--skill <version>]\n// node run-log.mjs <domain> run-update [--kind …] [--model …] [--skill …]\n// node run-log.mjs <domain> run-resume [--reason \"<text>\"]\n// node run-log.mjs <domain> stage-start <stage> [--phase <phase>] [--started-ago 6m | --started-at <ISO time>]\n// node run-log.mjs <domain> stage-end <stage> <result> [--phase <phase>] [--note \"<text>\"] [--started-ago 6m | --started-at <ISO time>]\n// node run-log.mjs <domain> stage-fix <stage> [--phase <phase>] (--started-ago 6m | --started-at <ISO time>)\n// node run-log.mjs <domain> event <issue|feedback|skill_gap|good> '<json>'\n// node run-log.mjs <domain> report [--outcome live|stopped|failed|abandoned] [--tokens '<json>']\n// node run-log.mjs <domain> unsent\n// node run-log.mjs <domain> report-sent [<run>]\n// node run-log.mjs <domain> report-rejected [<run>]\n\nimport { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst STAGES = ['connect', 'check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy', 'done'];\nconst RESULTS = ['ok', 'ok_after_retries', 'ok_with_fixups', 'failed', 'skipped', 'stopped'];\nconst OUTCOMES = ['live', 'stopped', 'failed', 'abandoned'];\nconst EVENT_FIELDS = {\n issue: { required: ['stage', 'symptom'], optional: ['cause', 'workaround', 'severity', 'resolved', 'user_had_to_intervene'] },\n feedback: { required: ['stage', 'category', 'paraphrase'], optional: [] },\n skill_gap: { required: ['skill', 'gap'], optional: ['stage'] },\n good: { required: ['text'], optional: ['stage'] },\n};\nconst EVENT_LISTS = { issue: 'issues', feedback: 'user_feedback', skill_gap: 'skill_gaps', good: 'what_went_well' };\n\nconst here = dirname(fileURLToPath(import.meta.url));\nconst now = () => new Date().toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n// A start logged late is kept but marked estimated, so the report can tell measured times from remembered ones.\nfunction lateStart(flags, run) {\n let at = null;\n if (typeof flags['started-at'] === 'string') {\n if (Number.isNaN(Date.parse(flags['started-at']))) fail(`--started-at \"${flags['started-at']}\" is not a time; use e.g. 2026-10-13T06:26:00Z`);\n at = Date.parse(flags['started-at']);\n } else if (typeof flags['started-ago'] === 'string') {\n const m = flags['started-ago'].match(/^(\\d+(?:\\.\\d+)?)\\s*([smh])$/);\n if (!m) fail(`--started-ago \"${flags['started-ago']}\" should look like 90s, 6m or 1h`);\n at = Date.now() - Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5 }[m[2]];\n }\n if (at === null) return null;\n const clamped = Math.min(Math.max(at, Date.parse(run.started_at)), Date.now());\n return new Date(clamped).toISOString().replace(/\\.\\d{3}Z$/, 'Z');\n}\n\nconst fail = (message) => {\n console.error(`run-log: ${message}`);\n process.exit(1);\n};\n\nfunction parseArgs(argv) {\n const positional = [];\n const flags = {};\n for (let i = 0; i < argv.length; i += 1) {\n if (argv[i].startsWith('--')) {\n const key = argv[i].slice(2);\n const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[(i += 1)] : true;\n flags[key] = value;\n } else positional.push(argv[i]);\n }\n return { positional, flags };\n}\n\nconst readJson = (file, fallback) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback);\nconst writeJson = (file, value) => writeFileSync(file, `${JSON.stringify(value, null, 2)}\\n`);\n\nfunction workspace(domain) {\n if (!domain || domain.startsWith('-')) fail('first argument must be the domain');\n const dir = join(homedir(), '.hostinger', domain);\n if (!existsSync(dir)) fail(`no workspace at ${dir}; connect creates it`);\n return {\n dir,\n domain,\n statePath: join(dir, 'state.json'),\n eventsPath: join(dir, 'events.jsonl'),\n reportsDir: join(dir, 'reports'),\n };\n}\n\nconst loadState = (ws) => readJson(ws.statePath, {});\nfunction saveState(ws, state) {\n state.updated_at = now();\n writeJson(ws.statePath, state);\n}\n\nfunction currentRun(ws, state) {\n if (!state.run?.id) fail('no run started; call run-start first (check-state does this)');\n return state.run;\n}\n\nfunction readEvents(ws) {\n if (!existsSync(ws.eventsPath)) return [];\n return readFileSync(ws.eventsPath, 'utf8')\n .split('\\n')\n .filter(Boolean)\n .map((line) => JSON.parse(line));\n}\n\nfunction runStart(ws, flags) {\n const state = loadState(ws);\n // A previous run that never reached its report (closed window, crash) is closed out first, so\n // its log still turns into a report.\n if (state.run?.id && !state.run.reported_at) {\n buildReport(ws, { outcome: 'abandoned' });\n console.log(`closed previous run ${state.run.id} as abandoned`);\n }\n const fresh = loadState(ws);\n const previous = fresh.run ?? {};\n const startedAt = now();\n const connect = readJson(join(ws.dir, 'connect.json'), {});\n // Run ids are the start time; a second run in the same second gets a suffix.\n const taken = new Set([\n ...(fresh.history ?? []).map((h) => h.run),\n ...(existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).map((f) => f.replace(/\\.(json|html)$/, '')) : []),\n ]);\n const base = startedAt.replace(/:/g, '-');\n let id = base;\n for (let n = 2; taken.has(id) || id === fresh.run?.id; n += 1) id = `${base}-${n}`;\n const kind = flags.kind ?? 'build';\n if (!['build', 'resume', 'redesign', 'iterate'].includes(kind)) fail(`unknown kind \"${kind}\"; one of build, resume, redesign, iterate`);\n fresh.run = {\n id,\n kind,\n started_at: startedAt,\n tool: flags.tool ?? connect.tool ?? fresh.tool ?? previous.tool ?? null,\n model: flags.model ?? previous.model ?? null,\n skill: flags.skill ?? previous.skill ?? null,\n };\n fresh.history ??= [];\n saveState(ws, fresh);\n console.log(`run ${fresh.run.id} started`);\n}\n\nfunction runUpdate(ws, flags) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n for (const key of ['kind', 'tool', 'model', 'skill']) if (typeof flags[key] === 'string') run[key] = flags[key];\n if (!['build', 'resume', 'redesign', 'iterate'].includes(run.kind)) fail(`unknown kind \"${run.kind}\"; one of build, resume, redesign, iterate`);\n saveState(ws, state);\n console.log(`run ${run.id} updated`);\n}\n\n// A follow-up in the same session (the user asks for a fix right after the final report) belongs\n// to the run it follows: reopen it, so the next `report` rewrites that run's report instead of\n// starting a second one.\nfunction runResume(ws, flags) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n if (!run.reported_at) {\n console.log(`run ${run.id} is still open; nothing to resume`);\n return;\n }\n run.follow_ups = [\n ...(run.follow_ups ?? []),\n { resumed_at: now(), after_report_at: run.reported_at, reason: typeof flags.reason === 'string' ? flags.reason : null },\n ];\n delete run.reported_at;\n saveState(ws, state);\n console.log(`run ${run.id} resumed (follow-up ${run.follow_ups.length})`);\n}\n\nfunction stageStart(ws, [stage], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"; one of ${STAGES.join(', ')}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n state.history ??= [];\n const phase = flags.phase ?? null;\n const open = state.history.find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at);\n if (open) {\n console.log(`${stage}${phase ? `/${phase}` : ''} already started at ${open.started_at}`);\n return;\n }\n const late = lateStart(flags, run);\n state.history.push({ run: run.id, stage, phase, started_at: late ?? now(), ended_at: null, result: null, note: null, ...(late ? { start_estimated: true } : {}) });\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} started${late ? ` (estimated start ${late})` : ''}`);\n}\n\nfunction stageEnd(ws, [stage, result], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"`);\n if (!RESULTS.includes(result)) fail(`unknown result \"${result}\"; one of ${RESULTS.join(', ')}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n state.history ??= [];\n const phase = flags.phase ?? null;\n let entry = [...state.history].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at);\n if (!entry) {\n // Missed stage-start: use the remembered start if given, else the end of the previous stage; either way estimated.\n const runEntries = state.history.filter((h) => h.run === run.id);\n const lastEnd = runEntries.map((h) => h.ended_at).filter(Boolean).sort().at(-1);\n const late = lateStart(flags, run);\n entry = { run: run.id, stage, phase, started_at: late ?? lastEnd ?? run.started_at, ended_at: null, result: null, note: null, start_estimated: true };\n state.history.push(entry);\n console.log(`${stage}${phase ? `/${phase}` : ''}: stage-start was not logged; start estimated as ${entry.started_at}${late ? '' : ' (end of the previous stage). Pass --started-ago if you know better'}`);\n } else if (flags['started-at'] || flags['started-ago']) {\n entry.started_at = lateStart(flags, run);\n entry.start_estimated = true;\n }\n entry.ended_at = now();\n entry.result = result;\n if (typeof flags.note === 'string') entry.note = flags.note;\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} ended: ${result}`);\n}\n\nfunction stageFix(ws, [stage], flags) {\n if (!STAGES.includes(stage)) fail(`unknown stage \"${stage}\"`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n const phase = flags.phase ?? null;\n const entry = [...(state.history ?? [])].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase);\n if (!entry) fail(`${stage}${phase ? `/${phase}` : ''} was never logged in this run; use stage-start with --started-ago instead`);\n const late = lateStart(flags, run);\n if (!late) fail('stage-fix needs --started-ago (e.g. 6m) or --started-at <ISO time>');\n if (entry.ended_at && late > entry.ended_at) fail(`the new start ${late} is after the stage ended (${entry.ended_at})`);\n entry.started_at = late;\n entry.start_estimated = true;\n saveState(ws, state);\n console.log(`${stage}${phase ? `/${phase}` : ''} start set to ${late} (estimated)`);\n}\n\nfunction event(ws, [type, raw]) {\n const spec = EVENT_FIELDS[type];\n if (!spec) fail(`unknown event type \"${type}\"; one of ${Object.keys(EVENT_FIELDS).join(', ')}`);\n let data;\n try {\n data = JSON.parse(raw ?? '');\n } catch {\n fail(`event ${type} needs a JSON object, e.g. '${JSON.stringify(Object.fromEntries(spec.required.map((k) => [k, '…'])))}'`);\n }\n const missing = spec.required.filter((k) => data[k] === undefined || data[k] === '');\n if (missing.length) fail(`event ${type} is missing ${missing.join(', ')}`);\n const unknown = Object.keys(data).filter((k) => !spec.required.includes(k) && !spec.optional.includes(k));\n if (unknown.length) fail(`event ${type} has unknown fields ${unknown.join(', ')}`);\n if (type === 'issue') {\n data.severity ??= 'medium';\n if (!['high', 'medium', 'low'].includes(data.severity)) fail('issue severity must be high, medium or low');\n data.resolved ??= false;\n data.user_had_to_intervene ??= false;\n }\n // A bad line in events.jsonl would fail every later report and run-start.\n validate({ ...data, logged_at: now() }, eventSchema(type), `event ${type}`);\n const state = loadState(ws);\n const run = currentRun(ws, state);\n appendFileSync(ws.eventsPath, `${JSON.stringify({ ts: now(), run: run.id, type, ...data })}\\n`);\n console.log(`${type} logged`);\n}\n\nconst minutesBetween = (a, b) => (a && b ? Math.round(((Date.parse(b) - Date.parse(a)) / 60000) * 10) / 10 : null);\n\nfunction frontendStack(dir) {\n // Framework is recorded in state.json by the frontend step.\n const state = readJson(join(dir, 'state.json'), {});\n const fe = state.frontend ?? {};\n const framework = fe.framework ?? fe.stack ?? null;\n if (framework) return fe.rendering ? `${framework} (${fe.rendering})` : framework;\n // Fallback: an older build may have a design.md with a \"## Stack\" heading.\n const design = existsSync(join(dir, 'design.md')) ? readFileSync(join(dir, 'design.md'), 'utf8') : '';\n return design.match(/## Stack\\s*\\n\\s*\\*\\*([^*]+)\\*\\*/)?.[1] ?? null;\n}\n\nfunction buildReport(ws, { outcome, tokens } = {}) {\n const state = loadState(ws);\n const run = currentRun(ws, state);\n const site = readJson(join(ws.dir, 'site.json'), {});\n const deploy = readJson(join(ws.dir, 'deploy.json'), {});\n const connect = readJson(join(ws.dir, 'connect.json'), {});\n const history = (state.history ?? []).filter((h) => h.run === run.id);\n // Lines logged before events were checked on write may not match the schema.\n const dropped = [];\n const events = readEvents(ws)\n .filter((e) => e.run === run.id)\n .filter((e) => {\n const { ts, run: _run, type, ...rest } = e;\n const problem = schemaError({ ...rest, logged_at: ts }, eventSchema(type), 'event');\n if (problem) dropped.push({ stage: typeof e.stage === 'string' ? e.stage : 'unknown', phase: null, problem: `left out a malformed ${type} event (${problem})` });\n return !problem;\n });\n const resolvedOutcome = outcome ?? (history.some((h) => h.stage === 'deploy' && h.result?.startsWith('ok')) ? 'live' : 'stopped');\n // A run ends at its last logged activity, so re-running `report` later (after fixing warnings, or\n // when the next run-start closes an abandoned run) doesn't stretch it to \"now\". Only a stage still\n // open on a live run keeps the clock running.\n const lastActivity = [run.started_at, ...history.flatMap((h) => [h.started_at, h.ended_at]), ...events.map((e) => e.ts)]\n .filter(Boolean)\n .sort()\n .at(-1);\n const stillOpen = resolvedOutcome !== 'abandoned' && history.some((h) => !h.ended_at);\n const endedAt = stillOpen ? now() : lastActivity;\n const stages = history.map((h) => ({ ...h, minutes: minutesBetween(h.started_at, h.ended_at ?? endedAt) }));\n const gaps = [...loggingGaps(stages, run, resolvedOutcome), ...dropped];\n const strip = ({ ts, run: _run, type: _type, ...rest }) => ({ ...rest, logged_at: ts });\n const liveStage = stages.find((s) => s.stage === 'deploy' && s.result?.startsWith('ok'));\n if (!OUTCOMES.includes(resolvedOutcome)) fail(`unknown outcome \"${resolvedOutcome}\"; one of ${OUTCOMES.join(', ')}`);\n\n const previous = existsSync(ws.reportsDir)\n ? readdirSync(ws.reportsDir)\n .filter((f) => f.endsWith('.json') && f !== `${run.id}.json`)\n .map((f) => readJson(join(ws.reportsDir, f), {}))\n .sort((a, b) => `${a.timing?.started_at}${a.run?.id}`.localeCompare(`${b.timing?.started_at}${b.run?.id}`))\n .map((r) => ({ run: r.run?.id, kind: r.run?.kind, outcome: r.info?.outcome, minutes: r.timing?.minutes_total, html: `${r.run?.id}.html` }))\n : [];\n\n const report = {\n schema_version: '1',\n generated_at: now(),\n // Intent, not delivery: delivery is tracked in state.json.\n consent: { sent: true, note: 'Sent to Hostinger automatically at the end of the run.' },\n run: { id: run.id, kind: run.kind, skill: run.skill, follow_ups: run.follow_ups ?? [] },\n info: {\n domain: ws.domain,\n tool: run.tool ?? connect.tool ?? null,\n model: run.model ?? null,\n os: process.platform,\n node: process.version,\n mcp: connect.mcp ?? null,\n backend: site.backend ?? null,\n frontend: frontendStack(ws.dir),\n outcome: resolvedOutcome,\n stopped_at: resolvedOutcome === 'live' ? null : stoppedAt(stages, state),\n live_url: resolvedOutcome === 'live' ? deploy.urls?.site ?? site.urls?.site ?? null : null,\n admin_url: site.urls?.wp_admin ?? null,\n },\n timing: {\n started_at: run.started_at,\n ended_at: endedAt,\n live_at: liveStage?.ended_at ?? null,\n minutes_total: minutesBetween(run.started_at, endedAt),\n minutes_to_live: liveStage ? minutesBetween(run.started_at, liveStage.ended_at) : null,\n stages: stages.map(({ run: _run, ...s }) => s),\n },\n // Not null: the MCP layer treats a null required field as missing, and the API reads {} as an array.\n tokens: tokens ?? { source: 'not reported' },\n issues: events.filter((e) => e.type === 'issue').map(strip),\n user_feedback: events.filter((e) => e.type === 'feedback').map(strip),\n skill_gaps: events.filter((e) => e.type === 'skill_gap').map(strip),\n what_went_well: events.filter((e) => e.type === 'good').map(strip),\n logging_gaps: gaps,\n previous_runs: previous,\n };\n\n validate(report, reportSchema());\n mkdirSync(ws.reportsDir, { recursive: true });\n writeJson(join(ws.reportsDir, `${run.id}.json`), report);\n writeFileSync(join(ws.reportsDir, `${run.id}.html`), renderHtml(report));\n copyFileSync(join(ws.reportsDir, `${run.id}.html`), join(ws.dir, 'report.html'));\n\n const fresh = loadState(ws);\n fresh.run.reported_at = now();\n saveState(ws, fresh);\n for (const g of gaps) console.log(`warning: ${g.stage}${g.phase ? `/${g.phase}` : ''}: ${g.problem}`);\n return join(ws.dir, 'report.html');\n}\n\n// A report counts as sent only while the sent copy's generated_at matches the file.\nfunction reportFile(ws, runId) {\n const id = runId ?? currentRun(ws, loadState(ws)).id;\n const file = join(ws.reportsDir, `${id}.json`);\n if (!existsSync(file)) fail(`no report for run ${id} at ${file}`);\n return { id, file, generatedAt: readJson(file, {}).generated_at };\n}\n\nfunction unsent(ws) {\n const state = loadState(ws);\n const files = existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).filter((f) => f.endsWith('.json')).sort() : [];\n // Older reports promised \"nothing is uploaded\".\n const pending = files\n .map((f) => {\n const report = readJson(join(ws.reportsDir, f), {});\n return { id: f.replace(/\\.json$/, ''), file: join(ws.reportsDir, f), generatedAt: report.generated_at, consent: report.consent };\n })\n .filter(({ consent }) => consent?.sent !== false)\n .filter(({ id, generatedAt }) => state.reports_sent?.[id] !== generatedAt && state.reports_rejected?.[id] !== generatedAt);\n if (!pending.length) console.log('no unsent reports');\n for (const { id, file } of pending) console.log(`${id} ${file}`);\n}\n\nfunction reportSent(ws, [runId]) {\n const { id, generatedAt } = reportFile(ws, runId);\n const state = loadState(ws);\n state.reports_sent = { ...state.reports_sent, [id]: generatedAt };\n saveState(ws, state);\n console.log(`report ${id} marked sent`);\n}\n\n// A 422 is schema drift, so resending won't help.\nfunction reportRejected(ws, [runId]) {\n const { id, generatedAt } = reportFile(ws, runId);\n const state = loadState(ws);\n state.reports_rejected = { ...state.reports_rejected, [id]: generatedAt };\n saveState(ws, state);\n const run = currentRun(ws, state);\n const symptom = `build report ${id} rejected with 422 by hosting_headless_submit-build-report`;\n appendFileSync(ws.eventsPath, `${JSON.stringify({ ts: now(), run: run.id, type: 'issue', stage: run.reported_at ? 'done' : 'check_state', symptom, severity: 'medium', resolved: false, user_had_to_intervene: false })}\\n`);\n console.log(`report ${id} marked rejected; issue logged in run ${run.id}`);\n}\n\n// Gaps are printed as warnings by `report`, so guesses never pass as measurements.\nconst EXPECTED_FOR_BUILD = ['check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy'];\nfunction loggingGaps(stages, run, outcome) {\n const gaps = [];\n for (const s of stages) {\n if (!s.ended_at) gaps.push({ stage: s.stage, phase: s.phase, problem: 'started but never ended' });\n else if (s.start_estimated) gaps.push({ stage: s.stage, phase: s.phase, problem: 'start time estimated, not measured' });\n else if (Date.parse(s.ended_at) - Date.parse(s.started_at) < 5000) gaps.push({ stage: s.stage, phase: s.phase, problem: 'logged with no duration; probably started and ended in one go after the work' });\n }\n if (run.kind === 'build' && outcome === 'live') {\n for (const stage of EXPECTED_FOR_BUILD) {\n if (!stages.some((s) => s.stage === stage)) gaps.push({ stage, phase: null, problem: 'never logged, though the site went live' });\n }\n }\n return gaps;\n}\n\nfunction stoppedAt(stages, state) {\n const stuck = [...stages].reverse().find((s) => !s.ended_at || s.result === 'failed' || s.result === 'stopped');\n return stuck?.stage ?? state.stage ?? null;\n}\n\n// Minimal JSON Schema subset, so reports from different tools share one shape without a dependency.\nconst reportSchema = () => readJson(join(here, 'report.schema.json'), null);\nconst eventSchema = (type) => reportSchema()?.properties?.[EVENT_LISTS[type]]?.items ?? null;\n\nfunction schemaError(value, schema, path = 'report') {\n if (!schema) return null;\n const types = [].concat(schema.type ?? []);\n const actual = value === null ? 'null' : Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value;\n if (types.length && !types.includes(actual) && !(actual === 'integer' && types.includes('number'))) {\n return `${path} should be ${types.join(' or ')}, got ${actual}`;\n }\n if (schema.enum && !schema.enum.includes(value)) return `${path} should be one of ${schema.enum.join(', ')}`;\n if (actual === 'object') {\n for (const key of schema.required ?? []) if (!(key in value)) return `${path}.${key} is missing`;\n for (const [key, sub] of Object.entries(schema.properties ?? {})) {\n const error = key in value ? schemaError(value[key], sub, `${path}.${key}`) : null;\n if (error) return error;\n }\n }\n if (actual === 'array' && schema.items) {\n for (const [i, item] of value.entries()) {\n const error = schemaError(item, schema.items, `${path}[${i}]`);\n if (error) return error;\n }\n }\n return null;\n}\n\nfunction validate(value, schema, path) {\n const error = schemaError(value, schema, path);\n if (error) fail(error);\n}\n\nconst esc = (v) => String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"');\nconst label = (s) => String(s ?? '').replace(/_/g, ' ');\nconst time = (iso) => (iso ? iso.slice(11, 19) : '—');\nconst fmt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : '—');\n\nfunction gantt(report) {\n const stages = report.timing.stages;\n if (!stages.length) return '<p class=\"muted\">No stages were logged for this run.</p>';\n const t0 = Date.parse(report.timing.started_at);\n const at = (iso) => (Date.parse(iso) - t0) / 60000;\n const total = Math.max(report.timing.minutes_total ?? 1, ...stages.map((s) => at(s.ended_at ?? report.timing.ended_at)), 1);\n const step = [1, 2, 5, 10, 15, 30, 60, 120, 240].find((s) => total / s <= 8) ?? 480;\n const span = Math.ceil(total / step) * step;\n const W = 760;\n const LABEL = 160;\n const ROW = 32;\n const TOP = 32;\n const H = TOP + ROW * stages.length + 32;\n const x = (m) => LABEL + ((W - LABEL - 40) * m) / span;\n const out = [`<svg viewBox=\"0 0 ${W} ${H}\" role=\"img\" aria-labelledby=\"gantt-title\" class=\"gantt\"><title id=\"gantt-title\">Stage timeline, minutes from the start of the run</title>`];\n for (let m = 0; m <= span; m += step) {\n out.push(`<line x1=\"${x(m).toFixed(1)}\" x2=\"${x(m).toFixed(1)}\" y1=\"${TOP - 8}\" y2=\"${H - 28}\" class=\"grid\"/><text x=\"${x(m).toFixed(1)}\" y=\"${H - 10}\" class=\"tick\" text-anchor=\"middle\">${m} min</text>`);\n }\n for (const f of report.run.follow_ups ?? []) {\n const fx = x(at(f.resumed_at)).toFixed(1);\n out.push(`<line x1=\"${fx}\" x2=\"${fx}\" y1=\"${TOP - 12}\" y2=\"${H - 28}\" class=\"follow-up\"/><text x=\"${Number(fx) + 6}\" y=\"${TOP - 12}\" class=\"follow-up-label\">Follow-up</text>`);\n }\n if (report.timing.live_at) {\n const lx = x(at(report.timing.live_at)).toFixed(1);\n out.push(`<line x1=\"${lx}\" x2=\"${lx}\" y1=\"${TOP - 12}\" y2=\"${H - 28}\" class=\"live\"/><text x=\"${lx - 6}\" y=\"${TOP - 12}\" class=\"live-label\" text-anchor=\"end\">Live · ${report.timing.minutes_to_live} min</text>`);\n }\n stages.forEach((s, i) => {\n const y = TOP + i * ROW;\n const a = at(s.started_at);\n const b = at(s.ended_at ?? report.timing.ended_at);\n const width = Math.max(x(b) - x(a), 4);\n const name = `${label(s.stage)}${s.phase ? ` · ${s.phase}` : ''}`;\n const tip = [name, `${s.minutes} min`, s.result ? label(s.result) : 'not finished', s.start_estimated ? 'start estimated' : null, s.note].filter(Boolean).join(' · ');\n const failed = s.result === 'failed' || s.result === 'stopped' || !s.ended_at;\n const fill = failed ? 'url(#hatch)' : s.start_estimated ? 'none' : 'var(--series-1)';\n out.push(`<text x=\"${LABEL - 12}\" y=\"${(y + ROW / 2 + 4).toFixed(1)}\" class=\"row-label\" text-anchor=\"end\">${esc(name)}</text>`);\n out.push(`<g class=\"bar\" tabindex=\"0\" data-tip=\"${esc(tip)}\"><rect x=\"${LABEL}\" y=\"${y}\" width=\"${W - LABEL - 16}\" height=\"${ROW}\" fill=\"transparent\"/><rect x=\"${x(a).toFixed(1)}\" y=\"${y + 8}\" width=\"${width.toFixed(1)}\" height=\"${ROW - 16}\" rx=\"4\" fill=\"${fill}\"${s.start_estimated && !failed ? ' stroke=\"var(--series-1)\" stroke-width=\"2\" stroke-dasharray=\"4 3\"' : ''}/></g>`);\n });\n out.push('<defs><pattern id=\"hatch\" width=\"6\" height=\"6\" patternUnits=\"userSpaceOnUse\" patternTransform=\"rotate(45)\"><rect width=\"6\" height=\"6\" fill=\"var(--series-1)\"/><line x1=\"0\" y1=\"0\" x2=\"0\" y2=\"6\" stroke=\"var(--surface-1)\" stroke-width=\"2.5\"/></pattern></defs></svg>');\n return out.join('');\n}\n\nfunction renderHtml(r) {\n const tile = (name, value, sub = '') => `<div class=\"tile\"><div class=\"tile-label\">${esc(name)}</div><div class=\"tile-value\">${esc(value)}</div><div class=\"tile-sub\">${esc(sub)}</div></div>`;\n const stepped = r.issues.filter((i) => i.user_had_to_intervene).length;\n const rejected = r.user_feedback.filter((f) => f.category === 'design_rejected').length;\n const t = r.tokens && ['input', 'output', 'cache_read', 'cache_write', 'est_cost_usd'].some((k) => r.tokens[k] != null) ? r.tokens : null;\n const tiles = [\n r.timing.minutes_to_live != null\n ? tile('Time to live', `${r.timing.minutes_to_live} min`, 'start of run → deploy finished')\n : tile('Outcome', label(r.info.outcome), r.info.stopped_at ? `stopped at ${label(r.info.stopped_at)}` : ''),\n tile('Run length', `${r.timing.minutes_total} min`, `${r.timing.stages.length} stage entries`),\n tile('Issues', String(r.issues.length), `${stepped} needed the user to step in`),\n tile('User feedback', String(r.user_feedback.length), rejected ? `${rejected} design rejections` : 'corrections and requests'),\n t ? tile('Tokens', fmt((t.input ?? 0) + (t.output ?? 0) + (t.cache_read ?? 0) + (t.cache_write ?? 0)), t.est_cost_usd != null ? `≈ $${t.est_cost_usd} API-equivalent` : t.source ?? '') : tile('Tokens', '—', 'not reported by this tool'),\n ].join('');\n const SEV = { high: ['●', 'critical', 'High'], medium: ['▲', 'serious', 'Medium'], low: ['■', 'warning', 'Low'] };\n const order = ['high', 'medium', 'low'];\n const issues = [...r.issues]\n .sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity))\n .map((i) => {\n const [icon, cls, name] = SEV[i.severity];\n return `<tr><td><span class=\"sev sev-${cls}\"><span aria-hidden=\"true\">${icon}</span> ${name}</span></td><td>${esc(label(i.stage))}</td><td>${esc(i.symptom)}${i.user_had_to_intervene ? ' <span class=\"chip\">user had to step in</span>' : ''}</td><td>${esc(i.cause)}</td><td>${esc(i.workaround)}</td><td>${i.resolved ? '✓ Resolved' : 'Open'}</td></tr>`;\n })\n .join('');\n const list = (items, render, empty) => (items.length ? `<ul>${items.map(render).join('')}</ul>` : `<p class=\"muted\">${empty}</p>`);\n const stageRows = r.timing.stages\n .map((s) => `<tr><td>${esc(label(s.stage))}${s.phase ? ` · ${esc(s.phase)}` : ''}</td><td>${time(s.started_at)}</td><td>${time(s.ended_at)}</td><td class=\"num\">${s.minutes ?? '—'}</td><td>${esc(label(s.result ?? 'not finished'))}</td><td>${esc(s.note)}</td></tr>`)\n .join('');\n const details = [\n ['Domain', r.info.domain],\n ['Outcome', [label(r.info.outcome), r.info.live_url].filter(Boolean).join(' · ')],\n ['Run', `${r.run.id} · ${r.run.kind}`],\n ['Tool / model', [r.info.tool, r.info.model].filter(Boolean).join(' · ') || '—'],\n ['Stack', [r.info.backend, r.info.frontend].filter(Boolean).join(' + ') || '—'],\n ['Environment', `${r.info.os} · Node ${r.info.node}${r.info.mcp ? ` · ${r.info.mcp}` : ''}`],\n ['Skill version', r.run.skill ?? '—'],\n ...(r.run.follow_ups?.length\n ? [['Follow-ups', r.run.follow_ups.map((f) => `resumed ${time(f.resumed_at)}${f.reason ? ` — ${f.reason}` : ''}`).join('; ')]]\n : []),\n ]\n .map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`)\n .join('');\n const tokenTable = t\n ? `<section><h2>Tokens</h2><div class=\"table-wrap\"><table><thead><tr><th class=\"num\">Requests</th><th class=\"num\">Input</th><th class=\"num\">Output</th><th class=\"num\">Cache read</th><th class=\"num\">Cache write</th><th class=\"num\">≈ API cost</th></tr></thead><tbody><tr><td class=\"num\">${fmt(t.requests)}</td><td class=\"num\">${fmt(t.input)}</td><td class=\"num\">${fmt(t.output)}</td><td class=\"num\">${fmt(t.cache_read)}</td><td class=\"num\">${fmt(t.cache_write)}</td><td class=\"num\">${t.est_cost_usd != null ? `$${t.est_cost_usd}` : '—'}</td></tr></tbody></table></div><p class=\"muted\">${esc(t.source ?? '')}</p></section>`\n : '';\n const previous = r.previous_runs.length\n ? `<section><h2>Earlier runs on this domain</h2><ul>${r.previous_runs.map((p) => `<li><a href=\"${esc(p.html)}\">${esc(p.run)}</a> — ${esc(p.kind)} · ${esc(label(p.outcome))}${p.minutes != null ? ` · ${p.minutes} min` : ''}</li>`).join('')}</ul></section>`\n : '';\n\n return `<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Build report — ${esc(r.info.domain)}</title>\n<style>\n.viz-root { color-scheme: light; --surface-0:#f4f4f2; --surface-1:#fcfcfb; --border:#e2e1dc; --text-primary:#0b0b0b; --text-secondary:#52514e; --text-muted:#6b6a65; --series-1:#2a78d6; --grid:#e8e7e2; --warning:#fab219; --serious:#ec835a; --critical:#d03b3b; }\n@media (prefers-color-scheme: dark) { .viz-root { color-scheme: dark; --surface-0:#111110; --surface-1:#1a1a19; --border:#33332f; --text-primary:#f0efec; --text-secondary:#c3c2b7; --text-muted:#a3a299; --series-1:#3987e5; --grid:#2a2a27; } }\n* { box-sizing: border-box; }\nbody { margin:0; background:var(--surface-0); color:var(--text-primary); font:15px/1.5 system-ui,-apple-system,\"Segoe UI\",sans-serif; }\nmain { max-width:1080px; margin:0 auto; padding:32px 24px 64px; }\nh1 { font-size:28px; line-height:1.2; margin:0 0 4px; } h2 { font-size:18px; margin:0 0 16px; }\na { color:var(--series-1); }\n.lede { color:var(--text-secondary); margin:0 0 24px; }\nsection { background:var(--surface-1); border:1px solid var(--border); border-radius:12px; padding:24px; margin-bottom:24px; }\n.tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:16px; margin-bottom:24px; }\n.tile { background:var(--surface-1); border:1px solid var(--border); border-radius:12px; padding:16px; }\n.tile-label { color:var(--text-secondary); font-size:13px; } .tile-value { font-size:28px; font-weight:650; line-height:1.3; font-variant-numeric:tabular-nums; } .tile-sub { color:var(--text-muted); font-size:13px; }\ndl { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:12px 24px; margin:0; } dt { color:var(--text-muted); font-size:13px; } dd { margin:0; overflow-wrap:anywhere; }\n.gantt { width:100%; height:auto; display:block; } .grid { stroke:var(--grid); stroke-width:1; } .tick { fill:var(--text-secondary); font-size:12px; } .row-label { fill:var(--text-primary); font-size:13px; text-transform:capitalize; }\n.live { stroke:var(--text-primary); stroke-width:2; stroke-dasharray:4 4; } .follow-up { stroke:var(--text-muted); stroke-width:1; stroke-dasharray:2 4; } .follow-up-label { fill:var(--text-muted); font-size:12px; } .live-label { fill:var(--text-primary); font-size:12px; font-weight:600; }\n.bar { outline:none; } .bar:hover rect:last-child, .bar:focus rect:last-child { stroke:var(--text-primary); stroke-width:2; }\n.legend { display:flex; gap:24px; flex-wrap:wrap; color:var(--text-secondary); font-size:13px; margin-top:8px; } .swatch { display:inline-block; width:24px; height:12px; border-radius:4px; vertical-align:-1px; margin-right:6px; }\n.sw-solid { background:var(--series-1); } .sw-est { border:2px dashed var(--series-1); } .sw-hatch { background:repeating-linear-gradient(45deg,var(--series-1) 0 3px,var(--surface-1) 3px 6px); }\n#tip { position:fixed; pointer-events:none; background:var(--text-primary); color:var(--surface-1); font-size:13px; padding:8px 12px; border-radius:8px; max-width:340px; opacity:0; transition:opacity .1s; z-index:5; }\ntable { width:100%; border-collapse:collapse; font-size:14px; } th,td { text-align:left; padding:8px 12px; border-bottom:1px solid var(--border); vertical-align:top; } th { color:var(--text-secondary); font-weight:600; font-size:13px; }\ntd.num,th.num { text-align:right; font-variant-numeric:tabular-nums; } .table-wrap { overflow-x:auto; }\n.sev { white-space:nowrap; font-weight:600; } .sev-critical span { color:var(--critical); } .sev-serious span { color:var(--serious); } .sev-warning span { color:var(--warning); }\n.chip { display:inline-block; font-size:12px; padding:0 8px; border-radius:999px; border:1px solid var(--border); color:var(--text-secondary); white-space:nowrap; }\n.cols { display:grid; grid-template-columns:repeat(auto-fit,minmax(320px,1fr)); gap:24px; margin-bottom:24px; } .cols section { margin:0; }\nul { margin:0; padding-left:20px; } li { margin-bottom:8px; } .muted { color:var(--text-muted); font-size:13px; }\ncode { font-size:13px; background:var(--surface-0); padding:1px 4px; border-radius:4px; }\ndetails summary { cursor:pointer; color:var(--text-secondary); margin-top:16px; }\n</style></head><body class=\"viz-root\"><main>\n<h1>Build report — ${esc(r.info.domain)}</h1>\n<p class=\"lede\">${esc(label(r.run.kind))} run · ${esc(r.timing.started_at.slice(0, 16).replace('T', ' '))} UTC · ${esc(label(r.info.outcome))}</p>\n<div class=\"tiles\">${tiles}</div>\n<section><h2>Stage timeline</h2>${gantt(r)}\n<div class=\"legend\"><span><span class=\"swatch sw-solid\"></span>Finished</span><span><span class=\"swatch sw-hatch\"></span>Failed, stopped or not finished</span><span><span class=\"swatch sw-est\"></span>Start estimated</span><span>Stages can overlap: prep work runs while provisioning settles.</span></div>\n<details><summary>Show as table</summary><div class=\"table-wrap\"><table><thead><tr><th>Stage</th><th>Start</th><th>End</th><th class=\"num\">Minutes</th><th>Result</th><th>Note</th></tr></thead><tbody>${stageRows}</tbody></table></div></details></section>\n${r.logging_gaps.length ? `<section><h2>Logging gaps (${r.logging_gaps.length})</h2><p class=\"muted\">Where the run log is missing or unsure, so times here are less reliable.</p><ul>${r.logging_gaps.map((g) => `<li><strong>${esc(label(g.stage))}${g.phase ? ` · ${esc(g.phase)}` : ''}</strong> — ${esc(g.problem)}</li>`).join('')}</ul></section>` : ''}\n<section><h2>Issues (${r.issues.length})</h2>${r.issues.length ? `<div class=\"table-wrap\"><table><thead><tr><th>Severity</th><th>Stage</th><th>Symptom</th><th>Cause</th><th>Workaround</th><th>Status</th></tr></thead><tbody>${issues}</tbody></table></div>` : '<p class=\"muted\">No issues logged.</p>'}</section>\n<div class=\"cols\"><section><h2>User feedback</h2>${list(r.user_feedback, (f) => `<li><span class=\"chip\">${esc(label(f.category))}</span> <span class=\"muted\">${esc(label(f.stage))}</span> — “${esc(f.paraphrase)}”</li>`, 'No feedback logged.')}</section>\n<section><h2>Skill gaps</h2>${list(r.skill_gaps, (g) => `<li><code>${esc(g.skill)}</code> — ${esc(g.gap)}</li>`, 'No skill gaps logged.')}</section></div>\n<div class=\"cols\"><section><h2>What went well</h2>${list(r.what_went_well, (g) => `<li>${esc(g.text)}</li>`, 'Nothing logged.')}</section>\n<section><h2>Run details</h2><dl>${details}</dl></section></div>\n${tokenTable}${previous}\n</main><div id=\"tip\" role=\"status\"></div>\n<script>\nconst tip = document.getElementById('tip');\nconst show = (el, x, y) => { tip.textContent = el.dataset.tip; tip.style.left = Math.min(x + 12, innerWidth - 360) + 'px'; tip.style.top = (y + 12) + 'px'; tip.style.opacity = 1; };\ndocument.querySelectorAll('.bar').forEach((el) => {\n el.addEventListener('mousemove', (e) => show(el, e.clientX, e.clientY));\n el.addEventListener('mouseleave', () => (tip.style.opacity = 0));\n el.addEventListener('focus', () => { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); });\n el.addEventListener('blur', () => (tip.style.opacity = 0));\n});\n</script></body></html>\n`;\n}\n\nconst { positional, flags } = parseArgs(process.argv.slice(2));\nconst [domain, command, ...rest] = positional;\nconst ws = workspace(domain);\nswitch (command) {\n case 'run-start':\n runStart(ws, flags);\n break;\n case 'run-update':\n runUpdate(ws, flags);\n break;\n case 'run-resume':\n runResume(ws, flags);\n break;\n case 'stage-start':\n stageStart(ws, rest, flags);\n break;\n case 'stage-end':\n stageEnd(ws, rest, flags);\n break;\n case 'stage-fix':\n stageFix(ws, rest, flags);\n break;\n case 'event':\n event(ws, rest);\n break;\n case 'report': {\n let tokens;\n if (typeof flags.tokens === 'string') {\n try {\n tokens = JSON.parse(flags.tokens);\n } catch {\n fail('--tokens must be JSON, e.g. \\'{\"input\":0,\"output\":0,\"source\":\"…\"}\\'');\n }\n // null and {} can't be sent; treat them as not reported.\n if (tokens === null || (typeof tokens === 'object' && !Array.isArray(tokens) && !Object.keys(tokens).length)) tokens = undefined;\n else validate(tokens, { ...reportSchema()?.properties?.tokens, type: 'object' }, '--tokens');\n }\n console.log(`report written: ${buildReport(ws, { outcome: flags.outcome, tokens })}`);\n const { id, file } = reportFile(ws);\n console.log(`send it: pass ${file} unchanged to hosting_headless_submit-build-report, then run report-sent ${id} (or report-rejected ${id} on a 422)`);\n break;\n }\n case 'unsent':\n unsent(ws);\n break;\n case 'report-sent':\n reportSent(ws, rest);\n break;\n case 'report-rejected':\n reportRejected(ws, rest);\n break;\n default:\n fail('command must be run-start, run-update, run-resume, stage-start, stage-end, stage-fix, event, report, unsent, report-sent or report-rejected');\n}\n"
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"uri": "skill://hostinger-headless/steps/01-check-state/SKILL.md",
|
|
151
|
+
"digest": "sha256:d0e87064e05b01c8cbca7cdbf8cc6ce68093d73e950863e702dfac43c89553f7",
|
|
152
|
+
"size": 10385,
|
|
153
|
+
"mimeType": "text/markdown",
|
|
154
|
+
"text": "---\nname: headless-step-check-state\ndescription: \"First build step of the Hostinger Headless flow, run right after connect. Works out where a given domain's build stands — is a run already in progress, does the site already exist, how far did it get — by reconciling the global workspace state under ~/.hostinger/<domain>/ with what the Hostinger connector reports, then guides the user to continue, switch or buy a domain, or start over. Owns all resume/position logic for the flow.\"\n---\n\n# headless-step-check-state\n\nThe first build step of the Hostinger Headless flow (orchestrated by `hostinger.com/ai/skill.md`),\nrun right after connect. Connect has already connected the Hostinger connector and confirmed the\naccount can build. Your job here is to\n**figure out where this domain stands and agree the route with the user** before any new work begins.\nThis step owns all resume/position logic — no other stage second-guesses it.\n\nNothing in this step creates, provisions, deploys, or deletes anything. It reads and guides.\n\n---\n\n## Where state lives\n\nEverything for a domain lives under the global site workspace **`~/.hostinger/<domain>/`** — one\ndirectory per domain, under the user's home (`~` = `$HOME`), **not** the current working directory.\nThe directory name **is** the domain, so `state.json` inside does not store it. Because it's global,\n`~/.hostinger/` holds every site the user has built and each one is discoverable and resumable from\nany directory — no collisions.\n\n`state.json` shape (`stage` is the *next* stage to run; `provision` is the background task's status,\npresent only once provision has fired):\n\n```json\n{ \"tool\": \"claude-code\", \"stage\": \"content_model\",\n \"provision\": { \"status\": \"in_progress\", \"domain\": \"<domain>\", \"started_at\": \"…\" },\n \"updated_at\": \"…\" }\n```\n\n`provision.status` is `in_progress` or `completed` — but it is a **hint**: always re-verify it against\n`hosting_websites_list-setups` (Step 1) before trusting it to gate an instance-barrier step.\n\n---\n\n## Step 0 — Start the run log\n\nBefore anything else, start a run and the stage (see *Run log and build report* in the top-level\n`SKILL.md`):\n\n```bash\nnode <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version>\nnode <skill>/run-log.mjs <domain> stage-start check_state\n```\n\nIf an earlier run on this domain never wrote its report, `run-start` closes it as `abandoned` first.\n\nThen send any build reports that haven't reached Hostinger yet (that abandoned run, or a send that\nfailed last time):\n\n```bash\nnode <skill>/run-log.mjs <domain> unsent\n```\n\nEach line is `<run> <path>`. For each, send the file through `hosting_headless_submit-build-report`\nand record the result, as described in *Run log and build report* in the top-level `SKILL.md`. One\ntry each. Don't ask first; if any went out, say so in one line (e.g. \"Sent 1 earlier build report to\nHostinger.\"). Don't let a failure hold up this step.\nCheck-state runs once per session. A follow-up after the final report, in the same session, doesn't\ncome back here: it calls `run-resume` (see *Run log and build report* in the top-level `SKILL.md`).\n\n## Step 1 — Is a build actively running right now? (check this before anything else)\n\nKeep two things separate, and **never guess from a timestamp.**\n\n- **A platform build actively in progress** — the only *reliable* \"running now\" signal. Check\n `hosting_websites_list-setups { \"domain\": \"<domain>\" }` and exact-match the domain row:\n - `status: running` → provisioning is underway **right now** (a background provision from an earlier\n turn). Don't start a second one — reconcile `state.json`'s `provision` to `in_progress`, and\n either resume prepare work while it finishes or poll it to `completed` at the barrier.\n - `status: failed` → not running; route to provision to self-heal (Step 3).\n - `completed` → provision is done; set `provision.status` to `completed`. Instance-barrier steps\n (content push, frontend data, deploy) are now unblocked.\n - no row → not provisioned yet; carry on.\n\n This check **is** the source of truth for `provision.status`: a stored `in_progress`/`completed` in\n `state.json` is only a hint until this reconciles it.\n\n- **A `.lock`** — `~/.hostinger/<domain>/.lock`. It only tells you *a previous run\n touched this domain*. **Do not judge it by age** — a 2-minute-old lock and a 3-year-old lock get the\n same treatment, and a lock on its own is **not** a reason to interrupt the user. Carry it into Step 5:\n if there's nothing to resume (new build), silently take it over; only if there's real state to resume\n do you also ask *is another Claude window building this domain right now?* (yes → stop; no → take\n over).\n\nOnce you're actively working this domain, **acquire/refresh the lock** (write the current timestamp),\nand **release it on a clean stop** (Step 6). The lock marks an active session, nothing more.\n\n---\n\n## Step 2 — Read the workspace directory\n\nLook at `~/.hostinger/<domain>/`:\n\n- **Absent** → no history for this domain in the workspace.\n- **Present** → read `state.json` (its `stage`) and whatever artifacts exist (`brief.md`,\n `site.json`, …). Treat these as a hint, not the truth.\n\n---\n\n## Step 3 — Verify against reality (reality wins)\n\nQuery the Hostinger connector — the local files are never trusted on their own:\n\n- **Does the site exist?** `hosting_websites_list { \"domain\": \"<domain>\" }`. ⚠️ This filter is a\n **case-insensitive substring match**, not exact — so `test.com` would also return\n `shop.test.com`. **Compare each returned row's `domain` to your target for an *exact* match**\n (case-insensitive) and ignore the rest. No exact row ⇒ the domain is free. An exact row ⇒ the site\n exists; note its `website_type` (`headless`, `wordpress`, `nodejs`, …) and `username`.\n- **How far did it get?** If the site exists, check its setup via\n `hosting_websites_list-setups { \"domain\": \"<domain>\" }` (exact-match the row the same way):\n - `status: completed` → provisioned; then, depending on backend, check whether a build has succeeded.\n - `status: running` → still provisioning (see Step 1).\n - `status: failed` → provisioning stopped before finishing → route to **provision** to self-heal.\n\nReconcile with the local state. **When they disagree, reality wins** and the local `stage` is\ncorrected.\n\n---\n\n## Step 4 — Determine the position\n\nConnect always writes `state.json` (with `stage: check_state`) before this step runs, so local\nstate is never truly empty. **\"Local state: none\" here means only connect artifacts exist** —\n`connect.json` + a `state.json` still pointing at `check_state`/`refine`, and **no build artifacts\nyet** (`brief.md`, `site.json`, …).\n\n| Local state | Reality (domain) | Position |\n|---|---|---|\n| none (connect only) | free | **new build** — start from refine |\n| none (connect only) | site exists | **adopt / resume** — never create a second site; route to the first unfinished stage |\n| build artifacts present, incomplete | matches | **resume** at `state.json`'s `stage` |\n| build artifacts present | disagrees | **reality wins** — recompute the stage from what actually exists |\n| build artifacts present, all done | live site exists | **finished** — this is now an *iterate* request, not a build |\n\n---\n\n## Step 5 — Guide the user\n\nThe question to ask depends on whether there is actually **something to resume** — i.e. **build\nartifacts** (`brief.md`, `site.json`, …) or a **live site** for this domain. A stray `.lock` or\nconnect-only files are *not* something to resume.\n\n- **New build** (no build artifacts and the domain is free) → just proceed to refine. **Don't ask\n resume vs start over** — there's nothing to resume; both would land in the same place. If a leftover\n `.lock` is lying around, silently take it over and continue (optionally mention it in one line).\n- **Something to resume** (build artifacts exist, or a site exists for the domain) → tell the user\n plainly what's there and **ask them to choose**, regardless of how old it is (don't guess from\n timestamps):\n\n- **Resume** — pick up at the computed stage. (If a `.lock` is present in this case, also confirm no\n other window is actively building this domain right now.)\n- **Start over / redo a step** — only safe **before anything is provisioned**. If the site is already\n provisioned or live, you **cannot** re-create it: that is forward-only *iterate* (change content or\n design and redeploy), not a fresh build. Never re-provision or overwrite a live site; if the user\n insists on a clean slate for a live domain, explain the limitation and point them at hPanel.\n- **Switch domain** — they want a different domain. A new domain is a **new workspace**\n (`~/.hostinger/<other-domain>/`, with its own `src/`). Hand off to start that domain's run.\n- **Buy a domain** — they want a custom domain instead of the temporary one. Route to the domain\n purchase / `connect-domain` flow, then continue with the chosen domain.\n\nWhen the intent is ambiguous, **ask** — don't guess.\n\n---\n\n## Step 6 — Record and route\n\nWrite `~/.hostinger/<domain>/state.json` with the agreed next `stage` and a fresh timestamp. If the\nroute isn't a new build, record it with `run-log.mjs <domain> run-update --kind resume` (or\n`redesign` / `iterate`), then end the stage with `stage-end check_state ok --note \"<the route>\"`.\nThen:\n\n- **Handing straight to the next stage in this same session?** Keep the `.lock` (you are still\n actively running) and continue.\n- **Stopping and returning control to the user?** (e.g. the next stage isn't built yet, or you are\n waiting on them.) **Delete the `.lock`** — the session is no longer active, and a leftover lock just\n forces an unnecessary \"is another window running?\" question on the next run. Always release the lock\n on a clean stop; it exists only to mark an actively-running session. A stop is still the end of a\n run: give the final report and save the build report (`09-done`).\n\n---\n\n## Invariants\n\n- **One directory per domain.** The domain is the key; state files never repeat it.\n- **Never create a second site** on a domain that already has one.\n- **Never re-provision or overwrite a live site.** Recovery is forward-only.\n- **Reality beats local state**, always.\n- **When ambiguous, ask.**\n"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"uri": "skill://hostinger-headless/steps/02-refine/SKILL.md",
|
|
158
|
+
"digest": "sha256:7d50e5007a73d9e22a3b3428da3dfb39f36eacb749ef385ac2d86f4b2cfcd76d",
|
|
159
|
+
"size": 11005,
|
|
160
|
+
"mimeType": "text/markdown",
|
|
161
|
+
"text": "---\nname: headless-step-refine\ndescription: \"Step 2 (refine) of the Hostinger Headless flow. Turns the user's seed — often just a domain — into a brief through conversation: asks what they want to build (purpose, audience, tone, pages, products, must-haves; images, local files, links all welcome), recommends the backend that fits (WordPress, Ecommerce, WordPress + Ecommerce, or Webapp for a custom app/SPA), lets the user confirm it, then writes brief.md.\"\n---\n\n# headless-step-refine\n\nFind out what the user wants to build, guess which backend fits, and write it down. This is a\n**conversation, not a form**: the pasted prompt names a domain and nothing else, so everything about\nthe site comes from talking to the user.\n\n**Keep it short.** The only thing you must know is *what they are building* — enough to guess the\nbackend. Every other question is skippable: the more the user gives, the better the site, but never\nhold the build hostage to refinement. Fill gaps with sensible defaults and say so in the brief.\n\n**In:** the domain (the workspace directory name), plus anything the user already shared.\n**Out:** `~/.hostinger/<domain>/brief.md`.\n\nNothing in this step touches Hostinger. It only asks, decides and writes.\n\n---\n\n## Step 1 — Read the workspace, then the resume check\n\nRead everything already in `~/.hostinger/<domain>/` (`state.json`, `connect.json`, any notes, images\nor files the user dropped there) — it all counts as input.\n\nIf `brief.md` already exists for this domain, **don't overwrite it**. Show the user a three-line\nsummary (what the site is, its pages, the suggested backend) and ask: *keep it, or change something?*\nKeep → go to Step 6. Change → edit only what they asked for, then re-confirm (Step 5).\n\nIf the site is already provisioned (`site.json` exists), the backend is **fixed** — never change the\nSuggested backend on a provisioned site. Explain that switching backends means a new domain.\n\n## Step 2 — Gather what you already have\n\nBefore asking anything, collect what the user has already given you, so you don't ask for it again:\n\n- Anything in the pasted prompt beyond the domain (a sentence, a link, a screenshot).\n- Files in the current project that look like site material (copy, logos, product lists, images).\n- What the agent already knows about the user from local memory or this conversation.\n- The domain name itself — a hint only (`coral-pottery.com` suggests pottery), never a conclusion.\n\n## Step 3 — Ask\n\nIf Step 2 already answers *what are you building* and *will they sell*, skip straight to Step 4.\n\nOtherwise open with one line: *\"Tell me what you want to build — a sentence is enough. Links,\nscreenshots or files help too. Skip anything you don't want to answer.\"*\n\nAsk in **short rounds of 2–3 questions**, and adapt each round to the previous answers. If your tool\nhas a structured question UI (multiple choice with an \"other\" field), use it for the choice-shaped\nquestions. **At most 3 rounds**, and stop early as soon as you can guess the backend and the user\nseems done — \"just build it\", \"you decide\", or a skipped question means *use defaults*, not *ask\nagain*.\n\n**Round 1 — what it is (the only required round):**\n\n1. **What are you building?** The business or project, and what the site is for.\n2. **Will you sell anything on it?** Physical products, digital products, services with checkout —\n or nothing. *This decides the backend.* Skip it only when the answer is already unmistakable\n (\"an online shop for my candles\", \"my personal blog\"); when in doubt, ask.\n3. **Who is it for?** The audience, and how they'll find the site. *(skippable)*\n\n**Round 2 — what goes on it (optional, adapt to Round 1):**\n\n- **Content they'll publish over time** — blog posts, articles, news, recipes, portfolio pieces,\n case studies? How often? Or is it a handful of pages that rarely change?\n- **Pages** — propose a page list from what you know and ask them to add or remove.\n- If they sell: **what and how many** — a rough product count, categories, whether products have\n variants (size, colour), and the shipping regions.\n\n**Round 3 — how it should feel (optional, skip what you already know):**\n\nThis is the round that captures **all design input** — the design step reads it from `brief.md` to derive\nthe design directions, so it is **not** asked again later; gather it here:\n\n- **Reference & look** — a **site they like (URL)** and what to take from it, plus a few adjectives for\n the vibe. Screenshots welcome.\n- **Colours & brand** — brand/accent colours, logo, fonts, existing copy, photos. Note where they live.\n- **Must-haves and no-gos** — contact form, newsletter, bookings, languages, things to avoid.\n\nIf the user asks for something outside the scope (user accounts, member areas, bookings with a\ncalendar backend, user-generated content), say plainly that the headless flow doesn't support it yet\nand record it under **Out of scope** — don't promise it.\n\n## Step 4 — Recommend the backend\n\nPick the backend that fits what the user told you:\n\n| Backend | Choose when | What it gives the owner |\n|---|---|---|\n| **WordPress** | No selling. The owner publishes and edits content: pages, blog, articles, portfolio. | Headless WordPress at `/cms` — write and edit content in wp-admin. |\n| **Ecommerce** | Selling is the point, and the rest is a few static pages (About, Contact, FAQ, policies). No ongoing blog. | Hostinger Ecommerce store — products, collections, checkout, payments, shipping, orders. |\n| **WordPress + Ecommerce** | Selling **and** regular editorial content (a blog, guides, lookbooks, recipes) the owner keeps adding to. | Both: WordPress for content, the Ecommerce store for products and checkout. |\n| **Webapp** | A custom **app, SPA or web application** that brings its own code and data — a dashboard, tool, calculator, booking app, a project the user is deploying — **not** a content site or a store. No CMS and no store engine. | A plain Node.js app host (an empty website, no `/cms`), with a MySQL database when the app needs one. The app owns all its routes and data. |\n\nRules of thumb:\n\n- **Is it an app, or a site?** A custom application the user builds/brings (its own logic, its own data,\n maybe a database) → **Webapp**. A content site or a shop → one of the three above. When the user says\n \"my app / SPA / dashboard / tool\", or hands you an existing app project to deploy, that's **Webapp**.\n- **Selling decides first (for sites).** No selling → WordPress. Selling → Ecommerce or\n WordPress + Ecommerce.\n- **Then the content.** Selling plus only a few fixed pages → Ecommerce. Selling plus content the\n owner will keep publishing → WordPress + Ecommerce.\n- **Don't add WordPress \"just in case\".** A second backend is a second admin to learn. Recommend the\n combination only when the user described ongoing content.\n- Selling a single service by enquiry (no checkout, no cart) is **not** commerce → WordPress with a\n contact page.\n- **Webapp never uses WordPress or a store.** If the user wants a custom app *and* a CMS/shop, that's two\n different sites — build the app as a Webapp and tell them the content/commerce backend is a separate\n domain.\n\n**Then set rendering** (write it in the brief's **Rendering** section): **Ecommerce → SSR required**\n(carts/search need per-request rendering — any SSR framework works; Next.js is the default, not a\nrequirement); **WordPress → SSG (Astro)** if the site is mostly static and the owner is fine rebuilding\nto publish, otherwise **SSR**; **Webapp → whatever the app is** (SSR for a server app, SPA for a\nclient-only app) — it deploys as a Node.js app either way. When unsure, default to **Next.js SSR**.\n\n## Step 5 — Confirm in one message\n\nSend **one** message that carries both the recommendation and the brief summary:\n\n- **Backend** — the choice, the one-line reason, and what it means day to day (where they'll edit\n content and products). Name the alternative if it was a close call.\n- **Brief** — what it is, audience, pages, products (if any), tone. Mark anything you defaulted\n (*\"assumed: warm, minimal look\"*) so the user sees what they skipped.\n\nEnd with *\"Good to go, or anything to change?\"* A yes (or silence-equivalent like \"go\") → write\n`brief.md`. Changes → apply them and write it; don't loop back into questions. If they pick a\ndifferent backend, use theirs and note it in the brief.\n\n## Step 6 — Write `brief.md` and hand off\n\nWrite `brief.md` from the template below. Fill every section from the conversation, defaulting what\nthe user skipped and tagging it `(assumed)`; leave out **Products** when nothing is sold. Use the\nuser's own words where you can. Then update `state.json`:\n`\"stage\": \"provision\"`, fresh `updated_at`.\n\nThe **Suggested backend** section is the contract with `03-provision/SKILL.md`, which routes on it.\nIts first line must be exactly one of `**WordPress**`, `**Ecommerce**`, `**WordPress + Ecommerce**` or\n`**Webapp**`, followed by an em dash and the one-line reason.\n\n## `brief.md` template\n\n```markdown\n# Brief — <domain>\n\n> Brief agreed with the user on <today>.\n\n## Purpose\n<what the site is, who runs it, what it's for — 2–3 sentences>\n\n## Audience\n<who visits, how they arrive, what device they mostly use>\n\n## Tone\n<voice in a sentence or two>\n\n## Design direction\n<look & feel in a few adjectives; **reference site(s)** the user named (URLs) and what to take from them\n(layout, structure, mood); colours / brand palette; any screenshots. This is where ALL design questions\nland — the frontend step reads this and does not re-ask. Write \"Agent's choice: <short style>\" when the\nuser didn't specify.>\n\n## Pages\n- **<Page>** — <one-line purpose>\n- …\n\n## Products\n<only when selling: what is sold, rough count, categories, variants, shipping regions, payments>\n\n## Content\n<what the owner will publish over time and how often — or \"A few fixed pages; no ongoing content.\">\n\n## Must-haves\n- …\n\n## Out of scope\n- …\n\n## Assets\n<logo, colours, photos, copy the user provided and where they are; \"None yet\" if none>\n\n## Suggested backend\n**<WordPress | Ecommerce | WordPress + Ecommerce | Webapp>** — <one-line reason>.\n<\"Confirmed by the user.\" or \"User chose this over the recommended <X> because <reason>.\">\n\n## Rendering\n**<SSR | SSG>** — <reason>. Rules: **Ecommerce requires SSR** (carts/search need per-request\nrendering — any SSR framework works; Next.js is the default, not a requirement); **WordPress may be\nSSG (Astro)** when the site is mostly static and rarely changes, otherwise **SSR**. Default when\nunsure: **Next.js SSR**.\n```\n\n## Example — WordPress + Ecommerce\n\n> A ceramicist sells around 30 handmade pieces and writes a monthly studio journal.\n\n```markdown\n## Suggested backend\n**WordPress + Ecommerce** — sells ~30 products with checkout, and the owner publishes a monthly\nstudio journal they want to edit themselves.\nConfirmed by the user.\n```\n"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"uri": "skill://hostinger-headless/steps/03-provision/SKILL.md",
|
|
165
|
+
"digest": "sha256:e803f3195165bdea9c42eeef36c93a0bb0110e09264b8584847c0aa4bc8db5f9",
|
|
166
|
+
"size": 3335,
|
|
167
|
+
"mimeType": "text/markdown",
|
|
168
|
+
"text": "---\nname: headless-step-provision\ndescription: \"Step 3 (provision) of the Hostinger Headless flow. Chooses the backend from brief.md (wordpress | ecommerce | webapp), then routes to the matching subfolder to provision it; writes site.json.\"\n---\n\n# headless-step-provision\n\nRouter. The backend is decided in **refine** and recorded in `brief.md`; this step only routes to the\nmatching subfolder, which provisions it.\n\n**In:** `brief.md`. **Out:** `site.json` (written by the subfolder).\n\n**This stage runs in the background.** Provisioning is mostly a poll wait, so it splits in two: a\n**fire** half (detect + create, returns straight away) and a **join** half (poll to `completed`,\nverify, write `site.json`). Fire it, record `provision.in_progress`, then go do the prepare work of\nthe other stages (content-model prepare, frontend shell) while it settles. Only cross back to\nthe join half when an instance-barrier step needs the live backend. See *Background provision & the\ninstance barrier* in the top-level `SKILL.md`. An agent that can't interleave simply does fire and\njoin back-to-back — still correct, just no overlap.\n\n## Step 1 — Route on the suggested backend\n\nRead the brief's **Suggested backend** section:\n\n| Suggested backend | Follow |\n|---|---|\n| WordPress | `wordpress/SKILL.md` |\n| Ecommerce | `ecommerce/SKILL.md` |\n| Webapp | `webapp/SKILL.md` |\n\nIf the section is missing or names something else, go back to **refine** — don't pick one here.\nTell the user the backend and its one-line reason before provisioning.\n\n> **Supported backends: `wordpress`, `ecommerce` and `webapp`.** `webapp` is a plain Node.js app host\n> (an empty website — no CMS, no store, no `/cms`) for any app/SPA that brings its own code and data; it\n> never installs WordPress. PocketBase is not available in this version — refine never proposes it, and\n> there is no `pocketbase/` subfolder; if a brief somehow names it, stop and tell the user rather than\n> improvising it. A **WordPress + Ecommerce** brief has no single combined `start-setup` type yet: stop,\n> tell the user it isn't supported as one site, and offer to continue with just WordPress or just\n> Ecommerce — then route to that one subfolder.\n\n## Step 2 — Fire (don't block)\n\nFollow the chosen subfolder's `SKILL.md` through **detect + create** (its \"start-setup\" call). The\nmoment the create is accepted and queued, record it in `state.json` and **return** — do not sit on the\nreadiness poll:\n\n```json\n{ \"stage\": \"content_model\",\n \"provision\": { \"status\": \"in_progress\", \"domain\": \"<domain>\", \"started_at\": \"<now>\" } }\n```\n\nNow go run the prepare work that doesn't need the backend (content-model prepare, frontend\nshell). The subfolder's readiness poll, verification and `site.json` are the **join** half below.\n\n## Step 3 — Join (at the instance barrier)\n\nWhen an instance-barrier step is about to run (content-model push, frontend data wiring, deploy) — or\nright away, if the agent can't interleave — finish the subfolder's `SKILL.md`: **poll to `completed`**,\nrun its verification, and write `site.json` (which records the chosen `backend`). Then set\n`provision.status` to `\"completed\"` in `state.json` with a fresh `updated_at`. On resume, treat a\nstored `in_progress` as a hint only — re-poll `hosting_websites_list-setups` for the truth first.\n"
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
"uri": "skill://hostinger-headless/steps/03-provision/ecommerce/SKILL.md",
|
|
172
|
+
"digest": "sha256:07730db45bf312e15c71c8efee48d990add6b21bc479e1306a20faed502b9e75",
|
|
173
|
+
"size": 7741,
|
|
174
|
+
"mimeType": "text/markdown",
|
|
175
|
+
"text": "---\nname: headless-step-provision-ecommerce\ndescription: \"Provision a Hostinger Ecommerce backend for the Hostinger Headless flow: ensure the hostinger-ecommerce MCP is connected, create the headless website (frontend host) and a Hostinger Ecommerce STORE with a custom (headless) sales channel, set shipping + payment, and record it all in site.json.\"\n---\n\n# headless-step-provision-ecommerce\n\nProvision a **Hostinger Ecommerce** backend for `<domain>` and prove it is ready. Two distinct things:\n\n1. A **headless website** (`type: \"headless_ecommerce\"`) — this is only the **hosting slot for the\n storefront you'll build**. It installs **no store engine** (no `/cms`, no API on the domain).\n2. A **Hostinger Ecommerce store** — a separate object created through the **Ecommerce API**, with a\n **custom (headless) sales channel**. The storefront reads this store's **public Storefront Core V2\n API**, keyed on the sales channel. This is the real backend.\n\n**Out:** `site.json` (store id, custom sales channel id, URLs).\n\n> ## ⚠️ Prerequisite: the `hostinger-ecommerce` MCP server must be connected\n> Store management is **not** in the `hosting`/`wordpress` MCP groups — it's the separate\n> **`hostinger-ecommerce`** group (`ecommerce_*` ops, OAuth-backed, no API token needed). If those\n> tools aren't available, add the server and **reconnect the session** before continuing:\n> ```bash\n> claude mcp add-json hostinger-ecommerce '{\"command\":\"npx\",\"args\":[\"--package=@hostinger/mcp@latest\",\"hostinger-ecommerce-mcp\"],\"env\":{\"USER_AGENT\":\"extension;claude-code;via-cursor;1.4.2\"}}' --scope user\n> ```\n> (For other tools, enable the **Ecommerce** MCP in the Hostinger Connector.) A newly added MCP server\n> only loads on a session restart/reconnect.\n>\n> **Can't restart your own session? Don't block — drive the server directly.** A coding agent usually\n> can't reconnect itself, so instead of stopping, run the stdio server yourself and speak MCP to it:\n> spawn `npx --package=@hostinger/mcp@latest hostinger-ecommerce-mcp` (same `USER_AGENT` as above) and\n> send newline-delimited JSON-RPC over its stdin/stdout — `initialize` → the `notifications/initialized`\n> notification → `tools/list` → `tools/call`, where the tools are `search` / `execute` / `multi-execute`\n> (call ops exactly as in-session: `{ \"name\": \"execute\", \"arguments\": { \"operation\": \"…\", \"params\": {…} } }`).\n> It self-authenticates with the same stored Hostinger OAuth creds as the other connected servers, so\n> there is **no token to handle** (keep any secret off the command line). Your sandbox may ask the user\n> to approve spawning it — ask once, then proceed. Only fall back to asking the user to restart if\n> driving the server directly isn't permitted.\n>\n> Verify the tools work (in-session or via the direct drive) with a search — e.g. \"list stores\" →\n> `ecommerce_stores_list` — before proceeding.\n> The authoritative, always-current build guide is `ecommerce_miscellaneous_custom-storefront-setup-instructions` — read it once per build.\n\nAll website-lifecycle calls go through the `hosting` connector (`execute`); all store calls through the\n`hostinger-ecommerce` MCP.\n\n## Step 1 — The headless website (frontend host)\n\nSame lifecycle as `../wordpress/SKILL.md` Steps 1–2, but `type: \"headless_ecommerce\"`:\n\n1. Detect: `hosting_websites_list` / `hosting_websites_list-setups` (exact-match the domain). `completed`\n → adopt. `running` → poll. `failed` → stop, point at hPanel.\n2. Create (only if absent): `hosting_websites_start-setup { order_id, type: \"headless_ecommerce\", domain }`\n — **omit `domain`** for a temporary subdomain: the setup generates one itself (don't call a separate\n subdomain op first — passing a generated free subdomain back as `domain` 422s). The response carries\n the generated domain; if it differs from the workspace dir, move `~/.hostinger/<old>/` → `<new>/` and\n update `brief.md`. Poll `list-setups` to `completed`. `website_type` will be `headless`.\n\n> Don't expect `/cms` or any API on this domain — it serves the storefront you build (Step 6) and\n> deploy (Step 8).\n\n## Step 2 — Resolve or create the store + custom sales channel\n\n`ecommerce_stores_list` → reuse an existing store if this build already made one; otherwise create:\n\n```json\n{ \"operation\": \"ecommerce_stores_create\",\n \"params\": { \"name\": \"<store name>\", \"country_code\": \"US\", \"company_email\": \"<email>\",\n \"company_name\": \"<store name>\", \"language\": \"en\",\n \"sales_channel\": { \"type\": \"custom\", \"external_id\": \"<slug>-headless\" } } }\n```\n\nThis returns `store.id` (`store_*`) **and** a custom `sales_channel.id` (`scha_*`) in one call — the\ncustom channel is what the headless storefront is keyed on. (You can also add one later with\n`ecommerce_sales-channels_create { store_id, type:\"custom\", name }`.) Currency comes from\n`country_code` (`default_currency_code`); all prices are **minor units** (cents).\n\n## Step 3 — Store readiness: shipping + payment\n\nA store needs ≥1 shipping zone and ≥1 payment method to check out.\n\n- **Shipping:** `ecommerce_shipping_set-store { store_id, price }` (minor units; 0 = free).\n- **Payment:** `ecommerce_payments_enable-manual-method { store_id, title }`.\n\n> **Known gate — free accounts have no payment method, and therefore no checkout.** On a **free\n> account** (and any account/store without a paid Hostinger Ecommerce plan), `enable-manual-method`\n> returns **HTTP 402**, and checkout later fails with `400 payment_provider_not_configured`. This is\n> **expected, not an error** — payment, and therefore checkout, is simply unavailable on the plan. The\n> catalog and the storefront still work fully. **Never block, stop, loop or retry the build on this:**\n> - Record `payment_enabled: false` in `site.json` (set `shipping_enabled` as usual).\n> - Tell the user in one line that checkout needs a paid Ecommerce plan, and carry straight on.\n> - Treat the 402 as a normal outcome — don't surface it as a failure or re-attempt the call.\n> - Never ask the user for payment credentials.\n>\n> Every downstream stage reads `site.json → ecommerce.payment_enabled` and, when it is `false`, skips\n> all checkout wiring and verification (see content-model Step 5, frontend, deploy). A store with\n> `payment_enabled: false` is a **complete, valid build** — a browsable catalog without checkout.\n\n## Step 4 — Verify\n\n- `ecommerce_stores_metadata { store_id }` → `has_shipping` true; `has_payment_methods` (may be false,\n see the gate); read `default_currency` (`decimal_digits`, `template`) here, don't guess.\n- Public Storefront V2 answers for the channel (no auth):\n `GET https://api-ecommerce.hostinger.com/v2/channels/<scha_*>/products?limit=100` → 200 (empty until\n content-model seeds products — fine here).\n- `GET https://<domain>/` returns the Hostinger default page until the storefront is deployed — expected.\n\n## Step 5 — Record `site.json`\n\n```json\n{\n \"backend\": \"ecommerce\",\n \"website_type\": \"headless\",\n \"order_id\": 1007223428,\n \"username\": \"u…\",\n \"ecommerce\": {\n \"store_id\": \"store_…\",\n \"sales_channel\": { \"id\": \"scha_…\", \"type\": \"custom\", \"external_id\": \"…-headless\" },\n \"currency\": \"usd\",\n \"payment_enabled\": false,\n \"shipping_enabled\": true\n },\n \"urls\": {\n \"site\": \"https://<domain>\",\n \"storefront_api\": \"https://api-ecommerce.hostinger.com/v2/channels/<scha_*>\",\n \"store_admin\": \"https://ecommerce.hostinger.com/store/<store_*>\"\n },\n \"provisioned_at\": \"…\",\n \"verified_at\": \"…\"\n}\n```\n\nNo secrets in `site.json`. After deploy, link the channel to the live domain with\n`ecommerce_sales-channels_update { store_id, sales_channel_id, url }` (done in the deploy stage).\n"
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"uri": "skill://hostinger-headless/steps/03-provision/webapp/SKILL.md",
|
|
179
|
+
"digest": "sha256:79989e076068cdd8300f833e2a224c9a029ea79edb17cbe7b3faed71de81b8cb",
|
|
180
|
+
"size": 9146,
|
|
181
|
+
"mimeType": "text/markdown",
|
|
182
|
+
"text": "---\nname: headless-step-provision-webapp\ndescription: \"Provision a plain Node.js app host for the Hostinger Headless flow: create an empty website (no WordPress, no store engine, no /cms), optionally a MySQL database, then record it in site.json. For any webapp / SPA / app that brings its own code and data.\"\n---\n\n# headless-step-provision-webapp\n\nProvision a bare **Node.js app host** for `<domain>` and prove it is ready. This is the backend for\n**any webapp, SPA or app** that brings its own code (and, if it needs one, its own database) — there is\nno CMS and no store engine here. The site is an **empty website**: just the hosting slot that the\nfrontend step builds into and the deploy step ships to as a Node.js application. **Out:** `site.json`.\n\n> **Not WordPress, not Ecommerce.** Never provision `headless_wordpress` / `headless_ecommerce` for a\n> webapp, and never install WordPress at `/cms`. A webapp owns its own routes, data and rendering.\n\nAll website/database operations below go through the Hostinger connector (`execute`).\n\n> **Fire vs join (background provision).** Steps 1–2 up to `hosting_websites_start-setup` are the\n> **fire** half: once the setup is queued, record `provision.in_progress` and return to do prepare work\n> (see the router `../SKILL.md`). The readiness **poll** at the end of Step 2, plus Steps 3–5, are the\n> **join** half — run them when an instance-barrier step needs the backend (or immediately, if the\n> agent can't interleave). An already-`completed` site detected in Step 1 means provision is done:\n> record `provision.completed` and adopt it.\n\n## Step 1 — Detect before you create (never provision twice)\n\n1. `hosting_websites_list { \"domain\": \"<domain>\" }` — substring filter, so **exact-match** the row's\n `domain`. If a row exists, note `username`, `order_id`, `website_type`.\n2. `hosting_websites_list-setups { \"domain\": \"<domain>\" }` — exact-match the row:\n - `completed` → already provisioned → skip to Step 3 (adopt it).\n - `running` → skip to Step 2 (poll).\n - `failed` → tell the user and point them at hPanel; do **not** call start-setup again on your own.\n3. If the site exists but is a different kind (`website_type` is `headless`, or a WordPress/store site),\n stop and tell the user — this domain already hosts something else.\n\n## Step 2 — Create (only when nothing exists)\n\n1. Pick the plan: `hosting_orders_list { \"statuses\": [\"active\"] }`. One active order → use it; several\n → ask the user which.\n2. Start the setup as an **empty website** — **omit `type`** (no WordPress, no store):\n\n ```json\n { \"operation\": \"hosting_websites_start-setup\",\n \"params\": { \"order_id\": <order_id>, \"domain\": \"<domain>\" } }\n ```\n\n `domain` must be a **customer-owned** domain. For a temporary `*.hostingersite.com` subdomain,\n **omit `domain`** — the setup generates one itself. Don't call a separate subdomain op to make one\n first: there's no need, and passing a generated free subdomain back as `domain` fails with\n `422 … Free subdomains cannot be provided`. The response then carries the generated domain: if it\n differs from the workspace directory name, move `~/.hostinger/<old>/` to `~/.hostinger/<new>/`,\n update the domain in `brief.md`, and tell the user the new domain.\n3. Poll `hosting_websites_list-setups { \"domain\": \"<domain>\" }` every ~15 s until `completed`\n (typically under a minute; allow a few minutes). `failed` → stop, point at hPanel.\n\n## Step 3 — Database (only if the app needs one)\n\nSkip this step entirely for an app with no database (static SPA, an app that talks to an external API).\nThis is **join-half** work — it needs the live website, so run it once `provision.status == \"completed\"`.\n\n**Preferred — let the platform create the database and inject the credentials:**\n`hosting_databases_setup-website { username, domain }` creates a MySQL database, **generates the\npassword, writes `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USER` / `DB_PASSWORD` / `DATABASE_URL` into the\nwebsite's environment variables, and restarts the app — in one call.** The password is never returned,\nso there is **nothing to persist and nothing to lose across sessions** — the app reads it from the\nenvironment. (It 422s if any of those env vars already exist; clear them first with\n`hosting_nodejs_replace-environment-variables`. Frameworks that bake vars into the build — Next.js,\n`NEXT_PUBLIC_*` — only pick up the values on the deploy build.)\n\n> **⚠️ A later `hosting_nodejs_replace-environment-variables` wipes these credentials.** That call is a\n> **full replace**, and when you list the current vars first the `DB_PASSWORD` (and `DATABASE_URL`) read\n> back **masked** (`********`) — the generated password is unknown, so you cannot resend it and the DB\n> env is lost. One rule, pick one:\n> - **(a) Set every other app env var first, and call `setup-website` last** — then nothing has to\n> replace the env again.\n> - **(b) If a later replace is unavoidable**, reset the password with\n> `hosting_databases_change-password { username, name, password }` and resend **all** of `DB_HOST`,\n> `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` and `DATABASE_URL` with the new password in that same\n> full replace.\n\n**Manual alternative** — only when you need a specific name/user or finer control:\n\n1. `hosting_databases_create` with a name, a user, and a password you generate — name and user are\n prefixed with the account username automatically. Read the full, prefixed `name`/`user` back from\n `hosting_databases_list`.\n2. **Which host the app connects to:** the app runs **on the website**, so Node.js connects to\n **`127.0.0.1:3306`** — never `localhost` (it can resolve to IPv6 `::1`, and not every database user\n has that grant), and never the external `host` from `hosting_databases_list` (`srvNNNN.hstgr.io`,\n which has no grant for connections from the server itself).\n3. **Set the env vars now, while you still hold the generated password.** The password is **not persisted\n anywhere** (never in `site.json`), so set `DB_HOST=127.0.0.1`, `DB_PORT=3306`, `DB_NAME`, `DB_USER`,\n `DB_PASSWORD`, plus `DATABASE_URL=mysql://USER:PASSWORD@127.0.0.1:3306/NAME` (URL-encode USER/PASSWORD)\n right here with `hosting_nodejs_replace-environment-variables` (a **full replace** — list current keys\n first; values come back masked; send the complete set with real values). Saving restarts the process;\n build-time vars need a fresh deploy (`hosting_deploy`) afterwards. **Don't defer this to deploy:** on\n a **cross-session resume** the generated password is gone, so you must **reset it** with\n `hosting_databases_change-password { username, name, password }` and set the env vars from the new one.\n4. **Never** put the password in source, a committed `.env`, client-side JavaScript, or a chat reply;\n never print it once stored.\n\n## Step 4 — Verify\n\n| Check | How | Expect |\n|---|---|---|\n| Website exists | `hosting_websites_list` exact row | the row is present, `username` known |\n| Setup finished | `hosting_websites_list-setups` exact row | `status: completed` |\n| Database reachable *(only if created)* | `hosting_databases_list { username }` | the prefixed db + user are listed |\n\n`GET https://<domain>/` returns the Hostinger default page (or 403) until the frontend is deployed —\nexpected, not a failure. There is **no `/cms` and no API** on this domain; the app itself serves every\nroute once it is deployed (Step 8).\n\nIf a check fails: retry once after ~30 s; if it still fails, stop and point the user at hPanel.\n\n## Step 5 — Record `site.json`\n\n```json\n{\n \"backend\": \"webapp\",\n \"website_type\": \"empty\",\n \"order_id\": 1007223428,\n \"username\": \"u…\",\n \"runtime\": \"nodejs\",\n \"database\": {\n \"enabled\": false,\n \"engine\": \"mysql\",\n \"name\": \"u…_appdb\",\n \"user\": \"u…_app\",\n \"host\": \"127.0.0.1\",\n \"port\": 3306\n },\n \"urls\": {\n \"site\": \"https://<domain>\"\n },\n \"provisioned_at\": \"…\",\n \"verified_at\": \"…\"\n}\n```\n\nNo passwords or tokens in `site.json` — only the non-secret connection shape (host/port/name/user). Set\n`\"database\": { \"enabled\": false }` (and drop the rest) when the app needs no database.\n\n## Deploy & content model (what happens next)\n\n- **Content model is skipped for a webapp** — there is no CMS or store to seed; the app owns its own\n data. The content-model router (`../../04-content-model/SKILL.md`) passes straight through to the\n frontend step for this backend.\n- **Deploy** is a standard Node.js application deploy, handled by `../../08-deploy/SKILL.md`: it archives\n the **source** (not the build output; exclude `node_modules/`, `.next/`, `dist/`, build output and\n `.git/`), then deploys with `hosting_deploy` (`type: \"nodejs\"`, upload + server-side `npm install` +\n build, one shot) passing the framework's `appType` + `outputDirectory` inline, and polls\n `hosting_get-deployment` until the build completes. Any supported `appType` works (Next.js, Nuxt,\n Express, NestJS, Fastify, Vite/React/Vue SPA, …). There is no `/cms` to preserve here, so the whole\n root is the app.\n"
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"uri": "skill://hostinger-headless/steps/03-provision/wordpress/SKILL.md",
|
|
186
|
+
"digest": "sha256:53137213e6498560ff0a3f0f4beb2efece91707022d2b25201bf9cf699255675",
|
|
187
|
+
"size": 5115,
|
|
188
|
+
"mimeType": "text/markdown",
|
|
189
|
+
"text": "---\nname: headless-step-provision-wordpress\ndescription: \"Provision a headless WordPress backend for the Hostinger Headless flow: create the headless website with WordPress at /cms, verify and self-heal, then record it in site.json.\"\n---\n\n# headless-step-provision-wordpress\n\nCreate the headless WordPress backend for `<domain>` and prove it is ready. One platform call lays\nevery headless site out identically: WordPress in `public_html/cms`, Hostinger AI Assistant + MCP\nadapter, site marked `headless`. **Out:** `site.json`.\n\nAll operations below go through the Hostinger connector (`execute`).\n\n> **Fire vs join (background provision).** Steps 1–2 up to `hosting_websites_start-setup` are the\n> **fire** half: after the setup is queued, record `provision.in_progress` and return to do prepare\n> work (see the router `../SKILL.md`). The readiness **poll** at the end of Step 2, plus Steps 3–4,\n> are the **join** half — run them when an instance-barrier step needs the backend (or immediately, if\n> the agent can't interleave). An already-`completed` site detected in Step 1 means provision is\n> already done: record `provision.completed` and adopt it.\n\n## Step 1 — Detect before you create (never provision twice)\n\n1. `hosting_websites_list { \"domain\": \"<domain>\" }` — substring filter, so **exact-match** the row's\n `domain`. If a row exists, note `username`, `order_id`, `website_type`.\n2. `hosting_websites_list-setups { \"domain\": \"<domain>\" }` — exact-match the row:\n - `completed` → already provisioned → skip to Step 3 (adopt it).\n - `running` → skip to Step 2 (poll).\n - `failed` → tell the user and point them at hPanel; do **not** call start-setup again on your own.\n3. If the site exists but `website_type` is not `headless`, stop and tell the user — this domain\n already hosts a different kind of site.\n\n## Step 2 — Create (only when nothing exists)\n\n1. Pick the plan: `hosting_orders_list { \"statuses\": [\"active\"] }`. One active order → use it; several\n → ask the user which.\n2. Start the setup:\n\n ```json\n { \"operation\": \"hosting_websites_start-setup\",\n \"params\": { \"order_id\": <order_id>, \"type\": \"headless_wordpress\", \"domain\": \"<domain>\" } }\n ```\n\n `domain` must be a **customer-owned** domain. For a temporary `*.hostingersite.com` subdomain,\n **omit `domain`** — the setup generates one itself. Don't call a separate subdomain op to make one\n first: there's no need, and passing a generated free subdomain back as `domain` fails with\n `422 … Free subdomains cannot be provided`. The response then carries the generated domain:\n if it differs from the workspace directory name, move `~/.hostinger/<old>/` to `~/.hostinger/<new>/`,\n update the domain in `brief.md`, and tell the user the new domain.\n Admin credentials are generated by the platform; never ask the user for a password.\n3. Poll `hosting_websites_list-setups { \"domain\": \"<domain>\" }` every ~15 s until `completed`\n (typically under a minute; allow a few minutes). `failed` → stop, point at hPanel.\n\n## Step 3 — Verify (all must pass)\n\n| Check | How | Expect |\n|---|---|---|\n| Site is headless | `hosting_websites_list` exact row | `website_type: \"headless\"` |\n| WordPress at `/cms` | `wordpress_installations_list { \"username\": \"<username>\", \"ownership\": \"all\" }`, exact-match `domain` | row with `directory: \"cms\"`, `is_valid: true`; note its `id` |\n| REST API answers | `GET https://<domain>/cms/wp-json/wp/v2/posts?per_page=1` | 200, JSON array |\n| AI Assistant + MCP loaded | `GET https://<domain>/cms/wp-json/` → `namespaces` | contains `hostinger-ai-assistant/v1` and `mcp` |\n| JWT mintable | `wordpress_installations_jwt-token { username, software: <id> }` | returns `token`, `mcp_url` |\n\nNotes:\n\n- `wordpress_installations_list` can lag a few seconds behind `completed` and return `[]` — retry\n briefly before failing. Its `domain` filter is a substring match, so exact-match the row.\n- The install record's `url` may say `http://`; always use `https://` URLs.\n- `GET https://<domain>/` returns 403 until the frontend is deployed — expected, not a failure.\n- **Never print, log or store the JWT.** It is minted on demand (24 h lifetime) by whichever stage\n needs the per-site WP MCP; only `mcp_url` is recorded.\n\nIf a check fails: retry once after ~30 s; if it still fails, stop and point the user at hPanel.\n\n## Step 4 — Record `site.json`\n\n```json\n{\n \"backend\": \"wordpress\",\n \"website_type\": \"headless\",\n \"order_id\": 1007223428,\n \"username\": \"u…\",\n \"wordpress\": {\n \"install_id\": \"30…\",\n \"directory\": \"cms\",\n \"admin_login\": \"…\"\n },\n \"urls\": {\n \"site\": \"https://<domain>\",\n \"cms\": \"https://<domain>/cms\",\n \"wp_admin\": \"https://<domain>/cms/wp-admin\",\n \"rest_api\": \"https://<domain>/cms/wp-json/wp/v2\",\n \"wp_mcp\": \"<mcp_url from the JWT call>\"\n },\n \"provisioned_at\": \"…\",\n \"verified_at\": \"…\"\n}\n```\n\nNo passwords or tokens in `site.json`. When the user needs wp-admin, mint a one-click link with\n`wordpress_login_create-links { username, software: <install_id> }` — never hand over a password.\n"
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
"uri": "skill://hostinger-headless/steps/04-content-model/SKILL.md",
|
|
193
|
+
"digest": "sha256:604e6c8026e0cfc252b27b62e715a4f5e05fa337c6937230fcf4b271d17cd57c",
|
|
194
|
+
"size": 8614,
|
|
195
|
+
"mimeType": "text/markdown",
|
|
196
|
+
"text": "---\nname: headless-step-content-model\ndescription: \"Step 4 (content model) of the Hostinger Headless flow. Prepare phase: turns brief.md into a content helper (content-model.md) — fixed Title/Description plus a tailored generation prompt per section (Navigation, Pages, Articles) — and generates the drafts/images, no live backend needed. Push phase: routes to the backend subfolder to create it the backend's way once provision is ready.\"\n---\n\n# headless-step-content-model\n\nTwo phases around the **instance barrier** (see the top-level `SKILL.md`):\n\n- **Prepare — no instance.** Build the content helper from `brief.md` and generate the content it\n calls for. Runs *during* the provision wait; needs only the brief.\n- **Push — needs the instance.** Create that content in the backend. Gated on\n `provision.status == \"completed\"`; routes to `wordpress/` or `ecommerce/`.\n\n**In:** `brief.md`. **Out:** `content-model.md` (+ any generated media under\n`~/.hostinger/<domain>/media/`).\n\n> Don't wait for provisioning — the helper comes from the brief alone.\n\n---\n\n## Prepare phase — write the content helper (no instance)\n\n`content-model.md` is a lightweight helper, not a schema. Fill the **fixed values** from the brief and\nwrite a **generation prompt** for each section — tailored to *this* site, not generic. Then run those\nprompts to produce the drafts (and images), so the push phase is just upload.\n\nWrite `content-model.md` from this template, adapting every prompt to the brief's purpose, audience\nand tone:\n\n```markdown\n# Content — <domain>\n\n> Content helper prepared from brief.md on <today>. Fixed values are set; each prompt is run\n> during the provision wait to generate the content and media. Push to the backend happens later.\n\n## Title\n<site name, from the brief>\n\n## Description\n<one–two line site description / default meta, from the brief>\n\n## Navigation\n> Generate the primary menu from the site's pages in reading order (<list the brief's pages>).\n> Short labels; note which items are static pages vs. listing pages. Add a footer menu if the\n> brief implies one (socials, contact).\n\n## Pages\n> Generate each page the brief lists. For every page: title, slug, a short section outline, the\n> copy for static pages (e.g. About, Contact), and SEO title + description.\n> <bullet the brief's pages with their one-line purpose>\n\n## Articles (optional)\n> Generate <N> articles across the brief's categories (<list them>). For each: title, slug,\n> body in the brief's tone, category, any relationship (e.g. destination), published date, SEO,\n> and a featured image (generated into media/, or sourced from Unsplash — see *Images* below) with\n> alt text. Omit if the brief has no article-like content.\n\n## Products (optional — required for a commerce brief)\n> Skip for a non-commerce brief. For a commerce brief, generate the catalog content here (prepare\n> work, no instance): for each product — name, slug, SKU, short + long description in the brief's\n> tone, price, the collection it belongs to, variants if any, and a product image (generated into\n> media/, or sourced from Unsplash — see *Images*) with alt text. Group products into the collections\n> the brief implies. The `ecommerce` push phase creates these on the store's sales channel.\n```\n\nGuidance:\n\n- **Fixed vs. prompt.** Title and Description are concrete values. Navigation, Pages and Articles are\n prompts because their volume/wording is generated — keep them as prompts so the push phase (or a\n rerun) can regenerate consistently.\n- **Run the prompts now.** Generate the actual page copy, article drafts and images during the wait —\n this is the slowest work and the whole reason it runs in parallel. Put generated media in\n `~/.hostinger/<domain>/media/` and reference it by filename. Record Unsplash images by URL (see\n *Images* below). Record alt text for every image.\n- **Optional sections.** Drop **Articles** if the brief has no article-like content. Drop **Products**\n for a non-commerce brief; for a commerce brief, generate the product catalog here (names, prices,\n descriptions, collections, images) — that's prepare work, and the `ecommerce` push phase then creates\n it on the store.\n- **Don't block on design.** The design step (5) owns the look, and it runs in parallel during the\n provision wait, so never wait for it here. If `design.md` already exists when you source images,\n follow its **Imagery** section; otherwise generate neutral, on-topic imagery from the brief.\n\n### Images — source from Unsplash when you can't generate them\n\nEvery article needs a featured image. If you can generate images, put them in `media/`. If you\ncan't (most coding agents can't), source real photos from Unsplash. This is still prepare work and\nneeds no live backend: you only pick the photos and record their URLs here. The push phase uploads\nthem.\n\n1. **Find candidates.** Use one photo per article, matching its subject and destination.\n - With an `UNSPLASH_ACCESS_KEY` in the environment, use the official API:\n `GET https://api.unsplash.com/search/photos?query=<subject>&orientation=landscape` with header\n `Authorization: Client-ID <key>`.\n - Without a key, use web search limited to `unsplash.com` (e.g. \"unsplash photo Lisbon tram\") and\n take the photo id from the result URL: the last segment of `unsplash.com/photos/<slug>-<id>`, or\n the whole segment for `unsplash.com/photos/<id>`.\n - Don't call `unsplash.com/napi/…`. It sits behind a bot challenge and returns 401.\n2. **Resolve a direct file URL.** `GET https://unsplash.com/photos/<id>/download?force=true` (don't\n follow the redirect) answers `302` with `Location: https://images.unsplash.com/photo-<…>?…`. The\n `dl=` parameter in it also carries the photographer's name. Drop the query string and request a\n web size: `https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max`. The upload ability\n needs this direct file URL, which returns 200 with `image/jpeg`. A photo *page* URL won't work.\n3. **Look before you keep it.** Download a small preview (`?w=500`) and view it. Search snippets\n only show captions, and a photo often isn't quite what its title says. Reject anything off-topic.\n If nothing fits exactly, take the closest real photo and note the gap rather than mislabel it.\n4. **Write alt text from what the photo shows**, not from the article's image brief. For example,\n use \"Shopfronts and parked scooters on a Hanoi street\" even if the article is about rain.\n5. **Record it** under each article in `content-model.md`:\n\n ```markdown\n Image: https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max\n Image credit: <Photographer> — https://unsplash.com/photos/<id> (Unsplash License)\n Image alt: <what the photo shows>\n Image file: <site>-<subject>.jpg\n ```\n\nUnsplash photos are free to use under the Unsplash License. Still credit the photographer: the push\nphase stores \"Photo by <Photographer> on Unsplash\" as the image caption.\n\nThen update `state.json`: set `stage` to the next prepare stage still to do, fresh `updated_at`. Do\n**not** advance past the barrier here.\n\n---\n\n## Push phase — create it the backend's way (needs the instance)\n\nOnly once `provision.status == \"completed\"` (re-verified against `hosting_websites_list-setups`),\nroute on the brief's **Suggested backend** and follow that subfolder, which maps the helper onto the\nlive backend — for WordPress, through the per-site WP MCP's ability tools (the installation JWT\nauthenticates that MCP session; `wp/v2` REST is the public read surface, not a write path for the\ntoken) to create the menu, pages, posts, categories and media:\n\n| Suggested backend | Follow |\n|---|---|\n| WordPress | `wordpress/SKILL.md` |\n| Ecommerce | `ecommerce/SKILL.md` |\n| Webapp | — (no push; skip to frontend) |\n\n> **Supported backends: `wordpress`, `ecommerce`, and `webapp`** — where `webapp` is \"supported\" by\n> **skipping this whole stage** (it has no content backend; see the note below). PocketBase is not\n> available in this version. Push to whichever single backend provision set up (see the provision\n> router) — don't try to push to two backends here.\n>\n> **Webapp has no content backend.** There is no CMS or store to seed, so a `webapp` brief **skips this\n> whole stage** — both prepare and push. The app owns its own data (its own API or database). Leave\n> `content-model.md` unwritten, mark content-model `skipped` in the run log, and go straight to the\n> frontend step.\n\nAfter the push completes, update `state.json` and continue to the frontend **data** phase.\n"
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"uri": "skill://hostinger-headless/steps/04-content-model/ecommerce/SKILL.md",
|
|
200
|
+
"digest": "sha256:d666a683d39963e18a619efeb64e34240782ae2aedb4e8b3f5ce35f7996e011a",
|
|
201
|
+
"size": 4397,
|
|
202
|
+
"mimeType": "text/markdown",
|
|
203
|
+
"text": "---\nname: headless-step-content-model-ecommerce\ndescription: \"Push phase of Step 4 for a Hostinger Ecommerce backend: create the catalog through the hostinger-ecommerce MCP — products (physical/digital) priced in minor units, product images from URLs, optional variants — on the store from site.json. Verified against the public Storefront V2 API. Idempotent, instance-gated.\"\n---\n\n# headless-step-content-model-ecommerce\n\n**Push phase — needs the store.** Run after provision created the store + custom sales channel (ids in\n`site.json → ecommerce`). All writes go through the **`hostinger-ecommerce`** MCP (`ecommerce_*`,\nOAuth). Prices are **integers in minor units** (cents) and live on **variants**.\n\n**In:** `content-model.md` (prepared catalog), `site.json` (`ecommerce.store_id`). **Out:** products +\nimages created in the store; ids recorded back into `content-model.md`.\n\n> If the `hostinger-ecommerce` MCP tools aren't available, don't stop — see the prerequisite in\n> `../../03-provision/ecommerce/SKILL.md` (add the server, and if you can't reconnect your session,\n> drive the stdio server directly rather than blocking).\n\n## Step 1 — Create products (idempotent)\n\nList first so a re-run doesn't duplicate: `ecommerce_products_list { store_id }` — match by title/slug;\nreuse existing ids. For each product in the helper:\n\n```json\n{ \"operation\": \"ecommerce_products_create-physical\",\n \"params\": { \"store_id\": \"store_…\", \"name\": \"…\", \"price\": 3999, \"description\": \"…\" } }\n```\n\n- `price` is minor units (3999 = $39.99); currency defaults to the store's. Each create makes one\n **variant** (its `id` is what checkout needs — read it from the product detail/list).\n- Digital goods: `ecommerce_products_create-digital` (takes a `download_url`).\n- Multiple variants (sizes/flavours): `ecommerce_product-variants_create { store_id, product_id,\n options:[{name,value}], prices:[{amount}] }`.\n- Batch several creates with `multi-execute` to keep it fast.\n\n## Step 2 — Product images (one call each)\n\n`ecommerce_products_upload-and-attach-image { store_id, product_id, image_url, is_thumbnail: true }` —\nfetches the image from a public URL and attaches it (raster only, no SVG, ≤15MB, HTTPS, ≤1 redirect).\nUnsplash direct URLs (`https://images.unsplash.com/photo-…?w=1200&q=80&fm=jpg&fit=max`) work directly.\nSource images as in the parent `../SKILL.md` (Unsplash). The store stores its own CDN copy\n(`cdn.zyrosite.com`), so the storefront never depends on the source URL.\n\n> Idempotency: check the product's existing `images`/`thumbnail` before attaching so a re-run doesn't pile\n> on duplicates.\n\n## Step 3 — Collections\n\nThe API does not expose a collection-create op in this flow, and `product_collections` comes back empty.\nRepresent collections (e.g. Proteins / Supplements) as a **frontend mapping** in the storefront\n(`lib/site.ts` → product-slug → collection), and record that mapping in `content-model.md`. If a real\ncollection API becomes available, prefer it.\n\n## Step 4 — Verify (public Storefront V2, no auth)\n\nKeyed on the **sales channel** (`site.json → ecommerce.sales_channel.id`):\n\n- `GET https://api-ecommerce.hostinger.com/v2/channels/<scha_*>/products?limit=100` → all products, each\n with `variants[].prices[]` (amount in minor units + currency `decimal_digits`/`template`), `thumbnail`\n and `images[]`. Count matches the plan.\n- Detail resolves by **product id** (`…/products/<id>`); a **slug 404s** on detail — map slug→id from the\n list in the frontend.\n\n## Step 5 — Hand off\n\nRecord the id map in `content-model.md` (`## Created in the store`: product → slug → id → variant id →\nprice → image). Update `state.json` (advance `stage` toward frontend; leave `provision`). The storefront\n(Step 6) reads the public Storefront V2 API for the channel; checkout is\n`POST /channels/<scha_*>/checkout` (needs a payment method — see the provision payment gate).\n\n> **No payment method = no checkout to seed or verify.** When `site.json → ecommerce.payment_enabled`\n> is `false` (free account / no paid Ecommerce plan — see the provision gate), the catalog push here is\n> **complete on its own**. There is nothing to seed for checkout, and you must **not** test the checkout\n> endpoint or wait on it — seeding products and verifying them over the Storefront V2 API is the whole\n> job. Hand off normally.\n"
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
"uri": "skill://hostinger-headless/steps/04-content-model/wordpress/SKILL.md",
|
|
207
|
+
"digest": "sha256:82b505da358614f9786b83e0a728f1b673f10cb83f5834af2708cd745d154a66",
|
|
208
|
+
"size": 11058,
|
|
209
|
+
"mimeType": "text/markdown",
|
|
210
|
+
"text": "---\nname: headless-step-content-model-wordpress\ndescription: \"Push phase of Step 4 for a headless WordPress backend: open an authenticated session to the per-site WP MCP with the installation JWT, then create the content helper's Title/Description, categories, media, pages, posts and navigation through the MCP ability tools. wp/v2 REST is the public read surface, not a write path for this token. Idempotent, instance-gated.\"\n---\n\n# headless-step-content-model-wordpress\n\n**Push phase — needs the live instance.** Run only after `provision.status == \"completed\"` (re-verify\nagainst `hosting_websites_list-setups`). It maps the backend-agnostic `content-model.md` onto real\nWordPress objects.\n\n**In:** `content-model.md`, `site.json` (both prepared earlier), generated media under\n`~/.hostinger/<domain>/media/`. **Out:** content created in WordPress; created IDs recorded back into\n`content-model.md`.\n\n## What creates what — and what authenticates it\n\nThe Hostinger **management** MCP (`wordpress_*`) is infra-only: it installs WordPress, plugins and\nthemes but has **no content endpoints**.\n\nWrites go through the **per-site WP MCP** (the Hostinger AI Assistant plugin), authenticated with the\ninstallation JWT. **The JWT authenticates the MCP endpoint only** — it is wired as the MCP server's\n`authenticate_request`, and the plugin registers no global `determine_current_user` /\n`rest_authentication_errors` filter. So a raw `POST /wp/v2/posts` with this token is **not**\nauthenticated; the admin user context (`wp_set_current_user`) exists only *inside* an MCP session.\n\n| Surface | Base (from `site.json`) | Role |\n|---|---|---|\n| **Per-site WP MCP** | `urls.wp_mcp` (`mcp_url`) | **all writes** — create content through its ability tools |\n| **WP REST API** `wp/v2` | `urls.rest_api` | **public reads only** (no auth needed) — what the Step 6 frontend consumes; do not write to it with the JWT |\n\n> **The WordPress content model is posts, pages and categories** (plus media). For **SEO**, install\n> **Yoast** through the connector so meta + schema come from a maintained plugin; the frontend reads each\n> item's `yoast_head_json` (Step 4.7 below).\n\n## Step 1 — Open the authenticated MCP session\n\nMint a short-lived token through the Hostinger connector:\n\n```json\n{ \"operation\": \"wordpress_installations_jwt-token\",\n \"params\": { \"username\": \"<site.json username>\", \"software\": \"<site.json wordpress.install_id>\" } }\n```\n\nReturns `token` (+ `mcp_url`, already stored as `urls.wp_mcp`). Connect to `urls.wp_mcp` as an MCP\nclient (`initialize` → `tools/list` → `tools/call`) and send the token on **every** request as:\n\n- `X-Hostinger-JWT-Token: <token>` — the plugin's preferred header (checked first), **or**\n- `Authorization: Bearer <token>` — accepted as a fallback (case-insensitive `bearer ` prefix).\n\nThe token lives ~24 h — mint on demand, **never print, log, or write it to `site.json` /\n`content-model.md`**. If a call returns `401/403` with `token_invalid`/`invalid_api_key`, the token\nexpired or was revoked → re-mint and reconnect.\n\n> **Transport gotchas (streamable HTTP MCP).** The endpoint is session-based: `initialize` returns an\n> **`Mcp-Session-Id` response header** — capture it and send it on **every** subsequent request\n> (`tools/list`, `tools/call`), or they fail with `-32600 Missing Mcp-Session-Id header`. Send\n> `Accept: application/json, text/event-stream`. After `initialize`, post the `notifications/initialized`\n> notification, and **tolerate a non-2xx on it** (it can answer 404 with no body — harmless; don't let\n> it abort the session). A raw curl/HTTP client works fine for all this.\n\n## Step 2 — Discover the ability tools\n\nThe server advertises either every ability as its own tool, or three **discovery meta-tools**\n(discover → get-info → execute) to keep `tools/list` small. Call `tools/list` first and work with\nwhat's there; with discovery, list abilities, then execute by ability name. The content tools that\ncover this step (exact names come from discovery — don't hardcode them):\n\n`SettingsTools` · `CategoriesTools` · `TagsTools` · `MediaUploadTools` / `MediaTools` · `PagesTools` ·\n`PostsTools` · `MenusTools` / `NavigationTools` · `SeoTools`. A generic\n`RestEndpointTool` can reach any `wp/v2` route *through the authenticated session* if no dedicated tool\nfits.\n\nSeen on a live install (Hostinger AI Assistant, discovery mode): the meta-tools are\n`hostinger-ai-discover-abilities`, `hostinger-ai-get-ability-info { name }` and\n`hostinger-ai-execute-ability { name, parameters }`. The abilities are named\n`hostinger-ai/<thing>-<action>`, e.g. `posts-create`, `posts-update`, `media-upload-from-url`,\n`media-delete`. Call `get-ability-info` for each schema before using it. Results come back as\n`{ ability, result: { data, … } }` for lists and `{ ability, result: {…} }` for single objects.\n\n## Step 3 — Idempotency rule (makes resume safe)\n\nEvery create is **list/get-by-slug first, then create only if absent** — a re-run must not duplicate.\nUse the matching read ability (or `RestEndpointTool` → `GET /wp/v2/posts?slug=…&status=any`): a hit →\nreuse its `id`; empty → create. Capture each returned `id` and record the id map back into\n`content-model.md` so later references resolve without re-querying.\n\n## Step 4 — Create in dependency order\n\n0. **Remove WordPress's default content first.** A fresh install ships a **\"Hello world!\" post**\n (id 1), a **\"Sample Page\"**, and a default comment — they will otherwise show up on the live site.\n Delete the default post (`posts-delete { id: 1, force: true }`) and, if present, the sample page\n (find it via `pages-search` for slug `sample-page`, then `pages-delete`). This is idempotent: if\n they're already gone, skip. Leave the default `Uncategorized` category — it's harmless once no post\n uses it (the frontend only lists categories with posts).\n1. **Site Title & Description** (the helper's global part) → the Settings ability.\n2. **Categories / tags** — one per term in the helper → Categories / Tags abilities. Keep `name → id`.\n3. **Media.** Upload every image in the helper, set `alt_text`, and keep `filename → id`. See\n *Media upload* below for both sources: a generated file in `media/`, or an Unsplash URL.\n4. **Pages** — each static/index page → Pages ability (`title`, `slug`, block/HTML `content`,\n `status: publish`). Keep `slug → id`.\n5. **Posts (articles)** — each → Posts ability (`title`, `slug`, `content`, `status: publish`,\n `categories: [id]`, `tags: [id…]`, `featured_media: <id>`, `date`). (The plugin block-converts post\n content on insert via `rest_pre_insert_post`, so HTML bodies are fine.)\n6. **Navigation** — Menus / Navigation abilities: create the menu, then an item per nav entry\n referencing a page `id` (or a custom URL for listing routes). **Headless note:** the Step 6\n frontend renders nav from `content-model.md`, so the WP menu is for owners editing in wp-admin, not\n what the live site reads — create it, but it isn't the source of truth.\n7. **SEO** — prefer **Yoast SEO**, installed through the connector:\n `wordpress_plugins_install { username, software, plugins: [\"wordpress-seo\"] }` (async), poll\n `wordpress_plugins_list-installed { username, software }` until `wordpress-seo` appears, then\n `wordpress_plugins_activate { username, software, plugin: \"wordpress-seo\" }`. It generates meta +\n schema, and the frontend reads `yoast_head_json` per post/page. Set each post's Yoast\n title/description if an ability exposes it; otherwise the excerpt + title are a fine fallback. Don't\n fail the push over SEO fields.\n\n### Media upload\n\n**From a URL (Unsplash or any direct file URL).** Use `hostinger-ai/media-upload-from-url`.\nWordPress downloads the file and stores its own copy, so the site never depends on the external URL\nstaying up:\n\n```json\n{ \"name\": \"hostinger-ai/media-upload-from-url\",\n \"parameters\": {\n \"url\": \"https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max\",\n \"filename\": \"<site>-<subject>.jpg\",\n \"title\": \"<short title>\",\n \"alt_text\": \"<what the photo shows>\",\n \"caption\": \"Photo by <Photographer> on Unsplash\",\n \"description\": \"Source: https://unsplash.com/photos/<id> (Unsplash License)\",\n \"post_id\": <post id, if the post already exists> } }\n```\n\n- `url` must return 200 with an image content type. A photo *page* URL fails.\n- The result carries the attachment `id` and `source_url`. WordPress also generates the smaller\n sizes (`300x200`, `768x…`, `1024x…`, …) under `media_details.sizes`, which the frontend can use\n for `srcset`.\n- Set `filename` yourself, so files in `wp-content/uploads/` have readable names.\n\n**From a local file in `media/`.** Use the Media-upload ability that discovery lists for local\nfiles.\n\n**Set the featured image.** Pass `featured_media: <id>` when creating the post (Step 4.5). If the\npost already exists (on resume, or when images are added later), use\n`hostinger-ai/posts-update { id, featured_media }`.\n\n**Idempotency trap: don't look up earlier uploads by filename.** `media-search` matches titles and\ncontent, not filenames or the slug you passed. A search for `<site>-lisbon-tram` finds nothing even\nthough the file exists, so a re-run would upload the image again. WordPress then stores the duplicate\nas `…-1.jpg` and silently repoints the post at it. Check this way instead:\n\n1. `posts-get { id }`. If `featured_media` is already set, skip that post. Its image exists.\n2. Otherwise, search by the **title** you set on upload (`media-search { search: \"<title>\" }`),\n and reuse a hit.\n3. Only upload when both checks come up empty.\n\nIf duplicates do slip in, point each post back at the original with `posts-update`, then delete the\nduplicates with `media-delete { id, force: true }`.\n\n## Step 5 — Verify and record\n\n- Read a couple of created objects back through `wp/v2` (public GET, no auth): e.g.\n `GET {urls.rest_api}/posts?per_page=1` and check the `X-WP-Total` header against the planned article\n count; fetch one post with `_embed` to confirm `featured_media` and `categories` resolved.\n- Media: `GET {urls.rest_api}/media?per_page=1` → `X-WP-Total` equals the number of images planned\n (more means duplicates). `GET {urls.rest_api}/media?include=<ids>` confirms `alt_text` and\n `caption` on each image. Each `source_url` must return 200 with an image type. Each post's\n `featured_media` must point at its image.\n- Write the created-id map into `content-model.md` (a `## Created in WordPress` section: object → slug\n → id) so the frontend data phase and any re-run resolve references directly. Include one row per\n media item: filename, credit and source page, the post it's featured on, and its id.\n\n## Step 6 — Hand off\n\nUpdate `state.json` (`provision` stays `completed`; advance `stage` toward the frontend **data**\nphase) with a fresh `updated_at`. The backend now holds the content; Step 6's data wiring points the\nfrontend at `urls.rest_api`.\n"
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
"uri": "skill://hostinger-headless/steps/05-design/SKILL.md",
|
|
214
|
+
"digest": "sha256:bc5db554b957892df584e62ea2b921d648465644245af96a9263662e52581066",
|
|
215
|
+
"size": 29042,
|
|
216
|
+
"mimeType": "text/markdown",
|
|
217
|
+
"text": "---\nname: headless-step-design\ndescription: \"Step 5 (design) of the Hostinger Headless flow. Derives three distinct design directions from brief.md (plus any reference sites and brand assets), lets the user pick one in a single question, checks the chosen one against the 8-point grid and WCAG 2.2 AA, designs the logo as outlined SVG (wordmark, icon mark, favicon), then writes design.md — tokens, type, logo, components, per-page composition, accessibility and imagery — as the contract the frontend implements (the framework/stack is chosen later by the frontend step). Prepare work: runs during the provision wait and never touches the backend.\"\n---\n\n# headless-step-design\n\nTurn the brief into a look the user has chosen, and write it down so the frontend can't drift from\nit. **One question, three options, one spec.** The output is **files** (`design.md` plus the logo),\nnot a conversation: the frontend step implements `design.md`, not the chat.\n\n**In:** `brief.md` (required), `content-model.md` (if it exists yet), reference sites and assets the\nuser gave. **Out:** `~/.hostinger/<domain>/design.md` and the logo files in\n`~/.hostinger/<domain>/brand/`.\n\nThis is **prepare work**: it runs while provision settles (see *Background provision & the instance\nbarrier* in the top-level `SKILL.md`). Nothing here calls the Hostinger connector or the backend.\n\n**Keep it short.** Refine already asked about tone and look; don't ask again. The step asks the user\n**one** question: either the keep/change question (Step 1) or the three directions (Step 4), never\nboth. \"You decide\" is a valid answer.\n\n**Talk like a person, not a designer.** Everything the user reads describes how the site will\n**look and feel**: colours by name (\"hot pink, butter yellow\"), lettering by feel (\"big chunky\nrounded headlines\"), and the details they'll notice (\"photos tilted like stickers on a scrapbook\").\nNo hex codes, font names, or design jargon (palette, accent, tokens, shape language, contrast).\n\n**Two hard rules apply to every direction, however loud or quiet it is:**\n\n- **The 8-point grid.** Every spacing, sizing and layout value is a multiple of 8 px (details in\n 5.2).\n- **WCAG 2.2 Level AA.** Colour, focus, motion, target size and reflow are decided here, so the\n frontend can't build them wrong (details in 5.1 and 5.3).\n\nThese rules are for you, not the user. **Never mention them**: no grid, contrast, WCAG or\naccessibility talk in anything the user reads, and no \"all options follow…\" reassurances. The user\njudges the look in the preview; the rules just make sure it works.\n\n---\n\n## Step 1 — Read the workspace, then the resume check\n\nRead `brief.md`, `state.json`, and `content-model.md` if it exists.\n\n- **`design.md` already exists and this is not a redesign** → don't overwrite it. Describe the\n current look in two or three plain sentences and ask: *keep it, or change something?* That is this\n step's one question.\n - **Keep** → leave the file alone and go straight to the `state.json` update at the end of Step 7.\n - **Change** → edit only what they asked for, re-run the 5.x checks on what changed, then save.\n- **The site is already deployed** (`deploy.json` exists, or `state.json` has `deployed_at`) and the\n user asked for a different look → this is a **redesign**. Tell them plainly: their content stays\n as it is, only the look changes, and they'll see a preview before it goes live. Then run Steps 2–7\n as normal. The current `design.md` becomes one more signal in Step 2, so carry over what the user\n wants to keep and move away from the rest.\n\n## Step 2 — Collect the signals\n\nBefore inventing anything, gather what already constrains the design:\n\n| Signal | Where | How it constrains |\n|---|---|---|\n| Tone, purpose, audience | `brief.md` → *Tone*, *Purpose*, *Audience* | Sets the mood all three directions must fit |\n| Brand assets | `brief.md` → *Assets* | Logo, brand colours and fonts are **fixed** in every direction. Never recolour a supplied logo; Step 6 uses it instead of designing one |\n| Pages and entities | `content-model.md` → *Navigation*, *Pages*, *Articles*; if it doesn't exist yet, `brief.md` → *Pages* | Drives the component inventory and page composition |\n| Reference sites | URLs in the brief or the conversation | Cues to borrow (see below) |\n| Images already sourced | `content-model.md` → the image lines under *Articles* (skip if none yet) | Glance at a few at small size; keep accents from clashing with their dominant colours |\n| Current design (redesign only) | existing `design.md` | What to keep, what to move away from |\n\n**Reference sites.** Open each one in a real browser if you have one. Static fetches are often\nblocked or rate-limited; without a browser, fetch the HTML and its linked CSS instead. If neither\nworks, say so in one line and continue from the brief alone. Pull **cues**:\n\n- **Fonts:** the computed `font-family` of `h1`/`h2` and of body `p`.\n- **Colours:** background colours weighted by painted area (element width × height), the top 5–8.\n Skip transparent and semi-transparent backgrounds and overlays (cookie banners, modals, chat\n widgets). Text colours come from headings, paragraphs and links.\n- **Feel:** look at the first screen and one further down (screenshots shown to you, not saved in\n the workspace) and note what creates the feel: type weight, colour blocking, shapes, density.\n\n**Borrow cues, never assets.** Don't use its logo, copy, images or exact layout. **The brief wins**\nover the live site when they disagree (the user's words are what they want). Note the mismatch in\nthe direction.\n\n**Brand colours that fail contrast** are still fixed, but only in roles where they pass. A pale brand\nyellow can be a background band or a large decorative shape, never body text. Say so in the\ndirection.\n\n## Step 3 — Derive three directions\n\nWrite three directions that **all fit the brief** but are **genuinely different**. Vary at least two\nof: palette (temperature, saturation, light vs. dark), type personality (geometric, grotesk, serif,\ndisplay), and shape language (sharp vs. round, flat vs. outlined, airy vs. dense). Three shades of\none idea is not a choice. Neither is a wildcard that ignores the brief.\n\nOrder them: **A is the recommendation**, the closest reading of the brief and references. B and C\npush in different directions.\n\nEach direction carries exactly this:\n\n- **Name** — 2–3 evocative words (\"Sticker-book maximal\", \"Quiet gallery\").\n- **Mood** — one line on how the site feels.\n- **Palette** — 5–7 hex colours with roles (base, text, primary accent, secondary accents, surface).\n These are the core colours the user sees; Step 5 may add derived shades (link, muted, error,\n focus) as tokens.\n- **Type** — a display font and a body font, both self-hostable through `@fontsource`.\n- **Shape language** — corners, borders, shadows and density in one phrase (\"chunky 3 px ink\n outlines, hard 8 px offset shadows, tilted cards\").\n- **Signature element** — the one memorable move (a scrolling marquee, stickers, oversized numerals,\n a duotone photo treatment). If it moves on its own, it gets a pause control (5.3).\n- **Logo idea** — one line on how the name becomes a logo, using a move from Step 6 (\"letters\n bounce in different colours and the O is a smiley\").\n\n**Only offer what can pass.** Before asking, run two quick gates on **all three**:\n\n1. **Contrast gate.** Every colour that will carry text (base, surfaces, and any accent used as a\n text block) has a palette text colour reaching **4.5:1** on it. Two text colours are fine (dark on\n light blocks, light on dark ones). Accents that only ever hold large text need 3:1.\n2. **Font gate.** Each font resolves on npm, and the display font also has a static `@fontsource`\n package, because the logo is built from it (5.4).\n\nA direction that fails either gate gets adjusted, not offered as-is. Don't offer a look that only\nworks by breaking the rules, such as body text on a neon accent or a marquee that can't be stopped.\nLoud is fine; inaccessible is not.\n\n**Avoid the template look** unless the brief asks for neutral: Inter or Roboto everywhere, a\nblue-to-purple gradient hero, centred hero with three feature cards, grey cards on white. Every\ndirection should look like *this* site, not a starter theme.\n\n## Step 4 — Ask once\n\nAsk **one** question, with A marked as the recommendation. With a structured question UI (multiple\nchoice plus an \"other\" field), each option's **label** is the direction's name and its\n**description** is one or two plain sentences, under ~160 characters. Without one, write each\noption as one line in the same shape.\n\nThe description says what the site would feel like, the main colours by name, what the lettering\nis like, the logo idea, and the one detail they'll remember. Hex codes and font names stay in your\nnotes.\n\n> **Sticker-book maximal** — Loud and playful, like a scrapbook: candy pink, butter yellow and lilac,\n> chunky headlines, tilted sticker photos and a bouncy logo with a smiley O.\n\n> **Midnight disco** — A dark, glowing night-out feel: deep purple with neon pink, yellow and\n> turquoise, heavy rounded headlines and a logo like a neon sign.\n\nThen:\n\n- **Pick** → use it as written.\n- **\"You decide\" / skipped** → use A, and tell them in one line which look you went with.\n- **A mix** (\"A's colours with C's fonts\") → merge them. Don't ask again.\n- **Something else** → build a direction from what they said, run Step 5, and use it. Don't send\n three new options.\n\nDon't ask about anything else in this step.\n\n## Step 5 — Check the chosen direction\n\nFix any problem found here by adjusting the direction, not by asking the user. Don't report these\nchecks or their fixes; the user sees the result in the preview.\n\n### 5.1 Contrast — WCAG 2.2 SC 1.4.3 and 1.4.11\n\n**Which pairs count:** walk the component inventory you're about to write (Components table). For\neach component, list every foreground/background combination in every state: default, hover,\nfocus, active, error. Include text on coloured bands (footer links on dark, marquee text, chips),\nplaceholder text and photo credits. Compute the WCAG contrast ratio for each:\n\n| Kind | Examples | Minimum |\n|---|---|---|\n| Body text | paragraphs, links, footer text, input text, placeholder text, captions | **4.5:1** |\n| Large text | ≥ 24 px regular, or ≥ 18.66 px (14 pt) bold | **3:1** |\n| Non-text | input and button boundaries (when the outline is the only edge), focus indicator against each adjacent colour, icons that carry meaning, chip outlines | **3:1** |\n\nA pair that fails gets a darker or lighter shade of the **same hue**; never drop a brand colour. A\nnew shade becomes a token (for example `--color-link` or `--color-muted`). Disabled controls and\npure decoration are exempt, but say which elements those are. **Text on photos** needs a scrim or\nsolid backing that gets the text to 4.5:1 against the lightest part of the image. If you can't\nguarantee that, don't put text on photos. Record every pair and its ratio in `design.md`.\n\n```js\n// node -e: relative luminance + contrast ratio, hex in → ratio out\nconst L = h => { const c = h.match(/\\w\\w/g).map(x => parseInt(x, 16) / 255)\n .map(v => v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);\n return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; };\nconst ratio = (a, b) => { const [x, y] = [L(a), L(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };\n```\n\n### 5.2 The 8-point grid\n\nValues are checked in px at the default 16 px root (`0.5rem` = 8 px).\n\n| What | Rule |\n|---|---|\n| Spacing (padding, margin, gap) | Multiples of 8: `8 16 24 32 48 64 96 128`. **4 px** is the only half-step, and only inside small components (chip padding, icon-to-label gap) |\n| Component sizes | Heights and fixed widths are multiples of 8. Buttons, nav items and inputs are **48 px** tall minimum |\n| Radii | `8 16 24 32`, or `999px` for pills. 4 px only on elements under 32 px tall |\n| Line heights | Fixed-size text: multiples of 8 (16/24, 18/32, 24/32), or of 4 for text ≤ 14 px (12/16, 14/20). Fluid headings (`clamp()` sizes) use a unitless line-height (1.0–1.2) and are exempt, because their line box can't stay on the grid at every size |\n| Fluid spacing | `clamp()` for spacing or sizes uses grid values at both ends. Fluid **font sizes** are exempt |\n| Shadow offsets | 4 or 8 multiples |\n| Layout | Content width, gutters and breakpoints are multiples of 8 (e.g. `1200`, `768`, `1024`, `1280`) |\n| Exempt | Border and outline widths (1–4 px), focus offset, font sizes, the `999px` pill radius, the 4-multiple line heights above, and anything that isn't a length (angles like a `-2deg` tilt, durations, opacity) |\n\nEvery value in the **Tokens** block must pass this table.\n\n### 5.3 WCAG 2.2 AA — what the design must decide\n\nThese criteria are settled at design time. Write each answer into the **Accessibility** section of\n`design.md`.\n\n| Criterion | The design must specify |\n|---|---|\n| 1.4.1 Use of colour | Links in running text are underlined, not colour-only. States (active nav, selected, error) also change shape, weight or an icon, not only colour |\n| 1.4.4 Resize text | Font sizes in `rem`; nothing breaks at 200 % zoom |\n| 1.4.10 Reflow | Works at **320 CSS px** wide with no horizontal scroll. Every component has a stated ≤ 320 px behaviour; decorative pieces never widen the page |\n| 1.4.12 Text spacing | No fixed-height boxes around text, so nothing clips when a user overrides spacing (line-height 1.5, paragraph spacing 2×, letter-spacing 0.12 em, word-spacing 0.16 em). Fixed line-height tokens are fine; clipping is not |\n| 2.2.2 Pause, Stop, Hide | Anything that moves on its own for more than 5 s (marquee, carousel, animated sticker) has a **visible pause control**, and doesn't move at all under `prefers-reduced-motion`. Hover-to-pause alone doesn't count |\n| 2.3.1 Three flashes | Nothing flashes more than 3 times a second |\n| 2.4.7 Focus visible | One focus style (e.g. 3 px outline, 2 px offset) whose colour reaches 3:1 against **every** surface it can appear on. Name a second colour for surfaces where the first fails |\n| 2.4.11 Focus not obscured *(new in 2.2)* | Sticky headers, cookie bars and chat bubbles never cover the focused element. Either the header isn't sticky, or `scroll-padding-top: var(--header-h)` |\n| 2.5.8 Target size *(new in 2.2)* | Interactive targets ≥ 24 × 24 px with spacing. On this grid: **48 px** for buttons, nav and inputs, ≥ 24 px for inline icon buttons |\n| 3.2.6 Consistent help *(new in 2.2)* | The contact or help link sits in the same place on every page (e.g. last nav item and footer) |\n| 3.3.2 Labels | Every form field has a visible label; placeholders are never the only label |\n| 1.1.1 Text alternatives | Content images get alt text describing what they show; purely decorative images and shapes get empty alt or `aria-hidden` |\n\n### 5.4 Fonts resolve\n\nFor each family, prefer the variable package and fall back to the static one:\n`npm view @fontsource-variable/<slug> version`, then `npm view @fontsource/<slug> version`. One must\nanswer with a version. If neither does, swap to the closest family that does. Record the exact\npackage names. The **display** family must also resolve as a static package\n(`npm view @fontsource/<slug> version`): the static packages ship `.woff` files the logo kit can\nread; the variable ones ship only `.woff2`.\n\n## Step 6 — Design the logo\n\n**The user supplied a logo** → copy it into `brand/` unchanged, note it in the **Logo** section, and\nskip the rest of this step. Never redraw or recolour it.\n\nOtherwise, turn the name into a **designed** logo: shaped, coloured and composed. Typing the name in\nthe display font with an emoji or symbol next to it doesn't count. Build the **logo idea** from the\nchosen direction with one or two of these moves:\n\n| Move | What it is | Suits |\n|---|---|---|\n| **Letter swap** | One letter becomes a simple shape tied to the subject: O → smiley, sun, loaf, plate, eye; I → candle, pencil, brush; A → roof, tent. Swap **one character** only, even if the letter appears twice, and the word must still read | Playful, friendly brands |\n| **Bounce** | Letters tilt ±3–6° and bob off the baseline, each in a palette colour, with an ink outline and hard shadow | Loud, maximal directions |\n| **Stack** | The name split over 2–3 lines on coloured tiles, or set as a tight block | Long names, bold directions |\n| **Tag** | The main word large; a short second word on a pill, ribbon or tab | Two-part names (\"Dopamine Den\") |\n| **Seal** | The name around a ring, with a monogram or symbol in the middle | A secondary mark (footer, hero sticker, social avatar), never the header logo |\n| **Typographic contrast** | Weight, style or case contrast between words (a light italic word next to a heavy upright one), custom tight spacing, one shared stroke or cut between letters | Quiet, refined, editorial directions |\n\nNo emoji, clip-art, gradients, or details that vanish at small sizes. Colours come only from the\npalette (base and surface colours count).\n\n### Build it with the logo kit\n\nThe kit (`logo-kit.mjs`, next to this file) does the error-prone parts: it outlines letters into\nvector paths, sets text on a circle, draws the outline-and-shadow treatment, measures, and validates\nthe output. You write the composition. Copy the kit into `brand/` so the build is self-contained\nand a redesign can re-run it:\n\n```bash\nmkdir -p ~/.hostinger/<domain>/brand && cd ~/.hostinger/<domain>/brand\ncp <the folder this SKILL.md is in>/logo-kit.mjs .\nnpm init -y >/dev/null && npm i opentype.js @fontsource/<display-slug>\n```\n\nWrite `brand/logo-build.mjs`, import from `./logo-kit.mjs`, and always run it from `brand/`\n(packages resolve from the working directory):\n\n| Kit function | Use |\n|---|---|\n| `loadFont(slug, weight, style)` | Loads a static `@fontsource` weight across all its subsets (`latin`, `latin-ext`, …), so accented letters like Ė or Ł resolve; `style` is `'normal'` or `'italic'`. Throws if that weight or style isn't in the package, so pick another |\n| `layout(font, text, size)` | Kerned glyph positions, total width, `capHeight`, `xHeight`; place letters one by one for bounce or swap. Throws if a character isn't in the font |\n| `glyphPath(glyph, x, baselineY, size)` / `textPath(font, text, x, baselineY, size)` | Outlined path data for one letter or a whole word. For typographic contrast, load two weights or styles and set each word with `textPath` |\n| `pathBox(font, text, x, baselineY, size)` | Tight box around a word's outlines; size the viewBox from it plus room for outlines, shadows and tilt |\n| `arcText(font, text, { cx, cy, r, size })` | Letters around a circle, for seals |\n| `sticker(d, { fill, ink, strokeWidth, shadow, shadowColor, transform })` | Ink outline + hard offset shadow, for outlined directions. On dark backgrounds give the outline and shadow a palette colour that shows (`shadowColor` defaults to `ink`) |\n| `svgDoc({ width, height, title, body })` | Root SVG with `role=\"img\"` and a `<title>` |\n| `check(svg)` | Throws on `NaN`, live `<text>`, a missing title or viewBox. Run it on every file before writing |\n\nNever call opentype's `path.toPathData()` directly; the kit's serializer exists because opentype.js\n2.0 writes `NaN` into some paths, and browsers silently stop drawing at the first `NaN`.\n\n### Deliver four files\n\n| File | What | Must read clearly at |\n|---|---|---|\n| `brand/logo.svg` | The primary, mostly horizontal logo for the header | Its header display heights (desktop and small screens) |\n| `brand/logo-mark.svg` | A square icon from the same idea (initial, monogram or the swapped symbol) on a solid rounded tile (corner radius ≈ ¼ of the side) in a palette colour | 32 px |\n| `brand/favicon.svg` | The simplest form of the mark: same tile, no shadow, no thin details, outlines thickened if it has any | 16 px, on both light and dark browser tab bars |\n| `brand/logo-build.mjs` | The script that built them, with `package.json`, kept so a redesign can adjust it | — |\n\nAdd `brand/logo-seal.svg` only if the direction uses a seal as a secondary mark. Delete\n`brand/node_modules` when you're done; a redesign runs `npm i` in `brand/` first.\n\n**Check the sizes.** Display heights are multiples of 8, with one for desktop and one for small\nscreens (they may be equal). Work them out from the viewBox:\n\n- **Letter size:** letter height on screen = letter height ÷ viewBox height × display height, and it\n must be ≥ 16 px. Use `xHeight` when the logo is mostly lowercase and `capHeight` when it's mostly\n capitals; for mixed fonts, take the smaller word. A two-line lockup usually needs 64 px.\n- **Headroom:** `pathBox` covers the letters, not what you add. Leave room above for accents (Ė, Ž, Å sit above cap height) and below for descenders, plus whatever bounce, tilt, outline and shadow add, so nothing touches the viewBox edge.\n- **Width:** width on screen = viewBox width ÷ viewBox height × display height. At the small-screen\n height it must be **≤ 192 px**, which leaves room for the menu button in a 320 px header. If it\n doesn't fit, lower the small-screen height (letters still ≥ 16 px), simplify the logo, or show\n `logo-mark.svg` with the name beside it on small screens.\n- **Look at it.** If you have a browser, render the files at both header heights, 32 px and 16 px,\n the favicon on a light and a dark background, and fix anything that reads wrong. Without a\n browser, rely on the numbers and list \"logo not visually checked\" under gaps in the final report.\n\nThe logo is exempt from text-contrast minimums, but the header link around it needs `alt` = the site\nname and a target of at least 24 px.\n\nDon't show the logo to the user in this step; they see it in the preview.\n\n## Step 7 — Write `design.md` and hand off\n\nWrite `design.md` from the template below. Fill every section. The **Tokens** block is copied\nverbatim into the frontend's global stylesheet, so it must be valid CSS. Every colour, font, type\nsize, line height, space, radius and shadow used elsewhere in the file must exist in it as a token.\nThe template's token list is the **minimum**: add what the design needs (`--color-error`,\n`--color-on-accent`, `--leading-h3`…) under the same naming scheme.\n**Breakpoints are the one exception**: CSS custom properties don't work inside `@media`, so they are\nlisted in *Grid, shape & motion* as plain px values.\n\nThen update `state.json`: set `\"stage\": \"frontend\"` (also for a redesign: restyle, then validate and\ndeploy) and `updated_at` to the current UTC time (ISO 8601). Don't touch `provision`, `run` or\n`history` (`run-log.mjs` owns those), or add other keys.\n\nIf content prepare hasn't sourced images yet, it now follows `design.md` → **Imagery** (see\n`04-content-model/SKILL.md`).\n\n## `design.md` template\n\n````markdown\n# Design — <domain>\n\n> Direction **<name>**, selected on <YYYY-MM-DD> from three options (others offered: <B name>,\n> <C name>). <\"Picked by the user.\" | \"User said 'you decide'; recommendation A used.\" |\n> \"Merged by the user: <what>.\">\n\n## Direction\n<2–3 sentences: the mood, what it borrows from the references (and any brief/reference mismatch),\nthe signature element.>\n\n## Stack\n**<chosen by the frontend step>** — design doesn't decide the stack. The frontend step (06) picks and\nconfirms the framework (default Next.js SSR; ecommerce must be SSR) and records it in\n`state.json → frontend`. Leave this as a placeholder for the frontend step to fill in.\n\n## Tokens\n```css\n:root {\n /* colour — semantic names, never \"--pink-2\" */\n --color-base: #…; --color-text: #…; --color-surface: #…; --color-muted: #…;\n --color-primary: #…; --color-accent-1: #…; --color-accent-2: #…; --color-link: #…;\n --color-focus: #…; --color-focus-alt: #…; /* for surfaces where --color-focus is < 3:1 */\n /* type */\n --font-display: '<Family> Variable', <fallback stack>;\n --font-body: '<Family> Variable', <fallback stack>;\n --text-body: …rem; --leading-body: …rem; /* 8-multiple in px */\n --text-small: …rem; --leading-small: …rem; /* 4-multiple in px */\n --text-h1: clamp(…rem, …, …rem); --text-h2: clamp(…); --text-h3: …rem;\n --leading-display: 1.05; /* unitless; fluid headings are grid-exempt */\n /* 8-point grid */\n --space-half: 4px; --space-1: 8px; --space-2: 16px; --space-3: 24px; --space-4: 32px;\n --space-6: 48px; --space-8: 64px; --space-12: 96px; --space-16: 128px;\n --target-min: 48px;\n /* shape */\n --radius-sm: 8px; --radius-card: …; --radius-pill: 999px;\n --border: …; --shadow: …; --shadow-hover: …;\n --focus-ring: 3px solid var(--color-focus); --focus-offset: 2px;\n /* layout */\n --content-max: …px; /* multiple of 8 */\n --header-h: …px; /* header band height, multiple of 8; scroll-padding-top uses it if sticky */\n --logo-h: …px; /* header logo height on desktop, multiple of 8 */\n --logo-h-sm: …px; /* on small screens; logo ≤ 192 px wide at this height */\n}\n```\n\n## Palette roles\n| Token | Hex | Used for |\n|---|---|---|\n\n## Contrast\n| Foreground | Background | Kind (body text / large text / non-text) | Ratio | Needs | Passes |\n|---|---|---|---|---|---|\n\n## Type\n- **Display:** <family> (`@fontsource-variable/<slug>@<version>`), weights …, tracking …; sizes `--text-h1…h3`, `--leading-display`\n- **Body:** <family> (`@fontsource-variable/<slug>@<version>`); `--text-body` / `--leading-body`, measure ≤ 68ch\n\n## Logo\n- **Concept:** <the move(s), and what any swapped letter or symbol stands for | \"Supplied by the user\">\n- **Built from:** `@fontsource/<display-slug>@<version>`, weight(s)/style(s) …\n- **Files:** `brand/logo.svg` (header, `--logo-h` desktop / `--logo-h-sm` small screens),\n `brand/logo-mark.svg`, `brand/favicon.svg`<, `brand/logo-seal.svg`>; source `brand/logo-build.mjs`\n- **Measured:** letters … px tall at `--logo-h-sm`; width … px at `--logo-h-sm`\n- **Use:** the frontend copies `brand/*.svg` into `public/`. Header: `<a href=\"/\"><img src=\"/logo.svg\"\n alt=\"<Site name>\" style=\"height: var(--logo-h)\"></a>` (switch to `--logo-h-sm` on small screens).\n Favicon: `<link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\">`\n- **Don't:** recolour, stretch, add effects, or retype the logo as live text.\n\n## Grid, shape & motion\n<section padding desktop/mobile (space tokens), gutters, breakpoints in px (multiples of 8), borders,\nradii, shadows, hover behaviour, every animation and its pause control, and \"no motion under\nprefers-reduced-motion\".>\n\n## Accessibility (WCAG 2.2 AA)\n- **Focus:** <style; which colour on which surface>\n- **Targets:** <48 px controls; inline exceptions>\n- **Links & states:** <underline rule; how active/error states differ beyond colour>\n- **Motion:** <what moves, where its pause control is>\n- **Sticky elements:** <none | header uses --header-h + scroll-padding-top>\n- **Help:** <where the contact link sits on every page>\n- **Text on images:** <none | scrim spec and resulting ratio>\n- **Reflow:** <anything special at 320 px>\n- **Exempt from contrast:** <disabled controls / decorative shapes, named>\n\n## Components\n| Component | Renders (from content model) | Look | ≤ 320 px |\n|---|---|---|---|\n| Header + nav | Navigation items | … | … |\n| Post card | Article: image, category, title, excerpt | … | … |\n| … | | | |\n\n## Page composition\n- **<Page>** — <sections top to bottom, each naming its component and band colour>\n- …\n\n## Imagery\n<subjects, mood and colour fit for photos; search keywords; treatment (frame, crop ratio, radius,\nduotone…); alt text written from what the photo shows.>\n\n## Don'ts\n- <3–6 site-specific rules, e.g. \"no grey cards\", \"never put body text on the primary accent\">\n````\n\n## Invariants\n\n- **One question.** Refine owns discovery; this step only chooses among directions (or keeps the\n existing one).\n- **8-point grid, always.** No spacing, size, radius or breakpoint off the grid except the listed\n exemptions.\n- **WCAG 2.2 AA is the floor.** No direction is offered, chosen or written that fails it. Brand\n colours keep their hue but only appear in roles where they pass.\n- **Brand assets are fixed.** A supplied logo, colour or font appears in every direction, unchanged.\n- **A logo is designed, not typed.** Outlined SVG paths built with the logo kit, never live text,\n emoji or clip-art, and always readable at its smallest use.\n- **Self-hosted fonts only** (`@fontsource`). No Google Fonts or other font CDN links in the output.\n- **The tokens block is the only source of values** (breakpoints excepted). Components and pages\n reference tokens, never raw hex codes, pixel values or font names.\n- **No backend calls.** Design never waits on provision and never writes to the instance.\n- **Never overwrite an existing `design.md` silently.** Keep or change, as in Step 1.\n"
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
"uri": "skill://hostinger-headless/steps/05-design/logo-kit.mjs",
|
|
221
|
+
"digest": "sha256:5c903ecc4ce313c6160ff8bf979ceaac0899a4214b00ee71ed6d95c651a1bf08",
|
|
222
|
+
"size": 9122,
|
|
223
|
+
"mimeType": "text/plain",
|
|
224
|
+
"text": "// Hostinger Headless — logo kit (step 05-design).\n//\n// Primitives for building a designed logo as outlined SVG: the letters become vector paths, so the\n// logo needs no web font and renders identically everywhere. The agent composes the design; this\n// file only does the parts that are easy to get wrong.\n//\n// Workflow (from ~/.hostinger/<domain>/brand/, so packages and this kit sit next to the build):\n// cp <installed skill>/steps/05-design/logo-kit.mjs .\n// npm init -y && npm i opentype.js @fontsource/<display-family-slug>\n// node logo-build.mjs // your script: import { … } from './logo-kit.mjs'\n// rm -rf node_modules // package.json keeps the deps; a redesign runs `npm i` again\n//\n// Packages resolve from the current working directory, so run the build from brand/.\n\nimport { createRequire } from 'node:module';\nimport { readFileSync } from 'node:fs';\nimport { join } from 'node:path';\n\nconst requireFromCwd = createRequire(join(process.cwd(), 'logo-build.js'));\nconst opentype = requireFromCwd('opentype.js');\n\n// @fontsource splits each weight into unicode subsets; `latin` alone has no ą č ę ė į š ų ū ž,\n// ő, ł, đ… so load every subset that exists and look each character up across them.\nconst SUBSETS = ['latin', 'latin-ext', 'vietnamese', 'cyrillic', 'cyrillic-ext', 'greek', 'greek-ext'];\n\nfunction parseWoff(file) {\n const buf = readFileSync(file);\n return opentype.parse(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));\n}\n\n/**\n * Load a static @fontsource weight (the static packages ship .woff, which opentype.js reads;\n * variable packages ship only .woff2, which it can't). The returned font is the `latin` subset\n * with `charToGlyph` widened to every other subset in the package, so accented letters resolve.\n * @param {string} slug family slug, e.g. \"unbounded\"\n * @param {number} weight e.g. 900\n */\nexport function loadFont(slug, weight = 400, style = 'normal') {\n const fonts = [];\n for (const subset of SUBSETS) {\n let file;\n try {\n file = requireFromCwd.resolve(`@fontsource/${slug}/files/${slug}-${subset}-${weight}-${style}.woff`);\n } catch {\n continue;\n }\n fonts.push(parseWoff(file));\n }\n if (!fonts.length) throw new Error(`logo-kit: @fontsource/${slug} has no ${weight} ${style} .woff files`);\n const [primary] = fonts;\n const own = primary.charToGlyph.bind(primary);\n primary.charToGlyph = (ch) => {\n for (const font of fonts) {\n const glyph = font === primary ? own(ch) : font.charToGlyph(ch);\n if (glyph.index) {\n glyph.kitFont = font;\n return glyph;\n }\n }\n return own(ch);\n };\n return primary;\n}\n\nconst num = (v) => {\n if (!Number.isFinite(v)) throw new Error(`logo-kit: non-finite coordinate ${v}`);\n return +v.toFixed(2);\n};\n\n/**\n * Serialize an opentype Path to SVG path data. Use this instead of path.toPathData():\n * opentype.js 2.0 emits \"NaN\" when a coordinate's fraction is a tiny float residue\n * (its roundDecimal builds strings like \"5.6e-14e+2\"), and the browser silently stops drawing\n * the path at the first NaN.\n */\nexport function toD(path) {\n return path.commands\n .map((c) => {\n switch (c.type) {\n case 'Z':\n return 'Z';\n case 'Q':\n return `Q${num(c.x1)} ${num(c.y1)} ${num(c.x)} ${num(c.y)}`;\n case 'C':\n return `C${num(c.x1)} ${num(c.y1)} ${num(c.x2)} ${num(c.y2)} ${num(c.x)} ${num(c.y)}`;\n default:\n return `${c.type}${num(c.x)} ${num(c.y)}`;\n }\n })\n .join('');\n}\n\n/**\n * Lay out a string with kerning. Positions are relative to x = 0 on the baseline.\n * One glyph per character: no ligatures or other substitutions, so `char` always matches its glyph\n * (and fonts whose substitution tables opentype.js can't run still work).\n * @returns {{ glyphs: { glyph, char: string, x: number, width: number }[], width: number, ascent: number, capHeight: number, xHeight: number }}\n */\nexport function layout(font, text, size, { tracking = 0 } = {}) {\n const scale = size / font.unitsPerEm;\n const chars = [...text];\n const glyphs = chars.map((ch) => font.charToGlyph(ch));\n glyphs.forEach((glyph, i) => {\n if (!glyph.index && chars[i].trim()) {\n throw new Error(`logo-kit: \"${chars[i]}\" is not in this font (it would render as an empty box); pick a font that has it`);\n }\n });\n let x = 0;\n const out = glyphs.map((glyph, i) => {\n // Kerning pairs only exist inside one subset file; across subsets the indexes don't match.\n if (i && (glyphs[i - 1].kitFont ?? font) === (glyph.kitFont ?? font)) {\n const kern = font.getKerningValue(glyphs[i - 1], glyph);\n x += (Number.isFinite(kern) ? kern : 0) * scale + tracking;\n }\n const item = { glyph, char: chars[i] ?? '', x, width: glyph.advanceWidth * scale };\n x += item.width;\n return item;\n });\n const capHeight = (font.tables.os2?.sCapHeight || font.ascender * 0.7) * scale;\n const xHeight = (font.tables.os2?.sxHeight || font.ascender * 0.5) * scale;\n return { glyphs: out, width: x, ascent: font.ascender * scale, capHeight, xHeight };\n}\n\n/**\n * Tight bounding box of a word's outlines at (x, baselineY), before any transform. Use it to size\n * the viewBox; add room for outlines, shadows and rotations yourself.\n * @returns {{ x1: number, y1: number, x2: number, y2: number }}\n */\nexport function pathBox(font, text, x, baselineY, size, opts) {\n const path = new opentype.Path();\n for (const g of layout(font, text, size, opts).glyphs) path.extend(g.glyph.getPath(x + g.x, baselineY, size));\n return path.getBoundingBox();\n}\n\n/** Path data for one glyph with its origin at (x, baselineY). */\nexport const glyphPath = (glyph, x, baselineY, size) => toD(glyph.getPath(x, baselineY, size));\n\n/** Path data for a whole string starting at (x, baselineY). */\nexport function textPath(font, text, x, baselineY, size, opts) {\n return layout(font, text, size, opts)\n .glyphs.map((g) => glyphPath(g.glyph, x + g.x, baselineY, size))\n .join('');\n}\n\n/**\n * Set text around a circle (seals, badges). Each glyph is centred on its arc position and rotated\n * to stand on the circle. `fill: true` stretches the spacing so the text closes the full ring.\n * @returns {{ d: string, transform: string, char: string }[]} style each glyph however you like\n */\nexport function arcText(font, text, { cx, cy, r, size, startDeg = -90, fill = true }) {\n const { glyphs, width, capHeight } = layout(font, text, size);\n const k = fill ? (2 * Math.PI * r) / width : 1;\n const offset = fill ? 0 : -width / 2; // centred on startDeg when not filling\n return glyphs.map((g) => {\n const along = (g.x + g.width / 2 + offset) * k;\n const angle = along / r + (startDeg * Math.PI) / 180;\n const px = cx + r * Math.cos(angle);\n const py = cy + r * Math.sin(angle);\n const deg = (angle * 180) / Math.PI + 90;\n return {\n d: glyphPath(g.glyph, -g.width / 2, capHeight / 2, size),\n transform: `translate(${num(px)} ${num(py)}) rotate(${num(deg)})`,\n char: g.char,\n };\n });\n}\n\n/**\n * The outlined \"cut-out\" treatment: an ink outline drawn behind the fill (paint-order) plus an\n * optional hard offset shadow. `shadowColor` defaults to the ink; set it separately on dark\n * backgrounds, where a dark ink outline vanishes but a coloured shadow still reads.\n * Returns SVG markup.\n */\nexport function sticker(d, { fill, ink = '#000', strokeWidth = 8, shadow = 8, shadowColor = ink, transform = '' }) {\n const t = transform ? ` transform=\"${transform}\"` : '';\n const shade = shadow\n ? `<path d=\"${d}\" fill=\"${shadowColor}\" stroke=\"${shadowColor}\" stroke-width=\"${strokeWidth}\" stroke-linejoin=\"round\" transform=\"translate(${shadow} ${shadow})\"/>`\n : '';\n return `<g${t}>${shade}<path d=\"${d}\" fill=\"${fill}\" stroke=\"${ink}\" stroke-width=\"${strokeWidth}\" stroke-linejoin=\"round\" paint-order=\"stroke\"/></g>`;\n}\n\n/** Root SVG with an accessible name. Pass a distinct `id` if two logos are inlined on one page. */\nexport function svgDoc({ width, height, title, body, id = 'logo-title' }) {\n const esc = String(title).replace(/&/g, '&').replace(/</g, '<');\n return `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${num(width)} ${num(height)}\" role=\"img\" aria-labelledby=\"${id}\"><title id=\"${id}\">${esc}</title>${body}</svg>`;\n}\n\n/**\n * Validate a finished SVG string before writing it. Throws on: NaN/undefined/Infinity in the\n * markup, live <text> (the logo must be outlined), a missing <title>, or a missing viewBox.\n * @returns {{ bytes: number }}\n */\nexport function check(svg) {\n const problems = [];\n if (/NaN|undefined|Infinity/.test(svg)) problems.push('contains NaN/undefined/Infinity');\n if (/<text[\\s>]/.test(svg)) problems.push('contains <text>; outline the letters with textPath/glyphPath');\n if (!/<title[^>]*>[^<]+<\\/title>/.test(svg)) problems.push('missing <title>');\n if (!/viewBox=\"0 0 [\\d.]+ [\\d.]+\"/.test(svg)) problems.push('missing or invalid viewBox');\n if (problems.length) throw new Error(`logo-kit check failed: ${problems.join('; ')}`);\n return { bytes: Buffer.byteLength(svg) };\n}\n"
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"uri": "skill://hostinger-headless/steps/06-frontend/SKILL.md",
|
|
228
|
+
"digest": "sha256:6d4ff103990f0ca0cb28497079ad6d5a3f902c51dcfeb3b70561bb8216d2a25a",
|
|
229
|
+
"size": 9079,
|
|
90
230
|
"mimeType": "text/markdown",
|
|
91
|
-
"text": "
|
|
231
|
+
"text": "---\nname: headless-step-frontend\ndescription: \"Step 6 (frontend) of the Hostinger Headless flow. Builds the website that renders the backend's content. Figures out (or confirms) the framework — defaulting to Next.js SSR — implements the look already chosen in design.md (the design step's contract), and builds the source into src/. Only ask for the clarifications you actually need.\"\n---\n\n# headless-step-frontend\n\nBuild the site's frontend against the backend from `site.json`, implementing the look the design step\nalready chose and wrote to **`design.md`** (plus the logo in `brand/`). The design is decided; this step\nis where it becomes real code. There's no per-framework recipe — use good judgement, follow `design.md`\nas the contract, and ask only for the clarifications you genuinely need. **Out:** source in the site\nworkspace `~/.hostinger/<domain>/src/`.\n\n> ## ⚠️ Webapp backend? Read this first\n> A `webapp` brief has **no CMS/store and no `content-model.md`** — the app owns its own data (its own\n> API, or the MySQL database recorded in `site.json → database`). It comes in two shapes, decided back in\n> **refine**:\n>\n> - **An existing app the user brought to deploy** (refine routes \"deploy my app\" here). **Use their\n> project as the source** — copy/point `~/.hostinger/<domain>/src/` at it and keep its own framework and\n> UI. **Do not scaffold a new project, and do not re-implement `design.md` over it** (so **the design\n> step does not apply** to an app that brings its own UI). Your job is only to make it deployable: set\n> `engines.node` to `22`, remove any `.htaccess`, wire its data/env, then hand off to deploy. **Skip the\n> Step 2 design pass**, and ignore the `content-model.md` requirement in Step 3.\n> - **A new app built from the brief.** Scaffold and build it from `brief.md` (and `design.md` if the\n> design step produced one — design *does* run for a from-scratch app). Its data is owned by the app, so\n> there is still **no `content-model.md`** to bind against.\n>\n> Framework choice (Step 1), the build, and hand-off (Step 4) apply to both. The per-backend bullets in\n> Step 3 are **WordPress/Ecommerce only** — for a webapp follow the Webapp bullet there.\n\n## Step 1 — Pick the framework (guess → default → confirm)\n\n**The rendering is already decided in `brief.md` → Rendering** (SSR or SSG, set during refine). Honour\nit, and the backend constraint:\n\n- **Ecommerce → SSR required** (carts/search need per-request rendering). Any SSR framework works —\n Next.js is the default, not a requirement (Express, Nuxt, SvelteKit, etc. are all fine).\n- **WordPress → SSG (Astro)** when the brief says SSG, otherwise **SSR (Next.js default)**.\n- **Webapp → the app's own stack.** An **existing app** keeps its current framework (don't impose one); a\n **new app** uses whatever fits the brief (SSR or SPA). Either way it deploys as a Node.js app (Step 8) —\n an SSR app runs a server, a SPA builds to static output.\n- Map rendering → framework: **`ssr` → Next.js**, **`ssg` → Astro** — unless the brief/prompt explicitly\n names another supported framework, in which case use that.\n\nSupported frameworks (what the Hostinger build pipeline can deploy — the `app_type` in Step 8 deploy):\n\n- **Server/SSR-capable:** Next.js, Nuxt.js, Astro, SvelteKit, Express, NestJS, Fastify.\n- **Static / SPA:** React, Vue, Angular, Vite, Parcel, Preact, Svelte, (Astro / SvelteKit static too).\n\nConfirm the framework with the user in one line only if the brief left it open. Record the choice in\n`state.json`:\n\n```json\n{ \"frontend\": { \"framework\": \"nextjs\", \"rendering\": \"ssr\" } }\n```\n\nIf the brief is silent, default to **Next.js SSR**. Ecommerce must be SSR (never SSG) — but the SSR\nframework itself is free to be any supported one, not just Next.js.\n\n## Step 2 — Implement `design.md` (don't re-design)\n\nThe look was already chosen and written by the design step. **Read `~/.hostinger/<domain>/design.md` and\nimplement it faithfully** — tokens (colour, type, spacing), components, per-page composition, imagery and\naccessibility — plus the logo files in `brand/`. Don't re-run design questions or invent a new direction here.\n\n- Treat `design.md` as the contract: the built pages should match its tokens, type scale, components and\n per-page composition, and the 8-point grid / WCAG 2.2 AA rules it carries.\n- Use the **logo** from `brand/` as-is (wordmark, icon mark, favicon); never recolour or redraw it.\n- If `design.md` is **missing** (e.g. a resumed older build), fall back to `brief.md → Design direction`\n (reference site, colours, brand, adjectives) and a tasteful, accessible default — but prefer `design.md`\n whenever it exists.\n- **Don't gate on approval here** — the result is shown at **validate** (Step 7).\n\n**Only ask about the code, and only if it matters** (design decisions belong in the design step):\n- Where the **source should live** — default `~/.hostinger/<domain>/src/`; ask only if they want a\n specific folder or an existing repo.\n- **Git** — initialise / connect / push (default: a local project, no remote).\n- A specific **component library** or technical must-have, if they implied one.\n\n## Step 3 — Build against the backend\n\nTwo phases around the instance barrier (see the top-level `SKILL.md`): scaffold + build the shell while\nprovision settles, then wire live data once `provision.status == \"completed\"`.\n\nRead the backend from `site.json`:\n\n- **WordPress** → `urls.rest_api` (`wp/v2`), public read. Content is in the posts/pages/categories/media\n the content-model push created; nav/plan come from `content-model.md`. Map each item's **SEO** from its\n `yoast_head_json` into the page `<head>` (title, description, Open Graph, JSON-LD schema).\n- **Ecommerce** → the **public Hostinger Storefront Core V2 API** (`https://api-ecommerce.hostinger.com/v2`,\n no auth, open CORS), keyed on the **custom sales channel** (`site.json → ecommerce.sales_channel.id`):\n `GET /channels/<scha_*>/products` (prices are **minor units** on variants), `POST /channels/<scha_*>/checkout`\n for buying. Cart is client-side; never embed a token. Read\n `ecommerce_miscellaneous_custom-storefront-setup-instructions` for the authoritative, current guide.\n - **Honour `site.json → ecommerce.payment_enabled`.** When it is `false` (free account / no paid\n Ecommerce plan — see the provision gate), there is **no checkout**: build the full catalog, product\n pages and the client-side cart as normal, but **do not call the checkout endpoint**. Instead render\n a clear, graceful state at the checkout step — e.g. a disabled \"Checkout\" button with a short note\n like \"Online checkout isn't available on this plan yet\" (and/or a contact/enquiry fallback). A\n missing checkout is **not** a build error here — never leave the storefront half-built, throw, or\n retry checkout because payment is off. When `payment_enabled` is `true`, wire checkout normally.\n- **Webapp** → **no `content-model.md` and no headless backend to read**. The app uses its own data source:\n the MySQL database from `site.json → database` (through the `DB_*` / `DATABASE_URL` env vars — set at\n provision/deploy, see `../03-provision/webapp/SKILL.md` Step 3; `127.0.0.1` only resolves on the server,\n not locally), or whatever external API the app calls. For an existing app, keep its data layer as-is.\n\nGuidelines (framework-agnostic):\n\n- **One data module** is the single place that talks to the backend (e.g. `src/lib/data.ts`) — one file\n to inspect, one base URL to switch. Keep it swappable so the shell can build against stubs first.\n- **Fetch at request time for SSR** (fresh content, no rebuild); a static build fetches at build time.\n- **A failed or incomplete fetch is an error, not a silent empty/placeholder.** Planned content missing\n from the API is a build error too — a site that builds with holes ships with holes. *(WordPress/Ecommerce;\n a webapp has no planned content to check.)*\n- **`content-model.md` is the source for what should exist** (pages, nav, collections mapping) — for\n WordPress/Ecommerce only; a **webapp has no `content-model.md`**.\n- **No `.htaccess`** in the project (deploy would overwrite the live one); don't commit `node_modules`\n or build output.\n- Set `engines.node` to `22` (the deploy builds on Node 22).\n\n## Step 4 — Hand off\n\nConfirm the project builds clean locally against the live backend, no placeholder/fixture content\nremains, and `state.json → frontend` matches what you built. Update `state.json` (`stage: \"validate\"`).\nValidate (Step 7) owns the clean rebuild + user preview; deploy (Step 8) ships it with the `app_type`\nand output directory that match the chosen framework + rendering.\n\n> **Webapp:** just confirm the app builds clean and `state.json → frontend` is right — there's no\n> `content-model.md` or headless backend to check. A DB-backed app can't reach its database locally\n> (`127.0.0.1` is server-only), so its data layer is verified **after** deploy, not here.\n"
|
|
92
232
|
},
|
|
93
233
|
{
|
|
94
|
-
"uri": "skill://hostinger-headless/
|
|
95
|
-
"digest": "sha256:
|
|
96
|
-
"size":
|
|
234
|
+
"uri": "skill://hostinger-headless/steps/07-validate/SKILL.md",
|
|
235
|
+
"digest": "sha256:aefa8026b43282235ce966ffc95c2867e3c3c6e932dc8febf3dbeb44ce7e40cd",
|
|
236
|
+
"size": 3144,
|
|
97
237
|
"mimeType": "text/markdown",
|
|
98
|
-
"text": "
|
|
238
|
+
"text": "---\nname: headless-step-validate\ndescription: \"Step 7 (validate) of the Hostinger Headless flow. OPTIONAL quick gate before deploy: every prior stage already self-verifies, so this is a fast confirmation (instance live + backend API answers with seeded content) plus optional local preview and user sign-off. Skip the heavy parts when nobody needs to approve.\"\n---\n\n# headless-step-validate\n\n> **Optional / fast.** Every stage before this one already verifies its own work (provision checks the\n> backend, content-model push checks the REST counts, frontend builds clean), and **deploy verifies the\n> live result**. So validate is **not** a re-run of all that — it's a quick confirmation, plus the\n> optional human preview/sign-off. **Skip Steps 3–4 (rebuild + approval) entirely** on an autonomous or\n> temp-domain run where no one is signing off; just do the two 10-second checks below and move on. Spend\n> time here only when a person wants to see it before it goes live.\n\n**In:** project in `~/.hostinger/<domain>/src/`, `site.json`, `state.json`.\n\n## 1 — Instance status (always, quick)\n\n`provision.status == \"completed\"` **and** re-verify against the connector:\n`hosting_websites_list-setups { \"domain\": \"<domain>\" }`, exact-match the row → `completed`. Anything\nelse → not ready; return to the instance barrier, don't deploy.\n\n## 2 — Backend API status (WordPress or Ecommerce, always, quick)\n\nHit the live API from `site.json` and confirm it answers **with the content the push created** — a 200\nwith an empty catalog means the push didn't land:\n\n- **WordPress:** `GET {urls.rest_api}/posts?per_page=1` → 200 JSON array; check `X-WP-Total` against the\n planned post count, and `GET {urls.rest_api}/media?per_page=1` → `X-WP-Total` matches the planned\n image count (more = duplicates).\n- **Ecommerce:** query `urls.storefront_api` for the sales channel → 200 with the seeded products and\n prices *(confirm against the store)*.\n\nFail or counts off → fix content-model push before building against live data.\n\n## 3 — Clean rebuild against the live API (optional — skip unless signing off)\n\nFrontend already produced a clean build; only redo this if you want a from-scratch guarantee before a\nhuman sign-off. Remove the build output and rebuild; a failed or incomplete build is a stop (no\nfixtures, placeholder copy or `.htaccess` in the output).\n\n- **Astro (SSG):** `rm -rf dist && npm run build && npm run check:dist`.\n- **Next.js (SSR):** `rm -rf .next && npm run check && npm run build`.\n\n## 4 — Preview & approval (optional — only when a person is signing off)\n\nWhen someone wants to see it first: start the local preview (Astro: `npm run preview`; Next.js:\n`npx next start`), check desktop and mobile widths, navigation and a couple of key routes, and present\nit for **explicit approval**. If preview tooling is unavailable, give the user the command and URL.\nOn an autonomous or temp-domain run, skip this — deploy will verify the live result anyway.\n\nThen (after approval, or immediately if 3–4 were skipped): `state.json` → `stage: \"deploy\"`, fresh\n`updated_at`; leave `provision` unchanged.\n"
|
|
99
239
|
},
|
|
100
240
|
{
|
|
101
|
-
"uri": "skill://hostinger-headless/
|
|
102
|
-
"digest": "sha256:
|
|
103
|
-
"size":
|
|
241
|
+
"uri": "skill://hostinger-headless/steps/08-deploy/SKILL.md",
|
|
242
|
+
"digest": "sha256:52721b6384c4eecf6945cf354e5987b6acb767bc47d2837fb681a78683f09155",
|
|
243
|
+
"size": 9966,
|
|
104
244
|
"mimeType": "text/markdown",
|
|
105
|
-
"text": "# Store — Hostinger Ecommerce with a custom storefront\n\n**Primary source:** execute `ecommerce_miscellaneous_custom-storefront-setup-instructions` at the start of every store run — it returns the current, server-maintained integration guide, and it supersedes this file wherever they disagree. This file carries the essentials so the run can be planned before that call, and a fallback when the ecommerce operations aren't enabled.\n\n## Mental model\n\nTwo API surfaces — don't mix them:\n\n| Surface | Base URL | Auth | Use for |\n| --- | --- | --- | --- |\n| Storefront Core V2 | `https://api-ecommerce.hostinger.com/v2` | none (public, open CORS) | read products/variants, checkout — called from the browser |\n| Management (MCP tools) | Hostinger API | authenticated | create stores/products/shipping/payments |\n\n- A **store** (`store_*`) has **sales channels** (`scha_*`). The Storefront API is keyed on the **`sales_channel_id`** of a `custom`-type channel — not the store id. Managed channels (e.g. `quick-link`) cannot be used here, and only `custom` channels are API-creatable.\n- **Prices are integers in minor units** (`2900` = €29.00 → divide by `10^decimal_digits`) and live on **variants**, not products.\n- **Security boundary:** storefront reads and checkout are public and run client-side — **never embed a Hostinger API token in the storefront**. Only the management/hosting side (MCP tools) is authenticated.\n\n## Backend setup (management tools)\n\n1. Resolve the `custom` sales channel: `ecommerce_stores_list` → `ecommerce_sales-channels_list`. If a `custom` channel already exists, confirm with the user that it's the one to use; otherwise add one to the existing store with `ecommerce_sales-channels_create`, or create a store with `ecommerce_stores_create` (with a `custom` sales channel) when no suitable store exists. Set the channel `url` to the storefront's public domain at creation when it's already known.\n2. For checkout to work the store needs all three: ≥ 1 product, ≥ 1 payment method, ≥ 1 shipping zone. Payment, shipping, and currency are store-level — check `ecommerce_stores_metadata` (`has_payment_methods` and `has_shipping` must be `true`; the same response carries `default_currency` with `code`, `decimal_digits`, `template` — read it here rather than guessing). Products are **per sales channel** — verify by listing products for the resolved channel on the Storefront API and confirming the list is non-empty. Seed what's missing with `ecommerce_products_create-physical` / `ecommerce_products_create-digital`, `ecommerce_payments_enable-manual-method`, and `ecommerce_shipping_set-store` (price `0` = free shipping) — confirming with the user before writing to a store that already has real data.\n3. After deploy, point the channel at the live site: `ecommerce_sales-channels_update { store_id, sales_channel_id, url }`.\n\nIf the ecommerce operations aren't available, ask the user to enable the Ecommerce product group in the Hostinger Connector — or to provide the `sales_channel_id` directly, after which the public endpoints suffice for the frontend work.\n\n## Frontend contract\n\nThe OpenAPI schema for the Storefront API is at `https://api-ecommerce.hostinger.com/v2/docs.json` — fetch it for exact endpoint shapes. Non-obvious rules the schema won't spell out:\n\n- **Fetch the catalog at runtime, not build time** — client-side fetch on page load (CORS is open). Baking products into a static build makes the site stale on the first catalog edit. (SSR / Node.js hosting may fetch it server-side per request.)\n- Each variant has a **`prices[]` array** (one entry per currency) — read `prices[0].amount` / `prices[0].sale_amount` (minor units) and `prices[0].currency` (which carries `decimal_digits` and the `template` whose `$1` placeholder you replace when formatting). There is **no** top-level `amount` on a variant.\n- Product detail resolves by **product id** only; a slug returns 404 (map slug→id from the list endpoint). `limit` max is 100 on list endpoints (else 400).\n- Filter variants with `product_ids[]=...`; the unfiltered variant list is eventually-consistent right after catalog edits.\n- Cart lives in the site (e.g. `localStorage`): persist only `variant_id` + `quantity`, resolve display data from the live catalog — never a saved copy — then send everything in one checkout call:\n\n```\nPOST /channels/{sales_channel_id}/checkout\n{ \"items\":[{ \"variant_id\":\"variant_01...\", \"quantity\":1 }],\n \"success_url\":\"https://YOURDOMAIN/checkout/success/\",\n \"cancel_url\":\"https://YOURDOMAIN/checkout/cancel/\",\n \"locale\":\"en\" }\n```\n\n`success_url`/`cancel_url` are required — build them from `window.location.origin` and make sure both pages exist. The response is `{ url, cart_token }`; redirect the browser to `url` (Hostinger's hosted checkout).\n\nA multi-step cart alternative exists (`POST /channels/{sales_channel_id}/carts`, `PATCH /carts/{cart_id}`, `POST /carts/{cart_id}/line-items`, `PUT /carts/{cart_id}/shipping-method`, `POST /carts/{cart_id}/complete`) — the single checkout call suffices for a simple buy flow.\n\n## Verify\n\n- A `POST` to the checkout endpoint returns a `url` — test it directly with curl before calling the run done.\n- The `success_url`/`cancel_url` pages return 200 (`curl -s -o /dev/null -w \"%{http_code}\" https://YOURDOMAIN/checkout/success/`).\n- Open the live site, add to cart, and confirm the redirect to `https://checkout.hostinger.com`.\n"
|
|
245
|
+
"text": "---\nname: headless-step-deploy\ndescription: \"Step 8 (deploy) of the Hostinger Headless flow. Archives the frontend source, then deploys through the connector's one-shot hosting_deploy (type nodejs — it uploads the source, runs npm install and the build on the server) with the chosen framework's build settings (appType + output dir) passed explicitly, polls to completion with hosting_get-deployment, verifies the live site and that the backend survived, then writes deploy.json.\"\n---\n\n# headless-step-deploy\n\nShip the frontend to the live domain through the Hostinger connector. You hand the connector a zip of\nthe project **source** and it uploads it, runs `npm install` + the build script on the server, and\nserves the result. **Out:** `deploy.json`.\n\nThis is an **instance-barrier** step: run it only after `provision.status == \"completed\"` and validate\n(if run) has passed. All operations go through the connector (`execute`).\n\n**In:** the project in `~/.hostinger/<domain>/src/`, `site.json` (`username`, `urls`), `state.json`\n(`frontend.framework`, `frontend.rendering`).\n\n> ## ⚠️ The one rule that makes this work: pass build settings explicitly\n> If you let `hosting_deploy` **auto-detect** build settings, for a headless site that detection returns\n> **null** (`appType:null`, `outputDirectory:null`, Node 20) — the build \"completes\" but the **root\n> serves 403** (no `appType`/output → the vhost serves a directory with no entry). This is reproducible.\n> **Always pass `appType`, `outputDirectory`, `nodeVersion`, `buildScript`, `packageManager` and\n> `rootDirectory` on the `hosting_deploy` call** with the framework's values; the deploy then resolves\n> them and serves correctly, and `/cms` is preserved.\n\n## Build configurations\n\nMap `frontend.framework` (from `state.json`) to its `appType` + build output directory, and pass those\non the `hosting_deploy` call. `appType` is the framework's value, e.g. `next`, `nuxt`, `astro`,\n`svelte-kit`, `nest`, `express`, `fastify`, `react`, `create-react-app`, `gatsby`, `vite`, `vue`,\n`angular`, `parcel`, `svelte`, `other`.\n\nCommon cases (all `nodeVersion: 22`, `buildScript: \"build\"`, `packageManager: \"npm\"`, `rootDirectory: \".\"`):\n\n| Framework / mode | Local config | `appType` | `outputDirectory` |\n|---|---|---|---|\n| **Next.js — SSR** *(default)* | `output: 'standalone'` | `next` | `.next` |\n| **Nuxt — SSR** | default | `nuxt` | `.output` |\n| **SvelteKit — SSR** | node adapter | `svelte-kit` | `build` |\n| **Astro — static** | `output: 'static'` | `astro` | `dist` |\n| **Vite / React / Vue — static (SPA)** | — | `vite` (or `react`/`vue`) | `dist` |\n| **Express / NestJS / Fastify — server** | — | `express`/`nest`/`fastify` | build output dir |\n\nSSR/server frameworks run a persistent Node process (root sends `x-powered-by` / `cache-control: no-store`);\nstatic frameworks serve their output dir as files. If unsure of a framework's output dir, check the\nframework's own build output, but **always pass `appType`/`outputDirectory` on the deploy call with the\nframework's correct values** — don't rely on auto-detection (it returns null → 403).\n\n## Step 1 — Pre-flight\n\n1. **Instance live.** `provision.status == \"completed\"`, re-verified with\n `hosting_websites_list-setups { \"domain\": \"<domain>\" }`.\n2. **Build output exists locally** (the framework's output dir — e.g. `.next/`, `dist/`, `.output/`) and\n the project builds clean (frontend/validate already did this). If not, go back.\n3. **Env vars (only if used).** Build-time vars (e.g. `NEXT_PUBLIC_*`) must be set **before** the build\n with `hosting_nodejs_replace-environment-variables` (full replace). Never put secrets in the repo or\n the archive. **Webapp with a database:** that full replace wipes the `DB_*`/`DATABASE_URL` that\n `hosting_databases_setup-website` injected (the generated password reads back masked), so set all app\n vars in the same replace and, if you can't, first reset the password with\n `hosting_databases_change-password` and resend every `DB_*`/`DATABASE_URL` — see\n `../03-provision/webapp/SKILL.md` Step 3.\n\n## Step 2 — Archive the source\n\nZip the project **source** with `package.json` at the archive root. **Include** the app/source,\n`public/`, the framework config (`next.config.mjs` / `astro.config.mjs`), `tsconfig.json`,\n`package.json`, `package-lock.json`, `.nvmrc`. **Exclude** `node_modules/`, the build output\n(`.next/`, `dist/`, `out/`), `.git/`, and any `.htaccess` — the server installs and builds from source.\n\n```bash\ncd ~/.hostinger/<domain>/src\nzip -r -X ../app.zip . -x 'node_modules/*' '.next/*' 'dist/*' 'out/*' '.git/*' '**/.htaccess'\n```\n\n## Step 3 — Deploy (one shot: settings + upload + build)\n\nPass the framework's build settings **inline** on the deploy call — there is no separate settings step.\n`path` is the archive from Step 2 (or the source directory itself; `hosting_deploy` archives a directory\nfor you, skipping `node_modules/`, `.git/` and `.env`, but it does **not** read `.gitignore`, so the\nbuild output must already be absent).\n\n```json\n{ \"operation\": \"hosting_deploy\",\n \"params\": {\n \"domain\": \"<domain>\",\n \"path\": \"/absolute/path/to/.hostinger/<domain>/app.zip\",\n \"type\": \"nodejs\",\n \"appType\": \"<framework appType, e.g. next>\",\n \"outputDirectory\": \"<framework output dir, e.g. .next>\",\n \"nodeVersion\": 22,\n \"buildScript\": \"build\",\n \"packageManager\": \"npm\",\n \"rootDirectory\": \".\" } }\n```\n\n`hosting_deploy` uploads the source and triggers `npm install` + the build **end-to-end** — do not call\nany upload or build operation yourself. It returns a build `buildUuid` at once. This **overwrites the\nwebsite root** (expected) and keeps `/cms` intact. Passing `appType`/`outputDirectory` explicitly is what\nkeeps the root from serving 403 (see the rule above).\n\n## Step 4 — Poll to completion\n\nPoll `hosting_get-deployment { \"domain\": \"<domain>\", \"buildUuid\": \"<buildUuid>\" }` with backoff until\n`build.state` is `completed`. On `failed`, the same response carries the end of the build log and the\nfailure analysis — read it, fix locally, re-archive and redeploy (pass `fromLine`, the line count from\nthe previous call, to read only new log output). `hosting_get-deployment` also returns the build settings\nstored for the site: `appType`/`outputDirectory`/`nodeVersion` should echo what you passed; if they read\n`null`, the settings didn't take — fix the deploy call before continuing. For an SSR app that built but\nhangs, `hosting_nodejs_restart-application { username, domain }` recovers the server without a rebuild.\n\n## Step 5 — Verify the live site (all must pass)\n\n| Check | How | Expect |\n|---|---|---|\n| Root serves | `GET https://<domain>/` | **200** (not 403 / 404) |\n| SSR is live *(server frameworks)* | response headers | `x-powered-by` present, `cache-control: no-store` |\n| Static serves *(static frameworks)* | `GET https://<domain>/` | 200 HTML with real home content |\n| Key routes | a couple of planned routes + one deep route | 200, real content |\n| **Backend survived** *(WordPress only)* | `GET https://<domain>/cms/wp-json/wp/v2/posts?per_page=1` | 200 |\n\n> **Only WordPress has a `/cms` on the domain**, so the *Backend survived* check is WordPress-only.\n> - **Ecommerce:** the backend is the **Hostinger Ecommerce store**, read over the external Storefront API\n> (`api-ecommerce.hostinger.com`) — nothing a deploy touches lives on this domain. There is no `/cms`;\n> confirm the storefront still fetches products as a frontend check if you like. **Only check that\n> checkout can be reached when `site.json → ecommerce.payment_enabled` is `true`** — on a free account\n> (`payment_enabled: false`) there is no checkout, so **skip that check entirely**; a catalog that serves\n> 200 is a passing, complete deploy. Never fail or hold the deploy because checkout is unavailable.\n> - **Webapp:** no content backend at all — the whole root is the app. Skip the check; `/cms` won't resolve.\n\nA `completed` build whose root 403s almost always means the build settings weren't passed to\n`hosting_deploy` (null settings) — pass them and redeploy. Caching can briefly serve a stale root;\nre-check after ~30 s.\n\n## Step 6 — Record `deploy.json`\n\n```json\n{\n \"build_uuid\": \"…\",\n \"stack\": \"nextjs\",\n \"rendering\": \"ssr\",\n \"app_type\": \"next\",\n \"output_directory\": \".next\",\n \"node_version\": 22,\n \"method\": \"hosting_deploy (type: nodejs, settings passed inline)\",\n \"deployed_at\": \"…\",\n \"verified_at\": \"…\",\n \"urls\": { \"live\": \"https://<domain>\", \"cms\": \"https://<domain>/cms\" },\n \"checks\": { \"root\": 200, \"ssr_headers\": true, \"backend_preserved\": true }\n}\n```\n\nNo secrets in `deploy.json`. The `urls.cms` field and the `backend_preserved` check above are\n**WordPress-only** — adjust the record to the backend:\n\n- **WordPress:** keep `urls.cms` and `checks.backend_preserved` as shown.\n- **Ecommerce:** omit `urls.cms`/`backend_preserved`; record the store URLs instead (`urls.storefront_api`,\n `urls.store_admin` from `site.json`) and link the custom sales channel to the live domain —\n `ecommerce_sales-channels_update { store_id, sales_channel_id, url }` (see the ecommerce provision step).\n- **Webapp:** omit `urls.cms` and `backend_preserved` entirely — `urls` is just `{ \"live\": \"https://<domain>\" }`.\n\nUpdate `state.json` (`stage` → `done`), then continue to Step 9 (done).\n\n---\n\n## Note — one deploy path, and never `type: \"static\"`\n\n`hosting_deploy` is the only path: give its `path` either the source **directory** (it archives it for\nyou) or a local **archive** (`zip`, `tar`, `tar.gz`, `tgz`, `7z`) — there is no separate upload or build\nstep to fall back to. Either way pass the explicit `appType`/`outputDirectory`/`nodeVersion`/\n`buildScript`/`packageManager`/`rootDirectory` from the table. Never deploy a headless site with\n`type: \"static\"` — it serves the directory as plain files and can overwrite `/cms`.\n"
|
|
106
246
|
},
|
|
107
247
|
{
|
|
108
|
-
"uri": "skill://hostinger-headless/
|
|
109
|
-
"digest": "sha256:
|
|
110
|
-
"size":
|
|
248
|
+
"uri": "skill://hostinger-headless/steps/09-done/SKILL.md",
|
|
249
|
+
"digest": "sha256:f567f793806f331539baf8e310f21208fcbdd17f401da06b2e5c0d106d88bc1c",
|
|
250
|
+
"size": 9587,
|
|
111
251
|
"mimeType": "text/markdown",
|
|
112
|
-
"text": "
|
|
252
|
+
"text": "---\nname: headless-step-done\ndescription: \"Step 9 (done) of the Hostinger Headless flow. Verifies the live site, then hands over the URLs (hPanel success page, main site, WordPress/store admin, dashboard), opens the success page in the browser on a successful run, and gives a summary of what was built and what the user can do next. Its final report format is also used whenever a run stops early.\"\n---\n\n# headless-step-done\n\nVerify the live site, then hand over the URLs (success page, main site, WordPress admin, dashboard), a\nsummary of what was built, and what the user can do next. **On a successful run, open the success page\nfor the user** (see *Open the success page*).\n\n## Verify the live site (brief)\n\nOn a run that deployed this session, re-confirm what you're about to hand over actually works (deploy\nalready checked this — this is a quick re-confirm, not a full re-run):\n\n- **Main site:** `GET https://<domain>/` → 200 (Next.js SSR: `x-powered-by: Next.js`; Astro SSG: static\n HTML with real content).\n- **WordPress admin:** `GET {urls.wp_admin}` → reachable (a login page, 200/302), not 404.\n- **Backend API:** `GET {urls.rest_api}/posts?per_page=1` → 200 (WordPress); or the storefront API for\n the sales channel → 200 (Ecommerce).\n\nIf a check fails, say so in the outcome paragraph and don't claim the site is live.\n\n## Final report\n\nEvery run ends with this report, whether it finishes here or stops at an earlier stage (a dummy or\nmissing step, a failed provision, the user pausing). Build it from `state.json`, `site.json`,\n`deploy.json` and what you actually did and checked in this run. Report only what you verified. Don't\nclaim a stage passed if you didn't check it.\n\nWrite it in this order:\n\n1. **One-paragraph outcome.** What was created and on which domain (bold it). If the run stopped\n early, name the stage it stopped at, why, and what the user will see right now (e.g. \"the site\n root returns 403 until the frontend is deployed\").\n\n2. **Steps completed** — a table with one row per stage that ran:\n\n | Stage | Result |\n |---|---|\n | connect | How the connector was reached, MCP version, how sign-in happened. |\n | 1 check state | New build / resumed at `<stage>` / adopted an existing site. |\n | 2 refine | What the brief is and which backend it chose (say if a sample brief was used). |\n | 3 provision | Plan and order used, how long setup took, which verification checks passed. |\n | 4 content model | What was created (counts of pages, posts, terms, menus, media, SEO) and how it was verified. |\n | … | One row for each further stage that ran (design, frontend, validate, deploy, done). |\n\n Stages that didn't run are left out of the table and named in the outcome paragraph instead.\n Use the stage numbers and names from the orchestrator's flow table.\n\n3. **Links** — every URL that exists at this point. Build the hPanel URLs from `site.json`\n (`order_id`, `username`, `domain`) and `state.json` (`tool`); take the rest from `site.json` /\n `deploy.json`. Lead with the one the user cares about most — the **main site** — then the admin\n and dashboards. **Don't list the success page here:** it opens on its own (see *Open the success\n page*), so printing its URL in the report is just noise — the only exception is when a browser\n can't be opened (handled in that section):\n\n - **Main site (live):** `urls.site` (`https://<domain>`) — only once deployed.\n - **WordPress admin (one-click auto-login):** for a WordPress backend, **mint and hand over the\n auto-login redirect URL** — `wordpress_login_create-links { username, software: <site.json\n wordpress.install_id> }` returns a temporary link that logs the user straight into wp-admin. Give\n that URL as the admin link (it lands on `/cms/wp-admin`), so the user never needs a password. Also\n note the plain `urls.wp_admin` as a fallback. **Never print or store a password.** For an\n Ecommerce store, link the store admin from `urls.store_admin` instead.\n - **hPanel site dashboard:** `https://hpanel.hostinger.com/websites/<domain>` (the headless website\n is installed by now).\n - **Onboarding (resume):** `https://hpanel.hostinger.com/headless-onboarding/<order_id>/<username>/`\n - **REST API** (for developers): `urls.rest_api`.\n\n > URL shape note: `<username>` may be dropped from the onboarding/success URLs in a future hPanel\n > release. If a built URL 404s, retry the same path without the `/<username>` segment before\n > reporting it as broken.\n\n4. **Where state lives** — the workspace path `~/.hostinger/<domain>/`, the `stage` that a\n resumed run will start from, and the build report `~/.hostinger/<domain>/report.html`.\n\n5. **Gaps / follow-ups** (only if any) — a short numbered list of anything skipped, approximated or\n left for the user (e.g. no images generated, default WordPress content left in place).\n\nNever include passwords, JWTs or other secrets in the report. For wp-admin access, give the URL and\noffer a one-click login link (`wordpress_login_create-links`), not credentials.\n\n## Open the success page\n\nOn a **successful** run (the deploy succeeded and the live checks passed), open the success page in the\nuser's **main / system browser** right after presenting the report, so they land on the hPanel\ncelebration/next-steps screen for their new site. This is a side effect only — **don't print the\nsuccess URL in the report output** (it just opens); the one exception is the can't-open-a-browser\nfallback below. Build the URL with `<tool>` as the running tool's onboarding slug (`claude` for\nClaude Code, else `cursor`, `codex`, `vscode`, `antigravity`, `devin`), mapped from\n`state.json → tool` (e.g. `claude-code` → `claude`):\n\n```bash\n# Build the URL first, then open it with the OS default (main) browser — not an embedded one.\n# <tool> = the onboarding slug (claude for Claude Code, else cursor/codex/vscode/antigravity/devin)\nURL=\"https://hpanel.hostinger.com/headless-onboarding/<order_id>/<username>/success/<domain>/<tool>\"\nopen \"$URL\" # macOS\n# xdg-open \"$URL\" # Linux\n# start \"\" \"$URL\" # Windows\n```\n\n- **Main browser only.** Use the OS opener above (`open` / `xdg-open` / `start`) so the page lands in\n Chrome, Safari, Edge, or whatever the user actually uses. **Do not** open it in an embedded or\n sandboxed browser (Cursor IDE browser / `cursor-ide-browser`, Playwright, Puppeteer, WebView, or\n any in-agent browser tab). Those stay inside the tool session and the user won't see the\n celebration page where they expect it.\n- Open it **once**, and only on a successful run — never on an early stop or a failed deploy.\n- If your tool can't open a browser (the command isn't available, or `open`/`xdg-open`/`start`\n fails), don't treat it as a failure and **don't fall back to an embedded browser**. This is the\n **one** case where you print the success URL — show it once in the report and tell the user to\n open it themselves in their main browser.\n\n## Build report\n\nRight after the final report, save the build report for this run. It turns the run log (stage times\nfrom `state.json`, events from `events.jsonl`) into `reports/<run>.json` and `.html`, and copies the\nHTML to `report.html` in the workspace:\n\n```bash\nnode <skill>/run-log.mjs <domain> event good '{\"text\":\"<something that went well>\"}' # one per thing, if not logged yet\nnode <skill>/run-log.mjs <domain> stage-end done ok\nnode <skill>/run-log.mjs <domain> report --outcome <live|stopped|failed> [--tokens '<json>']\n```\n\nThen send it to Hostinger: pass `reports/<run>.json` unchanged to `hosting_headless_submit-build-report`\nand run `report-sent <run>` (or `report-rejected <run>` on a 422). The `report` command prints the\nexact path and run id. Don't ask first; see *Run log and build report* in the top-level `SKILL.md`\nfor failures. Then tell the user in one line: \"Sent the build report to Hostinger to help improve\nthe flow.\" On a 422 say it's saved locally only; on any other failure say it'll be sent next session.\n\nIf the user asks for more changes after this report in the same session, call\n`run-log.mjs <domain> run-resume --reason \"<what they asked for>\"` before the work. Then log as\nusual and run `report` again when it's done. It updates this run's report in place, and the report\nlists the follow-up. Send the updated report the same way.\n\n- **Outcome:** `live` when the deploy succeeded this run, `failed` when a stage failed and you\n couldn't recover, `stopped` for everything else (the user paused, a dummy or missing step). Leave\n `--outcome` out and the script decides from the log.\n- **Before you write it,** check that every issue, correction and skill gap from this run is in\n `events.jsonl`. Log any that are missing now; the report only shows what was logged.\n- **After you write it,** read the `warning:` lines `report` prints. They list logging gaps: a stage\n that never ended, a start that was estimated, a stage logged with no duration, or (on a live build)\n a stage that was never logged. Fix what you can: `stage-end` an open stage, or correct a start with\n `stage-fix <stage> --started-ago <time>`, then run `report` again. What you can't fix stays in the report's\n *Logging gaps* section.\n- **Tokens:** only what your tool tells you about your own usage (see *Run log and build report* in\n the top-level `SKILL.md`).\n- In the final report's *Where state lives*, add the path to `report.html` so the user can open it.\n\nDo this on every run, including the early stops: those reports are the most useful ones.\n"
|
|
113
253
|
}
|
|
114
254
|
]
|
|
115
255
|
},
|