diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 1078c84..5319e0f 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "hostinger-connector", "displayName": "Hostinger Connector", - "version": "0.3.1", + "version": "0.3.2", "description": "MCP plugin to deploy and manage Hostinger websites, WordPress, domains, DNS, VPS, and subscriptions from inside Cursor.", "author": "Hostinger", "license": "MIT", diff --git a/CHANGELOG.md b/CHANGELOG.md index 468a348..24ca77a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.3.2] - 2026-10-28 + +### Changed + +- Synced with `@hostinger/mcp` 3.17.2: audit-hosting, deploy-to-hosting, hostinger-headless, migrate-to-hosting, operation catalog, troubleshoot-website + ## [0.3.1] - 2026-10-02 ### Changed diff --git a/mcp.json b/mcp.json index b614f3c..31fa3dd 100644 --- a/mcp.json +++ b/mcp.json @@ -3,47 +3,47 @@ "hostinger-hosting": { "command": "npx", "args": ["--yes", "--package=@hostinger/mcp@latest", "hostinger-hosting-mcp"], - "env": { "USER_AGENT": "plugin;cursor;0.3.1" } + "env": { "USER_AGENT": "plugin;cursor;0.3.2" } }, "hostinger-wordpress": { "command": "npx", "args": ["--yes", "--package=@hostinger/mcp@latest", "hostinger-wordpress-mcp"], - "env": { "USER_AGENT": "plugin;cursor;0.3.1" } + "env": { "USER_AGENT": "plugin;cursor;0.3.2" } }, "hostinger-agency-hosting": { "command": "npx", "args": ["--yes", "--package=@hostinger/mcp@latest", "hostinger-agency-hosting-mcp"], - "env": { "USER_AGENT": "plugin;cursor;0.3.1" } + "env": { "USER_AGENT": "plugin;cursor;0.3.2" } }, "hostinger-domains": { "command": "npx", "args": ["--yes", "--package=@hostinger/mcp@latest", "hostinger-domains-mcp"], - "env": { "USER_AGENT": "plugin;cursor;0.3.1" } + "env": { "USER_AGENT": "plugin;cursor;0.3.2" } }, "hostinger-dns": { "command": "npx", "args": ["--yes", "--package=@hostinger/mcp@latest", "hostinger-dns-mcp"], - "env": { "USER_AGENT": "plugin;cursor;0.3.1" } + "env": { "USER_AGENT": "plugin;cursor;0.3.2" } }, "hostinger-billing": { "command": "npx", "args": ["--yes", "--package=@hostinger/mcp@latest", "hostinger-billing-mcp"], - "env": { "USER_AGENT": "plugin;cursor;0.3.1" } + "env": { "USER_AGENT": "plugin;cursor;0.3.2" } }, "hostinger-reach": { "command": "npx", "args": ["--yes", "--package=@hostinger/mcp@latest", "hostinger-reach-mcp"], - "env": { "USER_AGENT": "plugin;cursor;0.3.1" } + "env": { "USER_AGENT": "plugin;cursor;0.3.2" } }, "hostinger-ecommerce": { "command": "npx", "args": ["--yes", "--package=@hostinger/mcp@latest", "hostinger-ecommerce-mcp"], - "env": { "USER_AGENT": "plugin;cursor;0.3.1" } + "env": { "USER_AGENT": "plugin;cursor;0.3.2" } }, "hostinger-vps": { "command": "npx", "args": ["--yes", "--package=@hostinger/mcp@latest", "hostinger-vps-mcp"], - "env": { "USER_AGENT": "plugin;cursor;0.3.1" } + "env": { "USER_AGENT": "plugin;cursor;0.3.2" } } } } diff --git a/scripts/mcp-tools.json b/scripts/mcp-tools.json index 874222a..74c7560 100644 --- a/scripts/mcp-tools.json +++ b/scripts/mcp-tools.json @@ -1,7 +1,7 @@ { "package": "@hostinger/mcp", - "version": "2.7.0", - "total": 403, + "version": "3.17.2", + "total": 443, "groups": { "agency-hosting": [ "agency-hosting_cache_clear-website", @@ -47,6 +47,11 @@ "agency-hosting_wordpress_list-versions", "agency-hosting_wordpress_settings" ], + "agent-readiness": [ + "agent-readiness_checks_list", + "agent-readiness_scans_get", + "agent-readiness_scans_run" + ], "billing": [ "billing_catalog_list", "billing_orders_create-purchase", @@ -153,8 +158,13 @@ ], "hosting": [ "hosting_cache_clear-website", + "hosting_cache_status", "hosting_cache_toggle-cacheless", "hosting_cache_toggle-website", + "hosting_cdn_disable", + "hosting_cdn_enable", + "hosting_cdn_purge-cache", + "hosting_cdn_status", "hosting_cron-jobs_create", "hosting_cron-jobs_delete", "hosting_cron-jobs_list", @@ -170,6 +180,7 @@ "hosting_databases_repair", "hosting_databases_setup-website", "hosting_datacenters_list", + "hosting_deploy", "hosting_deploy-js-application", "hosting_deploy-static-website", "hosting_deploy-wordpress-plugin", @@ -185,11 +196,17 @@ "hosting_files_generate-upload-url", "hosting_files_list-website-and-directories", "hosting_files_website-content", + "hosting_get-deployment", "hosting_git_auto-deployment-settings", "hosting_git_delete-auto-deployment-settings", + "hosting_git_deploy-website-repository", + "hosting_git_generate-ssh-key", "hosting_git_list-installation-repositories", "hosting_git_list-installations", + "hosting_git_list-website-repositories", + "hosting_git_ssh-public-key", "hosting_git_update-auto-deployment-settings", + "hosting_headless_submit-build-report", "hosting_import-wordpress-website", "hosting_list-js-deployments", "hosting_nodejs_analyse-failed-build", @@ -197,6 +214,7 @@ "hosting_nodejs_build-logs", "hosting_nodejs_build-settings", "hosting_nodejs_build-settings-from-archive", + "hosting_nodejs_build-settings-from-repository", "hosting_nodejs_clear-runtime-logs", "hosting_nodejs_list-builds", "hosting_nodejs_list-environment-variables", @@ -226,7 +244,9 @@ "hosting_websites_delete", "hosting_websites_deploy-static-site-archive", "hosting_websites_list", - "hosting_websites_list-setups" + "hosting_websites_list-setups", + "hosting_websites_start-setup", + "hosting_websites_trigger-event" ], "mail": [ "mail_aliases_create-alias", @@ -290,14 +310,30 @@ "reach_contacts_list-groups", "reach_contacts_list-profile", "reach_contacts_update", + "reach_forms_add-submitter-tags", + "reach_forms_create", "reach_forms_delete", + "reach_forms_duplicate", "reach_forms_get", "reach_forms_list", + "reach_forms_remove-submitter-tag", + "reach_forms_update", + "reach_forms_update-template", + "reach_integrations_connect-ai-website-builder-site", + "reach_integrations_disconnect-ai-website-builder-site", + "reach_integrations_disconnect-ecommerce-store", + "reach_integrations_disconnect-wordpress-site", + "reach_integrations_list-connected-websites", + "reach_integrations_list-ecommerce-stores", + "reach_profiles_connect-sending-domain", "reach_profiles_connected-sending-domain", + "reach_profiles_delete-sending-domain", "reach_profiles_domain-dns-status", "reach_profiles_list", "reach_profiles_list-plan-feature-access", "reach_profiles_remaining-plan-limits", + "reach_profiles_settings", + "reach_profiles_update-settings", "reach_segments_count-profile-contacts", "reach_segments_create", "reach_segments_create-profile", @@ -320,6 +356,7 @@ "reach_tags_remove-contacts-from", "reach_tags_rename", "reach_templates_create-email", + "reach_templates_email", "reach_templates_list-email" ], "vps": [ @@ -370,6 +407,9 @@ "vps_snapshots_delete", "vps_snapshots_get", "vps_snapshots_restore", + "vps_ssh-keys_add-virtual-machine", + "vps_ssh-keys_list-virtual-machine", + "vps_ssh-keys_remove-virtual-machine", "vps_templates_get", "vps_templates_list", "vps_virtual-machines_attached-public-keys", @@ -389,6 +429,8 @@ "vps_virtual-machines_stop" ], "wordpress": [ + "wordpress_ai-content_generate-images", + "wordpress_ai-content_generation-result", "wordpress_ai-tools_set-option-status", "wordpress_ai-tools_show-option-status", "wordpress_installations_check-if-are-valid", diff --git a/skills/audit-hosting/SKILL.md b/skills/audit-hosting/SKILL.md index fa71453..87f47da 100644 --- a/skills/audit-hosting/SKILL.md +++ b/skills/audit-hosting/SKILL.md @@ -30,7 +30,7 @@ Tell the user the size (plans, websites, WordPress installs) before the checks. Per Shared and Cloud website (skip `builder` and `horizons`): - `hosting_ssl_status` — flag `failed` (with `last_error`), `expired`, `not_installed` on a custom domain, `is_https_redirect_enabled: false`, and `expires_at` within 30 days when `is_lifetime` is false. Lifetime certificates renew on their own. -- Node.js sites: `hosting_nodejs_list-builds` with `per_page: 1` (latest build failed?) and `hosting_nodejs_list-vulnerabilities` with `severities: ["critical", "high"]`. +- Node.js sites: `hosting_get-deployment` (latest build failed? `build.state`) and `hosting_nodejs_list-vulnerabilities` with `severities: ["critical", "high"]`. Per hosting account (`username`): `hosting_databases_list` — flag `disk_usage_mb` above 80% of `max_size_mb`. diff --git a/skills/deploy-to-hosting/SKILL.md b/skills/deploy-to-hosting/SKILL.md index 96442d7..0ef0d5b 100644 --- a/skills/deploy-to-hosting/SKILL.md +++ b/skills/deploy-to-hosting/SKILL.md @@ -13,9 +13,23 @@ Ship a project that already exists — on disk or in a Git repository — to a H - Batch reads with `multi-execute`; values pass between steps as `$steps..`. Batches chain operations from one server only — the Hostinger Connector runs `hosting`, `wordpress` and `agency-hosting` as separate servers. - Every deploy overwrites the website's current contents. Confirm the target domain with the user before the first deploy to a site that already has content. +## 0. If a shell and the Hostinger CLI are available + +`hostinger up` does steps 1 to 3 in one call and is the shortest path when you can run commands and the `hostinger` CLI is installed (`brew install hostinger/tap/hostinger`), signed in and has `hostinger up`: it finds or creates the website, detects the type, packs the directory (honouring `.gitignore`; skipping `node_modules`, `.git`, `.env` files, credentials and private keys, and listing symlinks it does not follow), uploads, deploys or builds, waits for the build and links the directory in `.hostinger/site.json`, the same file this skill uses. Nothing is asked without a terminal. + +``` +hostinger up --domain --yes --format json # existing website; --yes only after the user agreed to replace its content +hostinger up --create --format json # new website on the only plan, free subdomain +hostinger up --domain --dry-run # the plan as JSON, changes nothing: show it to the user before a first deploy (overwritesContent: the real run needs --yes) +hostinger up --domain --type nodejs --app-type next --output-directory .next --build-script build --node-version 22 --yes --format json +hostinger up --domain --no-wait --format json # returns buildUuid at once; poll with `hostinger hosting nodejs build ` +``` + +Run it from the project directory: the link is written in the current directory, with `path` naming ``. The JSON result has `url`, `data.type`, `data.buildUuid`, `data.buildState`, `data.skippedFiles` and, on failure, `error`. Shared and Cloud plans only; for Agency plans, archives and WordPress plugins or themes, use the operations below. The MCP's `hosting_deploy` does the same work when there is no shell. + ## 1. Pick the target website -- `.hostinger/site.json` in the project: reuse its `domain` and `username` (or `website_uid`). +- `.hostinger/site.json` in the project: reuse its `domain` and `username` (or `website_uid`). The CLI's `hostinger up` and `hostinger link` write the same file and keep any fields you add while it names the same website (linking another website starts it afresh); its `type` may be `static`, `nodejs` or `php`. - Otherwise look the domain up with `hosting_websites_list` (Shared and Cloud; substring match — take the exact entry) and `agency-hosting_websites_list-plan` (Agency; keep `details.uid`). - No website yet: - Shared and Cloud: take a domain (the user's own, or `hosting_domains_generate-free-subdomain`) and an `order_id` from `hosting_orders_list`, then `hosting_websites_create`. `datacenter_code` is needed only for the first website on a new plan (first entry of `hosting_datacenters_list`). Poll `hosting_websites_list-setups` with `domain` every 10–15 s until `status: completed` — uploads and deploys return 404 or 409 before that. @@ -24,27 +38,30 @@ Ship a project that already exists — on disk or in a Git repository — to a H ## 2. Choose the deploy -Wrong method is the most common failure. Decide from the project on disk: +**Shared and Cloud, on the local `hostinger-api-mcp` server:** one operation does every deploy — `hosting_deploy` with `domain` and either `path` (a directory on this machine) or `git: { repository: "owner/name", branch }`. Given a directory it detects what the project is, packs it itself (it skips `node_modules`, `.git` and `.env` files, listing the `.env` ones as `skippedFiles` so you can set their values with `hosting_nodejs_replace-environment-variables`; it does not read `.gitignore`, so keep build output out of the directory) and does the upload, build and Git setup. Do not archive the directory or call upload and build operations by hand. -| Project | Shared / Cloud | +| Project | `hosting_deploy` treats it as | | --- | --- | -| Plain HTML/CSS/JS, or a framework's static build output | `hosting_deploy-static-website` | -| `package.json` with a build or a server (Next.js, Nuxt, Express, Vite…) | `hosting_deploy-js-application` | -| PHP code, no build step | `hosting_deploy-static-website` (extracts the archive as-is) | -| WordPress plugin or theme folder | `hosting_deploy-wordpress-plugin` / `hosting_deploy-wordpress-theme` (`activate` optional) | -| A whole WordPress site from elsewhere | the `migrate-to-hosting` skill | +| Plain HTML/CSS/JS, or a framework's static build output | `static` — extracts as-is | +| `package.json` with a build script, a start script or a server dependency (Next.js, Nuxt, Express, Vite…) | `nodejs` — starts a build, returns `buildUuid` | +| PHP code, no build step | `static` | +| WordPress plugin folder (a `Plugin Name:` header) or theme folder (`Theme Name:` in `style.css`) | `wordpress-plugin` / `wordpress-theme`; `activate: true` activates a theme | +| A whole WordPress site from elsewhere | `wordpress-site`, with `databaseDump` — see the `migrate-to-hosting` skill | + +- Pass `type` to override detection. It is required for an archive (an archive cannot be inspected) and when a directory matches more than one WordPress type, which `hosting_deploy` rejects as ambiguous. +- Node.js build settings come from the project; detection covers the framework and the build script. Set `appType`, `outputDirectory`, `buildScript`, `entryFile`, `nodeVersion` (default 22), `rootDirectory` or `packageManager` when it is wrong. A TypeScript or other compiled server app always needs `entryFile` set to the built file (for example `dist/server.js`): detection only guesses a root-level file, and the build fails with "Entry file not found". The entry file is relative to the root directory for express, fastify, hono, react-router and other, and to the output directory for nest, nuxt, nitro, astro and svelte-kit (`main.js`, not `dist/main.js`); Next.js ignores it. Per-framework values are in the Hostinger docs under Node.js (Supported Frameworks, Build Settings). +- Static, PHP, plugin and theme deploys are live when the call returns. A WordPress site import only starts (`status: import_started`) and finishes extracting a few minutes later; do not report it complete or verify it before then. Node.js builds run on the server: see step 3. -On Agency the website's type decides (`agency-hosting_websites_get`): sites created as `node-static` take `agency-hosting_deploy-node-static-website`, which runs the build when the project has one; every other site takes `agency-hosting_deploy-php-application`, which extracts the archive as-is. WordPress plugins and themes on Agency sites are installed from wp-admin. +**Agency:** the website's type decides (`agency-hosting_websites_get`): sites created as `node-static` take `agency-hosting_deploy-node-static-website`, which runs the build when the project has one; every other site takes `agency-hosting_deploy-php-application`, which extracts the archive as-is. They deploy archives, synchronously: the site is live when the call returns. WordPress plugins and themes on Agency sites are installed from wp-admin. -Archive rules (name archives `name_YYYYMMDD_HHMMSS.zip`): +Archive rules for Agency (name archives `name_YYYYMMDD_HHMMSS.zip`) and for archives you build yourself: - **Static:** build locally first. `index.html` must be at the archive root, not inside a folder: `cd dist && zip -r ../site_20260101_120000.zip .` - **Node.js source:** no `node_modules/`, no build output (`dist/`, `.next/`, `build/`), no `.env*`, nothing matched by `.gitignore`; 50 MB at most. `git archive --format=zip -o app_20260101_120000.zip HEAD` produces exactly the committed files — mention that uncommitted changes are left out. -- Agency deploys are synchronous: the site is live when the call returns. -The operations in the table read the archive from this machine, so only the local `hostinger-api-mcp` server has them. On the hosted server (`mcp.hostinger.com`) `search` does not find them — upload the files yourself as below. +`hosting_deploy` with `path` and the `agency-hosting_deploy-*` operations read files from this machine, so only the local server has them. The hosted server (`mcp.hostinger.com`) has `hosting_deploy` for Git repositories only (step 4) and `hosting_get-deployment` (step 3); to deploy files from this machine there, upload them yourself as below. -### Without the local deploy operations +### Without the local deploy operations (local files on the hosted server; Agency on either server) 1. Get upload credentials: `hosting_files_generate-upload-url` (`username`, `domain`); Agency: `agency-hosting_files_generate-upload-url` (`website_uid`). Both return `url`, `auth_key` and `rest_auth_key`, which authenticate the upload instead of the API token. 2. Upload each file with TUS, where `DEST` is its path in the website's storage: @@ -69,20 +86,23 @@ curl -sS -X PATCH "$URL/$DEST?override=true" -H "X-Auth: $AUTH_KEY" -H "X-Auth-R ## 3. Node.js builds -`hosting_deploy-js-application` uploads the archive to the document root, detects settings from `package.json` and starts a build. Track it with `hosting_nodejs_list-builds` and `hosting_nodejs_build` (by `uuid`), polling every 10–20 s — builds take minutes. +`hosting_deploy` returns a `buildUuid` at once; the build runs on the server and takes minutes. Track it — and a build started with `hosting_nodejs_start-build` after an upload — with `hosting_get-deployment` (`domain`, optionally `buildUuid`; the latest build by default), polling every 10–20 s until `build.state` is `completed` or `failed`. It returns the build, its log and the stored build settings; pass the previous `logs.lines` as `fromLine` to read only new output. + +- Failed build: the response already carries `analysis` (fetched once per build; the API allows 5 a minute). +- Wrong detection (framework, `output_directory`, or an entry file inside the build output, such as `dist/server.js` for a compiled app): deploy again with `appType`, `outputDirectory`, `buildScript` or `entryFile` set. An archive deploy sends its settings with the request and leaves the stored ones alone; a Git deploy also stores these values, so later pushes build with them. -- Failed build: `hosting_nodejs_analyse-failed-build` once per build (5 calls a minute), then `hosting_nodejs_build-logs` if the analysis is empty. -- Wrong detection (framework, `output_directory`, or a missing `entry_file` for express, fastify, nest, nuxt and hono): run `hosting_nodejs_start-build` with explicit values, `source_type: "archive"` and `source_options.archive_path` set to the archive's file name in the document root (check it is there with `hosting_files_list-website-and-directories`). Store the same values with `hosting_nodejs_update-build-settings`. +Local files on the hosted server, deployed from an upload (step 2): + +- Wrong detection: run `hosting_nodejs_start-build` again with explicit values, `source_type: "archive"` and `source_options.archive_path` set to the archive's file name in the document root (check it is there with `hosting_files_list-website-and-directories`). Store the same values with `hosting_nodejs_update-build-settings`. - To review detected settings before any build: upload the archive with `hosting_files_generate-upload-url` (the TUS steps are in its description), then `hosting_nodejs_build-settings-from-archive`. ## 4. Auto-deploy from Git -1. `hosting_git_list-installations`. With no `active` installation (check `status: "pending"` and `"suspended"` too), the user connects GitHub or GitLab once in hPanel under Websites → Manage → Advanced → Git; there is no API for that step. -2. `hosting_git_list-installation-repositories` with the installation `uuid` gives `owner`, `name` and `default_branch` (10 calls a minute). -3. `hosting_git_update-auto-deployment-settings` with `installation_uuid`, `owner`, `repository`, `branch` and optional `directory`. - - PHP and static sites deploy the branch immediately and again on every push. - - Node.js sites clone nothing on save: start the first build with `hosting_nodejs_start-build`, `source_type: "git"` and the same `source_options`. Later pushes build with the stored settings, so keep `hosting_nodejs_update-build-settings` correct. -4. Confirm with `hosting_git_auto-deployment-settings`. +On both the local and the hosted server, `hosting_deploy` with `git: { repository: "owner/name", branch }` does all of it: it finds the Git installation that can reach the repository and deploys it as the `type` you give. Always pass `type: "static"` or `"nodejs"` unless the site already runs a Node.js app (or you pass Node.js settings such as `appType`): a new site is not detected as Node.js, and a Node.js repository deployed as static would publish its source. Then: +- **PHP and static sites:** saves the auto-deployment settings, which deploys the branch immediately and again on every push; +- **Node.js sites:** starts a build of the branch with the stored build settings; later pushes build the same way. When none are stored, they are detected from the branch's `package.json` and stored for those pushes; pass `appType`, `nodeVersion`, `outputDirectory`, `buildScript` or `entryFile` to override them (a compiled app still needs `entryFile`). + +With no active installation it stops and says so: the user connects GitHub or GitLab once in hPanel under Websites → Manage → Advanced → Git; there is no API for that step. Confirm with `hosting_get-deployment`, which returns the stored `gitAutoDeployment`. Agency Plan sites have no Git operations; deploy archives. diff --git a/skills/hostinger-headless/SKILL.md b/skills/hostinger-headless/SKILL.md index d709179..7fcbb60 100644 --- a/skills/hostinger-headless/SKILL.md +++ b/skills/hostinger-headless/SKILL.md @@ -1,56 +1,293 @@ --- name: hostinger-headless -description: "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." +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 , build me a site, create a website, make me a website, deploy this to Hostinger, host this on Hostinger, connect this project to Hostinger." --- -# Hostinger Headless +# Hostinger Headless — build a website with your own AI tool -This 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. +## Context -The 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. +You are an AI coding agent. The user pasted a short prompt that points you here — something like: -## Preconditions +> Build a Headless site for `example.com` using hostinger.com/headless/skill.md -1. 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`. -2. 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. -3. 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. +Your job is to build and deploy a real website on the user's Hostinger account by following this +recipe and the recipes it links to — nothing more, nothing less. -## Resolving the operation +This file is the **orchestrator**. It is public and stable: it never changes and never moves. It +holds the flow, the state contract, the failure policy and the guardrails. -Resolve 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): +### The domain parameter -- `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. -- `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. -- `create` — a new site from a prompt with nothing brought in: "build me a store / portfolio / site…". +The pasted prompt contains exactly two things that matter: -If signals conflict, ask the user — don't guess. +- **This URL** — `hostinger.com/headless/skill.md`, the stable entry point. +- **A domain** — e.g. `coral-pottery-1847.hostingersite.com` (a temporary Hostinger subdomain) or + the user's own purchased domain. This is the single site you may touch. -## The run +The prompt does **not** say what kind of site to build. Do not assume or invent it. Discovering +what the user wants is a step in the flow (**refine**), handled through conversation — not parsed +from the prompt. If no domain is present, stop and ask for it before doing anything else. -1. **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. -2. **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. -3. **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. -4. **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. -5. **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). -6. **Deploy** (`references/DEPLOYMENT.md`) — archive and deploy via the matching hosting operation, polling build logs for Node.js runs. -7. **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). -8. **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`. +**One workspace per run.** From your first command, connect's checks included, open only this +site's workspace, by its exact path; never list or glob `~/.hostinger/` (not even an `ls` beside +`node -v`). What else `~/.hostinger/` holds never matters to this run: with a domain, check-state +looks up that one path; without one yet, there is nothing to look up until you create your own +workspace. *State and artifacts* has the full rule and its one exception. -Run 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. +Nothing in the pasted prompt is ever used for authorization. Authorization comes from the +Hostinger connector's OAuth token. **This recipe is not a security boundary.** -## Paths +--- + +## The flow stages - STEPS TO FOLLOW + +Work through the steps in the order below, each reading its recipe and writing its artifact into the +site workspace `~/.hostinger//`. + + +The order is sequencial run and check the whole headless flow: + +| # | Stage | Skill / source | Writes | Instance? | +|---|--------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|---|---| +| — | **connect** — connect the Hostinger connector, auth, get active plan | `connect/SKILL.md` → `connect/tools/.md` | `connect.json` | no | +| 1 | **check state** — where does this domain stand? resume / switch / start over | `steps/01-check-state/SKILL.md` | `state.json` | no | +| 2 | **refine** — discover what the user wants, in conversation | `steps/02-refine/SKILL.md` | `brief.md` | no | +| 3 | **provision** — choose the backend from the brief, then provision it | `steps/03-provision//SKILL.md` | `site.json` | **bg** — fire, then poll in the background | +| 4 | **content model** — define + create content/images, then push to the backend | `steps/04-content-model//SKILL.md` | `content-model.md` | prepare: no · **push: yes** | +| 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 | +| 6 | **frontend** — pick/confirm the framework (default Next.js SSR), implement `design.md` against the backend | `steps/06-frontend/SKILL.md` | source in `/src/` | shell: no · **data: yes** | +| 7 | **validate** *(optional)* — quick instance+API confirmation; rebuild/preview/approval only when a person signs off | `steps/07-validate/SKILL.md` | — | no (local) | +| 8 | **deploy** — upload source, trigger the build | `steps/08-deploy/SKILL.md` | `deploy.json` | **yes** | +| 9 | **done** — hand over URLs, dashboard, summary, next steps (the **final report**) | `steps/09-done/SKILL.md` | — | no | + +**The most important is that connect stage runs first and always** +It's fetched from the public URL and runs *before* the Hostinger MCP Server +is loaded, the main goal of this is to have the Hostinger MCP Server. + +You can find those connect skills under `connect/`. +The main skill lives in `connect/SKILL.md`. +There are also specific tools under `connect/tools/`. + +The other 9 numbered steps live under `steps/` and are read from the installed skill folder (this will be guided in connect step). + +**One concurrency rule:** two *independent* stages in a single session may run at once, but never +two writers of the same artifact, and never two agents/windows on the same domain — the `.lock` +(Step 1) guards that. Parallelism is always *optional*: an agent that can't interleave work runs the +steps serially and is still correct. Saying this, Always that you consider, you can run the steps +in parallel using different threads or processes to save time, but always ensure the right output. + +Steps can generate **State files** those files are only hints; the truth is what the Hostinger connector reports, so +verify 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 +the user rather than improvising the stage from its description. + +The **Instance?** column says whether a stage touches the live backend: +`no` = pure prepare work that can run during the provision wait; +`push`/`data`/`yes` = a commit phase that joins the instance barrier; +`bg` = provision itself, the background task. + +**Always end with the final report.** Whenever a run ends, give the user the final report defined +in `steps/09-done/SKILL.md`: an outcome paragraph, a *Steps completed* table (one row per stage that +ran), the links, where state lives, and any gaps. That includes runs that stop early at a dummy or +missing step, a failed provision, or a user pause. Every run also saves a **build report** +(`run-log.mjs report`, see *Run log and build report* below), on those early stops too. + +**Log the run as you go.** Wrap every stage (and each phase of a split stage) in +`run-log.mjs stage-start` / `stage-end`, and log an event the moment something goes wrong, the user +corrects you, or a skill is missing or wrong — before you continue. See *Run log and build report*. + +**Why this order.** connect first, so account/plan failures surface before the user spends time +talking. Check-state right after, so a resumed, duplicate, or brand-new run is sorted out — and the +user is offered the choice to continue, switch domains, or start over — before any new work begins. +Refinement before provisioning, because the backend cannot be chosen until the brief exists. +Validation before deploy, always — nothing reaches the live domain unseen. + +### When you ask the user + +A question tool's result is the user's answer only when it carries their choice or their words. When +it comes back skipped, rejected, empty or as an error (a *skipped* result), nobody has answered yet: +Cursor's `AskQuestion`, for one, returns "Questions skipped by the user" when no one touched it. Ask +the same questions again as plain text in your reply, end your turn, and continue from what the user +types. One plain-text re-ask is enough: it can't come back skipped. Fill a question you asked with a +default only when the user says so in their own words ("you decide", "just build it", "skip that"), +or when they said up front that no one will be there to answer; then list those defaults under gaps +in the final report. Every step that asks the user something follows this. + +### Background provision & the instance barrier + +**Provision always runs — what it does depends on what the domain already has.** Often onboarding on +hostinger.com has already created the website before you run, so the domain *has* a headless instance; +but an instance existing does **not** mean provision is done or can be skipped. `check_state` works out +which of these cases applies and records it, then provision takes the matching path: + +1. **An empty headless shell already exists for the domain (the common onboarding case).** Onboarding + creates the website first — an **empty headless shell** (`hosting_websites_start-setup { type: + "headless_empty" }`): `website_type` is `headless`, but **no backend is installed** (no WordPress at + `/cms`, no store). This still goes through provision, which **adopts the shell and swaps it to the + chosen backend** (the backend is picked in refine first): WordPress is installed into the shell's + `/cms` with `wordpress_installations_install { type: "headless" }`, or the shell is adopted as the + Ecommerce storefront host. **Never skip provision for a shell, and never create a second site.** See + the provision router's *Onboarding handoff*. +2. **No instance exists at all** (the user ran the headless flow without onboarding provisioning + anything). Provision creates a fresh headless instance with `hosting_websites_start-setup` using the + chosen backend's own `type`. +3. **User wants a webapp.** Provision is a plain Node.js app host, not headless — `webapp` ignores + headless shells entirely and is only reached by experimental actions, never the onboarding flow. + +Either way the create/install is asynchronous and returns `queued` immediately, then it's polled +every ~15 s for a few minutes — **what you poll depends on the path**: a fresh +`hosting_websites_start-setup` is polled with `hosting_websites_list-setups` until `completed`, while a +shell swap (`wordpress_installations_install`) creates no new setup, so it's polled with +`wordpress_installations_list` until the `/cms` row is `is_valid`. Don't block on that poll. The +single rule that makes the overlap safe: + +> **Fire provision as a background task, then do every stage's prepare work while it settles. The +> moment a step must write to the live instance, it waits for provision to report ready (via the +> backend step's readiness poll — see above). That wait is the *instance barrier*.** + +--- + +## State and artifacts + +Everything for a domain — its state **and** its generated source — lives under the **site workspace +`~/.hostinger//`** (resolve `~` to the home directory: `$HOME` in a shell, `os.homedir()` in +Node — `~/.hostinger` means `$HOME/.hostinger`). This is a **global, per-user** location, not a +project folder, so this domain's build is resumable from **any** directory — the current working +directory does not matter. Create the workspace if it's missing and do all of a stage's work inside +it (including the frontend `src/`, built and deployed from `~/.hostinger//src/`). + +**The directory name is the domain**, so the files inside it never repeat it. + +**Read and write only this site's workspace.** The other workspaces under `~/.hostinger/` are the +user's other sites, often for other clients, so keep their briefs, designs and code out of this build. +Open `~/.hostinger//` by its exact path; don't list or glob `~/.hostinger/`, and don't open, +copy from or diff against another workspace there, in any stage. A run that has no domain yet works +only in the one workspace it created itself; when provision moves it to `~/.hostinger//`, +that becomes this site's workspace. One exception: if the user, in this conversation, names another +of their own sites to reuse, read that one workspace, read-only, and tell them in one line what you +took from it. + +| File (under `~/.hostinger//`) | Written by | Contains | +|---|---|---| +| `state.json` | every stage | tool, next stage, Hostinger connector version, timestamps | +| `connect.json` | connect | tool, account/plan readiness | +| `brief.md` | refine | refined brief, audience, tone, pages, must-haves | +| `site.json` | provision | chosen backend, username, install id, all URLs | +| `content-model.md` | content model | entities, fields, pages, navigation | +| `design.md` (+ `brand/` logo files) | design | chosen design direction: tokens, type, logo, components, per-page composition, accessibility | +| `src/` | frontend | the website source implementing `design.md`; framework choice is recorded in `state.json → frontend` | +| `deploy.json` | deploy | build id, deployed at, live URLs | +| `events.jsonl` | every stage, via `run-log.mjs` | append-only log of issues, user feedback, skill gaps and wins | +| `reports/.json` + `.html`, `report.html` | done (or any early stop), via `run-log.mjs` | the build report for each run; `report.html` is the latest | + +`state.json` is the minimum you must keep current. **`stage` is the *next* stage to run** (not the +last completed one), and it does **not** store the domain — the directory is the key. Because +provision runs in the background, its status lives in its own `provision` field rather than in +`stage`, so prepare work can advance `stage` while provision is still settling: + +```json +{ + "tool": "claude-code", + "stage": "content_model", + "provision": { + "status": "in_progress", + "domain": "example.com", + "started_at": "2026-10-08T10:00:00Z" + }, + "updated_at": "2026-10-08T10:00:00Z" +} +``` + +- `provision` is **absent** until provision fires, `{"status":"in_progress", …}` while the + background poll runs, and `{"status":"completed", …}` once `hosting_websites_list-setups` confirms + it. `domain` is the poll target. +- Any **instance-barrier** step (content-model push, frontend data, deploy) must read + `provision.status == "completed"` — and re-verify it against the connector — before it runs. A + prepare step ignores `provision` entirely. +- On **resume**, `provision.status` is a hint only: re-poll `hosting_websites_list-setups` to learn + the truth, then update the field. +- `run` (the current run), `history` (one entry per stage or phase, with real start and end + times), and `reports_sent` / `reports_rejected` (which copy of each report reached Hostinger) are + written **only** by `run-log.mjs`. Leave them alone when you update `state.json` by hand. +- The **onboarding slug** is how hPanel names the coding tool: `claude` for Claude Code, else + `cursor`, `codex`, `vscode`, `antigravity`, `devin`. Map it from the `` you pass to + `run-start --tool` (`state.json → run.tool`, e.g. `claude-code` → `claude`). + +--- + +## Run log and build report + +`run-log.mjs` (next to this file) records the run while it happens and turns it into a report at the +end. Timestamps come from the script, so they're measured, not remembered. It needs only Node. +Every command is `node /run-log.mjs …`, where `` is the folder this file is +in. Write it out in full each time (a shell variable holding the command breaks in zsh): + +```bash +node /run-log.mjs run-start --tool --model --skill # check-state, first thing +node /run-log.mjs run-update --kind resume # check-state, once the route is agreed (or redesign / iterate) +node /run-log.mjs stage-start frontend # before a stage +node /run-log.mjs stage-end frontend ok_after_retries --note "second framework tried" +node /run-log.mjs stage-start frontend --phase shell # split stages log each phase +node /run-log.mjs event issue '{"stage":"content_model","symptom":"…","cause":"…","workaround":"…","severity":"medium","resolved":true}' +node /run-log.mjs report --outcome live # done, or wherever the run stops +node /run-log.mjs report-sent # after the report was sent (see below) +node /run-log.mjs report-rejected # the send returned 422 +node /run-log.mjs unsent # check-state: reports still to send +node /run-log.mjs run-resume --reason "about/contact layout fix" # follow-up in the same session +``` + +**Follow-ups continue the run.** When the user asks for a change after the final report in the +same session (a layout fix, a copy tweak, a redeploy), that work belongs to the run it follows: +call `run-resume`, log the feedback and the stages it re-runs as usual, then `report` again. That +rewrites the same `reports/.json` and `.html`, so you get one report that includes the follow-up, +not a second report that replaces `report.html`. Use `run-start` only in a new session; check-state +does that. + +`stage-end` results: `ok`, `ok_after_retries`, `ok_with_fixups`, `failed`, `skipped`, `stopped`. +Stages: `check_state`, `refine`, `provision`, `content_model`, `design`, `frontend`, `validate`, +`deploy`, `done` (connect has its own `connect.json`). Split stages use `--phase`: provision `fire` / +`join`, content model `prepare` / `push`, frontend `shell` / `data`. + +**Forgot a `stage-start`?** Log it as soon as you notice, with roughly when the stage began: +`stage-start frontend --started-ago 6m` (or `--started-at `). The same flags work on a +`stage-end` that has no start. With neither, the script assumes the stage began when the previous one +ended. Either way the start is marked as estimated in the report. Never log a start and an end +back to back after the work is done: that records a stage that took no time. If you already did, +correct it with `stage-fix frontend --started-ago 6m`. + +**Log an event the moment it happens**, not at the end — details are gone by then: + +| Event | Log it when | Fields (required in bold) | +|---|---|---| +| `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 | +| `feedback` | the user corrects you, rejects something, or changes scope | **stage**, **category** (e.g. `design_rejected`, `layout_bug`, `scope_add`, `wrong_assumption`), **paraphrase** | +| `skill_gap` | a step is dummy, missing, wrong or silent on what you needed | **skill** (path), **gap** | +| `good` | something worked notably well or fast | **text** | + +Paraphrase the user; don't paste long quotes, file contents, credentials or personal data. Set +`user_had_to_intervene` when the user had to point the problem out. + +**Tokens** go on `report --tokens ''` only if your tool tells you your own usage directly +(`{"source":"…","requests":…,"input":…,"output":…,"cache_read":…,"cache_write":…,"est_cost_usd":…}`). +Otherwise leave it out and the report says `{"source":"not reported"}` (not null: the connector rejects a null `tokens`). Never read session transcripts or log +files to get it. -| What | Path | -| --- | --- | -| Plan check + website/domain provisioning | `references/SETUP.md` | -| Store: seed the backend + the frontend API contract | `references/STORE.md` | -| WordPress: headless CMS/blog backend + the frontend read contract | `references/WORDPRESS.md` | -| Deploy: static vs Node.js, archive rules, logs, verify | `references/DEPLOYMENT.md` | -| Database + runtime: MySQL host per runtime, env vars, what to poll, runtime logs | `references/DATABASE.md` | +The report is saved to `reports/.json` and `.html`, and `report.html` in the workspace is the +latest. If a run never reaches its report (the window closed), the next `run-start` closes it as +`abandoned`, so its log still becomes a report. -## Where the how comes from +**Send every report to Hostinger.** Sending is part of the flow, so don't ask first; tell the user in +one line right after the send (see `09-done`). Right after each +`report` (follow-ups included), read `reports/.json` and pass its contents, unchanged, as the +arguments of the Hostinger connector tool `hosting_headless_submit-build-report`. Then record the +result: -The inputSchema returned by search is authoritative for request shapes — read it before calling. Two live sources supersede anything written here when they disagree: +- **Success:** `report-sent `. +- **422** (the report doesn't match the schema the endpoint holds): `report-rejected `. Don't + resend it. +- **Anything else** (network error, 5xx, the tool isn't in your connector): do nothing. The next + check-state sends it again. -- 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. -- The Hostinger API reference at https://developers.hostinger.com describes every endpoint behind the operations. +Never edit the JSON before sending, never add account details (the connector's login identifies the +account), and never let a failed send block, delay or change the run. Check-state sends any +reports still unsent (abandoned runs, earlier failures) with `unsent`. \ No newline at end of file diff --git a/skills/hostinger-headless/connect/SKILL.md b/skills/hostinger-headless/connect/SKILL.md new file mode 100644 index 0000000..bc910dd --- /dev/null +++ b/skills/hostinger-headless/connect/SKILL.md @@ -0,0 +1,98 @@ +--- +name: hostinger-headless-connect +description: "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." +--- + +# Hostinger Headless Skill — connect + +You are a coding agent, and this is a skill for you to execute. Follow the phases below in order. + +This 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. + +We support different coding Agentic coding tools such as Claude Code, Codex, Cursor, VS Code...and so on. + +Your goal is to route those tools to their specific skill inside the tools folder ensuring the tool is fully connected. + + +## Phase 0 — Node (the single one prerequisite) + +The Hostinger MCP server requires Node ≥ 20. Check it: + +```bash +node -v +``` + +If 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: + +- macOS: `brew install node` (or `nvm install 22 && nvm use 22`) +- Linux: `nvm install 22 && nvm use 22` (or your distro's Node 20+ package) +- Windows: `winget install OpenJS.NodeJS.LTS` (or download from nodejs.org) + +## Phase 1 — Run the bootstrap (deterministic) + +Download 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. + +The 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. + +```bash +# macOS/Linux: +curl -fsSL -O https://raw.githubusercontent.com/hostinger/api-mcp-server/main/skills/headless/connect/bootstrap.mjs +# Windows PowerShell: +iwr https://raw.githubusercontent.com/hostinger/api-mcp-server/main/skills/headless/connect/bootstrap.mjs -OutFile bootstrap.mjs + +node bootstrap.mjs +``` + +### Relay these events + +The script emits one JSON object per line: + +| Event | What to do | +| --- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `mcp_ok` | MCP server reachable — continue. | +| `logged_in` | Already authenticated (env token or a previous sign-in) — continue. | +| `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. | +| `success` | Bootstrap done — continue to Phase 2. | +| `mcp_unreachable` / `login_failed` (with `detail`) | Stop and show the user the `detail`. Do not improvise a parallel setup by hand. | + +If the environment has a `HOSTINGER_API_TOKEN` set (for example CI), the bootstrap reports `logged_in` immediately — no browser needed. + +### Sign in or out manually + +The 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. + +```bash +# With a global install (npm install -g @hostinger/mcp): +hostinger-api-mcp --login +hostinger-api-mcp --logout + +# Without a global install: +npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --login +npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --logout +``` + +Credentials 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`. + +## Phase 2 - Route to the specific tool instructions + +| Tool | Route | +|-------------|---------------------------------| +| Claude Code | `connect/tools/claude-code.md` | +| Codex | `connect/tools/codex.md` | +| Cursor | `connect/tools/cursor.md` | +| VS Code | `connect/tools/vscode.md` | +| Antigravity | `connect/tools/antigravity.md` | +| Devin | `connect/tools/devin.md` | + +If the user's tool isn't listed yet, connect it the generic way (register the Hostinger MCP server +for that tool, or drive `npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp` over stdio) and continue. + +## Phase 3 - Continue to Headless Hosting steps + +After evrything is verified and you have access to the Hostinger MCP Server and the skills then you can go back to `headless/SKILL.md` and follow the steps section to start the process. + + +## Reference Sources + +- https://mcpservers.org/servers/hostinger/api-mcp-server +- https://www.hostinger.com/connector diff --git a/skills/hostinger-headless/connect/bootstrap.mjs b/skills/hostinger-headless/connect/bootstrap.mjs new file mode 100644 index 0000000..7117b6c --- /dev/null +++ b/skills/hostinger-headless/connect/bootstrap.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// Hostinger Headless — bootstrap. +// Verifies the Hostinger MCP server is reachable and drives the OAuth sign-in, +// emitting one JSON event per line on stdout so a coding agent can relay +// progress to the user. No filesystem writes besides what the MCP itself +// stores (OAuth credentials); no network calls of its own. +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +// ── tiny event protocol (one JSON object per line) ─────────────────────────── +const emit = (event, extra = {}) => + process.stdout.write(JSON.stringify({ event, ...extra }) + '\n'); +const fail = (event, extra = {}) => { + emit(event, { ok: false, ...extra }); + process.exit(1); +}; + +// ── platform-safe binary names (npm/npx are .cmd on Windows) ───────────────── +const isWin = process.platform === 'win32'; +const bin = (name) => (isWin ? `${name}.cmd` : name); +// Run the MCP via npx — no global install, always the latest published version. +// Use --package so npx resolves the published package, then invoke the unified binary. +const MCP = [bin('npx'), '-y', '--package=@hostinger/mcp@latest', 'hostinger-api-mcp']; +// Node >= 18.20.2 / 20.12.2 refuses to spawn a .cmd/.bat without a shell +// (CVE-2024-27980), so npx.cmd needs shell:true on Windows. Our args are all +// fixed literals, so enabling the shell introduces no injection risk. +const spawnShell = isWin; + +function capture(cmd, args) { + const r = spawnSync(cmd, args, { encoding: 'utf8', shell: spawnShell }); + return { status: r.status ?? 1, out: `${r.stdout || ''}${r.stderr || ''}`, error: r.error }; +} + +// ── 1. MCP server reachable ────────────────────────────────────────────────── +function checkMcp() { + const r = capture(MCP[0], [...MCP.slice(1), '--version']); + if (r.status !== 0) fail('mcp_unreachable', { detail: r.out.trim().slice(0, 400) }); + // npx interleaves "npm notice …" lines, so pick the first semver-looking token. + const version = (r.out.match(/\d+\.\d+\.\d+[^\s]*/) || [])[0] || r.out.trim().split('\n').pop(); + emit('mcp_ok', { version }); +} + +// ── 2. Existing auth? ──────────────────────────────────────────────────────── +// An env token always wins (the MCP bypasses OAuth entirely when it is set). +// Otherwise stored OAuth credentials mean a previous sign-in can be reused — +// the MCP refreshes expired tokens automatically on the first tool call. +function existingAuth() { + if (process.env.HOSTINGER_API_TOKEN || process.env.API_TOKEN) { + return 'api_token'; + } + const base = isWin ? (process.env.APPDATA || os.homedir()) : path.join(os.homedir(), '.config'); + const credsPath = path.join(base, 'hostinger-mcp', 'credentials.json'); + if (existsSync(credsPath)) { + try { + const creds = JSON.parse(readFileSync(credsPath, 'utf8')); + if (creds.refresh_token || (creds.access_token && creds.expires_at > Date.now())) { + return 'oauth_cached'; + } + } catch { + /* unreadable credentials — fall through to a fresh login */ + } + } + return null; +} + +// ── 3. Login (human-in-the-loop browser flow) ──────────────────────────────── +// `hostinger-api-mcp --login` opens the Hostinger sign-in page and listens on +// an ephemeral localhost port for the OAuth callback, so the browser must run +// on this same machine. It prints the sign-in URL to stderr with a stable +// prefix — forward it so the agent can show it if the browser didn't open. +function login() { + return new Promise((resolve) => { + const child = spawn(MCP[0], [...MCP.slice(1), '--login'], { shell: spawnShell }); + let diag = ''; + let urlSent = false; + const collect = (chunk) => { + diag = (diag + chunk).slice(-2000); + if (!urlSent) { + const m = diag.match(/Opening browser for sign-in:\s*\n\s*(https?:\/\/\S+)/); + if (m) { + urlSent = true; + emit('awaiting_user', { + verificationUri: m[1], + note: 'A browser window should have opened. If not, open this URL manually on this machine and sign in.', + }); + } + } + }; + child.stderr?.on('data', collect); + child.stdout?.on('data', collect); + child.on('close', (code) => { + if (code === 0 && /Sign-in successful/i.test(diag)) { + emit('logged_in', { method: 'oauth' }); + resolve(); + return; + } + const detail = diag.trim().slice(-1500) || `--login exited (code ${code}) with no output.`; + fail('login_failed', { code, detail }); + }); + child.on('error', (e) => fail('login_failed', { detail: String(e) })); + }); +} + +// ── main ───────────────────────────────────────────────────────────────────── +checkMcp(); +const method = existingAuth(); +if (method) { + emit('logged_in', { method }); +} else { + await login(); +} +emit('success'); diff --git a/skills/hostinger-headless/connect/tools/antigravity.md b/skills/hostinger-headless/connect/tools/antigravity.md new file mode 100644 index 0000000..bdad7b8 --- /dev/null +++ b/skills/hostinger-headless/connect/tools/antigravity.md @@ -0,0 +1,54 @@ +--- +name: headless-connect-antigravity +description: "Specific Antigravity Hostinger MCP Server connection instructions for the Hostinger Headless flow." +--- + +## Goals + +- Make Hostinger MCP tools and Skills available in Google Antigravity. +- 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. + +## Step 1 — Make the connector callable (no restart needed) + +The later stages drive the Hostinger connector. If its Hostinger tools already respond in this +session, use them and move on. + +If they don't, **don't rely on registering the server and restarting** — Antigravity loads MCP servers +when the editor starts, and you can't restart it mid-run. The Hostinger MCP runs as a local **stdio** +process and the bootstrap already signed you in, so talk to it directly for this run: spawn +`npx -y @hostinger/mcp@latest` and speak newline-delimited JSON-RPC 2.0 over its stdin/stdout +(`initialize` → `notifications/initialized` → `tools/call`). It reuses your cached sign-in automatically +(`~/.config/hostinger-mcp/credentials.json`, or `HOSTINGER_API_TOKEN`) — no tokens to handle. It +exposes `search`, `execute`, `multi-execute`; everything the stages need goes through `execute`, and a +result is in `reply.result.content[0].text`. Write the little client yourself (~20 lines), keep one +process alive for the whole run, and use it wherever a stage calls the connector. + +**To persist it for future Antigravity sessions** (optional), add the server to +`~/.gemini/antigravity/mcp_config.json` (Windows: `C:\Users\\.gemini\antigravity\mcp_config.json`) — +reachable from the Agent panel's "..." → MCP Servers → Manage MCP Servers → View raw config — then fully +quit and reopen Antigravity once so it loads: + +```json +{ + "mcpServers": { + "hostinger": { + "command": "npx", + "args": ["-y", "@hostinger/mcp@latest"] + } + } +} +``` + +## Step 2 — Install the skills and hand off + +Download 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 (see +`connect/tools/claude-code.md` for the full tarball command): + +```bash +# macOS/Linux — downloads only skills/headless from the public repo +curl -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main \ + | tar -xz --strip-components=2 'api-mcp-server-main/skills/headless' +``` + +Then open `headless/SKILL.md` and follow its steps. That skill owns the rest of the run. diff --git a/skills/hostinger-headless/connect/tools/claude-code.md b/skills/hostinger-headless/connect/tools/claude-code.md new file mode 100644 index 0000000..c68be5a --- /dev/null +++ b/skills/hostinger-headless/connect/tools/claude-code.md @@ -0,0 +1,46 @@ +--- +name: headless-connect-claude-code +description: "Specific Claude Code Hostinger MCP Server connection instructions for the Hostinger Headless flow." +--- + +## Goals + +- Your goal is to make Hostinger MCP tools and Skills available in Claude Code. +- 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. + + +## Step 1 — Make the MCP callable (no restart needed) + +Stages 2–10 drive the Hostinger connector. If its tools are already in this session +(`mcp__hostinger__*`, or the scoped `mcp__hostinger-*__*`), use them and move on. + +If they aren't, **don't register the server and restart** — Claude Code only loads MCP servers at +startup, but you don't need it to. The Hostinger MCP runs as a local **stdio** process and the +bootstrap already signed you in, so talk to it directly: spawn `npx -y @hostinger/mcp@latest` and +speak newline-delimited JSON-RPC 2.0 over its stdin/stdout (`initialize` → +`notifications/initialized` → `tools/call`). It reuses your cached sign-in automatically +(`~/.config/hostinger-mcp/credentials.json`, or `HOSTINGER_API_TOKEN`) — no tokens to handle. It +exposes `search`, `execute`, `multi-execute`; everything the stages need goes through `execute`, and +a result is in `reply.result.content[0].text`. Write the little client yourself (~20 lines), keep one +process alive for the whole run, and use it wherever a stage calls the connector. + +## Step 2 — Install the skills and hand off + +Download 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: + +```bash +# macOS/Linux — downloads only skills/headless from the public repo +curl -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main \ + | tar -xz --strip-components=2 'api-mcp-server-main/skills/headless' +``` + +```powershell +# Windows PowerShell +curl.exe -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main -o headless-skill.tar.gz +tar -xf headless-skill.tar.gz --strip-components=2 'api-mcp-server-main/skills/headless' +``` + +This 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. + +Then open `headless/SKILL.md` and follow the steps on it. That skill owns the rest of the run — it resolves the operations sequence. + diff --git a/skills/hostinger-headless/connect/tools/codex.md b/skills/hostinger-headless/connect/tools/codex.md new file mode 100644 index 0000000..e9efcfd --- /dev/null +++ b/skills/hostinger-headless/connect/tools/codex.md @@ -0,0 +1,48 @@ +--- +name: headless-connect-codex +description: "Specific Codex Hostinger MCP Server connection instructions for the Hostinger Headless flow." +--- + +## Goals + +- Make Hostinger MCP tools and Skills available in Codex. +- 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. + +## Step 1 — Make the connector callable (no restart needed) + +The later stages drive the Hostinger connector. If its Hostinger tools already respond in this +session, use them and move on. + +If they don't, **don't rely on registering the server and restarting** — Codex loads MCP servers when +it starts, and you can't restart it mid-run. The Hostinger MCP runs as a local **stdio** process and the +bootstrap already signed you in, so talk to it directly for this run: spawn `npx -y @hostinger/mcp@latest` +and speak newline-delimited JSON-RPC 2.0 over its stdin/stdout (`initialize` → +`notifications/initialized` → `tools/call`). It reuses your cached sign-in automatically +(`~/.config/hostinger-mcp/credentials.json`, or `HOSTINGER_API_TOKEN`) — no tokens to handle. It +exposes `search`, `execute`, `multi-execute`; everything the stages need goes through `execute`, and a +result is in `reply.result.content[0].text`. Write the little client yourself (~20 lines), keep one +process alive for the whole run, and use it wherever a stage calls the connector. + +**To persist it for future Codex sessions** (optional), register the server with +`codex mcp add hostinger -- npx -y @hostinger/mcp@latest`, or add it to `~/.codex/config.toml`, then +restart Codex so it loads: + +```toml +[mcp_servers.hostinger] +command = "npx" +args = ["-y", "@hostinger/mcp@latest"] +``` + +## Step 2 — Install the skills and hand off + +Download 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 (see +`connect/tools/claude-code.md` for the full tarball command): + +```bash +# macOS/Linux — downloads only skills/headless from the public repo +curl -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main \ + | tar -xz --strip-components=2 'api-mcp-server-main/skills/headless' +``` + +Then open `headless/SKILL.md` and follow its steps. That skill owns the rest of the run. diff --git a/skills/hostinger-headless/connect/tools/cursor.md b/skills/hostinger-headless/connect/tools/cursor.md new file mode 100644 index 0000000..b40f355 --- /dev/null +++ b/skills/hostinger-headless/connect/tools/cursor.md @@ -0,0 +1,109 @@ +--- +name: headless-connect-cursor +description: "Specific Cursor Hostinger MCP Server connection instructions for the Hostinger Headless flow." +--- + +## Goals + +- Install (or reach) the Hostinger MCP in Cursor. +- Sign the user in with OAuth 2.0 PKCE — the MCP opens a browser so they only need to log in. +- 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. + +## Step 1 — Install / reach Hostinger MCP + +**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. + +The 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. + +### Option A — no global install (recommended) + +```bash +# Verify the package/binary is reachable +npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --version +``` + +### Option B — global install + +```bash +npm install -g @hostinger/mcp +# or: yarn global add @hostinger/mcp +# or: pnpm add -g @hostinger/mcp + +hostinger-api-mcp --version +``` + +### Persist for future Cursor sessions (optional) + +Register 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: + +```json +{ + "mcpServers": { + "hostinger": { + "command": "npx", + "args": ["--yes", "--package=@hostinger/mcp@latest", "hostinger-api-mcp"] + } + } +} +``` + +If 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. + +## Step 2 — Authenticate with OAuth (browser opens) + +Prefer 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). + +If the connect bootstrap already emitted `logged_in` / `success`, credentials are already on disk — continue to Step 3. + +Otherwise 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: + +```bash +npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --login +``` + +(With a global install: `hostinger-api-mcp --login`.) + +What to tell the user: + +1. A browser window should open automatically for Hostinger sign-in. +2. 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:/oauth/callback`). +3. Wait until the command prints that sign-in succeeded and exits 0. + +Credentials are stored and reused automatically: + +- macOS / Linux: `~/.config/hostinger-mcp/credentials.json` (mode 0600) +- Windows: `%APPDATA%\hostinger-mcp\credentials.json` + +They are shared across every Hostinger MCP binary. Expired access tokens refresh on their own; a revoked refresh token re-triggers the browser flow. + +To sign out later: + +```bash +npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp --logout +``` + +**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. + +## Step 3 — Use the connector in this run (no Cursor restart) + +Cursor 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 + +```bash +npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp +``` + +and 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. + +## Step 4 — Install the skills and hand off + +Download 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 (see +`connect/tools/claude-code.md` for the full tarball command): + +```bash +# macOS/Linux — downloads only skills/headless from the public repo +curl -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main \ + | tar -xz --strip-components=2 'api-mcp-server-main/skills/headless' +``` + +Then open `headless/SKILL.md` and follow its steps. That skill owns the rest of the run. diff --git a/skills/hostinger-headless/connect/tools/devin.md b/skills/hostinger-headless/connect/tools/devin.md new file mode 100644 index 0000000..bbc2350 --- /dev/null +++ b/skills/hostinger-headless/connect/tools/devin.md @@ -0,0 +1,50 @@ +--- +name: headless-connect-devin +description: "Specific Devin Hostinger MCP Server connection instructions for the Hostinger Headless flow." +--- + +## Goals + +- Make Hostinger MCP tools and Skills available in Devin. +- 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. + +## Step 1 — Make the connector callable + +The later stages drive the Hostinger connector. If its Hostinger tools already respond in this +session, use them and move on. + +If they don't, register the Hostinger MCP server for Devin. The local MCP runs as a **stdio** process +and the bootstrap already signed you in, so it reuses your cached sign-in automatically +(`~/.config/hostinger-mcp/credentials.json`, or `HOSTINGER_API_TOKEN`) — no tokens to handle. + +- **Devin CLI:** `devin mcp add hostinger -- npx -y @hostinger/mcp@latest` +- **Devin app:** Settings → MCP Marketplace → **Add Your Own**, transport **STDIO**, with: + + ```json + { + "command": "npx", + "args": ["-y", "@hostinger/mcp@latest"] + } + ``` + +On Devin Desktop, fully quit and reopen the app once after adding the server so it loads. + +If you can't reach the registered server from this run, drive `npx -y @hostinger/mcp@latest` directly +over stdio instead: spawn it and speak newline-delimited JSON-RPC 2.0 over its stdin/stdout +(`initialize` → `notifications/initialized` → `tools/call`). It exposes `search`, `execute`, +`multi-execute`; everything the stages need goes through `execute`, and a result is in +`reply.result.content[0].text`. + +## Step 2 — Install the skills and hand off + +Download 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 (see +`connect/tools/claude-code.md` for the full tarball command): + +```bash +# macOS/Linux — downloads only skills/headless from the public repo +curl -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main \ + | tar -xz --strip-components=2 'api-mcp-server-main/skills/headless' +``` + +Then open `headless/SKILL.md` and follow its steps. That skill owns the rest of the run. diff --git a/skills/hostinger-headless/connect/tools/vscode.md b/skills/hostinger-headless/connect/tools/vscode.md new file mode 100644 index 0000000..9b320ce --- /dev/null +++ b/skills/hostinger-headless/connect/tools/vscode.md @@ -0,0 +1,57 @@ +--- +name: headless-connect-vscode +description: "Specific VS Code Hostinger MCP Server connection instructions for the Hostinger Headless flow." +--- + +## Goals + +- Make Hostinger MCP tools and Skills available in VS Code (GitHub Copilot / agent mode). +- 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. + +## Step 1 — Make the connector callable + +The later stages drive the Hostinger connector. If its Hostinger tools already respond in this +session, use them and move on. + +If they don't, register the Hostinger MCP server for VS Code. The local MCP runs as a **stdio** +process and the bootstrap already signed you in, so it reuses your cached sign-in automatically +(`~/.config/hostinger-mcp/credentials.json`, or `HOSTINGER_API_TOKEN`) — no tokens to handle. Add it to +`.vscode/mcp.json` (workspace) or your user `settings.json` under `mcp`: + +```json +// .vscode/mcp.json +{ + "servers": { + "hostinger": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@hostinger/mcp@latest"] + } + } +} +``` + +Or add it in one command: `code --add-mcp '{"name":"hostinger","command":"npx","args":["-y","@hostinger/mcp@latest"]}'`. +VS Code picks up the server and its tools become available immediately — no editor restart needed. +(Alternatively, the official Hostinger Connector extension installs the same server: +`code --install-extension hostinger-official.hostinger-connector`.) + +If you can't reach the registered server from this run, drive `npx -y @hostinger/mcp@latest` directly +over stdio instead: spawn it and speak newline-delimited JSON-RPC 2.0 over its stdin/stdout +(`initialize` → `notifications/initialized` → `tools/call`). It exposes `search`, `execute`, +`multi-execute`; everything the stages need goes through `execute`, and a result is in +`reply.result.content[0].text`. + +## Step 2 — Install the skills and hand off + +Download 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 (see +`connect/tools/claude-code.md` for the full tarball command): + +```bash +# macOS/Linux — downloads only skills/headless from the public repo +curl -fsSL https://codeload.github.com/hostinger/api-mcp-server/tar.gz/refs/heads/main \ + | tar -xz --strip-components=2 'api-mcp-server-main/skills/headless' +``` + +Then open `headless/SKILL.md` and follow its steps. That skill owns the rest of the run. diff --git a/skills/hostinger-headless/references/DATABASE.md b/skills/hostinger-headless/references/DATABASE.md deleted file mode 100644 index 4e70a41..0000000 --- a/skills/hostinger-headless/references/DATABASE.md +++ /dev/null @@ -1,49 +0,0 @@ -# Database and runtime — MySQL hosts, env vars, and what to poll - -Use this when the app needs MySQL, or when a deploy "succeeded" but the app cannot reach its database. Everything here uses the **hosting** MCP operations; Agency Plan differences are at the end. - -## 1. Create the database - -1. `hosting_databases_create` on the site's `username` with a database name, a user and a password you generate — name and user are prefixed with the account username automatically. -2. Read the full, prefixed `name` and `user` back from `hosting_databases_list`; every other database operation wants the full name. -3. Do not print the password to the user once it is stored, and never commit it. - -## 2. Which host the app connects to - -| Where the code runs | Host | Port | Why | -| --- | --- | --- | --- | -| PHP on the website | `localhost` or `127.0.0.1` | 3306 | `localhost` uses the MySQL socket; both are granted | -| Node.js on the website | `127.0.0.1` | 3306 | `localhost` may resolve to IPv6 `::1`, and not every database user has that grant | -| Outside Hostinger (a laptop, another server) | the `host` from `hosting_databases_list` (`srvNNNN.hstgr.io`) | 3306 | needs `hosting_databases_create-remote-connection` for that client's IP first — never `%`, it opens the database to every address on the internet | - -Do not put the remote `host` into the deployed app — it has no grant for connections that come from the server itself. - -## 3. Hand credentials to the app - -- **Node.js:** environment variables via `hosting_nodejs_replace-environment-variables`. Conventional keys: `DB_HOST=127.0.0.1`, `DB_PORT=3306`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, plus `DATABASE_URL=mysql://USER:PASSWORD@127.0.0.1:3306/NAME` for ORMs that want one string — URL-encode `USER` and `PASSWORD` there (`encodeURIComponent`), or a password containing `@`, `:`, `/` or `#` breaks the URL. The call is a **full replace**: list the current keys with `hosting_nodejs_list-environment-variables` (values come back masked as `********` — never copy those), then send the complete set with real values. Saving restarts the process; frameworks that bake variables into the build (Next.js, `NEXT_PUBLIC_*`) need a new `hosting_nodejs_start-build` afterwards. -- **PHP:** a config file on the server that the app reads (its own `config.php`, WordPress's `wp-config.php`). Ship it with the deploy, keep it out of git. -- **Never** in client-side JavaScript, in a committed `.env`, or in chat replies. - -## 4. What is asynchronous, and what to poll - -| You called | Poll this until | Typical wait | -| --- | --- | --- | -| `hosting_websites_create` | `hosting_websites_list-setups` with the domain reports `status: completed` | up to a few minutes | -| `hosting_deploy-js-application` | `hosting_list-js-deployments` shows the build finished | minutes | -| `hosting_nodejs_start-build` | `hosting_nodejs_build` state is `completed` or `failed` | minutes | -| `wordpress_installations_install`, plugin/theme/core jobs | `wordpress_installations_list` lists the install | 1–2 minutes | -| `hosting_databases_repair`, `hosting_websites_delete` | nothing to poll — runs in the background | minutes | -| `agency-hosting_website-setups_create` | `agency-hosting_website-setups_status` with `setup_uuid` is `completed` | minutes | - -Poll with backoff (a few seconds, then tens of seconds). A queued response is not a failure, and re-sending the write does not speed it up. - -## 5. When the app is up but broken - -- Build failed: `hosting_nodejs_build-logs` for the raw log, `hosting_nodejs_analyse-failed-build` for a diagnosis. -- Build passed, app errors: `hosting_nodejs_runtime-logs` with a `period` such as `1h`; entries before `last_deployed_at` belong to the previous deploy. -- `Access denied for user 'x'@'::1'`: the app used `localhost` from Node.js — set `DB_HOST` to `127.0.0.1`. -- PHP limits (memory, upload size): `hosting_php_get`, then `hosting_php_update-options`. - -## Agency Plan websites (`agency-hosting_*`) - -`agency-hosting_databases_create-website` creates the database and its single user; the user is granted on `localhost`, so the PHP app connects to `localhost`. Deploys are synchronous there — `agency-hosting_deploy-php-application` returns when the site is live. Other jobs (SSL, backups) show up in `agency-hosting_websites_list-processes`. diff --git a/skills/hostinger-headless/references/DEPLOYMENT.md b/skills/hostinger-headless/references/DEPLOYMENT.md deleted file mode 100644 index e3926e3..0000000 --- a/skills/hostinger-headless/references/DEPLOYMENT.md +++ /dev/null @@ -1,36 +0,0 @@ -# Deployment — getting the build live - -Match the deploy method to what the project actually is — this is the single most common failure point. - -The deploy operations below read the archive from this machine, so only the local `hostinger-api-mcp` server has them. On the hosted server (`mcp.hostinger.com`) `search` does not find them: upload the files and deploy from the upload as described in the `deploy-to-hosting` skill ("Without the local deploy operations"). Agency Plan sites deploy through the Agency operations listed there as well. - -## Static site → `hosting_deploy-static-website` - -For pre-built files only: plain HTML/CSS/JS, or the **build output** of a framework (run the build locally first). - -- The archive (zip/tar) must have `index.html` at its **root** — not nested inside a folder. -- Name it `name_YYYYMMDD_HHMMSS.zip`; pass `removeArchive: true` to clean up after upload. -- Deployment is effectively immediate — verify right after. - -## Node.js app → `hosting_deploy-js-application` - -For anything that needs a server or a server-side build (Express, Next.js, NestJS, SSR frameworks). - -- Archive the **source**, not the output: exclude `node_modules/`, `dist/`, `.next/`, `build/`, and everything matched by `.gitignore`. The install and build run on Hostinger. -- Hard cap: **50 MB** archive. -- The upload starts a build. Track it with `hosting_list-js-deployments`; on failure pull `hosting_show-js-deployment-logs`, fix, and redeploy. Poll with backoff — builds take minutes, not seconds. - -## WordPress → `hosting_import-wordpress-website` / plugin & theme operations - -Only when the user explicitly wants a WordPress *site* migrated or themed. Site imports take an archive plus a `.sql` dump and can run for several minutes. (WordPress as a headless content backend behind an agent-built frontend is a different flow — that's `WORDPRESS.md`, installed fresh via the API, not imported here.) - -## Verify (every deploy) - -```bash -curl -s -o /dev/null -w "%{http_code}\n" https://YOURDOMAIN/ -curl -s https://YOURDOMAIN/ | grep -o "SOME_REAL_HEADLINE_TEXT" -``` - -Replace the grep target with copy you actually rendered — a 200 alone can be a placeholder page. A freshly created site may serve a default page for a short while; if content doesn't match, clear the cache (`hosting_cache_clear-website`) and retry before assuming the deploy failed. For store runs, also run the checkout verification in `STORE.md`. - -When the site is live, report the URL and remind the user the site is managed from hPanel (https://hpanel.hostinger.com). diff --git a/skills/hostinger-headless/references/SETUP.md b/skills/hostinger-headless/references/SETUP.md deleted file mode 100644 index 632ab67..0000000 --- a/skills/hostinger-headless/references/SETUP.md +++ /dev/null @@ -1,35 +0,0 @@ -# Setup — plan check and website provisioning - -Everything here uses the **hosting** MCP operations. If they're missing, ask the user to enable the Websites product group in the Hostinger Connector (or use the `hostinger-hosting-mcp` scoped binary). - -## 1. Plan check (gate — run this first) - -A website can only be created on an active hosting plan. - -1. `hosting_websites_list` — if it returns websites, the account has a working plan; note any existing `order_id` and `username` for step 2. -2. Otherwise `hosting_orders_list` — look for an order in a usable state. A fresh order that has never hosted a website still works; note its `order_id`. -3. Agency Plan orders are not in that list: check `agency-hosting_orders_list` too. An active Agency order is a usable plan — create the website with `agency-hosting_website-setups_create` and poll `agency-hosting_website-setups_status` until `completed` (it returns the `website_uid`), then deploy with the Agency operations in the `deploy-to-hosting` skill. -4. If there is no usable order: **stop and tell the user** that an active Hostinger hosting plan is required to deploy, link https://www.hostinger.com/web-hosting, and offer to continue building the site locally in the meantime. When they confirm the purchase, re-run this check. - -Never purchase a plan, domain, or any paid item without the user explicitly approving that specific purchase. - -## 2. Choose the domain - -- **Default: free subdomain.** `hosting_domains_generate-free-subdomain` returns a `*.hostingersite.com` domain. No verification needed. Tell the user a custom domain can be connected later. -- **User-owned domain:** `hosting_domains_verify-ownership` first. If not accessible, relay the TXT record it returns, remind them propagation can take ~10 minutes, and re-verify before continuing. -- **New domain purchase:** only on explicit request — check availability with `domains_availability_check`, state the price, and get an explicit yes before `domains_portfolio_purchase` (needs the domains product group). - -## 3. Create the website and wait for it - -Generating a subdomain does **not** create a website — deploying straight to it fails with `No website found for domain`. The working sequence: - -1. `hosting_websites_create { domain, order_id }` — `datacenter_code` is required only for the first website on a brand-new plan (pick the first entry from `hosting_datacenters_list`). -2. **Poll** `hosting_websites_list-setups` filtered by the domain every 10–15 s until `status` is `completed`. The site shows up in `hosting_websites_list` before its setup finishes, and uploads, deploys and database calls return 404 or 409 until then. Creation takes up to a few minutes — don't fail fast. -3. Note the site's `username` — deployment and database operations are keyed on it. - -If the domain already has a website (an `iterate` run, or the user pointed at an existing site), skip creation entirely. - -## 4. Optional extras (only when the run needs them) - -- **Database:** when the app needs MySQL, follow `DATABASE.md` — creating it, which host each runtime connects to (Node.js must use `127.0.0.1`), and handing the credentials to the app without hard-coding them. -- **DNS records:** the DNS operations (`dns_records_update` etc.) for custom-domain records; take a snapshot (`dns_snapshots_list` context) before destructive changes. diff --git a/skills/hostinger-headless/references/STORE.md b/skills/hostinger-headless/references/STORE.md deleted file mode 100644 index d370006..0000000 --- a/skills/hostinger-headless/references/STORE.md +++ /dev/null @@ -1,52 +0,0 @@ -# Store — Hostinger Ecommerce with a custom storefront - -**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. - -## Mental model - -Two API surfaces — don't mix them: - -| Surface | Base URL | Auth | Use for | -| --- | --- | --- | --- | -| Storefront Core V2 | `https://api-ecommerce.hostinger.com/v2` | none (public, open CORS) | read products/variants, checkout — called from the browser | -| Management (MCP tools) | Hostinger API | authenticated | create stores/products/shipping/payments | - -- 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. -- **Prices are integers in minor units** (`2900` = €29.00 → divide by `10^decimal_digits`) and live on **variants**, not products. -- **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. - -## Backend setup (management tools) - -1. 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. -2. 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. -3. After deploy, point the channel at the live site: `ecommerce_sales-channels_update { store_id, sales_channel_id, url }`. - -If 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. - -## Frontend contract - -The 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: - -- **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.) -- 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. -- 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). -- Filter variants with `product_ids[]=...`; the unfiltered variant list is eventually-consistent right after catalog edits. -- 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: - -``` -POST /channels/{sales_channel_id}/checkout -{ "items":[{ "variant_id":"variant_01...", "quantity":1 }], - "success_url":"https://YOURDOMAIN/checkout/success/", - "cancel_url":"https://YOURDOMAIN/checkout/cancel/", - "locale":"en" } -``` - -`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). - -A 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. - -## Verify - -- A `POST` to the checkout endpoint returns a `url` — test it directly with curl before calling the run done. -- The `success_url`/`cancel_url` pages return 200 (`curl -s -o /dev/null -w "%{http_code}" https://YOURDOMAIN/checkout/success/`). -- Open the live site, add to cart, and confirm the redirect to `https://checkout.hostinger.com`. diff --git a/skills/hostinger-headless/references/WORDPRESS.md b/skills/hostinger-headless/references/WORDPRESS.md deleted file mode 100644 index 9e1a3c0..0000000 --- a/skills/hostinger-headless/references/WORDPRESS.md +++ /dev/null @@ -1,51 +0,0 @@ -# WordPress — headless CMS and blog backend - -Use WordPress when the site has **owner-managed content**: a blog, news, articles, or any content the owner must edit without a developer or a redeploy. The frontend stays agent-built and is deployed like any other run; WordPress runs separately as the content backend, and the owner writes in wp-admin. - -**When NOT to use this:** static copy that rarely changes (an about page, a services list, a brochure site) — bake that into the frontend. Installing WordPress for content the owner will never edit adds a moving part for nothing. Any buy/sell intent is `STORE.md`, not this file. - -Everything in Setup uses the **hosting** and **wordpress** MCP operation groups; ask the user to enable them in the Hostinger Connector if operations are missing. - -## Mental model - -Two surfaces, like the store recipe: - -| Surface | Base URL | Auth | Use for | -| --- | --- | --- | --- | -| WP REST API | `https://CMS_DOMAIN/wp-json/wp/v2` | none for published content (open CORS) | frontend reads: posts, pages, media, categories | -| Management (MCP tools + wp-admin) | Hostinger API / wp-admin | authenticated | install WP, plugins, cache; the owner writes content | - -- Published content is publicly readable and the REST API allows cross-origin browser reads — a static frontend on another domain fetches it directly at runtime. -- Drafts, previews, and **writes need authentication** (WP application passwords). Never put credentials of any kind in the frontend; a public site only needs the anonymous read path. - -## Setup (management tools) - -1. **Check for an existing installation first**: `wordpress_installations_list` filtered by the site's username/domain. Reuse a valid install when the user agrees — never overwrite one silently. -2. **Choose where WordPress lives.** Default: a dedicated subdomain of the site, e.g. `cms.` — create it with `hosting_domains_create-website-subdomain` so the main domain stays free for the frontend. A separate free-subdomain website (per `SETUP.md`) also works when the plan allows another website. -3. **Install**: `wordpress_installations_install` on that domain. The call only queues the job — **poll** `wordpress_installations_list` until the installation appears (typically 1–2 minutes). Don't proceed on the queued response alone. -4. **Hand the owner their editor**: mint a one-click wp-admin link with `wordpress_login_create-links` and show it to the user. Content authoring happens in wp-admin — the skill does not seed posts (there is no anonymous write path, by design). The fresh install ships with a sample post, which is enough to build and verify the frontend against. -5. **Caching** (recommended before finishing): enable the object cache with `wordpress_object-cache_toggle-memcached`; after config changes, purge with `wordpress_litespeed-cache_purge-lite-speed`. - -## Frontend contract - -All paths relative to `https://CMS_DOMAIN/wp-json/wp/v2`. Non-obvious rules: - -- **Fetch at runtime, not build time** — content changes whenever the owner publishes; baking it into a static build defeats the purpose. Client-side fetch on page load is fine (CORS is open). -- List posts with `/posts?per_page=10&_embed` — `_embed` inlines featured images (`_embedded['wp:featuredmedia'][0].source_url`), authors, and terms; without it you get IDs that need extra requests. -- Post bodies are **rendered HTML**: use `title.rendered` / `content.rendered` / `excerpt.rendered` and render them as HTML — do not treat them as plain text and do not try to restyle their inner markup beyond CSS. -- Single post by slug: `/posts?slug=my-post` — returns an **array** (take the first element); there is no direct slug path. -- Pagination comes from the `X-WP-Total` / `X-WP-TotalPages` response headers, not the body. -- Only **published** content is returned anonymously. An empty list is a normal state — the frontend must render it gracefully, not error. -- SEO is the frontend's job: page titles, meta tags, and the sitemap come from the frontend build, not from WordPress plugins. - -## Verify - -```bash -curl -s -o /dev/null -w "%{http_code}\n" "https://CMS_DOMAIN/wp-json/wp/v2/posts?per_page=1" -``` - -Expect 200 with a JSON array. Then confirm the deployed frontend renders the sample post (or a clean empty state), and show the user two links: the live site and the wp-admin login for writing content. - -## Record - -Add `"cms_domain"` to `.hostinger/site.json` (see `SKILL.md` §Record) so iterate runs know a WordPress backend exists and reuse it instead of installing again. diff --git a/skills/hostinger-headless/report.schema.json b/skills/hostinger-headless/report.schema.json new file mode 100644 index 0000000..0cba837 --- /dev/null +++ b/skills/hostinger-headless/report.schema.json @@ -0,0 +1,149 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Hostinger Headless build report", + "description": "Written by run-log.mjs at the end of every run (including early stops) to ~/.hostinger//reports/.json.", + "type": "object", + "required": ["schema_version", "generated_at", "consent", "run", "info", "timing", "tokens", "issues", "user_feedback", "skill_gaps", "what_went_well", "previous_runs"], + "properties": { + "schema_version": { "type": "string", "enum": ["1"] }, + "generated_at": { "type": "string" }, + "consent": { + "type": "object", + "required": ["sent"], + "properties": { "sent": { "type": "boolean" }, "note": { "type": "string" } } + }, + "run": { + "type": "object", + "required": ["id", "kind"], + "properties": { + "id": { "type": "string" }, + "kind": { "type": "string", "enum": ["build", "resume", "redesign", "iterate"] }, + "skill": { "type": ["string", "null"] }, + "follow_ups": { + "type": "array", + "items": { + "type": "object", + "required": ["resumed_at"], + "properties": { "resumed_at": { "type": "string" }, "after_report_at": { "type": ["string", "null"] }, "reason": { "type": ["string", "null"] } } + } + } + } + }, + "info": { + "type": "object", + "required": ["domain", "tool", "outcome"], + "properties": { + "domain": { "type": "string" }, + "tool": { "type": ["string", "null"] }, + "model": { "type": ["string", "null"] }, + "os": { "type": "string" }, + "node": { "type": "string" }, + "mcp": { "type": ["string", "null"] }, + "backend": { "type": ["string", "null"] }, + "frontend": { "type": ["string", "null"] }, + "outcome": { "type": "string", "enum": ["live", "stopped", "failed", "abandoned"] }, + "stopped_at": { "type": ["string", "null"] }, + "live_url": { "type": ["string", "null"] }, + "admin_url": { "type": ["string", "null"] } + } + }, + "timing": { + "type": "object", + "required": ["started_at", "ended_at", "minutes_total", "stages"], + "properties": { + "started_at": { "type": "string" }, + "ended_at": { "type": "string" }, + "live_at": { "type": ["string", "null"] }, + "minutes_total": { "type": ["number", "null"] }, + "minutes_to_live": { "type": ["number", "null"] }, + "stages": { + "type": "array", + "items": { + "type": "object", + "required": ["stage", "started_at", "ended_at", "result"], + "properties": { + "stage": { "type": "string", "enum": ["connect", "check_state", "refine", "provision", "content_model", "design", "frontend", "validate", "deploy", "done"] }, + "phase": { "type": ["string", "null"] }, + "started_at": { "type": "string" }, + "ended_at": { "type": ["string", "null"] }, + "result": { "type": ["string", "null"], "enum": ["ok", "ok_after_retries", "ok_with_fixups", "failed", "skipped", "stopped", null] }, + "note": { "type": ["string", "null"] }, + "minutes": { "type": ["number", "null"] }, + "start_estimated": { "type": "boolean" } + } + } + } + } + }, + "tokens": { + "description": "Only when the tool reports its own usage. Never read the user's transcripts to get it.", + "type": ["object", "null"], + "properties": { + "source": { "type": "string" }, + "requests": { "type": "integer" }, + "input": { "type": "integer" }, + "output": { "type": "integer" }, + "cache_read": { "type": "integer" }, + "cache_write": { "type": "integer" }, + "est_cost_usd": { "type": "number" } + } + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "required": ["stage", "symptom", "severity", "resolved", "user_had_to_intervene"], + "properties": { + "stage": { "type": "string" }, + "symptom": { "type": "string" }, + "cause": { "type": "string" }, + "workaround": { "type": "string" }, + "severity": { "type": "string", "enum": ["high", "medium", "low"] }, + "resolved": { "type": "boolean" }, + "user_had_to_intervene": { "type": "boolean" }, + "logged_at": { "type": "string" } + } + } + }, + "user_feedback": { + "type": "array", + "items": { + "type": "object", + "required": ["stage", "category", "paraphrase"], + "properties": { "stage": { "type": "string" }, "category": { "type": "string" }, "paraphrase": { "type": "string" }, "logged_at": { "type": "string" } } + } + }, + "skill_gaps": { + "type": "array", + "items": { + "type": "object", + "required": ["skill", "gap"], + "properties": { "skill": { "type": "string" }, "gap": { "type": "string" }, "stage": { "type": "string" }, "logged_at": { "type": "string" } } + } + }, + "what_went_well": { + "type": "array", + "items": { + "type": "object", + "required": ["text"], + "properties": { "text": { "type": "string" }, "stage": { "type": "string" }, "logged_at": { "type": "string" } } + } + }, + "logging_gaps": { + "description": "Where the run log is missing or unsure: unfinished stages, estimated starts, zero-length entries, stages never logged on a live build.", + "type": "array", + "items": { + "type": "object", + "required": ["stage", "problem"], + "properties": { "stage": { "type": "string" }, "phase": { "type": ["string", "null"] }, "problem": { "type": "string" } } + } + }, + "previous_runs": { + "type": "array", + "items": { + "type": "object", + "properties": { "run": { "type": ["string", "null"] }, "kind": { "type": ["string", "null"] }, "outcome": { "type": ["string", "null"] }, "minutes": { "type": ["number", "null"] }, "html": { "type": "string" } } + } + } + } +} diff --git a/skills/hostinger-headless/run-log.mjs b/skills/hostinger-headless/run-log.mjs new file mode 100644 index 0000000..9bcd440 --- /dev/null +++ b/skills/hostinger-headless/run-log.mjs @@ -0,0 +1,662 @@ +#!/usr/bin/env node +// Hostinger Headless run log: records a build as it happens and writes reports/.json + .html. +// +// node run-log.mjs run-start [--kind build|resume|redesign|iterate] [--tool ] [--model ] [--skill ] +// node run-log.mjs run-update [--kind …] [--model …] [--skill …] +// node run-log.mjs run-resume [--reason ""] +// node run-log.mjs stage-start [--phase ] [--started-ago 6m | --started-at ] +// node run-log.mjs stage-end [--phase ] [--note ""] [--started-ago 6m | --started-at ] +// node run-log.mjs stage-fix [--phase ] (--started-ago 6m | --started-at ) +// node run-log.mjs event '' +// node run-log.mjs report [--outcome live|stopped|failed|abandoned] [--tokens ''] +// node run-log.mjs unsent +// node run-log.mjs report-sent [] +// node run-log.mjs report-rejected [] + +import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const STAGES = ['connect', 'check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy', 'done']; +const RESULTS = ['ok', 'ok_after_retries', 'ok_with_fixups', 'failed', 'skipped', 'stopped']; +const OUTCOMES = ['live', 'stopped', 'failed', 'abandoned']; +const EVENT_FIELDS = { + issue: { required: ['stage', 'symptom'], optional: ['cause', 'workaround', 'severity', 'resolved', 'user_had_to_intervene'] }, + feedback: { required: ['stage', 'category', 'paraphrase'], optional: [] }, + skill_gap: { required: ['skill', 'gap'], optional: ['stage'] }, + good: { required: ['text'], optional: ['stage'] }, +}; +const EVENT_LISTS = { issue: 'issues', feedback: 'user_feedback', skill_gap: 'skill_gaps', good: 'what_went_well' }; + +const here = dirname(fileURLToPath(import.meta.url)); +const now = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); +// A start logged late is kept but marked estimated, so the report can tell measured times from remembered ones. +function lateStart(flags, run) { + let at = null; + if (typeof flags['started-at'] === 'string') { + 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`); + at = Date.parse(flags['started-at']); + } else if (typeof flags['started-ago'] === 'string') { + const m = flags['started-ago'].match(/^(\d+(?:\.\d+)?)\s*([smh])$/); + if (!m) fail(`--started-ago "${flags['started-ago']}" should look like 90s, 6m or 1h`); + at = Date.now() - Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5 }[m[2]]; + } + if (at === null) return null; + const clamped = Math.min(Math.max(at, Date.parse(run.started_at)), Date.now()); + return new Date(clamped).toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +const fail = (message) => { + console.error(`run-log: ${message}`); + process.exit(1); +}; + +function parseArgs(argv) { + const positional = []; + const flags = {}; + for (let i = 0; i < argv.length; i += 1) { + if (argv[i].startsWith('--')) { + const key = argv[i].slice(2); + const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[(i += 1)] : true; + flags[key] = value; + } else positional.push(argv[i]); + } + return { positional, flags }; +} + +const readJson = (file, fallback) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback); +const writeJson = (file, value) => writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); + +function workspace(domain) { + if (!domain || domain.startsWith('-')) fail('first argument must be the domain'); + const dir = join(homedir(), '.hostinger', domain); + if (!existsSync(dir)) fail(`no workspace at ${dir}; connect creates it`); + return { + dir, + domain, + statePath: join(dir, 'state.json'), + eventsPath: join(dir, 'events.jsonl'), + reportsDir: join(dir, 'reports'), + }; +} + +const loadState = (ws) => readJson(ws.statePath, {}); +function saveState(ws, state) { + state.updated_at = now(); + writeJson(ws.statePath, state); +} + +function currentRun(ws, state) { + if (!state.run?.id) fail('no run started; call run-start first (check-state does this)'); + return state.run; +} + +function readEvents(ws) { + if (!existsSync(ws.eventsPath)) return []; + return readFileSync(ws.eventsPath, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +function runStart(ws, flags) { + const state = loadState(ws); + // A previous run that never reached its report (closed window, crash) is closed out first, so + // its log still turns into a report. + if (state.run?.id && !state.run.reported_at) { + buildReport(ws, { outcome: 'abandoned' }); + console.log(`closed previous run ${state.run.id} as abandoned`); + } + const fresh = loadState(ws); + const previous = fresh.run ?? {}; + const startedAt = now(); + const connect = readJson(join(ws.dir, 'connect.json'), {}); + // Run ids are the start time; a second run in the same second gets a suffix. + const taken = new Set([ + ...(fresh.history ?? []).map((h) => h.run), + ...(existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).map((f) => f.replace(/\.(json|html)$/, '')) : []), + ]); + const base = startedAt.replace(/:/g, '-'); + let id = base; + for (let n = 2; taken.has(id) || id === fresh.run?.id; n += 1) id = `${base}-${n}`; + const kind = flags.kind ?? 'build'; + if (!['build', 'resume', 'redesign', 'iterate'].includes(kind)) fail(`unknown kind "${kind}"; one of build, resume, redesign, iterate`); + fresh.run = { + id, + kind, + started_at: startedAt, + tool: flags.tool ?? connect.tool ?? fresh.tool ?? previous.tool ?? null, + model: flags.model ?? previous.model ?? null, + skill: flags.skill ?? previous.skill ?? null, + }; + fresh.history ??= []; + saveState(ws, fresh); + console.log(`run ${fresh.run.id} started`); +} + +function runUpdate(ws, flags) { + const state = loadState(ws); + const run = currentRun(ws, state); + for (const key of ['kind', 'tool', 'model', 'skill']) if (typeof flags[key] === 'string') run[key] = flags[key]; + if (!['build', 'resume', 'redesign', 'iterate'].includes(run.kind)) fail(`unknown kind "${run.kind}"; one of build, resume, redesign, iterate`); + saveState(ws, state); + console.log(`run ${run.id} updated`); +} + +// A follow-up in the same session (the user asks for a fix right after the final report) belongs +// to the run it follows: reopen it, so the next `report` rewrites that run's report instead of +// starting a second one. +function runResume(ws, flags) { + const state = loadState(ws); + const run = currentRun(ws, state); + if (!run.reported_at) { + console.log(`run ${run.id} is still open; nothing to resume`); + return; + } + run.follow_ups = [ + ...(run.follow_ups ?? []), + { resumed_at: now(), after_report_at: run.reported_at, reason: typeof flags.reason === 'string' ? flags.reason : null }, + ]; + delete run.reported_at; + saveState(ws, state); + console.log(`run ${run.id} resumed (follow-up ${run.follow_ups.length})`); +} + +function stageStart(ws, [stage], flags) { + if (!STAGES.includes(stage)) fail(`unknown stage "${stage}"; one of ${STAGES.join(', ')}`); + const state = loadState(ws); + const run = currentRun(ws, state); + state.history ??= []; + const phase = flags.phase ?? null; + const open = state.history.find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at); + if (open) { + console.log(`${stage}${phase ? `/${phase}` : ''} already started at ${open.started_at}`); + return; + } + const late = lateStart(flags, run); + state.history.push({ run: run.id, stage, phase, started_at: late ?? now(), ended_at: null, result: null, note: null, ...(late ? { start_estimated: true } : {}) }); + saveState(ws, state); + console.log(`${stage}${phase ? `/${phase}` : ''} started${late ? ` (estimated start ${late})` : ''}`); +} + +function stageEnd(ws, [stage, result], flags) { + if (!STAGES.includes(stage)) fail(`unknown stage "${stage}"`); + if (!RESULTS.includes(result)) fail(`unknown result "${result}"; one of ${RESULTS.join(', ')}`); + const state = loadState(ws); + const run = currentRun(ws, state); + state.history ??= []; + const phase = flags.phase ?? null; + let entry = [...state.history].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase && !h.ended_at); + if (!entry) { + // Missed stage-start: use the remembered start if given, else the end of the previous stage; either way estimated. + const runEntries = state.history.filter((h) => h.run === run.id); + const lastEnd = runEntries.map((h) => h.ended_at).filter(Boolean).sort().at(-1); + const late = lateStart(flags, run); + entry = { run: run.id, stage, phase, started_at: late ?? lastEnd ?? run.started_at, ended_at: null, result: null, note: null, start_estimated: true }; + state.history.push(entry); + 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'}`); + } else if (flags['started-at'] || flags['started-ago']) { + entry.started_at = lateStart(flags, run); + entry.start_estimated = true; + } + entry.ended_at = now(); + entry.result = result; + if (typeof flags.note === 'string') entry.note = flags.note; + saveState(ws, state); + console.log(`${stage}${phase ? `/${phase}` : ''} ended: ${result}`); +} + +function stageFix(ws, [stage], flags) { + if (!STAGES.includes(stage)) fail(`unknown stage "${stage}"`); + const state = loadState(ws); + const run = currentRun(ws, state); + const phase = flags.phase ?? null; + const entry = [...(state.history ?? [])].reverse().find((h) => h.run === run.id && h.stage === stage && h.phase === phase); + if (!entry) fail(`${stage}${phase ? `/${phase}` : ''} was never logged in this run; use stage-start with --started-ago instead`); + const late = lateStart(flags, run); + if (!late) fail('stage-fix needs --started-ago (e.g. 6m) or --started-at '); + if (entry.ended_at && late > entry.ended_at) fail(`the new start ${late} is after the stage ended (${entry.ended_at})`); + entry.started_at = late; + entry.start_estimated = true; + saveState(ws, state); + console.log(`${stage}${phase ? `/${phase}` : ''} start set to ${late} (estimated)`); +} + +function event(ws, [type, raw]) { + const spec = EVENT_FIELDS[type]; + if (!spec) fail(`unknown event type "${type}"; one of ${Object.keys(EVENT_FIELDS).join(', ')}`); + let data; + try { + data = JSON.parse(raw ?? ''); + } catch { + fail(`event ${type} needs a JSON object, e.g. '${JSON.stringify(Object.fromEntries(spec.required.map((k) => [k, '…'])))}'`); + } + const missing = spec.required.filter((k) => data[k] === undefined || data[k] === ''); + if (missing.length) fail(`event ${type} is missing ${missing.join(', ')}`); + const unknown = Object.keys(data).filter((k) => !spec.required.includes(k) && !spec.optional.includes(k)); + if (unknown.length) fail(`event ${type} has unknown fields ${unknown.join(', ')}`); + if (type === 'issue') { + data.severity ??= 'medium'; + if (!['high', 'medium', 'low'].includes(data.severity)) fail('issue severity must be high, medium or low'); + data.resolved ??= false; + data.user_had_to_intervene ??= false; + } + // A bad line in events.jsonl would fail every later report and run-start. + validate({ ...data, logged_at: now() }, eventSchema(type), `event ${type}`); + const state = loadState(ws); + const run = currentRun(ws, state); + appendFileSync(ws.eventsPath, `${JSON.stringify({ ts: now(), run: run.id, type, ...data })}\n`); + console.log(`${type} logged`); +} + +const minutesBetween = (a, b) => (a && b ? Math.round(((Date.parse(b) - Date.parse(a)) / 60000) * 10) / 10 : null); + +function frontendStack(dir) { + // Framework is recorded in state.json by the frontend step. + const state = readJson(join(dir, 'state.json'), {}); + const fe = state.frontend ?? {}; + const framework = fe.framework ?? fe.stack ?? null; + if (framework) return fe.rendering ? `${framework} (${fe.rendering})` : framework; + // Fallback: an older build may have a design.md with a "## Stack" heading. + const design = existsSync(join(dir, 'design.md')) ? readFileSync(join(dir, 'design.md'), 'utf8') : ''; + return design.match(/## Stack\s*\n\s*\*\*([^*]+)\*\*/)?.[1] ?? null; +} + +function buildReport(ws, { outcome, tokens } = {}) { + const state = loadState(ws); + const run = currentRun(ws, state); + const site = readJson(join(ws.dir, 'site.json'), {}); + const deploy = readJson(join(ws.dir, 'deploy.json'), {}); + const connect = readJson(join(ws.dir, 'connect.json'), {}); + const history = (state.history ?? []).filter((h) => h.run === run.id); + // Lines logged before events were checked on write may not match the schema. + const dropped = []; + const events = readEvents(ws) + .filter((e) => e.run === run.id) + .filter((e) => { + const { ts, run: _run, type, ...rest } = e; + const problem = schemaError({ ...rest, logged_at: ts }, eventSchema(type), 'event'); + if (problem) dropped.push({ stage: typeof e.stage === 'string' ? e.stage : 'unknown', phase: null, problem: `left out a malformed ${type} event (${problem})` }); + return !problem; + }); + const resolvedOutcome = outcome ?? (history.some((h) => h.stage === 'deploy' && h.result?.startsWith('ok')) ? 'live' : 'stopped'); + // A run ends at its last logged activity, so re-running `report` later (after fixing warnings, or + // when the next run-start closes an abandoned run) doesn't stretch it to "now". Only a stage still + // open on a live run keeps the clock running. + const lastActivity = [run.started_at, ...history.flatMap((h) => [h.started_at, h.ended_at]), ...events.map((e) => e.ts)] + .filter(Boolean) + .sort() + .at(-1); + const stillOpen = resolvedOutcome !== 'abandoned' && history.some((h) => !h.ended_at); + const endedAt = stillOpen ? now() : lastActivity; + const stages = history.map((h) => ({ ...h, minutes: minutesBetween(h.started_at, h.ended_at ?? endedAt) })); + const gaps = [...loggingGaps(stages, run, resolvedOutcome), ...dropped]; + const strip = ({ ts, run: _run, type: _type, ...rest }) => ({ ...rest, logged_at: ts }); + const liveStage = stages.find((s) => s.stage === 'deploy' && s.result?.startsWith('ok')); + if (!OUTCOMES.includes(resolvedOutcome)) fail(`unknown outcome "${resolvedOutcome}"; one of ${OUTCOMES.join(', ')}`); + + const previous = existsSync(ws.reportsDir) + ? readdirSync(ws.reportsDir) + .filter((f) => f.endsWith('.json') && f !== `${run.id}.json`) + .map((f) => readJson(join(ws.reportsDir, f), {})) + .sort((a, b) => `${a.timing?.started_at}${a.run?.id}`.localeCompare(`${b.timing?.started_at}${b.run?.id}`)) + .map((r) => ({ run: r.run?.id, kind: r.run?.kind, outcome: r.info?.outcome, minutes: r.timing?.minutes_total, html: `${r.run?.id}.html` })) + : []; + + const report = { + schema_version: '1', + generated_at: now(), + // Intent, not delivery: delivery is tracked in state.json. + consent: { sent: true, note: 'Sent to Hostinger automatically at the end of the run.' }, + run: { id: run.id, kind: run.kind, skill: run.skill, follow_ups: run.follow_ups ?? [] }, + info: { + domain: ws.domain, + tool: run.tool ?? connect.tool ?? null, + model: run.model ?? null, + os: process.platform, + node: process.version, + mcp: connect.mcp ?? null, + backend: site.backend ?? null, + frontend: frontendStack(ws.dir), + outcome: resolvedOutcome, + stopped_at: resolvedOutcome === 'live' ? null : stoppedAt(stages, state), + live_url: resolvedOutcome === 'live' ? deploy.urls?.site ?? site.urls?.site ?? null : null, + admin_url: site.urls?.wp_admin ?? null, + }, + timing: { + started_at: run.started_at, + ended_at: endedAt, + live_at: liveStage?.ended_at ?? null, + minutes_total: minutesBetween(run.started_at, endedAt), + minutes_to_live: liveStage ? minutesBetween(run.started_at, liveStage.ended_at) : null, + stages: stages.map(({ run: _run, ...s }) => s), + }, + // Not null: the MCP layer treats a null required field as missing, and the API reads {} as an array. + tokens: tokens ?? { source: 'not reported' }, + issues: events.filter((e) => e.type === 'issue').map(strip), + user_feedback: events.filter((e) => e.type === 'feedback').map(strip), + skill_gaps: events.filter((e) => e.type === 'skill_gap').map(strip), + what_went_well: events.filter((e) => e.type === 'good').map(strip), + logging_gaps: gaps, + previous_runs: previous, + }; + + validate(report, reportSchema()); + mkdirSync(ws.reportsDir, { recursive: true }); + writeJson(join(ws.reportsDir, `${run.id}.json`), report); + writeFileSync(join(ws.reportsDir, `${run.id}.html`), renderHtml(report)); + copyFileSync(join(ws.reportsDir, `${run.id}.html`), join(ws.dir, 'report.html')); + + const fresh = loadState(ws); + fresh.run.reported_at = now(); + saveState(ws, fresh); + for (const g of gaps) console.log(`warning: ${g.stage}${g.phase ? `/${g.phase}` : ''}: ${g.problem}`); + return join(ws.dir, 'report.html'); +} + +// A report counts as sent only while the sent copy's generated_at matches the file. +function reportFile(ws, runId) { + const id = runId ?? currentRun(ws, loadState(ws)).id; + const file = join(ws.reportsDir, `${id}.json`); + if (!existsSync(file)) fail(`no report for run ${id} at ${file}`); + return { id, file, generatedAt: readJson(file, {}).generated_at }; +} + +function unsent(ws) { + const state = loadState(ws); + const files = existsSync(ws.reportsDir) ? readdirSync(ws.reportsDir).filter((f) => f.endsWith('.json')).sort() : []; + // Older reports promised "nothing is uploaded". + const pending = files + .map((f) => { + const report = readJson(join(ws.reportsDir, f), {}); + return { id: f.replace(/\.json$/, ''), file: join(ws.reportsDir, f), generatedAt: report.generated_at, consent: report.consent }; + }) + .filter(({ consent }) => consent?.sent !== false) + .filter(({ id, generatedAt }) => state.reports_sent?.[id] !== generatedAt && state.reports_rejected?.[id] !== generatedAt); + if (!pending.length) console.log('no unsent reports'); + for (const { id, file } of pending) console.log(`${id} ${file}`); +} + +function reportSent(ws, [runId]) { + const { id, generatedAt } = reportFile(ws, runId); + const state = loadState(ws); + state.reports_sent = { ...state.reports_sent, [id]: generatedAt }; + saveState(ws, state); + console.log(`report ${id} marked sent`); +} + +// A 422 is schema drift, so resending won't help. +function reportRejected(ws, [runId]) { + const { id, generatedAt } = reportFile(ws, runId); + const state = loadState(ws); + state.reports_rejected = { ...state.reports_rejected, [id]: generatedAt }; + saveState(ws, state); + const run = currentRun(ws, state); + const symptom = `build report ${id} rejected with 422 by hosting_headless_submit-build-report`; + 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`); + console.log(`report ${id} marked rejected; issue logged in run ${run.id}`); +} + +// Gaps are printed as warnings by `report`, so guesses never pass as measurements. +const EXPECTED_FOR_BUILD = ['check_state', 'refine', 'provision', 'content_model', 'design', 'frontend', 'validate', 'deploy']; +function loggingGaps(stages, run, outcome) { + const gaps = []; + for (const s of stages) { + if (!s.ended_at) gaps.push({ stage: s.stage, phase: s.phase, problem: 'started but never ended' }); + else if (s.start_estimated) gaps.push({ stage: s.stage, phase: s.phase, problem: 'start time estimated, not measured' }); + 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' }); + } + if (run.kind === 'build' && outcome === 'live') { + for (const stage of EXPECTED_FOR_BUILD) { + if (!stages.some((s) => s.stage === stage)) gaps.push({ stage, phase: null, problem: 'never logged, though the site went live' }); + } + } + return gaps; +} + +function stoppedAt(stages, state) { + const stuck = [...stages].reverse().find((s) => !s.ended_at || s.result === 'failed' || s.result === 'stopped'); + return stuck?.stage ?? state.stage ?? null; +} + +// Minimal JSON Schema subset, so reports from different tools share one shape without a dependency. +const reportSchema = () => readJson(join(here, 'report.schema.json'), null); +const eventSchema = (type) => reportSchema()?.properties?.[EVENT_LISTS[type]]?.items ?? null; + +function schemaError(value, schema, path = 'report') { + if (!schema) return null; + const types = [].concat(schema.type ?? []); + const actual = value === null ? 'null' : Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value; + if (types.length && !types.includes(actual) && !(actual === 'integer' && types.includes('number'))) { + return `${path} should be ${types.join(' or ')}, got ${actual}`; + } + if (schema.enum && !schema.enum.includes(value)) return `${path} should be one of ${schema.enum.join(', ')}`; + if (actual === 'object') { + for (const key of schema.required ?? []) if (!(key in value)) return `${path}.${key} is missing`; + for (const [key, sub] of Object.entries(schema.properties ?? {})) { + const error = key in value ? schemaError(value[key], sub, `${path}.${key}`) : null; + if (error) return error; + } + } + if (actual === 'array' && schema.items) { + for (const [i, item] of value.entries()) { + const error = schemaError(item, schema.items, `${path}[${i}]`); + if (error) return error; + } + } + return null; +} + +function validate(value, schema, path) { + const error = schemaError(value, schema, path); + if (error) fail(error); +} + +const esc = (v) => String(v ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +const label = (s) => String(s ?? '').replace(/_/g, ' '); +const time = (iso) => (iso ? iso.slice(11, 19) : '—'); +const fmt = (n) => (typeof n === 'number' ? n.toLocaleString('en-US') : '—'); + +function gantt(report) { + const stages = report.timing.stages; + if (!stages.length) return '

No stages were logged for this run.

'; + const t0 = Date.parse(report.timing.started_at); + const at = (iso) => (Date.parse(iso) - t0) / 60000; + const total = Math.max(report.timing.minutes_total ?? 1, ...stages.map((s) => at(s.ended_at ?? report.timing.ended_at)), 1); + const step = [1, 2, 5, 10, 15, 30, 60, 120, 240].find((s) => total / s <= 8) ?? 480; + const span = Math.ceil(total / step) * step; + const W = 760; + const LABEL = 160; + const ROW = 32; + const TOP = 32; + const H = TOP + ROW * stages.length + 32; + const x = (m) => LABEL + ((W - LABEL - 40) * m) / span; + const out = [`Stage timeline, minutes from the start of the run`]; + for (let m = 0; m <= span; m += step) { + out.push(`${m} min`); + } + for (const f of report.run.follow_ups ?? []) { + const fx = x(at(f.resumed_at)).toFixed(1); + out.push(``); + } + if (report.timing.live_at) { + const lx = x(at(report.timing.live_at)).toFixed(1); + out.push(`Live · ${report.timing.minutes_to_live} min`); + } + stages.forEach((s, i) => { + const y = TOP + i * ROW; + const a = at(s.started_at); + const b = at(s.ended_at ?? report.timing.ended_at); + const width = Math.max(x(b) - x(a), 4); + const name = `${label(s.stage)}${s.phase ? ` · ${s.phase}` : ''}`; + const tip = [name, `${s.minutes} min`, s.result ? label(s.result) : 'not finished', s.start_estimated ? 'start estimated' : null, s.note].filter(Boolean).join(' · '); + const failed = s.result === 'failed' || s.result === 'stopped' || !s.ended_at; + const fill = failed ? 'url(#hatch)' : s.start_estimated ? 'none' : 'var(--series-1)'; + out.push(`${esc(name)}`); + out.push(``); + }); + out.push(''); + return out.join(''); +} + +function renderHtml(r) { + const tile = (name, value, sub = '') => `
${esc(name)}
${esc(value)}
${esc(sub)}
`; + const stepped = r.issues.filter((i) => i.user_had_to_intervene).length; + const rejected = r.user_feedback.filter((f) => f.category === 'design_rejected').length; + const t = r.tokens && ['input', 'output', 'cache_read', 'cache_write', 'est_cost_usd'].some((k) => r.tokens[k] != null) ? r.tokens : null; + const tiles = [ + r.timing.minutes_to_live != null + ? tile('Time to live', `${r.timing.minutes_to_live} min`, 'start of run → deploy finished') + : tile('Outcome', label(r.info.outcome), r.info.stopped_at ? `stopped at ${label(r.info.stopped_at)}` : ''), + tile('Run length', `${r.timing.minutes_total} min`, `${r.timing.stages.length} stage entries`), + tile('Issues', String(r.issues.length), `${stepped} needed the user to step in`), + tile('User feedback', String(r.user_feedback.length), rejected ? `${rejected} design rejections` : 'corrections and requests'), + 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'), + ].join(''); + const SEV = { high: ['●', 'critical', 'High'], medium: ['▲', 'serious', 'Medium'], low: ['■', 'warning', 'Low'] }; + const order = ['high', 'medium', 'low']; + const issues = [...r.issues] + .sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity)) + .map((i) => { + const [icon, cls, name] = SEV[i.severity]; + return ` ${name}${esc(label(i.stage))}${esc(i.symptom)}${i.user_had_to_intervene ? ' user had to step in' : ''}${esc(i.cause)}${esc(i.workaround)}${i.resolved ? '✓ Resolved' : 'Open'}`; + }) + .join(''); + const list = (items, render, empty) => (items.length ? `
    ${items.map(render).join('')}
` : `

${empty}

`); + const stageRows = r.timing.stages + .map((s) => `${esc(label(s.stage))}${s.phase ? ` · ${esc(s.phase)}` : ''}${time(s.started_at)}${time(s.ended_at)}${s.minutes ?? '—'}${esc(label(s.result ?? 'not finished'))}${esc(s.note)}`) + .join(''); + const details = [ + ['Domain', r.info.domain], + ['Outcome', [label(r.info.outcome), r.info.live_url].filter(Boolean).join(' · ')], + ['Run', `${r.run.id} · ${r.run.kind}`], + ['Tool / model', [r.info.tool, r.info.model].filter(Boolean).join(' · ') || '—'], + ['Stack', [r.info.backend, r.info.frontend].filter(Boolean).join(' + ') || '—'], + ['Environment', `${r.info.os} · Node ${r.info.node}${r.info.mcp ? ` · ${r.info.mcp}` : ''}`], + ['Skill version', r.run.skill ?? '—'], + ...(r.run.follow_ups?.length + ? [['Follow-ups', r.run.follow_ups.map((f) => `resumed ${time(f.resumed_at)}${f.reason ? ` — ${f.reason}` : ''}`).join('; ')]] + : []), + ] + .map(([k, v]) => `
${esc(k)}
${esc(v)}
`) + .join(''); + const tokenTable = t + ? `

Tokens

RequestsInputOutputCache readCache write≈ API cost
${fmt(t.requests)}${fmt(t.input)}${fmt(t.output)}${fmt(t.cache_read)}${fmt(t.cache_write)}${t.est_cost_usd != null ? `$${t.est_cost_usd}` : '—'}

${esc(t.source ?? '')}

` + : ''; + const previous = r.previous_runs.length + ? `

Earlier runs on this domain

    ${r.previous_runs.map((p) => `
  • ${esc(p.run)} — ${esc(p.kind)} · ${esc(label(p.outcome))}${p.minutes != null ? ` · ${p.minutes} min` : ''}
  • `).join('')}
` + : ''; + + return ` +Build report — ${esc(r.info.domain)} +
+

Build report — ${esc(r.info.domain)}

+

${esc(label(r.run.kind))} run · ${esc(r.timing.started_at.slice(0, 16).replace('T', ' '))} UTC · ${esc(label(r.info.outcome))}

+
${tiles}
+

Stage timeline

${gantt(r)} +
FinishedFailed, stopped or not finishedStart estimatedStages can overlap: prep work runs while provisioning settles.
+
Show as table
${stageRows}
StageStartEndMinutesResultNote
+${r.logging_gaps.length ? `

Logging gaps (${r.logging_gaps.length})

Where the run log is missing or unsure, so times here are less reliable.

    ${r.logging_gaps.map((g) => `
  • ${esc(label(g.stage))}${g.phase ? ` · ${esc(g.phase)}` : ''} — ${esc(g.problem)}
  • `).join('')}
` : ''} +

Issues (${r.issues.length})

${r.issues.length ? `
${issues}
SeverityStageSymptomCauseWorkaroundStatus
` : '

No issues logged.

'}
+

User feedback

${list(r.user_feedback, (f) => `
  • ${esc(label(f.category))} ${esc(label(f.stage))} — “${esc(f.paraphrase)}”
  • `, 'No feedback logged.')}
    +

    Skill gaps

    ${list(r.skill_gaps, (g) => `
  • ${esc(g.skill)} — ${esc(g.gap)}
  • `, 'No skill gaps logged.')}
    +

    What went well

    ${list(r.what_went_well, (g) => `
  • ${esc(g.text)}
  • `, 'Nothing logged.')}
    +

    Run details

    ${details}
    +${tokenTable}${previous} +
    + +`; +} + +const { positional, flags } = parseArgs(process.argv.slice(2)); +const [domain, command, ...rest] = positional; +const ws = workspace(domain); +switch (command) { + case 'run-start': + runStart(ws, flags); + break; + case 'run-update': + runUpdate(ws, flags); + break; + case 'run-resume': + runResume(ws, flags); + break; + case 'stage-start': + stageStart(ws, rest, flags); + break; + case 'stage-end': + stageEnd(ws, rest, flags); + break; + case 'stage-fix': + stageFix(ws, rest, flags); + break; + case 'event': + event(ws, rest); + break; + case 'report': { + let tokens; + if (typeof flags.tokens === 'string') { + try { + tokens = JSON.parse(flags.tokens); + } catch { + fail('--tokens must be JSON, e.g. \'{"input":0,"output":0,"source":"…"}\''); + } + // null and {} can't be sent; treat them as not reported. + if (tokens === null || (typeof tokens === 'object' && !Array.isArray(tokens) && !Object.keys(tokens).length)) tokens = undefined; + else validate(tokens, { ...reportSchema()?.properties?.tokens, type: 'object' }, '--tokens'); + } + console.log(`report written: ${buildReport(ws, { outcome: flags.outcome, tokens })}`); + const { id, file } = reportFile(ws); + 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)`); + break; + } + case 'unsent': + unsent(ws); + break; + case 'report-sent': + reportSent(ws, rest); + break; + case 'report-rejected': + reportRejected(ws, rest); + break; + default: + fail('command must be run-start, run-update, run-resume, stage-start, stage-end, stage-fix, event, report, unsent, report-sent or report-rejected'); +} diff --git a/skills/hostinger-headless/steps/01-check-state/SKILL.md b/skills/hostinger-headless/steps/01-check-state/SKILL.md new file mode 100644 index 0000000..017949a --- /dev/null +++ b/skills/hostinger-headless/steps/01-check-state/SKILL.md @@ -0,0 +1,225 @@ +--- +name: headless-step-check-state +description: "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// 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." +--- + +# headless-step-check-state + +The first build step of the Hostinger Headless flow (orchestrated by `hostinger.com/headless/skill.md`), +run right after connect. Connect has already connected the Hostinger connector and confirmed the +account can build. Your job here is to +**figure out where this domain stands and agree the route with the user** before any new work begins. +This step owns all resume/position logic — no other stage second-guesses it. + +Nothing in this step creates, provisions, deploys, or deletes anything. It reads and guides. Its +only writes to Hostinger are unsent build reports (Step 0) and the tool-connected event (Step 3). + +--- + +## Where state lives + +Everything for a domain lives under the global site workspace **`~/.hostinger//`** — one +directory per domain, under the user's home (`~` = `$HOME`), **not** the current working directory. +The directory name **is** the domain, so `state.json` inside does not store it. Because it's global, +this domain's build is resumable from any directory. + +`state.json` shape (`stage` is the *next* stage to run; `provision` is the background task's status, +present only once provision has fired): + +```json +{ "tool": "claude-code", "stage": "content_model", + "provision": { "status": "in_progress", "domain": "", "started_at": "…" }, + "updated_at": "…" } +``` + +`provision.status` is `in_progress` or `completed` — but it is a **hint**: always re-verify it against +`hosting_websites_list-setups` (Step 1) before trusting it to gate an instance-barrier step. + +--- + +## Step 0 — Start the run log + +Before anything else, start a run and the stage (see *Run log and build report* in the top-level +`SKILL.md`): + +```bash +node /run-log.mjs run-start --tool --model --skill +node /run-log.mjs stage-start check_state +``` + +If an earlier run on this domain never wrote its report, `run-start` closes it as `abandoned` first. + +Then send any build reports that haven't reached Hostinger yet (that abandoned run, or a send that +failed last time): + +```bash +node /run-log.mjs unsent +``` + +Each line is ` `. For each, send the file through `hosting_headless_submit-build-report` +and record the result, as described in *Run log and build report* in the top-level `SKILL.md`. One +try each. Don't ask first; if any went out, say so in one line (e.g. "Sent 1 earlier build report to +Hostinger."). Don't let a failure hold up this step. +Check-state runs once per session. A follow-up after the final report, in the same session, doesn't +come back here: it calls `run-resume` (see *Run log and build report* in the top-level `SKILL.md`). + +## Step 1 — Is a build actively running right now? (check this before anything else) + +Keep two things separate, and **never guess from a timestamp.** + +- **A platform build actively in progress** — the only *reliable* "running now" signal. Check + `hosting_websites_list-setups { "domain": "" }` and exact-match the domain row: + - `status: running` → provisioning is underway **right now** (a background provision from an earlier + turn). Don't start a second one — reconcile `state.json`'s `provision` to `in_progress`, and + either resume prepare work while it finishes or poll it to `completed` at the barrier. + - `status: failed` → not running; route to provision to self-heal (Step 3). + - `completed` → provision is done; set `provision.status` to `completed`. Instance-barrier steps + (content push, frontend data, deploy) are now unblocked. + - no row → not provisioned yet; carry on. + + This check **is** the source of truth for `provision.status`: a stored `in_progress`/`completed` in + `state.json` is only a hint until this reconciles it. + +- **A `.lock`** — `~/.hostinger//.lock`. It only tells you *a previous run + touched this domain*. **Do not judge it by age** — a 2-minute-old lock and a 3-year-old lock get the + same treatment, and a lock on its own is **not** a reason to interrupt the user. Carry it into Step 5: + if there's nothing to resume (new build), silently take it over; only if there's real state to resume + do you also ask *is another Claude window building this domain right now?* (yes → stop; no → take + over). + +Once you're actively working this domain, **acquire/refresh the lock** (write the current timestamp), +and **release it on a clean stop** (Step 6). The lock marks an active session, nothing more. + +--- + +## Step 2 — Read the workspace directory + +Check `~/.hostinger//` by its exact path (for example `test -d ~/.hostinger/`), not +by listing `~/.hostinger/`: + +- **Absent** → no history for this domain in the workspace. +- **Present** → read `state.json` (its `stage`) and whatever artifacts exist (`brief.md`, + `site.json`, …). Treat these as a hint, not the truth. + +--- + +## Step 3 — Verify against reality (reality wins) + +Query the Hostinger connector — the local files are never trusted on their own: + +- **Does the site exist?** `hosting_websites_list { "domain": "" }`. ⚠️ This filter is a + **case-insensitive substring match**, not exact — so `test.com` would also return + `shop.test.com`. **Compare each returned row's `domain` to your target for an *exact* match** + (case-insensitive) and ignore the rest. No exact row ⇒ the domain is free. An exact row ⇒ the site + exists; note its `website_type` (`headless`, `wordpress`, `nodejs`, …) and `username`. +- **Tell hPanel the tool connected.** As soon as you have the exact row, call + `hosting_websites_trigger-event { "username": "", "domain": "", "event": { "name": "headless_onboarding.tool.connected", "params": { "tool": "" } } }` + once, where `` is the onboarding slug (see *State and artifacts* in the top-level `SKILL.md`). + The call is required: hPanel's onboarding card waits for it to switch to "connected". Skip the + call when there's no exact row, your tool has no onboarding slug, or + `hosting_websites_trigger-event` isn't in your connector. Right before the call, tell the user in + one line (e.g. "Telling hPanel is connected so your onboarding card updates."), so + whoever reviews the outgoing call knows why it's made. If your own environment stopped the call + before it reached Hostinger (a sandbox, command review, an approval prompt, or a network error in + a sandboxed shell), try it one more time the way that environment allows, such as requesting + approval or network access, then go on. Ignore any other failure, such as a non-2xx response from + Hostinger: no retry, nothing more said to the user, and the run carries on unchanged. +- **How far did it get?** If the site exists, check its setup via + `hosting_websites_list-setups { "domain": "" }` (exact-match the row the same way): + - `status: completed` → provisioned; then, depending on backend, check whether a build has succeeded. + - `status: running` → still provisioning (see Step 1). + - `status: failed` → provisioning stopped before finishing → route to **provision** to self-heal. +- **Is it an empty headless shell from onboarding?** A `headless` site whose setup is `completed` is + **not necessarily finished** — onboarding now creates the website *before* the build runs, as an + **empty headless shell** (`hosting_websites_start-setup { type: "headless_empty" }`): `website_type` + is `headless` but **no backend is installed** (no WordPress at `/cms`, no store). Tell a shell from a + real backend the same way provision does: within 24 h the setup `type` is `headless_empty`; otherwise + `wordpress_installations_list { username, domain, ownership: "all" }` returns **no** `/cms` row and + there is no `site.json` recording a backend. An empty shell means **provision has not chosen or + installed a backend yet** — this is a **new build** that happens to already own its website, not a + finished or resumable one. Route it to **refine** (choose the backend), then **provision** adopts the + shell and swaps it to that backend (see the provision router's *Onboarding handoff*). Never create a + second site, and never skip provision for it. + +Reconcile with the local state. **When they disagree, reality wins** and the local `stage` is +corrected. + +--- + +## Step 4 — Determine the position + +Connect always writes `state.json` (with `stage: check_state`) before this step runs, so local +state is never truly empty. **"Local state: none" here means only connect artifacts exist** — +`connect.json` + a `state.json` still pointing at `check_state`/`refine`, and **no build artifacts +yet** (`brief.md`, `site.json`, …). + +| Local state | Reality (domain) | Position | +|---|---|---| +| none (connect only) | free | **new build** — start from refine | +| none (connect only) | empty headless shell (onboarding) | **new build on an existing shell** — start from refine; provision adopts the shell and swaps it to the chosen backend. Never create a second site, never skip provision | +| none (connect only) | site exists with a real backend | **adopt / resume** — never create a second site; route to the first unfinished stage | +| build artifacts present, incomplete | matches | **resume** at `state.json`'s `stage` | +| build artifacts present | disagrees | **reality wins** — recompute the stage from what actually exists | +| build artifacts present, all done | live site exists | **finished** — this is now an *iterate* request, not a build | + +--- + +## Step 5 — Guide the user + +The question to ask depends on whether there is actually **something to resume** — i.e. **build +artifacts** (`brief.md`, `site.json`, …) or a **live site** for this domain. A stray `.lock` or +connect-only files are *not* something to resume. + +- **New build** (no build artifacts and either the domain is free **or** it holds only an empty + headless shell from onboarding) → just proceed to refine. **Don't ask resume vs start over** — + there's nothing to resume; both would land in the same place. An empty headless shell is not + "something to resume": it has no backend and no content yet, so it's a fresh build that already owns + its website — provision will adopt and swap it. Mention it in one line ("Using the headless site + onboarding already created for this domain."). If a leftover `.lock` is lying around, silently take + it over and continue (optionally mention it in one line). +- **Something to resume** (build artifacts exist, or a site exists for the domain **with a real backend + installed** — WordPress at `/cms`, a store, or a deployed app) → tell the user plainly what's there + and **ask them to choose**, regardless of how old it is (don't guess from timestamps): + +- **Resume** — pick up at the computed stage. (If a `.lock` is present in this case, also confirm no + other window is actively building this domain right now.) +- **Start over / redo a step** — only safe **before anything is provisioned**. If the site is already + provisioned or live, you **cannot** re-create it: that is forward-only *iterate* (change content or + design and redeploy), not a fresh build. Never re-provision or overwrite a live site; if the user + insists on a clean slate for a live domain, explain the limitation and point them at hPanel. +- **Switch domain** — they want a different domain. A new domain is a **new workspace** + (`~/.hostinger//`, with its own `src/`). Hand off to start that domain's run. +- **Buy a domain** — they want a custom domain instead of the temporary one. Route to the domain + purchase / `connect-domain` flow, then continue with the chosen domain. + +When the intent is ambiguous, **ask** — don't guess. A skipped tool result is ambiguous; see +*When you ask the user* in the top-level `SKILL.md`. + +--- + +## Step 6 — Record and route + +Write `~/.hostinger//state.json` with the agreed next `stage` and a fresh timestamp. If the +route isn't a new build, record it with `run-log.mjs run-update --kind resume` (or +`redesign` / `iterate`), then end the stage with `stage-end check_state ok --note ""`. +Then: + +- **Handing straight to the next stage in this same session?** Keep the `.lock` (you are still + actively running) and continue. +- **Stopping and returning control to the user?** (e.g. the next stage isn't built yet, or you are + waiting on them.) **Delete the `.lock`** — the session is no longer active, and a leftover lock just + forces an unnecessary "is another window running?" question on the next run. Always release the lock + on a clean stop; it exists only to mark an actively-running session. A stop is still the end of a + run: give the final report and save the build report (`09-done`). + +--- + +## Invariants + +- **One directory per domain.** The domain is the key; state files never repeat it. +- **Only this domain's workspace.** Never list `~/.hostinger/` or open another site's workspace (see + *State and artifacts* in the top-level `SKILL.md`). +- **Never create a second site** on a domain that already has one. +- **Never re-provision or overwrite a live site.** Recovery is forward-only. +- **Reality beats local state**, always. +- **When ambiguous, ask.** diff --git a/skills/hostinger-headless/steps/02-refine/SKILL.md b/skills/hostinger-headless/steps/02-refine/SKILL.md new file mode 100644 index 0000000..d85e3ea --- /dev/null +++ b/skills/hostinger-headless/steps/02-refine/SKILL.md @@ -0,0 +1,326 @@ +--- +name: headless-step-refine +description: "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), then *infers* the backend that fits (WordPress, Ecommerce, WordPress + Ecommerce, WordPress + WooCommerce for a one-site content+store, or Webapp for a custom app/SPA) — it never asks the user to pick a backend or a technology; the user confirms the plan in plain language, then it writes brief.md. Hostinger Ecommerce stays the default for selling; WooCommerce is chosen only when the user explicitly asks for it or wants content and a store on one WordPress site." +--- + +# headless-step-refine + +Find out what the user wants to build, guess which backend fits, and write it down. This is a +**conversation, not a form**: the pasted prompt names a domain and nothing else, so everything about +the site comes from talking to the user. + +**Keep it short.** You must know what they are building, whether they sell and whether they'll +publish content over time — enough to guess the backend — plus, when Step 2 found a project in the folder, the +**Existing project** confirms. Every other question is optional: the more the user gives, the better the site, but never +hold the build hostage to refinement. Fill gaps with sensible defaults and say so in the brief. A +skipped question tool is not the user passing on a question; see *When you ask the user* in the +top-level `SKILL.md`. + +> **Never ask the user to choose a backend or a technology.** Don't ask "WordPress or Ecommerce?", +> don't present WordPress/Ecommerce/WooCommerce/Webapp as options, and don't surface the backend in a +> multiple-choice UI. Open with **"What do you want to build?"** and infer the backend yourself in +> Step 4 from what they describe (and whether they sell). The backend is a technical decision the flow +> makes *for* the user — they confirm the **plan** (what the site is and does), not the plumbing. The +> only exception is when the user *volunteers* a backend ("build it on WordPress", "use WooCommerce"): +> then honour their choice and note it in the brief. + +**In:** the domain (the workspace directory name), plus anything the user already shared. +**Out:** `~/.hostinger//brief.md`. + +Nothing in this step touches Hostinger. It only asks, decides and writes. + +--- + +## Step 1 — Read the workspace, then the resume check + +Read everything already in `~/.hostinger//` (`state.json`, `connect.json`, any notes, images +or files the user dropped there) — it all counts as input. + +If `brief.md` already exists for this domain, **don't overwrite it**. Show the user a three-line +summary (what the site is, its pages, the suggested backend) and ask: *keep it, or change +something?* Keep → go to Step 6. Change → edit only what they asked for, then re-confirm (Step 5). A +skipped tool result is not Keep; see *When you ask the user* in the top-level `SKILL.md`. + +If the site is already provisioned (`site.json` exists), the backend is **fixed** — never change the +Suggested backend on a provisioned site. Explain that switching backends means a new domain. + +## Step 2 — Gather what you already have + +Before asking anything, collect what the user has already given you, so you don't ask for it again: + +- **Inspect the directory the command was run in** — this is the biggest signal for the build mode + (Step 3). Look for an existing **frontend / webapp / commerce project**: a `package.json`, a + framework config (`next.config.*`, `astro.config.*`, `vite.config.*`, `svelte.config.*`, `nuxt.*`), + a `src/` or `app/` tree, `index.html`, a storefront/cart, etc. A real project here usually means the + user **already has their own frontend** and mainly needs a Hostinger backend to wire it to — lean + toward "bring your own frontend" in Step 3. That is what *Step 2 found a project in the folder* + means everywhere below. +- Anything in the pasted prompt beyond the domain (a sentence, a link, a screenshot). +- Files in the current project that look like site material (copy, logos, product lists, images). +- What the agent already knows about the user from local memory or this conversation (a prior build, + stack preferences, a frontend they always use). +- The domain name itself — a hint only (`coral-pottery.com` suggests pottery), never a conclusion. + +## Step 3 — Ask + +When Step 2 found a project in the folder, ask **Build mode** and the **Existing project** confirms +below in your first message, after Round 1 when Round 1 still needs asking; they don't count toward +the 3 rounds, and code that answers Round 1 never skips them. Otherwise don't ask Build mode: it +defaults (see *Build mode* below). + +If Step 2 already answers the three Round 1 questions, skip the Round 1–3 questions below. Otherwise +open with one line and **wait for the user's answer**: *"Tell me what you want to build — a +sentence is enough. Links, screenshots or files help too. Skip anything you don't want to answer."* + +> **"What do you want to build?" is an OPEN question — ask it in plain prose and wait.** Never turn it +> into a multiple-choice list of pre-baked site ideas ("Coffee shop / Travel blog / Portfolio …"): +> that puts words in the user's mouth and defeats the point of asking. Let them describe it in their +> own words. The structured question UI is only for genuinely *choice-shaped* follow-ups (selling +> yes/no, which pages, tone adjectives, the build mode) — not for the opening question, and never for +> the backend. + +Ask in **short rounds of 2–3 questions**, and adapt each round to the previous answers. If your tool +has a structured question UI (multiple choice with an "other" field), use it for the choice-shaped +follow-up questions (pages, selling yes/no, tone) — **never for the opening "what do you want to +build?" and never for the backend** (that's your decision, not theirs). **At most 3 rounds**, and +stop early as soon as you can guess the backend and the user seems done — "just build it" or "you +decide" means *use defaults*, not *ask again*. A skipped tool result is not their answer: re-ask +that round in plain text, as *When you ask the user* in the top-level `SKILL.md` says. The re-ask +repeats the round; it doesn't count toward the 3. + +**Round 1 — what it is (required; these three decide the backend):** + +1. **What are you building?** The business or project, and what the site is for. +2. **Will you sell anything on it?** Physical products, digital products, services with checkout — + or nothing. Skip it only when the answer is already unmistakable ("an online shop for my + candles", "my personal blog"); when in doubt, ask. +3. **Will you publish content over time?** Blog posts, articles, news, recipes, portfolio pieces, + case studies, and how often — or a handful of pages that rarely change. Skip it only when the + answer is already unmistakable ("my personal blog", an app the user is deploying). + +**Round 2 — what goes on it (optional, adapt to Round 1):** + +- **Who is it for?** The audience, and how they'll find the site. +- **Pages** — propose a page list from what you know and ask them to add or remove. +- If they sell: **what and how many** — a rough product count, categories, whether products have + variants (size, colour), and the shipping regions. + +**Round 3 — how it should feel (optional, skip what you already know):** + +This is the round that captures **all design input** — the design step reads it from `brief.md` to derive +the design directions, so it is **not** asked again later; gather it here: + +- **Reference & look** — a **site they like (URL)** and what to take from it, plus a few adjectives for + the vibe. Screenshots welcome. +- **Colours & brand** — brand/accent colours, logo, fonts, existing copy, photos. Note where they live. +- **Must-haves and no-gos** — contact form, newsletter, bookings, languages, things to avoid. + +**Existing project — when Step 2 found a project in the folder (required, in the Build mode message):** + +Build mode answers the look. The code shows the rest, but the user knows what has to survive the +move, so ask each as a confirm of what you found, naming it, so the user only says yes or corrects you: + +1. **Routes that open directly** — the client-side routes you found (name them, e.g. `/stats`, + `/settings`) must open from a typed link and survive a refresh? Ask only when the code routes in + the browser (React Router, Vue Router, a history or hash router); a server-routed app has none. +2. **Data** — the database and environment variable names you found (or "none"): is that all the + app needs? Hostinger gives the app MySQL only: a different database (Postgres, MongoDB, a SQLite + file) goes under **Out of scope** as a migration the user owns, not as `Database: MySQL`. + +Record the routes and data in **Existing project**. + +If the user asks for something outside the scope (user accounts, member areas, bookings with a +calendar backend, user-generated content), say plainly that the headless flow doesn't support it yet +and record it under **Out of scope** — don't promise it. + +> **A contact form *is* supported (WordPress backends).** If the user wants one, keep it in +> **Must-haves** and name it "Contact form". Provision installs **Contact Form 7** on `/cms` and the +> frontend wires a contact page whose form emails the site owner through WordPress's built-in +> `wp_mail()` — no third-party service and no API keys. It's **opt-in**: built only when the brief asks +> for it. (A form that needs accounts, payments or a booking calendar is still out of scope — this is a +> plain name/email/message enquiry form.) + +### Build mode — a user choice when the folder holds a project + +The backend is your call, but **how the frontend gets built is the user's**. When Step 2 found a +project in the folder, ask **one** question (a structured choice is ideal here); it also settles the +app's look: + +- **Bring your own frontend — keep my app's look** *(the default)* — the agent **uses the user's + existing project in the current folder as the source**, collaborates on design/code at the user's + direction, and wires it to the provisioned backend. There is **no agent-generated design** — the + design step defers to the user (see the Build mode box at the top of the design step), and the + frontend step builds on their project rather than scaffolding a new one. Build mode is **Bring your + own frontend**; write "Keep the look we have: the app in this folder" in **Design direction**. +- **Agent rebuilds it — keep my app's look** — the design step reads the look (colours, fonts, logo) + from their project, and the frontend step starts from the project's source, keeping its screens and + features, and rebuilds its UI to that look. Build mode is **Agent designs and builds**; write "Keep + the look we have: the app in this folder" in **Design direction**. +- **Agent rebuilds it — a new design** — the design step proposes directions and the frontend step + rebuilds the app from them. Build mode is **Agent designs and builds**; take **Design direction** + from Round 3. + +A skipped tool result is not a pick: re-ask in plain text, and use the default only when the user +says so (see *When you ask the user* in the top-level `SKILL.md`). + +Otherwise don't ask: Build mode is **Agent designs and builds**. When the user says they have their +own code elsewhere, ask for its path, inspect it as in Step 2, and from then on treat it as a project +found in the folder. + +Record the Build mode the chosen option names in the brief's **Build mode**, and its look in **Design +direction**. This is the one frontend/technology question you *do* put to the user; everything else +about the stack (framework, SSR/SSG) is still inferred. The deliverable is the same either way — a working +site on the backend; only *who drives the frontend* changes. + +## Step 4 — Decide the backend (your call, from the brief — not a question) + +From what the user described, **you** pick the backend that fits — this is an internal decision, never +a question put back to the user. Pick the backend that fits what the user told you: + +| Backend | Choose when | What it gives the owner | +|---|---|---| +| **WordPress** | No selling. The owner publishes and edits content: pages, blog, articles, portfolio. | Headless WordPress at `/cms` — write and edit content in wp-admin. | +| **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. | +| **WordPress + Ecommerce** | Selling **and** regular editorial content (a blog, guides, lookbooks, recipes) the owner keeps adding to, run as **two** backends (WordPress site + separate Hostinger Ecommerce store). | Both: WordPress for content, the Ecommerce store for products and checkout. | +| **WordPress + WooCommerce** | **Only** when the user explicitly names WooCommerce, or explicitly wants content **and** a store on **one** WordPress site (one admin, one domain). The one-site alternative to WordPress + Ecommerce. | A single headless WordPress at `/cms` with the WooCommerce plugin — products, cart, coupons and checkout (Cash on Delivery) over the WooCommerce Store API, plus WP posts/pages on the same site. | +| **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. | + +Rules of thumb: + +- **Is it an app, or a site?** A custom application the user builds/brings (its own logic, its own data, + maybe a database) → **Webapp**. A content site or a shop → one of the three above. When the user says + "my app / SPA / dashboard / tool", or hands you an existing app project to deploy, that's **Webapp**. +- **Selling decides first (for sites).** No selling → WordPress. Selling → Ecommerce or + WordPress + Ecommerce. +- **Then the content.** Selling plus only a few fixed pages → Ecommerce. Selling plus content the + owner will keep publishing → WordPress + Ecommerce. +- **Hostinger Ecommerce is the default for selling — WooCommerce is never the default and is never + recommended on its own.** Choose **WordPress + WooCommerce** only when the user explicitly names + WooCommerce, or explicitly wants content **and** a store on one WordPress site (one admin, one + domain — the one-site alternative to WordPress + Ecommerce). For every other selling case, keep + Hostinger Ecommerce. If the user wants both content and a store but hasn't said "one site" or + "WooCommerce", offer it as an option — don't switch to it silently. +- **Don't add WordPress "just in case".** A second backend is a second admin to learn. Recommend the + combination only when the user described ongoing content. +- Selling a single service by enquiry (no checkout, no cart) is **not** commerce → WordPress with a + contact page. +- **Webapp never uses WordPress or a store.** If the user wants a custom app *and* a CMS/shop, that's two + different sites — build the app as a Webapp and tell them the content/commerce backend is a separate + domain. + +**Then set rendering** (write it in the brief's **Rendering** section): **Ecommerce → SSR required** +(carts/search need per-request rendering — any SSR framework works; Next.js is the default, not a +requirement); **WordPress + WooCommerce → SSR required** (same reason — cart/checkout need per-request +rendering); **WordPress → SSG (Astro)** if the site is mostly static and the owner is fine rebuilding +to publish, otherwise **SSR**; **Webapp → whatever the app is** (SSR for a server app, SPA for a +client-only app) — it deploys as a Node.js app either way. When unsure, default to **Next.js SSR**. + +## Step 5 — Confirm the plan in one message + +Send **one** message that confirms **what you'll build** — the plan, not the plumbing. Lead with the +site, not the backend: + +- **The plan** — what it is, audience, pages, products (if any), tone. Mark anything you defaulted + (*"assumed: warm, minimal look"*) so the user sees what they skipped. +- **Your app** *(when Step 2 found a project in the folder)* — one line: keeps its look or gets a new + one, the routes that must open directly, and whether a database is created. +- **How it's built** — state the backend as a *consequence* in one plain line, focused on what it + means for them day to day ("I'll build this on WordPress, so you edit pages and posts in a normal + WordPress admin"), **not** as a choice you're asking them to make. Don't ask "is WordPress OK?" or + offer alternatives unless it was a genuine close call *and* the difference matters to the user. + +End with *"Good to go, or anything to change?"* A yes (or "go") → write `brief.md`. A skipped tool +result is not a yes (see *When you ask the user* in the top-level `SKILL.md`). Changes → apply them +and write it; don't loop back into questions. If the user *volunteers* a different backend, use +theirs and note it in the brief. + +## Step 6 — Write `brief.md` and hand off + +Write `brief.md` from the template below. Fill every section from the conversation, defaulting what +the user skipped and tagging it `(assumed)`; leave out **Products** when nothing is sold and +**Existing project** unless Step 2 found a project in the folder. Use the user's own words where you +can. Then update `state.json`: +`"stage": "provision"`, fresh `updated_at`. + +The **Suggested backend** section is the contract with `03-provision/SKILL.md`, which routes on it. +Its first line must be exactly one of `**WordPress**`, `**Ecommerce**`, `**WordPress + Ecommerce**`, +`**WordPress + WooCommerce**` or `**Webapp**`, followed by an em dash and the one-line reason. +`**WordPress + WooCommerce**` is the WordPress backend with the WooCommerce store turned on — provision +routes it to the `wordpress` subfolder with the store enabled (it is **not** a separate backend). + +## `brief.md` template + +```markdown +# Brief — + +> Brief agreed with the user on . + +## Purpose + + +## Audience + + +## Tone + + +## Design direction +" when the +user didn't specify. Record in the user's own words a request to keep their current look ("Keep the look +we have: ") and any logo idea they describe; the design step acts +on both.> + +## Pages +- **** — +- … + +## Products + + +## Content + + +## Must-haves +- … + +## Out of scope +- … + +## Assets + + +## Existing project + + +## Suggested backend +**** — . +<"Confirmed by the user." or "User chose this over the recommended because ."> + +## Build mode +**** — . The design step and the frontend step both read this: **Bring your own frontend** → design +defers to the user and frontend builds on their project; **Agent designs and builds** → both run as normal. + +## Rendering +**** — . Rules: **Ecommerce requires SSR** and **WordPress + WooCommerce requires +SSR** (carts/checkout need per-request rendering — any SSR framework works; Next.js is the default, not +a requirement); **WordPress may be SSG (Astro)** when the site is mostly static and rarely changes, +otherwise **SSR**. Default when unsure: **Next.js SSR**. +``` + +## Example — WordPress + Ecommerce + +> A ceramicist sells around 30 handmade pieces and writes a monthly studio journal. + +```markdown +## Suggested backend +**WordPress + Ecommerce** — sells ~30 products with checkout, and the owner publishes a monthly +studio journal they want to edit themselves. +Confirmed by the user. +``` diff --git a/skills/hostinger-headless/steps/03-provision/SKILL.md b/skills/hostinger-headless/steps/03-provision/SKILL.md new file mode 100644 index 0000000..85afdbf --- /dev/null +++ b/skills/hostinger-headless/steps/03-provision/SKILL.md @@ -0,0 +1,117 @@ +--- +name: headless-step-provision +description: "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. WordPress + WooCommerce routes to the wordpress subfolder with the WooCommerce store turned on — it is not a separate backend." +--- + +# headless-step-provision + +Router. The backend is decided in **refine** and recorded in `brief.md`; this step only routes to the +matching subfolder, which provisions it. + +**In:** `brief.md`. **Out:** `site.json` (written by the subfolder). + +**This stage runs in the background.** Provisioning is mostly a poll wait, so it splits in two: a +**fire** half (detect + create, returns straight away) and a **join** half (poll to `completed`, +verify, write `site.json`). Fire it, record `provision.in_progress`, then go do the prepare work of +the other stages (content-model prepare, frontend shell) while it settles. Only cross back to +the join half when an instance-barrier step needs the live backend. See *Background provision & the +instance barrier* in the top-level `SKILL.md`. An agent that can't interleave simply does fire and +join back-to-back — still correct, just no overlap. + +## Step 1 — Route on the suggested backend + +Read the brief's **Suggested backend** section: + +| Suggested backend | Follow | +|---|---| +| WordPress | `wordpress/SKILL.md` | +| WordPress + WooCommerce | `wordpress/SKILL.md` **with the WooCommerce store on** (see below) | +| Ecommerce | `ecommerce/SKILL.md` | +| Webapp | `webapp/SKILL.md` | + +If the section is missing or names something else, go back to **refine** — don't pick one here. +Tell the user the backend and its one-line reason before provisioning. + +> **WordPress + WooCommerce is the WordPress backend with a store — not a new backend.** Route it to +> `wordpress/SKILL.md` and follow that recipe's **optional WooCommerce tail** (install + activate the +> `woocommerce` plugin and verify the store endpoints; the catalog is seeded later through the per-site WP +> MCP's native `wc-*` abilities — no custom plugin, no consumer key). `site.json → backend` stays +> `"wordpress"`; the store is recorded under `site.json → woocommerce` (`enabled: true`). Everything else +> downstream that routes on +> `wordpress` (content-model push, deploy's `/cms` preservation, done's wp-admin login) then works +> unchanged. + +> **Supported backends: `wordpress` (optionally with a WooCommerce store), `ecommerce` and `webapp`.** +> `webapp` is a plain Node.js app host (an empty website — no CMS, no store, no `/cms`) for any app/SPA +> that brings its own code and data; it never installs WordPress. PocketBase is not available in this +> version — refine never proposes it, and there is no `pocketbase/` subfolder; if a brief somehow names +> it, stop and tell the user rather than improvising it. A **WordPress + Ecommerce** brief (two separate +> backends — a WordPress site **and** a Hostinger Ecommerce store) has no single combined `start-setup` +> type yet: stop, tell the user it isn't supported as one site, and offer either to continue with just +> WordPress or just Ecommerce (route to that one subfolder), **or**, if they want content and a store on +> **one** site, to switch the brief to **WordPress + WooCommerce** — the one-site path, which routes to +> `wordpress/SKILL.md` with the store on. + +## Onboarding handoff — swap an existing empty headless shell (do this before creating anything) + +Onboarding can create the website **before** you ever run, then paste the build prompt into this agent. +When it does, the domain already has an **empty headless shell**: a website set up with +`hosting_websites_start-setup { type: "headless_empty" }` — `website_type` is `headless`, but **nothing +is installed** (no WordPress at `/cms`, no store). It is the hosting slot only, waiting for a backend. + +So provision has **two entry shapes**, and each subfolder's *detect* step picks between them: + +- **An empty headless shell already exists for the domain → adopt it and *swap* it to the chosen + backend. Never call `start-setup` again, never create a second site.** The swap is backend-specific: + - **WordPress** → install WordPress **into the existing shell's `/cms`** with + `wordpress_installations_install { type: "headless" }` (it rewrites the root `.htaccess` and marks + the site headless-WordPress). Do **not** use `start-setup { type: "headless_wordpress" }` — that is + only for creating a fresh site. + - **Ecommerce** → the empty headless shell **is** the storefront host already (a `headless_ecommerce` + website installs no store engine either). Adopt it as the frontend host and go straight to creating + the store + custom sales channel. +- **No site exists for the domain at all → create it fresh, exactly as each subfolder defines today** + (`start-setup` with the backend's own `type`). + +> **Webapp is not part of this handoff at all.** `webapp` is **not** reached on the usual onboarding +> flow — it is only triggered by experimental actions — so it **ignores headless instances entirely**: +> it never detects, adopts or swaps an empty headless shell, and provisions its own **Node.js app host** +> exactly as `webapp/SKILL.md` defines today. The swap below is **WordPress and Ecommerce only**. + +**How to tell an empty shell from an already-provisioned backend** (both read `website_type: headless`): + +1. Exact-match the domain row in `hosting_websites_list` — if there is **no** row, nothing exists → create fresh. +2. Within 24 h of onboarding, `hosting_websites_list-setups` still shows the setup `type` + (`headless_empty` = empty shell; `headless_wordpress` = WordPress already; `headless_ecommerce` = an + ecommerce host). Beyond 24 h the setup drops off — fall back to the backend probe: +3. `wordpress_installations_list { username, domain, ownership: "all" }` — a row with `directory: "cms"` + and `is_valid: true` means WordPress is **already** installed (adopt, don't swap); **no** such row on + a `headless` site means the shell is still empty and WordPress must be swapped in. A local `site.json` + with a recorded backend means a previous run already provisioned — trust reality over it, but it tells + you not to expect an empty shell. + +This only changes *detect*. Everything after the swap (verify, `site.json`, the WooCommerce tail, the +background fire/join split) is identical to a fresh provision. A swap that installs WordPress is still +asynchronous — fire it, record `provision.in_progress`, and join at the barrier just like a fresh create. + +## Step 2 — Fire (don't block) + +Follow the chosen subfolder's `SKILL.md` through **detect + create** (its "start-setup" call). The +moment the create is accepted and queued, record it in `state.json` and **return** — do not sit on the +readiness poll: + +```json +{ "stage": "content_model", + "provision": { "status": "in_progress", "domain": "", "started_at": "" } } +``` + +Now go run the prepare work that doesn't need the backend (content-model prepare, frontend +shell). The subfolder's readiness poll, verification and `site.json` are the **join** half below. + +## Step 3 — Join (at the instance barrier) + +When an instance-barrier step is about to run (content-model push, frontend data wiring, deploy) — or +right away, if the agent can't interleave — finish the subfolder's `SKILL.md`: **poll to `completed`**, +run its verification, and write `site.json` (which records the chosen `backend`). Then set +`provision.status` to `"completed"` in `state.json` with a fresh `updated_at`. On resume, treat a +stored `in_progress` as a hint only — re-poll `hosting_websites_list-setups` for the truth first. diff --git a/skills/hostinger-headless/steps/03-provision/ecommerce/SKILL.md b/skills/hostinger-headless/steps/03-provision/ecommerce/SKILL.md new file mode 100644 index 0000000..aea6c01 --- /dev/null +++ b/skills/hostinger-headless/steps/03-provision/ecommerce/SKILL.md @@ -0,0 +1,144 @@ +--- +name: headless-step-provision-ecommerce +description: "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." +--- + +# headless-step-provision-ecommerce + +Provision a **Hostinger Ecommerce** backend for `` and prove it is ready. Two distinct things: + +1. A **headless website** (`type: "headless_ecommerce"`) — this is only the **hosting slot for the + storefront you'll build**. It installs **no store engine** (no `/cms`, no API on the domain). +2. A **Hostinger Ecommerce store** — a separate object created through the **Ecommerce API**, with a + **custom (headless) sales channel**. The storefront reads this store's **public Storefront Core V2 + API**, keyed on the sales channel. This is the real backend. + +**Out:** `site.json` (store id, custom sales channel id, URLs). + +> ## ⚠️ Prerequisite: the `hostinger-ecommerce` MCP server must be connected +> Store management is **not** in the `hosting`/`wordpress` MCP groups — it's the separate +> **`hostinger-ecommerce`** group (`ecommerce_*` ops, OAuth-backed, no API token needed). If those +> tools aren't available, add the server and **reconnect the session** before continuing: +> ```bash +> 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 +> ``` +> (For other tools, enable the **Ecommerce** MCP in the Hostinger Connector.) A newly added MCP server +> only loads on a session restart/reconnect. +> +> **Can't restart your own session? Don't block — drive the server directly.** A coding agent usually +> can't reconnect itself, so instead of stopping, run the stdio server yourself and speak MCP to it: +> spawn `npx --package=@hostinger/mcp@latest hostinger-ecommerce-mcp` (same `USER_AGENT` as above) and +> send newline-delimited JSON-RPC over its stdin/stdout — `initialize` → the `notifications/initialized` +> notification → `tools/list` → `tools/call`, where the tools are `search` / `execute` / `multi-execute` +> (call ops exactly as in-session: `{ "name": "execute", "arguments": { "operation": "…", "params": {…} } }`). +> It self-authenticates with the same stored Hostinger OAuth creds as the other connected servers, so +> there is **no token to handle** (keep any secret off the command line). Your sandbox may ask the user +> to approve spawning it — ask once, then proceed. Only fall back to asking the user to restart if +> driving the server directly isn't permitted. +> +> Verify the tools work (in-session or via the direct drive) with a search — e.g. "list stores" → +> `ecommerce_stores_list` — before proceeding. +> The authoritative, always-current build guide is `ecommerce_miscellaneous_custom-storefront-setup-instructions` — read it once per build. + +All website-lifecycle calls go through the `hosting` connector (`execute`); all store calls through the +`hostinger-ecommerce` MCP. + +## Step 1 — The headless website (frontend host) + +Same lifecycle as `../wordpress/SKILL.md` Steps 1–2, but `type: "headless_ecommerce"`: + +1. Detect: `hosting_websites_list` / `hosting_websites_list-setups` (exact-match the domain). `completed` + → adopt. `running` → poll. `failed` → stop, point at hPanel. If `website_type` is not `headless` + (plain WordPress, Node.js, …), stop and tell the user — don't overwrite another kind of site. +2. Create (only if **no** site exists): `hosting_websites_start-setup { order_id, type: "headless_ecommerce", domain }` + — **omit `domain`** for a temporary subdomain: the setup generates one itself and returns it (don't + call a separate subdomain op first — there's no need in this flow; the API would also accept one you + generated via `hosting_domains_generate-free-subdomain`, valid 24 h from the client that made it). The + response carries the generated domain; if it differs from the workspace dir, move + `~/.hostinger//` → `/` (if `/` already exists, stop and ask instead of moving + into it) and update `brief.md`. Poll `list-setups` to `completed`. `website_type` will be `headless`. + +> **Onboarding handoff — an empty headless shell needs no new website.** If the domain already has an +> empty headless shell (`website_type: headless`, setup `type: headless_empty`, nothing installed — +> typically created in onboarding), **adopt it as the storefront host and skip the create.** A +> `headless_ecommerce` website installs no store engine either, so an empty headless shell *is* the host +> you need — there is no backend to "swap" on the website side. Record its `username`/`order_id` and go +> straight to Step 2 (store + custom sales channel). Never call `start-setup` on a domain that already +> has a site. + +> Don't expect `/cms` or any API on this domain — it serves the storefront you build (Step 6) and +> deploy (Step 8). + +## Step 2 — Resolve or create the store + custom sales channel + +`ecommerce_stores_list` → reuse an existing store if this build already made one; otherwise create: + +```json +{ "operation": "ecommerce_stores_create", + "params": { "name": "", "country_code": "US", "company_email": "", + "company_name": "", "language": "en", + "sales_channel": { "type": "custom", "external_id": "-headless" } } } +``` + +This returns `store.id` (`store_*`) **and** a custom `sales_channel.id` (`scha_*`) in one call — the +custom channel is what the headless storefront is keyed on. (You can also add one later with +`ecommerce_sales-channels_create { store_id, type:"custom", name }`.) Currency comes from +`country_code` (`default_currency_code`); all prices are **minor units** (cents). + +## Step 3 — Store readiness: shipping + payment + +A store needs ≥1 shipping zone and ≥1 payment method to check out. + +- **Shipping:** `ecommerce_shipping_set-store { store_id, price }` (minor units; 0 = free). +- **Payment:** `ecommerce_payments_enable-manual-method { store_id, title }`. + +> **Known gate — free accounts have no payment method, and therefore no checkout.** On a **free +> account** (and any account/store without a paid Hostinger Ecommerce plan), `enable-manual-method` +> returns **HTTP 402**, and checkout later fails with `400 payment_provider_not_configured`. This is +> **expected, not an error** — payment, and therefore checkout, is simply unavailable on the plan. The +> catalog and the storefront still work fully. **Never block, stop, loop or retry the build on this:** +> - Record `payment_enabled: false` in `site.json` (set `shipping_enabled` as usual). +> - Tell the user in one line that checkout needs a paid Ecommerce plan, and carry straight on. +> - Treat the 402 as a normal outcome — don't surface it as a failure or re-attempt the call. +> - Never ask the user for payment credentials. +> +> Every downstream stage reads `site.json → ecommerce.payment_enabled` and, when it is `false`, skips +> all checkout wiring and verification (see content-model Step 5, frontend, deploy). A store with +> `payment_enabled: false` is a **complete, valid build** — a browsable catalog without checkout. + +## Step 4 — Verify + +- `ecommerce_stores_metadata { store_id }` → `has_shipping` true; `has_payment_methods` (may be false, + see the gate); read `default_currency` (`decimal_digits`, `template`) here, don't guess. +- Public Storefront V2 answers for the channel (no auth): + `GET https://api-ecommerce.hostinger.com/v2/channels//products?limit=100` → 200 (empty until + content-model seeds products — fine here). +- `GET https:///` returns the Hostinger default page until the storefront is deployed — expected. + +## Step 5 — Record `site.json` + +```json +{ + "backend": "ecommerce", + "website_type": "headless", + "order_id": 1007223428, + "username": "u…", + "ecommerce": { + "store_id": "store_…", + "sales_channel": { "id": "scha_…", "type": "custom", "external_id": "…-headless" }, + "currency": "usd", + "payment_enabled": false, + "shipping_enabled": true + }, + "urls": { + "site": "https://", + "storefront_api": "https://api-ecommerce.hostinger.com/v2/channels/", + "store_admin": "https://ecommerce.hostinger.com/store/" + }, + "provisioned_at": "…", + "verified_at": "…" +} +``` + +No secrets in `site.json`. After deploy, link the channel to the live domain with +`ecommerce_sales-channels_update { store_id, sales_channel_id, url }` (done in the deploy stage). diff --git a/skills/hostinger-headless/steps/03-provision/webapp/SKILL.md b/skills/hostinger-headless/steps/03-provision/webapp/SKILL.md new file mode 100644 index 0000000..3f98d39 --- /dev/null +++ b/skills/hostinger-headless/steps/03-provision/webapp/SKILL.md @@ -0,0 +1,158 @@ +--- +name: headless-step-provision-webapp +description: "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." +--- + +# headless-step-provision-webapp + +Provision a bare **Node.js app host** for `` and prove it is ready. This is the backend for +**any webapp, SPA or app** that brings its own code (and, if it needs one, its own database) — there is +no CMS and no store engine here. The site is an **empty website**: just the hosting slot that the +frontend step builds into and the deploy step ships to as a Node.js application. **Out:** `site.json`. + +> **Not WordPress, not Ecommerce.** Never provision `headless_wordpress` / `headless_ecommerce` for a +> webapp, and never install WordPress at `/cms`. A webapp owns its own routes, data and rendering. + +All website/database operations below go through the Hostinger connector (`execute`). + +> **Fire vs join (background provision).** Steps 1–2 up to `hosting_websites_start-setup` are the +> **fire** half: once the setup is queued, record `provision.in_progress` and return to do prepare work +> (see the router `../SKILL.md`). The readiness **poll** at the end of Step 2, plus Steps 3–5, are the +> **join** half — run them when an instance-barrier step needs the backend (or immediately, if the +> agent can't interleave). An already-`completed` site detected in Step 1 means provision is done: +> record `provision.completed` and adopt it. + +## Step 1 — Detect before you create (never provision twice) + +1. `hosting_websites_list { "domain": "" }` — substring filter, so **exact-match** the row's + `domain`. If a row exists, note `username`, `order_id`, `website_type`. +2. `hosting_websites_list-setups { "domain": "" }` — exact-match the row: + - `completed` → already provisioned → skip to Step 3 (adopt it). + - `running` → skip to Step 2 (poll). + - `failed` → tell the user and point them at hPanel; do **not** call start-setup again on your own. +3. If the site exists but is a different kind (`website_type` is `headless`, or a WordPress/store site), + stop and tell the user — this domain already hosts something else. + +## Step 2 — Create (only when nothing exists) + +1. Pick the plan: `hosting_orders_list { "statuses": ["active"] }`. One active order → use it; several + → ask the user which. +2. Start the setup as an **empty website** — **omit `type`** (no WordPress, no store): + + ```json + { "operation": "hosting_websites_start-setup", + "params": { "order_id": , "domain": "" } } + ``` + + `domain` must be a **customer-owned** domain. For a temporary `*.hostingersite.com` subdomain, + **omit `domain`** — the setup generates one itself and returns it in the response. Don't call a + separate subdomain op to make one first: there's no need in this flow. (The API would also accept a + subdomain you generated via `hosting_domains_generate-free-subdomain`, valid for 24 h from the client + that made it — this flow just doesn't use that.) The response then carries the generated domain: if it + differs from the workspace directory name, move `~/.hostinger//` to `~/.hostinger//` + (if `/` already exists, stop and ask instead of moving into it), + update the domain in `brief.md`, and tell the user the new domain. +3. Poll `hosting_websites_list-setups { "domain": "" }` every ~15 s until `completed` + (typically under a minute; allow a few minutes). `failed` → stop, point at hPanel. + +## Step 3 — Database (only if the app needs one) + +Skip this step entirely for an app with no database (static SPA, an app that talks to an external API). +The app needs one when `brief.md` → **Existing project** says **Database: MySQL**, or, for a new app, +when the brief describes data the app stores itself. +This is **join-half** work — it needs the live website, so run it once `provision.status == "completed"`. + +**Preferred — let the platform create the database and inject the credentials:** +`hosting_databases_setup-website { username, domain }` creates a MySQL database, **generates the +password, writes `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USER` / `DB_PASSWORD` / `DATABASE_URL` into the +website's environment variables, and restarts the app — in one call.** The password is never returned, +so there is **nothing to persist and nothing to lose across sessions** — the app reads it from the +environment. (It 422s if any of those env vars already exist; clear them first with +`hosting_nodejs_replace-environment-variables`. Frameworks that bake vars into the build — Next.js, +`NEXT_PUBLIC_*` — only pick up the values on the deploy build.) + +> **⚠️ A later `hosting_nodejs_replace-environment-variables` wipes these credentials.** That call is a +> **full replace**, and when you list the current vars first the `DB_PASSWORD` (and `DATABASE_URL`) read +> back **masked** (`********`) — the generated password is unknown, so you cannot resend it and the DB +> env is lost. One rule, pick one: +> - **(a) Set every other app env var first, and call `setup-website` last** — then nothing has to +> replace the env again. +> - **(b) If a later replace is unavoidable**, reset the password with +> `hosting_databases_change-password { username, name, password }` and resend **all** of `DB_HOST`, +> `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` and `DATABASE_URL` with the new password in that same +> full replace. + +**Manual alternative** — only when you need a specific name/user or finer control: + +1. `hosting_databases_create` with a name, a user, and a password you generate — name and user are + prefixed with the account username automatically. Read the full, prefixed `name`/`user` back from + `hosting_databases_list`. +2. **Which host the app connects to:** the app runs **on the website**, so Node.js connects to + **`127.0.0.1:3306`** — never `localhost` (it can resolve to IPv6 `::1`, and not every database user + has that grant), and never the external `host` from `hosting_databases_list` (`srvNNNN.hstgr.io`, + which has no grant for connections from the server itself). +3. **Set the env vars now, while you still hold the generated password.** The password is **not persisted + anywhere** (never in `site.json`), so set `DB_HOST=127.0.0.1`, `DB_PORT=3306`, `DB_NAME`, `DB_USER`, + `DB_PASSWORD`, plus `DATABASE_URL=mysql://USER:PASSWORD@127.0.0.1:3306/NAME` (URL-encode USER/PASSWORD) + right here with `hosting_nodejs_replace-environment-variables` (a **full replace** — list current keys + first; values come back masked; send the complete set with real values). Saving restarts the process; + build-time vars need a fresh deploy (`hosting_deploy`) afterwards. **Don't defer this to deploy:** on + a **cross-session resume** the generated password is gone, so you must **reset it** with + `hosting_databases_change-password { username, name, password }` and set the env vars from the new one. +4. **Never** put the password in source, a committed `.env`, client-side JavaScript, or a chat reply; + never print it once stored. + +## Step 4 — Verify + +| Check | How | Expect | +|---|---|---| +| Website exists | `hosting_websites_list` exact row | the row is present, `username` known | +| Setup finished | `hosting_websites_list-setups` exact row | `status: completed` | +| Database reachable *(only if created)* | `hosting_databases_list { username }` | the prefixed db + user are listed | + +`GET https:///` returns the Hostinger default page (or 403) until the frontend is deployed — +expected, not a failure. There is **no `/cms` and no API** on this domain; the app itself serves every +route once it is deployed (Step 8). + +If a check fails: retry once after ~30 s; if it still fails, stop and point the user at hPanel. + +## Step 5 — Record `site.json` + +```json +{ + "backend": "webapp", + "website_type": "empty", + "order_id": 1007223428, + "username": "u…", + "runtime": "nodejs", + "database": { + "enabled": false, + "engine": "mysql", + "name": "u…_appdb", + "user": "u…_app", + "host": "127.0.0.1", + "port": 3306 + }, + "urls": { + "site": "https://" + }, + "provisioned_at": "…", + "verified_at": "…" +} +``` + +No passwords or tokens in `site.json` — only the non-secret connection shape (host/port/name/user). Set +`"database": { "enabled": false }` (and drop the rest) when the app needs no database. + +## Deploy & content model (what happens next) + +- **Content model is skipped for a webapp** — there is no CMS or store to seed; the app owns its own + data. The content-model router (`../../04-content-model/SKILL.md`) passes straight through to the + frontend step for this backend. +- **Deploy** is a standard Node.js application deploy, handled by `../../08-deploy/SKILL.md`: it archives + the **source** (not the build output; exclude `node_modules/`, `.next/`, `dist/`, build output and + `.git/`), then deploys with `hosting_deploy` (`type: "nodejs"`, upload + server-side `npm install` + + build, one shot) passing the framework's `appType` + `outputDirectory` inline, and polls + `hosting_get-deployment` until the build completes. Any supported `appType` works (Next.js, Nuxt, + Express, NestJS, Fastify, Vite/React/Vue SPA, …). There is no `/cms` to preserve here, so the whole + root is the app. diff --git a/skills/hostinger-headless/steps/03-provision/wordpress/SKILL.md b/skills/hostinger-headless/steps/03-provision/wordpress/SKILL.md new file mode 100644 index 0000000..ade3f5b --- /dev/null +++ b/skills/hostinger-headless/steps/03-provision/wordpress/SKILL.md @@ -0,0 +1,314 @@ +--- +name: headless-step-provision-wordpress +description: "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." +--- + +# headless-step-provision-wordpress + +Create the headless WordPress backend for `` and prove it is ready. One platform call lays +every headless site out identically: WordPress in `public_html/cms`, Hostinger AI Assistant + MCP +adapter, site marked `headless`. **Out:** `site.json`. + +> **WordPress + WooCommerce?** This same recipe serves it. When the brief's **Suggested backend** is +> `**WordPress + WooCommerce**`, provision the WordPress site exactly as below, then run the **optional +> WooCommerce tail in Step 5** (install + activate the `woocommerce` plugin and verify the store +> endpoints; the catalog is seeded in content-model via the per-site WP MCP). +> WooCommerce is a plugin on this WordPress install, **not** a separate backend: `site.json → backend` +> stays `"wordpress"` and the store is recorded under `site.json → woocommerce`. For a plain WordPress +> brief, skip Step 5 entirely. + +All operations below go through the Hostinger connector (`execute`). + +> **Fire vs join (background provision).** Step 1, plus the queuing call — `hosting_websites_start-setup` +> for a fresh site (Step 2) **or** `wordpress_installations_install` to swap an empty headless shell +> (Step 2b) — are the **fire** half: once the job is queued, record `provision.in_progress` and return +> to do prepare work (see the router `../SKILL.md`). The readiness **poll** at the end of Step 2 / 2b, +> plus Steps 3–6, are the **join** half — run them when an instance-barrier step needs the backend (or +> immediately, if the agent can't interleave). An already-`completed` site with WordPress at `/cms` +> detected in Step 1 means provision is already done: record `provision.completed` and adopt it. + +## Step 1 — Detect before you create (never provision twice) + +1. `hosting_websites_list { "domain": "" }` — substring filter, so **exact-match** the row's + `domain`. If a row exists, note `username`, `order_id`, `website_type`. +2. **No exact row** → nothing exists on this domain → **create fresh** (Step 2). +3. **A row exists.** If `website_type` is **not** `headless`, stop and tell the user — this domain + already hosts a different kind of site (plain WordPress, Node.js, builder, …); never overwrite it. + If it **is** `headless`, work out whether WordPress is already installed or the shell is still empty + (see the router's *Onboarding handoff*): check `hosting_websites_list-setups { "domain": "" }` + (exact row) and `wordpress_installations_list { username, domain, ownership: "all" }` (exact row): + - Setup `running` → provisioning is underway → skip to Step 2's poll. + - Setup `failed` → tell the user and point them at hPanel; do **not** call start-setup again yourself. + - A WordPress install with `directory: "cms"` already present (or setup `type: headless_wordpress`) + → **already provisioned** → skip to Step 3 (adopt it). + - A `headless` site with **no** `/cms` install (an **empty headless shell** — setup + `type: headless_empty`, typically from onboarding) → **swap it to WordPress** (Step 2b). Do **not** + call `start-setup` again — the website already exists; you only install WordPress into it. + +## Step 2 — Create a fresh headless WordPress site (only when no site exists) + +1. Pick the plan: `hosting_orders_list { "statuses": ["active"] }`. One active order → use it; several + → ask the user which. +2. Start the setup: + + ```json + { "operation": "hosting_websites_start-setup", + "params": { "order_id": , "type": "headless_wordpress", "domain": "" } } + ``` + + `domain` must be a **customer-owned** domain. For a temporary `*.hostingersite.com` subdomain, + **omit `domain`** — the setup generates one itself and returns it in the response. Don't call a + separate subdomain op to make one first: there's no need in this flow. (The API would also accept a + subdomain you generated via `hosting_domains_generate-free-subdomain`, valid for 24 h from the client + that made it — this flow just doesn't use that.) The response then carries the generated domain: + if it differs from the workspace directory name, move `~/.hostinger//` to `~/.hostinger//` + (if `/` already exists, stop and ask instead of moving into it), + update the domain in `brief.md`, and tell the user the new domain. + Admin credentials are generated by the platform; never ask the user for a password. +3. Poll `hosting_websites_list-setups { "domain": "" }` every ~15 s until `completed` + (typically under a minute; allow a few minutes). `failed` → stop, point at hPanel. + +## Step 2b — Swap an empty headless shell to WordPress (only when onboarding already made the shell) + +The website already exists as an empty `headless` shell, so there is **no `start-setup`** here — you +install WordPress straight into its `/cms` directory. This is the onboarding handoff path. + +1. Use the shell's `username` and `order_id` from Step 1's row — no new order pick, no new website. +2. Install WordPress into `/cms`, flagged headless: + + ```json + { "operation": "wordpress_installations_install", + "params": { "username": "", "domain": "", "type": "headless", + "site_title": "", + "credentials": { "login": "", "email": "", + "password": "" } } } + ``` + + - `type: "headless"` installs into `/cms` with the Hostinger headless MU plugin and marks the site + headless-WordPress; **`directory` is not allowed with it** (it is always `cms`). The target must + already be `headless` — the headless install rewrites the root `.htaccess`, so the API rejects it + with `422` on any non-headless site (which is why Step 1 confirms `website_type: headless` first). + - **You generate the admin password** (strong, 8–50 chars, mixed case + a digit) — never ask the user + for one and never print it. The user signs in later through a one-click login link (Step 6), so the + password only has to exist, not be remembered. + - A new database is created for the install unless you pass `database.name` of an existing one; the + generated database counts against the plan's limit. +3. The call is **asynchronous** — a success response only means the install job was **queued**. This is + the *fire*: record `provision.in_progress` and return to prepare work. Poll + `wordpress_installations_list { username, domain, ownership: "all" }` (exact row) at the join; when a + row with `directory: "cms"` and `is_valid: true` appears, WordPress is ready. Allow 1–2 minutes; + `wordpress_installations_list` can lag a few seconds behind the job finishing and return `[]` — retry + briefly before failing. + +## Step 3 — Verify (all must pass) + +| Check | How | Expect | +|---|---|---| +| Site is headless | `hosting_websites_list` exact row | `website_type: "headless"` | +| WordPress at `/cms` | `wordpress_installations_list { "username": "", "ownership": "all" }`, exact-match `domain` | row with `directory: "cms"`, `is_valid: true`; note its `id` | +| REST API answers | `GET https:///cms/wp-json/wp/v2/posts?per_page=1` | 200, JSON array | +| AI Assistant + MCP loaded | `GET https:///cms/wp-json/` → `namespaces` | contains `hostinger-ai-assistant/v1` and `mcp` | +| JWT mintable | `wordpress_installations_jwt-token { username, software: }` | returns `token`, `mcp_url` | + +Notes: + +- `wordpress_installations_list` can lag a few seconds behind `completed` and return `[]` — retry + briefly before failing. Its `domain` filter is a substring match, so exact-match the row. +- The install record's `url` may say `http://`; always use `https://` URLs. +- `GET https:///` returns 403 until the frontend is deployed — expected, not a failure. +- **Never print, log or store the JWT.** It is minted on demand (24 h lifetime) by whichever stage + needs the per-site WP MCP; only `mcp_url` is recorded. + +If a check fails: retry once after ~30 s; if it still fails, stop and point the user at hPanel. + +## Step 4 — SEO plugin (Yoast) *(every WordPress site — on by default)* + +Every headless WordPress site gets **Yoast SEO** (`wordpress-seo`) installed and activated by default, +so per-page title/description/Open Graph/JSON-LD come from a maintained plugin's `yoast_head_json` +rather than being hand-generated from each item's content by the frontend. This is **join-half** work +(needs the live `/cms` install), so run it once `provision.status == "completed"`, the same way as the +WooCommerce tail. Tell the user in one line that SEO (Yoast) is being set up; if they explicitly don't +want it, skip this step and record `seo.enabled: false` — but the default is to install it. + +1. **Install + activate Yoast** (management MCP, async — poll, never blind-retry): + - `wordpress_plugins_install { username, software: , plugins: ["wordpress-seo"] }` + - Poll `wordpress_plugins_list-installed { username, software }` until `wordpress-seo` shows + `active` (install + activate can complete together), or activate it with + `wordpress_plugins_activate { username, software, plugin: "wordpress-seo" }`. + - **Known quirk:** the `hostinger-wordpress` MCP sometimes returns **HTTP 500 even though the job + succeeded**. Treat a 500 as "verify, don't assume": poll `list-installed` before retrying, and + never fire a second install blindly. +2. **Verify** `GET https:///cms/wp-json/` → `namespaces` once more, and that a sample post/page + response carries `yoast_head_json`. The per-item Yoast meta (focus title/description per post) is set + later in the **content-model push** — provision only installs + activates the plugin here. Don't fail + provision over SEO: if the install can't be confirmed after one retry, record `seo.enabled: false`, + tell the user, and let the frontend fall back to generating tags from content. + +## Step 5 — WooCommerce store *(only for a `WordPress + WooCommerce` brief — skip otherwise)* + +Run this only when the brief's **Suggested backend** is `**WordPress + WooCommerce**`. It turns the +headless WordPress site into a headless **store**: WooCommerce is the `woocommerce` plugin on this same +`/cms` install — **not a separate backend**. This is **join-half** work (needs the live instance), so run +it once `provision.status == "completed"`. The authoritative data-layer reference is +[`../../04-content-model/wordpress/woocommerce.md`](../../04-content-model/wordpress/woocommerce.md). + +> **No custom plugin — the catalog is driven through the per-site WP MCP.** A custom plugin can't be +> installed into a headless `/cms` from a coding agent, so there is **no seed/config plugin and no REST +> consumer key in this flow.** Instead, the Hostinger AI Assistant (the per-site WP MCP, same JWT session +> content-model uses) exposes **native WooCommerce abilities** — `wc-products-*`, `wc-product-categories-*`, +> `wc-coupons-*`, `wc-orders-*`, `media-upload-from-url`, `wp-settings-*`. Provision just **installs +> WooCommerce and verifies the store endpoints**; the catalog + coupons are seeded in the content-model +> push through those abilities (see the cookbook). + +1. **Install + activate WooCommerce** (management MCP, async — poll, never blind-retry): + - `wordpress_plugins_install { username, software: , plugins: ["woocommerce"] }` + - Poll `wordpress_plugins_list-installed { username, software }` until `woocommerce` shows `active` + (install + activate can complete together), or activate it with + `wordpress_plugins_activate { username, software, plugin: "woocommerce" }`. + - Confirm with `wordpress_plugins_check-if-woo-commerce-is-installed { domain: "" }`. Allow a + moment for WooCommerce's installer to create its tables and the Shop/Cart/Checkout pages. + - **Known quirk:** the `hostinger-wordpress` MCP sometimes returns **HTTP 500 even though the job + succeeded** (seen on both `plugins_install` and `jwt-token`). Treat a 500 as "verify, don't assume": + poll `list-installed` / re-check before retrying, and never fire a second install blindly. +2. **Verify the store endpoints (public, no auth):** + + | Check | How | Expect | + |---|---|---| + | WooCommerce active | `wordpress_plugins_check-if-woo-commerce-is-installed { domain }` | installed + active | + | Store API answers | `GET https:///cms/wp-json/wc/store/v1/products?per_page=1` | 200 (empty until content-model seeds — fine) | + | Both namespaces loaded | `GET https:///cms/wp-json/` → `namespaces` | contains `wc/store/v1` **and** `wc/v3` | + + Pretty permalinks are on by default on a headless install, so the Store API resolves immediately; if a + store route ever 404s, the permalink structure is the thing to check. + +> **Checkout needs owner setup — treat it like the Ecommerce payment gate.** A fresh WooCommerce install +> has **no payment gateway and no shipping zone enabled**, and that configuration is **not reachable from +> the headless tooling** (there is no payment/shipping MCP ability, and `wp-settings-update` only writes +> standard WP settings). So **checkout is unavailable until the owner enables a payment method + shipping +> in wp-admin** (hand them the one-click login in Step 6). Record `woocommerce.payment_enabled: false` +> accordingly. This is **not a build failure**: the catalog, product pages, client-side cart and coupon +> application all work headlessly — the downstream frontend renders a graceful disabled-checkout state and +> never calls `/checkout` while `payment_enabled` is `false` (see content-model + frontend). Never ask the +> user for payment credentials. Store currency and address (`woocommerce_*` options) likewise aren't +> settable headlessly — they follow the install default and the owner can change them in wp-admin. + +## Step 5b — Contact form (Contact Form 7) *(only when the brief's Must-haves include a contact form — skip otherwise)* + +Run this only when the brief's **Must-haves** include a contact form. It installs **Contact Form 7** +(`contact-form-7`) on this same `/cms` install, so the frontend can render a contact page whose form +emails the site owner through WordPress's built-in `wp_mail()` — no third-party service and no API keys. +Like Yoast/WooCommerce this is **join-half** work: run it once `provision.status == "completed"`. The form +itself (id + fields + recipient) — and a tiny **spam-guard plugin** that makes a honeypot work headlessly — +are resolved/deployed in the content-model push — see +[`../../04-content-model/wordpress/contact-form.md`](../../04-content-model/wordpress/contact-form.md). + +> **Why CF7 and not a bespoke mailer.** A contact form is maintained, spam-hardened functionality worth +> reusing rather than reinventing (you *can* deploy a custom plugin to a headless `/cms` — see the +> content-model cookbook — but you needn't write your own mailer). CF7 is a wordpress.org-slug plugin, +> installs through the same management MCP as Yoast, and ships a **public, unauthenticated** submit +> endpoint (`POST /cms/wp-json/contact-form-7/v1/contact-forms//feedback`). The frontend and `/cms` +> are same-origin, so the browser posts to it directly — no proxy, no CORS, no secret on the client. + +1. **Install + activate CF7** (management MCP, async — poll, never blind-retry): + - `wordpress_plugins_install { username, software: , plugins: ["contact-form-7"] }` + - Poll `wordpress_plugins_list-installed { username, software }` until `contact-form-7` shows `active` + (install + activate can complete together), or activate it with + `wordpress_plugins_activate { username, software, plugin: "contact-form-7" }`. + - **Don't install the `contact-form-7-honeypot` add-on.** It only acts when a `[honeypot]` form-tag is + present in the form — a wp-admin edit the headless agent can't make — so on the default form it does + nothing. Headless spam protection instead comes from a small **spam-guard plugin deployed in the + content-model push** (it hooks `wpcf7_spam`); see the cookbook. + - **Known quirk:** the `hostinger-wordpress` MCP sometimes returns **HTTP 500 even though the job + succeeded**. Treat a 500 as "verify, don't assume": poll `list-installed` before retrying, and + never fire a second install blindly. +2. **Verify** `GET https:///cms/wp-json/` lists the **`contact-form-7/v1`** namespace, then + record `contact_form.enabled: true` in `site.json` (Step 6). If the install can't be confirmed after + one retry, record `contact_form.enabled: false`, tell the user in one line, and let the frontend + render the contact page without a form (graceful — not a build failure). Don't fail provision over it. + +The owner's email is **CF7's default recipient** (`[_site_admin_email]`, the admin address from +install) — fine out of the box, so "does Hostinger set up the email?" is **yes** for basic delivery via +`wp_mail`. Deliverability (SPF/DKIM) is most reliable when that address is a mailbox **on the same +domain**; the content-model push keeps the default recipient and validate sends a test submission to +confirm mail actually leaves. Never ask the user for mail credentials. + +## Step 6 — Record `site.json` + +```json +{ + "backend": "wordpress", + "website_type": "headless", + "order_id": 1007223428, + "username": "u…", + "wordpress": { + "install_id": "30…", + "directory": "cms", + "admin_login": "…" + }, + "seo": { + "plugin": "wordpress-seo", + "enabled": true + }, + "urls": { + "site": "https://", + "cms": "https:///cms", + "wp_admin": "https:///cms/wp-admin", + "rest_api": "https:///cms/wp-json/wp/v2", + "wp_mcp": "" + }, + "provisioned_at": "…", + "verified_at": "…" +} +``` + +No passwords or tokens in `site.json`. When the user needs wp-admin, mint a one-click link with +`wordpress_login_create-links { username, software: }` — never hand over a password. + +**For a `WordPress + WooCommerce` brief, also add a `woocommerce` block and the store URLs** (the +`backend` field still stays `"wordpress"`): + +```json +{ + "woocommerce": { + "enabled": true, + "seeded_via": "per-site-wp-mcp", + "currency": "", + "payment_enabled": false, + "checkout_setup": "owner enables a payment method + shipping in wp-admin" + }, + "urls": { + "store_api": "https:///cms/wp-json/wc/store/v1", + "wc_rest_api": "https:///cms/wp-json/wc/v3" + } +} +``` + +**For a brief with a contact form (CF7 installed in Step 5b), also add a `contact_form` block.** +Provision records only `enabled` + `plugin` here; the content-model push fills `form_id`, +`feedback_url`, `fields` and `honeypot_field` once it resolves the form and deploys the spam guard: + +```json +{ + "contact_form": { + "enabled": true, + "plugin": "contact-form-7", + "form_id": null, + "feedback_url": null, + "fields": [], + "honeypot_field": null, + "recipient": "site-admin-email" + } +} +``` + +`form_id` / `feedback_url` / `fields` / `honeypot_field` stay `null` / `[]` until content-model resolves the form (the +submit URL is `https:///cms/wp-json/contact-form-7/v1/contact-forms//feedback`). +`recipient` is informational — CF7's `To` is `[_site_admin_email]`; no address, token or secret is +stored in `site.json`. + +`rest_api` stays `wp/v2` (the WordPress content contract, for the blog/pages on the same site); +`store_api` is the public storefront surface (browse/cart/coupons/checkout, no auth). `wc_rest_api` +(`wc/v3`) is recorded only as the surface the **owner's own** integrations use with a consumer key they +generate in wp-admin — **this flow never creates or stores a key** (the catalog is seeded through the +per-site WP MCP's `wc-*` abilities; the storefront uses the public Store API). `payment_enabled` starts +`false` and flips to `true` only once a gateway + shipping are configured — no secrets in `site.json`. diff --git a/skills/hostinger-headless/steps/04-content-model/SKILL.md b/skills/hostinger-headless/steps/04-content-model/SKILL.md new file mode 100644 index 0000000..cea7755 --- /dev/null +++ b/skills/hostinger-headless/steps/04-content-model/SKILL.md @@ -0,0 +1,240 @@ +--- +name: headless-step-content-model +description: "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." +--- + +# headless-step-content-model + +Two phases around the **instance barrier** (see the top-level `SKILL.md`): + +- **Prepare — no instance.** Build the content helper from `brief.md` and generate the content it + calls for. Runs *during* the provision wait; needs only the brief. +- **Push — needs the instance.** Create that content in the backend. Gated on + `provision.status == "completed"`; routes to `wordpress/` or `ecommerce/`. + +**In:** `brief.md`. **Out:** `content-model.md` (+ any generated media under +`~/.hostinger//media/`). + +> Don't wait for provisioning — the helper comes from the brief alone. + +--- + +## Prepare phase — write the content helper (no instance) + +`content-model.md` is a lightweight helper, not a schema. Fill the **fixed values** from the brief and +write a **generation prompt** for each section — tailored to *this* site, not generic. Then run those +prompts to produce the drafts (and images), so the push phase is just upload. + +Write `content-model.md` from this template, adapting every prompt to the brief's purpose, audience +and tone: + +```markdown +# Content — + +> Content helper prepared from brief.md on . Fixed values are set; each prompt is run +> during the provision wait to generate the content and media. Push to the backend happens later. + +## Title + + +## Description + + +## Navigation +> Generate the primary menu from the site's pages in reading order (). +> Short labels; note which items are static pages vs. listing pages. Add a footer menu if the +> brief implies one (socials, contact). + +## Pages +> Generate each page the brief lists. For every page: title, slug, a short section outline, the +> copy for static pages (e.g. About, Contact), and SEO title + description. +> + +## Articles (optional) +> Generate articles across the brief's categories (). For each: title, slug, +> body in the brief's tone, category, any relationship (e.g. destination), published date, SEO, +> and a featured image (see *Images* below — Hostinger AI generation or Unsplash stock) with +> alt text. Omit if the brief has no article-like content. + +## Products (optional — required for a commerce brief) +> Skip for a non-commerce brief. For a commerce brief, generate the catalog content here (prepare +> work, no instance): for each product — name, slug, SKU, short + long description in the brief's +> tone, price, the collection it belongs to, variants if any, and a product image (see *Images* — +> Hostinger AI generation or Unsplash stock) with alt text. Group products into the collections +> the brief implies, and generate any **coupons** the brief calls for (code, type, amount). The +> `ecommerce` push phase creates these on the store's sales channel; a **WordPress + WooCommerce** brief +> seeds them into WooCommerce instead (see `wordpress/SKILL.md` + `wordpress/woocommerce.md`) — there +> the **SKU is the idempotency key** and coupons + product categories are part of the catalog. +``` + +Guidance: + +- **Fixed vs. prompt.** Title and Description are concrete values. Navigation, Pages and Articles are + prompts because their volume/wording is generated — keep them as prompts so the push phase (or a + rerun) can regenerate consistently. +- **Run the prompts now.** Generate the actual page copy and article drafts during the wait — this is + the slowest work and the whole reason it runs in parallel. Images depend on the chosen source (see + *Images* below): Unsplash photos and your own generated files can be produced now; Hostinger AI + images only generate once the instance exists, so write their brief now and generate at the barrier. + Put any generated/own media in `~/.hostinger//media/` and reference it by filename. Record + alt text for every image. +- **Optional sections.** Drop **Articles** if the brief has no article-like content. Drop **Products** + for a non-commerce brief; for a commerce brief, generate the product catalog here (names, prices, + descriptions, collections, images) — that's prepare work, and the `ecommerce` push phase then creates + it on the store. +- **Don't block on design.** The design step (5) owns the look, and it runs in parallel during the + provision wait, so never wait for it here. If `design.md` already exists when you source images, + follow its **Imagery** section; otherwise generate neutral, on-topic imagery from the brief. + +### Images — generate with Hostinger AI, or source stock from Unsplash + +Every article (and every product) needs an image. There are two sources, and **you MUST offer the user +both and let them choose — never pick silently.** State the default for this backend in one line, name +the other option, and proceed on their answer ("you decide" = use the default; a skipped tool result is +not an answer, see *When you ask the user* in the top-level `SKILL.md`): + +| Option | Tool | Backends | Needs the live instance? | +|---|---|---|---| +| **A — Hostinger AI image generation** *(default for WordPress / WooCommerce)* | `wordpress_ai-content_generate-images` | WordPress, WordPress + WooCommerce | **yes** — runs at the instance barrier | +| **B — Unsplash stock photos** *(default for Ecommerce / Webapp; the fallback everywhere)* | Unsplash API, or web search + web fetch | any | no — pure prepare work | + +> **Do not silently fall back to stock on a WordPress site.** On WordPress / WooCommerce the default is +> **Hostinger AI (Option A)** — on-brand, generated from `design.md`. Using Unsplash on a WordPress build +> is only correct when **the user chose it**, or when **AI generation actually failed** (and then you say +> so). Taking stock because it is faster, synchronous, or avoids the async poll is a **defect**, not a +> shortcut: the async start → poll → upload-before-expiry flow (Option A below) is the expected work, not +> an optional extra. If you are tempted to skip it "for speed", stop and offer the user the choice instead. + +If you can generate images yourself (some agents can), offer that as a third option; only if the user +picks it, put the files in `media/` (no tool needed). **Record alt text for every image whichever source +you use.** + +**During the prepare wait, write an image brief for each image** into `content-model.md` no matter the +option (subject, `image_type`, aspect, and the alt text you expect). Option B can also *fetch* the +photo now (below); Option A only generates once the instance exists, so its briefs wait for the barrier. + +#### Option A — Hostinger AI image generation (WordPress backends) + +`wordpress_ai-content_generate-images` generates on-brand images for a live WordPress install, so it +runs **at the instance barrier** (after `provision.status == "completed"`), using the install from +`site.json` (`username`, and `wordpress.install_id` → the `software` path param). Follow `design.md`'s +**Imagery** section for look and palette. It is **asynchronous**: + +1. **Start** one generation per image: + + ```json + { "operation": "wordpress_ai-content_generate-images", + "params": { + "username": "", + "software": "", + "prompt": "", + "image_type": "hero|background|inline|gallery|product|logo", + "num_of_images": 1, + "style": "", + "color_guidance": "" } } + ``` + + `prompt` (3–2500 chars) and `image_type` are required; `width`/`height` (128–2048) default per type + and are ignored for `logo`. Returns `202` with a `generation_id` and `status: in-progress`. + **Only one generation runs per install at a time** — a `409` means one is already running (and it + returns no id), so wait for it to finish before starting the next. The endpoint allows 10 + requests/minute. + +2. **Poll** `wordpress_ai-content_generation-result` with `{ username, software, generation_id }` + every few seconds until `status` is `completed` (or `failed`). A completed result carries + `result.images[]` — each with a **temporary** `url`, plus `width`, `height`, `content_type` — and + `result.expires_at`. + +3. **Use the images before they expire.** The URLs are short-lived, so in the push phase hand each + `url` straight to WordPress with `media-upload-from-url`, which downloads and stores WP's own copy + (see the wordpress push `SKILL.md`). Don't persist the temporary URL as the live image. There is no + photographer to credit — leave `caption` empty or a short on-brand one. Record under the + article/product in `content-model.md`: + + ```markdown + Image: Hostinger AI (image_type: , generation_id: ) + Image alt: + Image file: -.jpg + ``` + + On `failed`, retry once with a tightened prompt; if it fails again, fall back to Option B and note + the gap. Never block the whole push on a single image. + +#### Option B — stock photos from Unsplash (any backend, no instance) + +This is still prepare work and needs no live backend: you pick the photos and record their URLs here, +and the push phase uploads them. + +1. **Find candidates.** Use one photo per article, matching its subject and destination. + - With an `UNSPLASH_ACCESS_KEY` in the environment, use the official API: + `GET https://api.unsplash.com/search/photos?query=&orientation=landscape` with header + `Authorization: Client-ID `. + - Without a key, use web search limited to `unsplash.com` (e.g. "unsplash photo Lisbon tram") and + take the photo id from the result URL: the last segment of `unsplash.com/photos/-`, or + the whole segment for `unsplash.com/photos/`. +2. **Resolve a direct file URL.** With the API, the result's `urls.raw` is the file and `user.name` + the photographer; for each photo you keep, also `GET` its `links.download_location` (same header), + as the API terms require. Without a key, open the photo page (`https://unsplash.com/photos/<…>`) + with your **web-fetch tool** and ask it for the main photo's `images.unsplash.com/photo-<…>` URL, + quoted exactly, and the photographer's name. Either way, drop the query string and request a web + size: `https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max`. Check that it returns + 200 with `image/jpeg`: the upload ability needs this direct file URL, not the photo page. + - Every other `unsplash.com` request (curl on the page, `/photos//download`, `/napi/…`, + `/oembed`) hits a bot challenge: a `307` to `/.within.website` (Anubis) or a 401. If your fetch + tool gets the challenge page too, stop: no user-agent spoofing or other workarounds. On a + WordPress backend where Option A hasn't already failed, offer it; otherwise ask the user for an + `UNSPLASH_ACCESS_KEY` or record the gap (step 5). +3. **Look before you keep it.** Download a small preview (`?w=500`) and view it. Search snippets and + fetch summaries only describe the page, and can hand you a related photo's thumbnail instead of + the main one; a photo also often isn't quite what its title says. Reject anything off-topic. + If nothing fits exactly, take the closest real photo and note the gap rather than mislabel it. +4. **Write alt text from what the photo shows**, not from the article's image brief. For example, + use "Shopfronts and parked scooters on a Hanoi street" even if the article is about rain. +5. **Record it** under each article in `content-model.md`: + + ```markdown + Image: https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max + Image credit: — https://unsplash.com/photos/ (Unsplash License) + Image alt: + Image file: -.jpg + ``` + + If you can't get a real photo, write `Image gap: ` in place of these lines and tell the + user. Never substitute drawn or generated images without saying so. + +Unsplash photos are free to use under the Unsplash License. Still credit the photographer: the push +phase stores "Photo by on Unsplash" as the image caption. + +Then update `state.json`: set `stage` to the next prepare stage still to do, fresh `updated_at`. Do +**not** advance past the barrier here. + +--- + +## Push phase — create it the backend's way (needs the instance) + +Only once `provision.status == "completed"` (re-verified against `hosting_websites_list-setups`), +route on the brief's **Suggested backend** and follow that subfolder, which maps the helper onto the +live backend — for WordPress, through the per-site WP MCP's ability tools (the installation JWT +authenticates that MCP session; `wp/v2` REST is the public read surface, not a write path for the +token) to create the menu, pages, posts, categories and media: + +| Suggested backend | Follow | +|---|---| +| WordPress | `wordpress/SKILL.md` | +| WordPress + WooCommerce | `wordpress/SKILL.md` — pushes WP content **and** seeds the WooCommerce catalog (its WooCommerce section + `wordpress/woocommerce.md`) | +| Ecommerce | `ecommerce/SKILL.md` | +| Webapp | — (no push; skip to frontend) | + +> **Supported backends: `wordpress` (optionally with a WooCommerce store), `ecommerce`, and `webapp`** — +> where `webapp` is "supported" by **skipping this whole stage** (it has no content backend; see the note +> below). A **WordPress + WooCommerce** brief routes to `wordpress/SKILL.md`, which pushes the WP content +> **and** seeds the WooCommerce catalog in the same push. PocketBase is not available in this version. +> Push to whichever single backend provision set up (see the provision router) — don't try to push to two +> backends here. +> +> **Webapp has no content backend.** There is no CMS or store to seed, so a `webapp` brief **skips this +> whole stage** — both prepare and push. The app owns its own data (its own API or database). Leave +> `content-model.md` unwritten, mark content-model `skipped` in the run log, and go straight to the +> frontend step. + +After the push completes, update `state.json` and continue to the frontend **data** phase. diff --git a/skills/hostinger-headless/steps/04-content-model/ecommerce/SKILL.md b/skills/hostinger-headless/steps/04-content-model/ecommerce/SKILL.md new file mode 100644 index 0000000..efe93e4 --- /dev/null +++ b/skills/hostinger-headless/steps/04-content-model/ecommerce/SKILL.md @@ -0,0 +1,75 @@ +--- +name: headless-step-content-model-ecommerce +description: "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." +--- + +# headless-step-content-model-ecommerce + +**Push phase — needs the store.** Run after provision created the store + custom sales channel (ids in +`site.json → ecommerce`). All writes go through the **`hostinger-ecommerce`** MCP (`ecommerce_*`, +OAuth). Prices are **integers in minor units** (cents) and live on **variants**. + +**In:** `content-model.md` (prepared catalog), `site.json` (`ecommerce.store_id`). **Out:** products + +images created in the store; ids recorded back into `content-model.md`. + +> If the `hostinger-ecommerce` MCP tools aren't available, don't stop — see the prerequisite in +> `../../03-provision/ecommerce/SKILL.md` (add the server, and if you can't reconnect your session, +> drive the stdio server directly rather than blocking). + +## Step 1 — Create products (idempotent) + +List first so a re-run doesn't duplicate: `ecommerce_products_list { store_id }` — match by title/slug; +reuse existing ids. For each product in the helper: + +```json +{ "operation": "ecommerce_products_create-physical", + "params": { "store_id": "store_…", "name": "…", "price": 3999, "description": "…" } } +``` + +- `price` is minor units (3999 = $39.99); currency defaults to the store's. Each create makes one + **variant** (its `id` is what checkout needs — read it from the product detail/list). +- Digital goods: `ecommerce_products_create-digital` (takes a `download_url`). +- Multiple variants (sizes/flavours): `ecommerce_product-variants_create { store_id, product_id, + options:[{name,value}], prices:[{amount}] }`. +- Batch several creates with `multi-execute` to keep it fast. + +## Step 2 — Product images (one call each) + +`ecommerce_products_upload-and-attach-image { store_id, product_id, image_url, is_thumbnail: true }` — +fetches the image from a public URL and attaches it (raster only, no SVG, ≤15MB, HTTPS, ≤1 redirect). +Unsplash direct URLs (`https://images.unsplash.com/photo-…?w=1200&q=80&fm=jpg&fit=max`) work directly. +Source images as in the parent `../SKILL.md` (Unsplash). The store stores its own CDN copy +(`cdn.zyrosite.com`), so the storefront never depends on the source URL. + +> Idempotency: check the product's existing `images`/`thumbnail` before attaching so a re-run doesn't pile +> on duplicates. + +## Step 3 — Collections + +The API does not expose a collection-create op in this flow, and `product_collections` comes back empty. +Represent collections (e.g. Proteins / Supplements) as a **frontend mapping** in the storefront +(`lib/site.ts` → product-slug → collection), and record that mapping in `content-model.md`. If a real +collection API becomes available, prefer it. + +## Step 4 — Verify (public Storefront V2, no auth) + +Keyed on the **sales channel** (`site.json → ecommerce.sales_channel.id`): + +- `GET https://api-ecommerce.hostinger.com/v2/channels//products?limit=100` → all products, each + with `variants[].prices[]` (amount in minor units + currency `decimal_digits`/`template`), `thumbnail` + and `images[]`. Count matches the plan. +- Detail resolves by **product id** (`…/products/`); a **slug 404s** on detail — map slug→id from the + list in the frontend. + +## Step 5 — Hand off + +Record the id map in `content-model.md` (`## Created in the store`: product → slug → id → variant id → +price → image). Update `state.json` (advance `stage` toward frontend; leave `provision`). The storefront +(Step 6) reads the public Storefront V2 API for the channel; checkout is +`POST /channels//checkout` (needs a payment method — see the provision payment gate). + +> **No payment method = no checkout to seed or verify.** When `site.json → ecommerce.payment_enabled` +> is `false` (free account / no paid Ecommerce plan — see the provision gate), the catalog push here is +> **complete on its own**. There is nothing to seed for checkout, and you must **not** test the checkout +> endpoint or wait on it — seeding products and verifying them over the Storefront V2 API is the whole +> job. Hand off normally. diff --git a/skills/hostinger-headless/steps/04-content-model/wordpress/SKILL.md b/skills/hostinger-headless/steps/04-content-model/wordpress/SKILL.md new file mode 100644 index 0000000..5ca1dc7 --- /dev/null +++ b/skills/hostinger-headless/steps/04-content-model/wordpress/SKILL.md @@ -0,0 +1,276 @@ +--- +name: headless-step-content-model-wordpress +description: "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." +--- + +# headless-step-content-model-wordpress + +**Push phase — needs the live instance.** Run only after `provision.status == "completed"` (re-verify +against `hosting_websites_list-setups`). It maps the backend-agnostic `content-model.md` onto real +WordPress objects. + +**In:** `content-model.md`, `site.json` (both prepared earlier), generated media under +`~/.hostinger//media/`. **Out:** content created in WordPress; created IDs recorded back into +`content-model.md`. + +## What creates what — and what authenticates it + +The Hostinger **management** MCP (`wordpress_*`) is infra-only: it installs WordPress, plugins and +themes but has **no content endpoints**. + +Writes go through the **per-site WP MCP** (the Hostinger AI Assistant plugin), authenticated with the +installation JWT. **The JWT authenticates the MCP endpoint only** — it is wired as the MCP server's +`authenticate_request`, and the plugin registers no global `determine_current_user` / +`rest_authentication_errors` filter. So a raw `POST /wp/v2/posts` with this token is **not** +authenticated; the admin user context (`wp_set_current_user`) exists only *inside* an MCP session. + +| Surface | Base (from `site.json`) | Role | +|---|---|---| +| **Per-site WP MCP** | `urls.wp_mcp` (`mcp_url`) | **all writes** — create content through its ability tools | +| **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 | + +> **The WordPress content model is posts, pages and categories** (plus media). For **SEO**, **Yoast** +> is already installed + activated at provision (every headless WordPress site — see provision Step 4), +> so meta + schema come from a maintained plugin and the frontend reads each item's `yoast_head_json`. +> This push only sets per-item Yoast meta (Step 4.7 below). + +> **A store on this site? See [`woocommerce.md`](woocommerce.md).** When the brief's **Suggested +> backend** is `**WordPress + WooCommerce**` (`site.json → woocommerce.enabled: true`), this push also +> seeds the WooCommerce catalog — products, categories and coupons — through the **same per-site WP MCP +> session**, which exposes native `wc-products-*`, `wc-product-categories-*` and `wc-coupons-*` abilities +> (no custom plugin, no consumer key). Do the WordPress content below as normal, then run the **WooCommerce +> catalog** section before you hand off. + +> **Needs more than posts and pages? See [`custom-plugins.md`](custom-plugins.md).** The per-site MCP can +> create content *data* but can't register a **custom post type**, a **custom taxonomy**, or a **global +> settings page** (footer text, phone, social links the frontend reads). When the content model calls for +> those, author a small custom plugin and deploy it into the headless `/cms` with +> `hosting_deploy { type: "wordpress-plugin", headless: true }`, exposing everything over REST for the +> frontend. **Deploy the plugin first** (the schema — the deploy activates it), then the steps below populate +> its entries through the MCP's native `cpt-create` ability (custom-taxonomy terms are seeded in the plugin — see the cookbook). + +> **A contact form on this site? See [`contact-form.md`](contact-form.md).** When the brief's +> **Must-haves** include a contact form (`site.json → contact_form.enabled: true`), provision installed +> **Contact Form 7**; this push resolves the form's numeric id + field names (in the **same session**) +> and records them into `site.json` so the Step 6 frontend can submit to CF7's public feedback endpoint. +> Run the **Contact form** section after the WordPress content below. + +## Step 1 — Open the authenticated MCP session + +Mint a short-lived token through the Hostinger connector: + +```json +{ "operation": "wordpress_installations_jwt-token", + "params": { "username": "", "software": "" } } +``` + +Returns `token` (+ `mcp_url`, already stored as `urls.wp_mcp`). Connect to `urls.wp_mcp` as an MCP +client (`initialize` → `tools/list` → `tools/call`) and send the token on **every** request as: + +- `X-Hostinger-JWT-Token: ` — the plugin's preferred header (checked first), **or** +- `Authorization: Bearer ` — accepted as a fallback (case-insensitive `bearer ` prefix). + +The token lives ~24 h — mint on demand, **never print, log, or write it to `site.json` / +`content-model.md`**. If a call returns `401/403` with `token_invalid`/`invalid_api_key`, the token +expired or was revoked → re-mint and reconnect. + +> **Transport gotchas (streamable HTTP MCP).** The endpoint is session-based: `initialize` returns an +> **`Mcp-Session-Id` response header** — capture it and send it on **every** subsequent request +> (`tools/list`, `tools/call`), or they fail with `-32600 Missing Mcp-Session-Id header`. Send +> `Accept: application/json, text/event-stream`. After `initialize`, post the `notifications/initialized` +> notification, and **tolerate a non-2xx on it** (it can answer 404 with no body — harmless; don't let +> it abort the session). A raw curl/HTTP client works fine for all this. + +## Step 2 — Discover the ability tools + +The server advertises either every ability as its own tool, or three **discovery meta-tools** +(discover → get-info → execute) to keep `tools/list` small. Call `tools/list` first and work with +what's there; with discovery, list abilities, then execute by ability name. The content tools that +cover this step (exact names come from discovery — don't hardcode them): + +`SettingsTools` · `CategoriesTools` · `TagsTools` · `MediaUploadTools` / `MediaTools` · `PagesTools` · +`PostsTools` · `MenusTools` / `NavigationTools` · `SeoTools`, plus **`cpt-*`** for custom post types. There +is **no** generic `wp/v2` passthrough tool — every write goes through a dedicated ability, so a `wp/v2` +route with no matching ability (e.g. the terms of a *custom* taxonomy) isn't reachable from the MCP; +register and seed those in a custom plugin instead (see the backend's custom-plugins cookbook). + +Seen on a live install (Hostinger AI Assistant, discovery mode): the meta-tools are +`hostinger-ai-discover-abilities`, `hostinger-ai-get-ability-info { name }` and +`hostinger-ai-execute-ability { name, parameters }`. The abilities are named +`hostinger-ai/-`, e.g. `posts-create`, `posts-update`, `media-upload-from-url`, +`media-delete`. Call `get-ability-info` for each schema before using it. Results come back as +`{ ability, result: { data, … } }` for lists and `{ ability, result: {…} }` for single objects — so a +create answers **`200`** (not `201`) and the new object's `id` is **inside** `result` (`result.id`, or +`result.data.id`), never at the top level. A missing top-level `id` is not a failure; read it from +`result`. + +## Step 3 — Idempotency rule (makes resume safe) + +Every create is **list/get-by-slug first, then create only if absent** — a re-run must not duplicate. +Use the matching read ability (`posts-search`, `pages-search`, `categories-list`, `tags-list`, +`cpt-search`, `media-search`, …): a hit → reuse its `id`; empty → create. Capture each returned `id` and record the id map back into +`content-model.md` so later references resolve without re-querying. + +## Step 4 — Create in dependency order + +> **Custom types/settings? Deploy the plugin before this list.** If the content model needs a custom post +> type, taxonomy or global settings page, deploy that plugin **first** (the deploy activates it — see +> [`custom-plugins.md`](custom-plugins.md)) so its REST routes exist — then the steps below populate its +> entries through the MCP's native `cpt-create` ability, as they do core posts and pages (custom-taxonomy +> terms are seeded in the plugin — the MCP has no taxonomy-term path; see the cookbook). + +0. **Remove WordPress's default content first.** A fresh install ships a **"Hello world!" post** + (id 1), a **"Sample Page"**, and a default comment — they will otherwise show up on the live site. + Delete the default post (`posts-delete { id: 1, force: true }`) and, if present, the sample page + (find it via `pages-search` for slug `sample-page`, then `pages-delete`). This is idempotent: if + they're already gone, skip. Leave the default `Uncategorized` category — it's harmless once no post + uses it (the frontend only lists categories with posts). +1. **Site Title & Description** (the helper's global part) → the Settings ability. +2. **Categories / tags** — one per term in the helper → Categories / Tags abilities. Keep `name → id`. +3. **Media.** Upload every image in the helper, set `alt_text`, and keep `filename → id`. See + *Media upload* below for all sources: a Hostinger-AI image URL, an Unsplash URL, or a generated + file in `media/`. For a Hostinger-AI image, generate it now (its URL is temporary — see the + content-model `SKILL.md`, *Images* Option A) and upload straight away, before `expires_at`. +4. **Pages** — each static/index page → Pages ability. Pass `title`, `content` and `excerpt` as + **`{ "raw": "…" }` objects** (see the note below), plus `slug`, `status: publish`. Keep `slug → id`. +5. **Posts (articles)** — each → Posts ability: `title`, `content` and `excerpt` as **`{ "raw": "…" }`** + (note below), plus `slug`, `status: publish`, `categories: [id]`, `tags: [id…]`, + `featured_media: `, `date`. (The plugin block-converts post content on insert via + `rest_pre_insert_post`, so HTML bodies in `content.raw` are fine.) + + > **Create payload gotcha (`pages-create` / `posts-create`).** These abilities take `title`, + > `content` and `excerpt` as WordPress's core **`{ "raw": "…" }` objects**, not bare strings. Pass a + > bare string and the field is silently dropped — the create still answers `200`, so you get a + > *published-but-empty* page or post with nothing to key off. Always send `{ "raw": "…" }` and + > read the new `id` from `result` (see Step 2). Confirm the exact field shape with + > `get-ability-info` if a schema differs. +6. **Navigation** — Menus / Navigation abilities: create the menu, then an item per nav entry + referencing a page `id` (or a custom URL for listing routes). **Headless note:** the Step 6 + frontend renders nav from `content-model.md`, so the WP menu is for owners editing in wp-admin, not + what the live site reads — create it, but it isn't the source of truth. +7. **SEO** — **Yoast SEO** (`wordpress-seo`) is already installed + activated at provision (Step 4 of + the WordPress provision recipe), so it generates meta + schema and the frontend reads + `yoast_head_json` per post/page. Here, just set each post's Yoast title/description if an ability + exposes it; otherwise the excerpt + title are a fine fallback. Don't fail the push over SEO fields. + If `site.json → seo.enabled` is `false` (the owner declined Yoast, or provision couldn't confirm the + install), skip this and let the frontend generate tags from content — don't try to install the + plugin from the content stage. + +### Media upload + +**From a URL (a Hostinger-AI image, Unsplash, or any direct file URL).** Use +`hostinger-ai/media-upload-from-url`. WordPress downloads the file and stores its own copy, so the +site never depends on the external URL staying up — which is exactly why a **Hostinger-AI** image +(whose `url` from `wordpress_ai-content_generation-result` is temporary) must be uploaded here, while +it is still valid; drop the Unsplash-only `caption`/`description` for AI images: + +```json +{ "name": "hostinger-ai/media-upload-from-url", + "parameters": { + "url": "https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max", + "filename": "-.jpg", + "title": "", + "alt_text": "", + "caption": "Photo by on Unsplash", + "description": "Source: https://unsplash.com/photos/ (Unsplash License)", + "post_id": } } +``` + +- `url` must return 200 with an image content type. A photo *page* URL fails. +- The result carries the attachment `id` and `source_url`. WordPress also generates the smaller + sizes (`300x200`, `768x…`, `1024x…`, …) under `media_details.sizes`, which the frontend can use + for `srcset`. +- Set `filename` yourself, so files in `wp-content/uploads/` have readable names. + +**From a local file in `media/`.** Use the Media-upload ability that discovery lists for local +files. + +**Set the featured image.** Pass `featured_media: ` when creating the post (Step 4.5). If the +post already exists (on resume, or when images are added later), use +`hostinger-ai/posts-update { id, featured_media }`. + +**Idempotency trap: don't look up earlier uploads by filename.** `media-search` matches titles and +content, not filenames or the slug you passed. A search for `-lisbon-tram` finds nothing even +though the file exists, so a re-run would upload the image again. WordPress then stores the duplicate +as `…-1.jpg` and silently repoints the post at it. Check this way instead: + +1. `posts-get { id }`. If `featured_media` is already set, skip that post. Its image exists. +2. Otherwise, search by the **title** you set on upload (`media-search { search: "" }`), + and reuse a hit. +3. Only upload when both checks come up empty. + +If duplicates do slip in, point each post back at the original with `posts-update`, then delete the +duplicates with `media-delete { id, force: true }`. + +## Step 5 — Verify and record + +- Read a couple of created objects back through `wp/v2` (public GET, no auth): e.g. + `GET {urls.rest_api}/posts?per_page=1` and check the `X-WP-Total` header against the planned article + count; fetch one post with `_embed` to confirm `featured_media` and `categories` resolved. +- Media: `GET {urls.rest_api}/media?per_page=1` → `X-WP-Total` equals the number of images planned + (more means duplicates). `GET {urls.rest_api}/media?include=<ids>` confirms `alt_text` and + `caption` on each image. Each `source_url` must return 200 with an image type. Each post's + `featured_media` must point at its image. +- Write the created-id map into `content-model.md` (a `## Created in WordPress` section: object → slug + → id) so the frontend data phase and any re-run resolve references directly. Include one row per + media item: filename, credit and source page, the post it's featured on, and its id. + +## WooCommerce catalog *(only for a `WordPress + WooCommerce` brief — skip otherwise)* + +When `site.json → woocommerce.enabled` is `true`, also seed the store **in this same authenticated MCP +session**, using WooCommerce abilities the Assistant exposes alongside the content ones. Full recipe (incl. +the storefront Store API and the checkout gate): [`woocommerce.md`](woocommerce.md). In short: + +1. **Product categories** — `wc-product-categories-list` (reuse by slug/name) else + `wc-product-categories-create { name, slug }`. Keep `name → id`. +2. **Products** — **idempotent by SKU**: `wc-products-search { sku }` → reuse, else + `wc-products-create { name, type:"simple", sku, regular_price:"<decimal>", description, status:"publish", + categories:[{id}] }`. For images, `media-upload-from-url` then `wc-products-update { id, images:[{id}] }`. +3. **Coupons** — **idempotent by code**: `wc-coupons-search { code }` → reuse, else + `wc-coupons-create { code, discount_type, amount }`. +4. **Verify over the public Store API** (no auth): `GET {urls.store_api}/products?per_page=100` count + + `prices` (minor units), `GET {urls.store_api}/products/categories`. Record the SKU→id map, category ids + and coupon codes into `content-model.md` under `## Created in WooCommerce`. + +> **Don't seed or test checkout here.** A fresh WooCommerce install has no payment gateway/shipping and +> that config isn't reachable headlessly (`site.json → woocommerce.payment_enabled` is `false` — the owner +> finishes it in wp-admin). Seeding + verifying the catalog over the Store API is the whole job; never call +> `/checkout` or block on it. See the cookbook §4. + +## Contact form (Contact Form 7) *(only when `site.json → contact_form.enabled` is true — skip otherwise)* + +Provision installed Contact Form 7. Here, resolve the form the frontend submits to, deploy a tiny +spam-guard plugin (a headless honeypot), and record both into `site.json`. Full recipe: +[`contact-form.md`](contact-form.md). In short: + +1. **Find the form id (idempotent).** The per-site MCP has **no generic REST ability** and + `wpcf7_contact_form` is not a public CPT, so read the id by **probing the public feedback endpoint**: + a **`multipart/form-data`** `POST {urls.cms}/wp-json/contact-form-7/v1/contact-forms/<id>/feedback` with + `_wpcf7_unit_tag=wpcf7-f<id>-o1`, for `id` in `1`‒`30`. The hit is the id whose `200` JSON reply carries + `"contact_form_id": <id>` (plus a CF7 `"status"`) — the default **"Contact form 1"** (fields `your-name`, + `your-email`, `your-subject`, `your-message`); `404`/`wpcf7_not_found` is a miss, and anything else + (`415`, `403`, `429`, `5xx`) is inconclusive, never a hit. **The content type matters:** CF7 answers + `415` to a non-multipart body *before* it looks up the id, so a bare POST would wrongly pick id 1. Reuse + it on re-run. CF7's default mail already sends to `[_site_admin_email]`, so no recipient change is needed. + *(Fallback if nothing answers: hand the owner the one-click wp-admin login — `wordpress_login_create-links` + — to read the id from Contact → Contact Forms, the WooCommerce-checkout-gate owner-handoff pattern.)* See + the cookbook for the full method. +2. **Deploy the spam guard (honeypot).** The `contact-form-7-honeypot` add-on can't work headlessly (it + needs a `[honeypot]` form-tag only an owner can add), so deploy a ~15-line `wpcf7_spam` plugin to `/cms` + with `hosting_deploy { type: "wordpress-plugin", slug: "cf7-headless-spamguard", headless: true }` — it + flags a submission as `spam` (no mail) when a fixed decoy field (`your_website`) is filled. Cookbook §2b. +3. **Record into `site.json`** — fill the `contact_form` block provision started: `form_id`, + `feedback_url` (`{urls.cms}/wp-json/contact-form-7/v1/contact-forms/<form_id>/feedback`), `fields` + (the form's input names, e.g. `["your-name","your-email","your-subject","your-message"]`) and + `honeypot_field` (`"your_website"`, the decoy the frontend renders empty). Note the form id in + `content-model.md` under `## Created in WordPress`. + +> **Don't send a real test here.** Resolving the form id + fields is the whole job for this stage; the +> throwaway `mail_sent` test belongs to **validate** (Step 7), so one dummy email reaches the owner, not +> several. See the cookbook for the feedback contract and the hidden `_wpcf7*` fields the frontend needs. + +## Step 6 — Hand off + +Update `state.json` (`provision` stays `completed`; advance `stage` toward the frontend **data** +phase) with a fresh `updated_at`. The backend now holds the content; Step 6's data wiring points the +frontend at `urls.rest_api` (and, for a store, `urls.store_api`). diff --git a/skills/hostinger-headless/steps/04-content-model/wordpress/contact-form.md b/skills/hostinger-headless/steps/04-content-model/wordpress/contact-form.md new file mode 100644 index 0000000..9f2524d --- /dev/null +++ b/skills/hostinger-headless/steps/04-content-model/wordpress/contact-form.md @@ -0,0 +1,227 @@ +# headless — Contact Form 7 cookbook (contact form on headless WordPress) + +A companion recipe to `SKILL.md` (WordPress content model). Use it whenever the brief's **Must-haves** +include a contact form (`site.json → contact_form.enabled: true`). The goal: a contact page whose form, +submitted from the **decoupled frontend**, sends the enquiry to the site owner by email using +WordPress's built-in `wp_mail()` — no third-party form service, no API keys, no secret on the client. + +**Contact Form 7 is not a separate backend.** It is the `contact-form-7` plugin on the same headless +WordPress install at `/cms` (installed in the provision step). This cookbook is the authoritative home for +the contact-form data layer: how to **resolve the form** (content-model push), **deploy a spam guard**, +what the **frontend submits** (Step 6), and how **validate** proves mail actually leaves. + +--- + +## 1 — Why CF7, and why it works headlessly + +You **could** deploy a custom plugin into the headless `/cms` and write a bespoke mailer (see +[`custom-plugins.md`](custom-plugins.md)), but a contact form is exactly the kind of maintained, +spam-hardened functionality you should **reuse rather than reinvent**. CF7 is the right choice because: + +- It is a **wordpress.org-slug plugin** → installs through the same management MCP (`wordpress_plugins_install`) + as Yoast and WooCommerce. No custom code to deploy. +- It ships a **public, unauthenticated submit endpoint**: + `POST {urls.cms}/wp-json/contact-form-7/v1/contact-forms/<form_id>/feedback`. Submissions don't need + the JWT or any key. +- The frontend (apex domain) and `/cms` are **same-origin**, so the browser posts straight to that + endpoint — **no proxy route, no CORS, no credentials client-side.** +- On submit CF7 runs its mail template through `wp_mail()`, which on Hostinger routes through the + server's mailer. The default form's `To` is `[_site_admin_email]` — the admin address set at install — + so **basic email delivery works out of the box.** + +**Spam protection comes from a tiny deployed plugin, not the Honeypot add-on.** CF7's own +`contact-form-7-honeypot` add-on only acts when a `[honeypot]` form-tag is present in the form — a wp-admin +edit the headless agent can't make — so on the default form it does **nothing**. Instead, deploy a +~15-line `wpcf7_spam` guard into `/cms` (the capability from [`custom-plugins.md`](custom-plugins.md)): it +rejects a submission whose decoy field is filled, with no owner step and no keys. See *§2b — Spam +protection* below. (reCAPTCHA is the alternative but needs owner-provided Google keys set in wp-admin — +out of reach for the headless agent.) + +--- + +## 2 — Content-model push: resolve the form and record it + +A fresh CF7 install ships one form, **"Contact form 1"**, with the default fields `your-name`, +`your-email`, `your-subject`, `your-message` and a submit, and mails `[_site_admin_email]`. You need +its **numeric form id** (and, if you tailored the form, its field names) to build `feedback_url`. + +> **Reality check — how to read the id.** The per-site WP MCP (Hostinger AI Assistant, discovery mode) +> exposes **no generic REST/`RestEndpointTool` ability**, and `wpcf7_contact_form` is **not a public CPT** +> (it is absent from `cpt-list-types` and from `wp/v2/types`), so the MCP cannot read the form, and CF7's +> own `GET /contact-form-7/v1/contact-forms` is capability-gated (no public read). The reliable, +> tool-free way is to **probe the public feedback endpoint**, which needs no auth. + +1. **Find the form id by probing the feedback endpoint (idempotent).** For `id` in a small range + (`1`‒`30` covers a fresh install — the default form lands a few ids after the sample content), send a + **`multipart/form-data`** `POST {urls.cms}/wp-json/contact-form-7/v1/contact-forms/<id>/feedback` with + a single field `_wpcf7_unit_tag=wpcf7-f<id>-o1`. **The content type matters:** CF7 checks it *before* + it looks up the id and answers **415** to any non-multipart body, so a bare POST returns non-404 for + *every* id and would wrongly pick id 1. + - **Hit** → `200` whose JSON body carries `"contact_form_id": <id>` (matching the id you probed) and a + CF7 `"status"` (e.g. `validation_failed`). **This id is the form.** On a fresh single-form install + that is the only hit; reuse it on a re-run. + - **Miss** → `404` with `"code": "wpcf7_not_found"`. Keep going. + - **Inconclusive** → anything else (`415` from a wrong content type, `403`, `429`, `5xx`) is **not** a + hit — never select such an id. Fix the request or retry, widen the range, and fall back to the owner + handoff (step 2) if nothing resolves. + + The default field names are `your-name`, `your-email`, `your-subject`, `your-message`. (A full test + submission that confirms both the id and the field names — and that mail actually sends — belongs to + **validate**, so you send just one real email; see §4.) + +2. **(Optional) tailor the form — owner-side.** The headless tooling can't edit the CF7 form (no REST + ability reaches it). If the design needs different fields, keep the default form for the build and note + it; the owner edits fields/recipient in wp-admin (Contact → Contact Forms) via the one-click login. + The default form is enough for a name/email/subject/message enquiry, and its recipient + (`[_site_admin_email]`) is already correct. + +3. **Record into `site.json`** — fill the `contact_form` block provision started: + + ```json + { + "contact_form": { + "enabled": true, + "plugin": "contact-form-7", + "form_id": 4, + "feedback_url": "https://<domain>/cms/wp-json/contact-form-7/v1/contact-forms/4/feedback", + "fields": ["your-name", "your-email", "your-subject", "your-message"], + "cf7_version": "6.2.2", + "honeypot_field": "your_website", + "recipient": "site-admin-email" + } + } + ``` + + `cf7_version` is the installed CF7 version (from `wordpress_plugins_list-installed`) — the frontend + sends it as `_wpcf7_version`. `honeypot_field` is the decoy the spam guard checks (§2b) — the frontend + renders it empty and the server rejects a filled one. Also note the form id in `content-model.md`. No + address, token or secret goes into `site.json`. + +> **Fallback if the probe finds nothing** (an unusual install, or the default form was removed): mint a +> one-click wp-admin link (`wordpress_login_create-links { username, software }`) and ask the owner to +> read the form id from **Contact → Contact Forms** (the shortcode shows `id="…"`). Record it into +> `site.json` and continue — the same owner-handoff used for the WooCommerce checkout gate, not a build +> failure. + +> **Aside — Hostinger's own shortcode.** The per-site MCP's `pages-create` ability suggests +> `[hostinger_contact_form]` for a contact form *inside a WordPress page*. That is server-rendered +> shortcode output, not useful to a decoupled React frontend that reads REST JSON — this flow uses CF7's +> JSON feedback endpoint instead. Mentioned only so the shortcode hint isn't mistaken for the headless path. + +--- + +## 2b — Spam protection: deploy the honeypot guard + +CF7's default form has **no spam protection**, and the `contact-form-7-honeypot` add-on can't help +headlessly (it needs a `[honeypot]` form-tag only an owner can add — see §1). Instead, deploy a tiny +plugin that enforces a honeypot **server-side**, on the same REST feedback path the frontend uses. This +reuses the custom-plugin deploy from [`custom-plugins.md`](custom-plugins.md) — the only moving part is one +~15-line file. + +**Why it works.** CF7's `wpcf7_spam` filter runs inside `submit()`, which the REST feedback endpoint +calls, so returning `true` sets the submission `status` to `spam` and **suppresses the mail**. The hook can +read any posted field via `WPCF7_Submission::get_posted_data()` — including a decoy field that isn't a CF7 +form-tag, **as long as its name doesn't start with `_`** (CF7 strips `_`-prefixed keys). So the frontend +sends a decoy field (empty for humans), and the guard flags the submission as spam when a bot fills it. + +1. **Write the guard** to `~/.hostinger/<domain>/plugins/cf7-headless-spamguard/cf7-headless-spamguard.php`: + + ```php + <?php + /** + * Plugin Name: CF7 Headless Spam Guard + * Description: Server-side honeypot for Contact Form 7 submissions from a headless frontend. + * Version: 1.0.0 + * Requires PHP: 7.4 + * License: GPL-2.0-or-later + */ + if ( ! defined( 'ABSPATH' ) ) { exit; } + + // Reject a submission whose decoy field is filled. The name is a fixed convention the frontend + // renders empty (site.json → contact_form.honeypot_field); it must NOT start with "_". + add_filter( 'wpcf7_spam', function ( $spam, $submission ) { + if ( $spam ) { return $spam; } // keep any earlier spam verdict + $decoy = $submission->get_posted_data( 'your_website' ); + if ( is_string( $decoy ) && trim( $decoy ) !== '' ) { + $spam = true; // bot filled it → status 'spam', no mail + } + return $spam; + }, 10, 2 ); + ``` + +2. **Deploy it** into the headless `/cms` (activates on install; idempotent — re-deploy to update): + + ```json + { "operation": "hosting_deploy", + "params": { + "domain": "<domain>", + "type": "wordpress-plugin", + "path": "~/.hostinger/<domain>/plugins/cf7-headless-spamguard", + "slug": "cf7-headless-spamguard", + "headless": true } } + ``` + +3. **Record** `contact_form.honeypot_field: "your_website"` in `site.json` (done in §2 step 3) so the + frontend knows which decoy to render. Keep the plugin **generic** (no site-specific values) — it's one + reusable artifact, not per-site code. + +> **Nonce note.** CF7's built-in spam check also flags nonce failure, but nonce verification is **inactive +> for logged-out visitors by default** — which is why a plain headless submission returns `mail_sent`, not +> `spam`. The guard adds the honeypot on top. Confirm end-to-end in validate (§4). + +--- + +## 3 — What the frontend submits (Step 6 reference) + +The feedback endpoint expects `multipart/form-data` with the form's own fields **plus** CF7's hidden +control fields. The frontend builds this in a `submitContactForm(data)` helper in the one data module: + +| Field | Value | +|---|---| +| each name in `contact_form.fields` | the user's input (`your-name`, `your-email`, …) | +| `_wpcf7` | `contact_form.form_id` (number) | +| `_wpcf7_version` | CF7 version string, e.g. `"5.9.8"` (not strictly validated) | +| `_wpcf7_locale` | e.g. `"en_US"` | +| `_wpcf7_unit_tag` | any stable string, e.g. `"wpcf7-f<form_id>-o1"` | +| `_wpcf7_container_post` | `"0"` | +| `contact_form.honeypot_field` (the decoy, e.g. `your_website`) | **empty** — rendered hidden/off-screen, `autocomplete="off"`, `tabindex="-1"`; the §2b guard rejects it as spam if filled | + +`POST` to `contact_form.feedback_url` and **do not set `Content-Type`** — let the browser add the +multipart boundary. Same-origin, so no CORS and no secret. + +**Response** is JSON: + +```json +{ "contact_form_id": 42, "status": "mail_sent", "message": "Thank you for your message. It has been sent.", + "invalid_fields": [], "posted_data_hash": "…" } +``` + +Branch on `status`: + +- `mail_sent` → success state (clear the form, show the thank-you message). +- `validation_failed` → map each `invalid_fields[].field` to its input and show the message inline. +- `spam` / `aborted` / `mail_failed` / `acceptance_missing` → a generic "couldn't send, try again" error. + +A user validation error is a **normal UI state, not a build error**. When `contact_form.enabled` is +`false` or absent, the frontend renders the contact page with plain contact details and **no form**. + +--- + +## 4 — Email delivery & the `wp_mail` caveat (what to tell the owner) + +CF7's default form mails `[_site_admin_email]` via `wp_mail()`, so **Hostinger does "set up" basic email +by default** — nothing else to configure for the form to send. Two caveats worth surfacing: + +- **Deliverability.** `wp_mail` from a shared host can land in spam for external recipients without + SPF/DKIM. The most reliable recipient is a **Hostinger mailbox on the same domain**. Keep CF7's admin + recipient; if the owner wants a different inbox, point them to a same-domain mailbox. +- **Prove it, don't assume it.** Validate (Step 7) POSTs a throwaway test submission and asserts + `status == "mail_sent"`. A `mail_failed` is a real finding — report it (CF7 is wired, but the mailer + needs attention), don't pass silently. +- **Check the honeypot too.** Send one more throwaway submission with the decoy + (`contact_form.honeypot_field`) **filled** — it must come back `status: "spam"` with **no** mail. If it + returns `mail_sent`, the §2b guard plugin isn't active (re-check its deploy). The normal empty-decoy + submission still returns `mail_sent`, so this costs one extra throwaway email at most. + +Don't install an SMTP plugin or ask for mail credentials as part of this flow — the default `wp_mail` +path is the intended behaviour; SMTP is an owner decision in wp-admin if deliverability needs hardening. diff --git a/skills/hostinger-headless/steps/04-content-model/wordpress/custom-plugins.md b/skills/hostinger-headless/steps/04-content-model/wordpress/custom-plugins.md new file mode 100644 index 0000000..176a636 --- /dev/null +++ b/skills/hostinger-headless/steps/04-content-model/wordpress/custom-plugins.md @@ -0,0 +1,481 @@ +# headless — WordPress custom plugins cookbook (structure the backend in code) + +A companion recipe to `SKILL.md` (WordPress content model). Use it when the content model needs **more +than posts, pages, categories and media** — when the brief calls for **structured content types**, +**global site settings the frontend reads**, or small **site-specific helpers**. Those cannot be created +through the per-site WP MCP (it has no endpoint to register a post type, a taxonomy, or an options page); +they need PHP, which means a **small custom plugin** deployed into the headless `/cms`. + +**When to reach for this (and when not to).** The per-site WP MCP already covers content *data* through +dedicated abilities — `posts-*`, `pages-*`, `categories-*`, `tags-*`, `media-*`, `menus-*`, and `cpt-*` for +custom post types — but there is **no generic `wp/v2` passthrough** (no `RestEndpointTool`). A plugin is for +*structure and behaviour* that data sits in, and for anything those abilities can't reach: + +| Need | Plugin? | Do it with | +|---|---|---| +| Blog posts, static pages, categories, media, nav | **no** | per-site WP MCP (`SKILL.md` Step 4) | +| A **custom post type** — Team, Services, Properties, Testimonials, a restaurant's dishes | **yes** | *Custom post type* below | +| A **custom taxonomy** — Property Type, Cuisine, Service Area | **yes** | *Custom taxonomy* below | +| **Global site content** the frontend needs — footer text, phone, address, opening hours, social links, a CTA line | **yes** | *Settings page + public settings route* below | +| Extra **fields on a post/CPT** exposed in REST (price, subtitle, map coords) | **yes** | *Custom fields in REST* below | +| A small **helper** — a shortcode, a computed REST value, a cron tidy-up | **yes** | *Helpers* below | +| Payment, membership, page-builder, SEO, caching, or anything a maintained plugin already does well | **no** | install the real plugin at provision — don't reinvent it | + +> **Plugins carry structure, not catalog or content data.** You deploy a plugin you've written into the +> headless `/cms` with `hosting_deploy { type: "wordpress-plugin", headless: true }` (see *Deploy* below). +> Author one only for the *structure and behaviour* the per-site MCP can't express — types, taxonomies, +> settings, helpers. Don't write a **seed/config plugin** to load WooCommerce catalog data or page content; +> that goes through the per-site MCP's native abilities (`woocommerce.md`, `SKILL.md` Step 4), never a plugin. + +--- + +## 1 — The one rule: everything the frontend needs must be in the public REST API + +The Step 6 frontend is a separate app that reads the backend over the **public `wp/v2` REST API** (open +CORS on a headless install, no auth for reads). So a custom post type the frontend never sees in REST is +useless to it. **Register everything headless-visible with REST on:** + +- **Post types** → `'show_in_rest' => true` and an explicit `'rest_base'` → the frontend reads + `GET {urls.rest_api}/<rest_base>`. +- **Taxonomies** → `'show_in_rest' => true` and an explicit `'rest_base'` → readable, and embeddable on + their objects with `?_embed`. +- **Meta fields** → `register_post_meta(..., [ 'show_in_rest' => true ])`, or `register_rest_field` for a + computed value → appears under the object's `meta` (or your field) in REST. +- **Global settings** → a **public read-only REST route** the frontend fetches once for footer/contact/etc. + +This is the whole reason the plugin exists in a headless build: it is a **schema + settings layer the +frontend consumes**, not an admin experience. Everything below keeps `show_in_rest` front and centre. + +> **Register on the right hooks.** Post types and taxonomies register on `init`; meta on `init`; REST routes +> on `rest_api_init`; settings/admin pages on `admin_init` / `admin_menu`. Registering a post type outside +> `init` silently misbehaves. + +--- + +## 2 — Plugin anatomy and WordPress standards + +Keep it a **single, small, self-contained plugin per site** — one folder, prefixed, headless-focused. Ship +it as a directory (not a zip); the deploy tool uploads the folder's files and installs from them. + +``` +<site>-headless/ slug = folder name = plugin slug, e.g. "acme-headless" +├── <site>-headless.php main file with the plugin header (same base name as the folder) +├── inc/ +│ ├── post-types.php +│ ├── taxonomies.php +│ ├── settings.php +│ └── rest.php +├── uninstall.php optional — clean up options on delete +└── readme.txt optional +``` + +**Main file** — header, the `ABSPATH` guard, a constant for the version, and the activation flush: + +```php +<?php +/** + * Plugin Name: Acme Headless + * Description: Content types, taxonomies and site settings for the Acme headless frontend. + * Version: 1.0.0 + * Requires at least: 6.4 + * Requires PHP: 7.4 + * Author: Acme + * License: GPL-2.0-or-later + * Text Domain: acme-headless + */ + +// No direct access — every PHP file in the plugin starts with this. +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +define( 'ACME_HEADLESS_VERSION', '1.0.0' ); + +require_once __DIR__ . '/inc/post-types.php'; +require_once __DIR__ . '/inc/taxonomies.php'; +require_once __DIR__ . '/inc/settings.php'; +require_once __DIR__ . '/inc/rest.php'; + +// Post types/taxonomies are registered on `init` by the files above. Rewrite rules for their +// permalinks must be flushed once, on activation only (never on every load — it is expensive). +register_activation_hook( __FILE__, function () { + acme_headless_register_post_types(); + acme_headless_register_taxonomies(); + flush_rewrite_rules(); +} ); +register_deactivation_hook( __FILE__, 'flush_rewrite_rules' ); +``` + +**Standards that matter (every snippet below follows them):** + +- **Prefix everything** — functions, options, the text domain, the REST namespace — with a per-site token + (`acme_headless_`, `acme-headless`). Two plugins must never collide. +- **Guard every file** with `if ( ! defined( 'ABSPATH' ) ) { exit; }`. +- **Sanitize on input, escape on output.** `sanitize_text_field`, `sanitize_textarea_field`, `esc_url_raw`, + `absint` on the way in; `esc_html`, `esc_attr`, `esc_url`, `wp_kses_post` on the way out. +- **Check capability + nonce** on any admin write (`current_user_can( 'manage_options' )`; the Settings API + handles the nonce for you). Set `permission_callback` on every REST route — `__return_true` only for + genuinely public reads, a capability check for anything that writes. +- **Never hardcode a secret** in the plugin, and never expose one over a public route. Site settings the + frontend reads are public content (footer text, phone) — API keys and the like are not; keep those out. +- **i18n:** wrap user-facing strings in `__( 'text', 'acme-headless' )`. Headless admin labels are low-stakes + but costs nothing to do right. + +> **Flushing rewrite rules headlessly.** The activation hook above runs when the plugin is activated, so CPT +> *permalinks* resolve. But the frontend reads **REST**, and REST routes work the moment the type is +> registered — they don't depend on a rewrite flush. So even if activation timing is odd, `GET /<rest_base>` +> is live as soon as the plugin is active. Don't block the build on permalink flushing. + +--- + +## 3 — Custom post type + +One `register_post_type` per type, on `init`, with REST on and an explicit `rest_base`: + +```php +// inc/post-types.php +if ( ! defined( 'ABSPATH' ) ) { exit; } + +function acme_headless_register_post_types() { + register_post_type( 'acme_service', array( + 'labels' => array( + 'name' => __( 'Services', 'acme-headless' ), + 'singular_name' => __( 'Service', 'acme-headless' ), + ), + 'public' => true, + 'has_archive' => true, + 'menu_icon' => 'dashicons-hammer', + 'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'custom-fields' ), + 'show_in_rest' => true, // REQUIRED for the headless frontend + 'rest_base' => 'services', // GET {rest_api}/services + 'rewrite' => array( 'slug' => 'services' ), + ) ); +} +add_action( 'init', 'acme_headless_register_post_types' ); +``` + +- Pick a **prefixed internal key** (`acme_service`, ≤ 20 chars) but a **clean `rest_base`** (`services`) — + the frontend route reads naturally and won't clash with core. +- `'supports' => [ 'thumbnail' ]` enables the featured image; `'custom-fields'` lets meta ride along. +- After deploy, the per-site WP MCP can **populate entries** into this type with its **native + `cpt-*` abilities** — `cpt-list-types` (confirm the type is registered, with its `rest_base`), + then `cpt-create { post_type: "acme_service", title, content, excerpt?, status: "publish" }` + (and `cpt-search` / `cpt-get` / `cpt-update` / `cpt-delete`). Deploy the plugin first (the schema), + then seed entries through the MCP. Note `cpt-create` takes **no taxonomy/term fields** — see §4 for how + custom-taxonomy terms are handled. + +## 4 — Custom taxonomy + +```php +// inc/taxonomies.php +if ( ! defined( 'ABSPATH' ) ) { exit; } + +function acme_headless_register_taxonomies() { + register_taxonomy( 'acme_service_area', array( 'acme_service' ), array( + 'labels' => array( + 'name' => __( 'Service Areas', 'acme-headless' ), + 'singular_name' => __( 'Service Area', 'acme-headless' ), + ), + 'public' => true, + 'hierarchical' => true, // true = categories, false = tags + 'show_in_rest' => true, // REQUIRED for the frontend + 'rest_base' => 'service-areas', + 'rewrite' => array( 'slug' => 'service-areas' ), + ) ); +} +add_action( 'init', 'acme_headless_register_taxonomies' ); +``` + +**Seeding terms is a PHP job, not an MCP one.** The per-site MCP has no custom-taxonomy ability +(`categories-*` / `tags-*` cover only the built-in `category` / `post_tag`, and there is no generic REST +passthrough), and `cpt-create` takes no term fields. So seed the taxonomy's starter terms **in the plugin**, +on `init`, **gated by a version option** — not the activation hook, which doesn't re-run when you redeploy +an already-active plugin, so later-added terms would never seed. Bump `ACME_HEADLESS_VERSION` and redeploy +to re-run the seed: + +```php +add_action( 'init', function () { + if ( get_option( 'acme_headless_seed_version' ) === ACME_HEADLESS_VERSION ) { + return; // already seeded for this version + } + foreach ( array( 'Downtown', 'Suburbs', 'Coast' ) as $area ) { + if ( ! term_exists( $area, 'acme_service_area' ) ) { + wp_insert_term( $area, 'acme_service_area' ); // idempotent via the term_exists guard + } + } + update_option( 'acme_headless_seed_version', ACME_HEADLESS_VERSION ); +}, 11 ); // after register_taxonomy (init, priority 10) +``` + +Terms then read over REST (`GET {rest_api}/service-areas`) and embed on their objects with +`GET {rest_api}/services?_embed`. **Pre-assigning a term to an entry is separate** — `cpt-create` can't set +terms. Either the owner assigns them in wp-admin, or the same versioned seed attaches them **by entry slug +once the entries exist** (`wp_set_object_terms( get_page_by_path( '<slug>', OBJECT, 'acme_service' )->ID, +'<term>', 'acme_service_area' )`) — seed the entries first, then bump `ACME_HEADLESS_VERSION` and redeploy +so the seed re-runs with the entries in place. + +## 5 — Settings page + a public settings route (footer, phone, generic text) + +The classic headless need: **one place the owner edits global content** (footer blurb, phone, email, +address, opening hours, social links, a CTA line) and **one REST route the frontend fetches** to render it +site-wide. Store it as a **single option holding an array**, edited with the Settings API, and exposed +read-only over REST. + +```php +// inc/settings.php +if ( ! defined( 'ABSPATH' ) ) { exit; } + +const ACME_HEADLESS_OPTION = 'acme_headless_settings'; + +// --- Admin page (wp-admin → Settings → Acme Headless): where the owner edits global content. --- +add_action( 'admin_menu', function () { + add_options_page( + __( 'Acme Headless', 'acme-headless' ), + __( 'Acme Headless', 'acme-headless' ), + 'manage_options', // capability check + 'acme-headless', + 'acme_headless_settings_page' + ); +} ); + +add_action( 'admin_init', function () { + // register_setting wires in the nonce + capability check and runs our sanitizer on save. + register_setting( 'acme_headless', ACME_HEADLESS_OPTION, array( + 'type' => 'object', + 'sanitize_callback' => 'acme_headless_sanitize_settings', + 'default' => array(), + ) ); +} ); + +// Sanitize on input — never trust the posted array. +function acme_headless_sanitize_settings( $input ) { + return array( + 'phone' => sanitize_text_field( $input['phone'] ?? '' ), + 'email' => sanitize_email( $input['email'] ?? '' ), + 'address' => sanitize_textarea_field( $input['address'] ?? '' ), + 'footer_text' => sanitize_textarea_field( $input['footer_text'] ?? '' ), + 'facebook' => esc_url_raw( $input['facebook'] ?? '' ), + 'instagram' => esc_url_raw( $input['instagram'] ?? '' ), + ); +} + +function acme_headless_settings_page() { + if ( ! current_user_can( 'manage_options' ) ) { return; } + $o = get_option( ACME_HEADLESS_OPTION, array() ); + ?> + <div class="wrap"> + <h1><?php esc_html_e( 'Acme Headless', 'acme-headless' ); ?></h1> + <form method="post" action="options.php"> + <?php settings_fields( 'acme_headless' ); // nonce + option group ?> + <table class="form-table" role="presentation"> + <tr> + <th><label for="ah_phone"><?php esc_html_e( 'Phone', 'acme-headless' ); ?></label></th> + <td><input name="<?php echo esc_attr( ACME_HEADLESS_OPTION ); ?>[phone]" id="ah_phone" + type="text" value="<?php echo esc_attr( $o['phone'] ?? '' ); ?>" class="regular-text"></td> + </tr> + <tr> + <th><label for="ah_footer"><?php esc_html_e( 'Footer text', 'acme-headless' ); ?></label></th> + <td><textarea name="<?php echo esc_attr( ACME_HEADLESS_OPTION ); ?>[footer_text]" id="ah_footer" + rows="3" class="large-text"><?php echo esc_textarea( $o['footer_text'] ?? '' ); ?></textarea></td> + </tr> + <!-- email, address, social … same pattern --> + </table> + <?php submit_button(); ?> + </form> + </div> + <?php +} +``` + +Now expose it to the frontend as a **public, read-only** route. This is global site chrome — public content +by definition — so the read is open. The values were **sanitized on save** (the Settings API sanitizer +above), so return them as **plain JSON** — the frontend escapes on render — and never add a secret to it. +Don't HTML-escape here: `esc_html()`/`esc_url()` in a JSON response double-encode `&`: + +```php +// inc/rest.php +if ( ! defined( 'ABSPATH' ) ) { exit; } + +add_action( 'rest_api_init', function () { + register_rest_route( 'acme/v1', '/settings', array( + 'methods' => WP_REST_Server::READABLE, // GET + 'permission_callback' => '__return_true', // public read — global chrome only + 'callback' => function () { + $o = get_option( ACME_HEADLESS_OPTION, array() ); + return array( + // Already sanitized on save (see the Settings API sanitizer above); return as plain + // JSON strings — the frontend escapes on render. Don't esc_html()/esc_url() here, or + // a JSON client double-encodes "&" (e.g. "Tea & Co" → "Tea & Co"). + 'phone' => $o['phone'] ?? '', + 'email' => sanitize_email( $o['email'] ?? '' ), + 'address' => $o['address'] ?? '', + 'footer_text' => wp_kses_post( $o['footer_text'] ?? '' ), + 'social' => array( + 'facebook' => esc_url_raw( $o['facebook'] ?? '' ), + 'instagram' => esc_url_raw( $o['instagram'] ?? '' ), + ), + ); + }, + ) ); +} ); +``` + +The frontend fetches `GET {urls.cms}/wp-json/acme/v1/settings` once (it's cacheable) and renders the +footer, header contact bar, etc. Record this route in `content-model.md` so Step 6 wires it in. + +> **Custom namespace base vs `urls.rest_api`.** `site.json → urls.rest_api` points at the `…/wp-json/wp/v2` +> base (core content). A custom namespace lives one level up, off `{urls.cms}/wp-json` — e.g. +> `{urls.cms}/wp-json/acme/v1/settings`. Record the full custom URL explicitly so the frontend doesn't try +> to hang it off the `wp/v2` base. + +## 6 — Custom fields in REST (meta on a post or CPT) + +For a simple stored field, `register_post_meta` with `show_in_rest` — it then reads/writes under the +object's `meta`: + +```php +add_action( 'init', function () { + register_post_meta( 'acme_service', 'price_from', array( + 'type' => 'string', + 'single' => true, + 'show_in_rest' => true, // appears in REST under meta.price_from + 'auth_callback' => function () { return current_user_can( 'edit_posts' ); }, + 'sanitize_callback' => 'sanitize_text_field', + ) ); +} ); +``` + +For a **computed/read-only** value (not stored, derived at response time), use `register_rest_field`: + +```php +add_action( 'rest_api_init', function () { + register_rest_field( 'acme_service', 'read_time', array( + 'get_callback' => function ( $post ) { + return max( 1, (int) ceil( str_word_count( wp_strip_all_tags( get_post( $post['id'] )->post_content ) ) / 200 ) ); + }, + 'schema' => array( 'type' => 'integer' ), + ) ); +} ); +``` + +## 7 — Helpers (shortcodes, computed values, small cron) + +Keep helpers tiny and single-purpose, in `inc/`. A headless frontend rarely renders WordPress HTML, so +prefer a **REST value over a shortcode**. If a cron job is genuinely needed, schedule it on activation and +clear it on deactivation (don't leave orphaned events): + +```php +register_activation_hook( __FILE__, function () { + if ( ! wp_next_scheduled( 'acme_headless_daily' ) ) { + wp_schedule_event( time(), 'daily', 'acme_headless_daily' ); + } +} ); +register_deactivation_hook( __FILE__, function () { + wp_clear_scheduled_hook( 'acme_headless_daily' ); +} ); +add_action( 'acme_headless_daily', 'acme_headless_run_daily' ); +``` + +> **The two `register_*_hook` lines must sit in the main `<site>-headless.php` file** (beside the plugin's +> existing activation hook), not in an `inc/` include: `__FILE__` has to resolve to the **main plugin file** +> or the hooks never fire. The schedule check and the callback (`add_action( 'acme_headless_daily', … )` and +> `acme_headless_run_daily()`) can live in `inc/`. + +Clean up on uninstall so deleting the plugin leaves no rows behind: + +```php +// uninstall.php +if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { exit; } +delete_option( 'acme_headless_settings' ); +``` + +--- + +## 8 — Deploy the plugin into the headless `/cms` + +Deploy the **plugin directory** with the connector's one-shot `hosting_deploy`, passing **`headless: true`** +so it lands in `cms/wp-content/plugins/` (not the document-root `wp-content/`): + +```json +{ "operation": "hosting_deploy", + "params": { + "domain": "<domain>", + "type": "wordpress-plugin", + "path": "~/.hostinger/<domain>/plugins/<site>-headless", + "slug": "<site>-headless", + "headless": true } } +``` + +- **`path`** is the plugin **folder** (it contains the main `*.php` with the plugin header). The tool scans + the directory and uploads every file; it skips `node_modules`, `.git`, OS junk and `.env*` on its own — + don't zip it yourself, don't pass a git repo (`headless` is path-only). +- **`slug`** becomes the install directory name (lower-case letters, digits, `-`, `_`). Keep it the same + across re-runs; the tool appends a random suffix to the *upload* dir, then installs from it. +- `type` is optional — the tool detects a WordPress plugin from the header — but pass it to be explicit. +- A plugin/theme deploy is **done when the call returns** (unlike a Node.js build). The result echoes + `{ type: "wordpress-plugin", status: "deployed", slug, headless: true }`. +- It **overwrites** a previous deploy of the same plugin — re-run freely to ship a fix. Confirm the domain + first, as with any deploy. + +**The deploy activates the plugin for you — then verify over public REST:** + +1. **Confirm it's active** (the headless plugin deploy activates it on install — you normally don't + call activate yourself). Check the per-site MCP's `plugin-list` ability for your slug, or just + rely on the REST check below. Only if it comes back inactive, activate it with + `wordpress_plugins_activate { username, software, plugin: "<slug>/<slug>.php" }` (or the bare + `"<slug>"` form) and re-check. +2. **Verify the REST surface is live** (public GET, no auth) — this is the real proof the plugin is + active and registered: + - CPT: `GET {urls.rest_api}/services` returns `200` (empty array before you seed entries — that's fine). + - Taxonomy: `GET {urls.rest_api}/service-areas` returns `200`. + - Settings: `GET {urls.cms}/wp-json/acme/v1/settings` returns your JSON shape. + - Namespace: `GET {urls.cms}/wp-json/` lists your `acme/v1` under `namespaces`. + A `404` on a route means the plugin isn't active (or the `rest_base`/namespace differs) — fix before moving on. + +> **Order matters.** Deploy the plugin **before** the content push seeds entries into the new types — the +> type must exist in REST for the MCP's `cpt-create` to accept entries. Default **Settings** values are +> seeded by the plugin itself (in the same versioned `init` seed, via `update_option`) or left for the owner +> to fill in wp-admin — record which in `content-model.md`. The MCP can't write the custom option +> (`wp-settings-update` only touches standard WordPress settings, and there's no generic REST passthrough). + +--- + +## 9 — Record and hand off + +Write what you built into `content-model.md` under a `## Custom plugin` section so Step 6 (frontend data) +and any re-run resolve it without re-discovery: + +- Plugin **slug** and version, and that it's deployed + active. +- Each **post type**: internal key → `rest_base` → the full read URL the frontend uses. +- Each **taxonomy**: key → `rest_base`, and which types it's attached to. +- Each **meta / REST field** exposed, and under what key it appears. +- The **settings route** full URL and its JSON shape (so the frontend knows the footer/contact fields). + +The frontend then reads these routes exactly like core `wp/v2` content — same host, same open CORS, no auth. + +--- + +## 10 — Checklist & further reading + +- [ ] One prefixed plugin folder; main file carries the plugin header; every PHP file guards `ABSPATH`. +- [ ] Post types & taxonomies: `show_in_rest: true` + explicit `rest_base`, registered on `init`. +- [ ] Settings stored in one option, Settings API for the admin page (capability + nonce), **sanitized in**. +- [ ] Public settings **read** route with `permission_callback`, values returned as **plain JSON** (sanitized on save, not HTML-escaped), no secrets. +- [ ] Rewrite flush on activation; cron (if any) cleared on deactivation; `uninstall.php` tidies options. +- [ ] Deployed with `hosting_deploy { type: "wordpress-plugin", headless: true }`, confirmed **active**, + every REST route returns `200`. +- [ ] Routes recorded in `content-model.md` for the frontend. + +**References** — this cookbook is self-contained, but for depth follow the maintained sources (don't invent +APIs; confirm a function against these): + +- WordPress **Plugin Handbook** — <https://developer.wordpress.org/plugins/> (headers, hooks, activation, + Settings API, security: nonces/capabilities/sanitizing/escaping). +- WordPress **REST API Handbook** — <https://developer.wordpress.org/rest-api/> (`show_in_rest`, + `register_rest_route`, `register_rest_field`, authentication). +- Post types & taxonomies — <https://developer.wordpress.org/reference/functions/register_post_type/> · + <https://developer.wordpress.org/reference/functions/register_taxonomy/>. +- **WordPress/agent-skills** (expert WordPress knowledge for AI assistants — blocks, themes, plugins, best + practices) — <https://github.com/WordPress/agent-skills>. If your coding agent exposes WordPress skills + (e.g. plugin-development, rest-api), prefer them for generating and reviewing the PHP; this cookbook is the + headless wiring around them. diff --git a/skills/hostinger-headless/steps/04-content-model/wordpress/woocommerce.md b/skills/hostinger-headless/steps/04-content-model/wordpress/woocommerce.md new file mode 100644 index 0000000..a08d15a --- /dev/null +++ b/skills/hostinger-headless/steps/04-content-model/wordpress/woocommerce.md @@ -0,0 +1,153 @@ +# headless — WooCommerce cookbook (headless store on WordPress) + +A companion recipe to `SKILL.md` (WordPress content model). Use it whenever the WordPress backend carries +a **WooCommerce store** (`site.json → woocommerce.enabled: true`) — a brief whose **Suggested backend** is +`**WordPress + WooCommerce**` (one site: content **and** commerce). + +**WooCommerce is not a separate backend.** It is the `woocommerce` plugin on a normal headless WordPress +install at `/cms` (installed in the provision step). This cookbook is the authoritative home for the store +data layer: how to **seed and CRUD** products, categories and coupons, and how the **storefront** reads +the cart, coupons and checkout. + +--- + +## 1 — The one rule: drive the store through the per-site WP MCP + +A **custom plugin cannot be installed into a headless `/cms`** from a coding agent (same limit that +removed `wp-development.md`). So there is **no seed plugin, no WP-CLI, and no REST consumer key in this +flow.** Instead, the **per-site WP MCP** (the Hostinger AI Assistant — the same JWT session the WordPress +content push uses, see `SKILL.md` Step 1) exposes **native WooCommerce abilities**. Seed and manage the +catalog through them, exactly as posts/pages are created. + +Relevant abilities (names from `hostinger-ai-discover-abilities`; call `get-ability-info` for each schema +before use — they mirror the WooCommerce REST v3 shapes): + +| Area | Abilities | +|---|---| +| Products | `wc-products-search` · `wc-products-get` · `wc-products-create` · `wc-products-update` · `wc-products-delete` | +| Categories / tags / brands | `wc-product-categories-{list,create,update,delete}` · `wc-product-tags-*` · `wc-product-brands-*` | +| Coupons | `wc-coupons-{search,get,create,update,delete,batch}` | +| Orders | `wc-orders-{search,get,create,update,delete}` | +| Reports | `wc-reports-{sales,orders-totals,products-totals,coupons-totals,…}` | +| Media | `media-upload-from-url` (product images — WordPress keeps its own copy) | + +All run as the install's admin **inside the MCP session** — no consumer keys, no Basic Auth. This is +proven: a `wc-products-create` call makes a product that immediately appears on the public Store API. + +--- + +## 2 — The storefront reads the public Store API + +The **frontend** never touches the per-site MCP or any key. It reads the **public WooCommerce Store API** +at `site.json → urls.store_api` (`/cms/wp-json/wc/store/v1`) — public, open CORS, no auth for reads. +Pretty permalinks are on by default on a headless install, so the Store API resolves immediately. + +| Store API call | Use | +|---|---| +| `GET /products?per_page=100` · `GET /products/<id>` · `?slug=<slug>` | catalog + product pages | +| `GET /products/categories` | category nav | +| `GET /cart` | current cart (seeds the `Cart-Token`) | +| `POST /cart/add-item` `{ id, quantity }` · `/cart/update-item` `{ key, quantity }` · `/cart/remove-item` `{ key }` | cart mutations | +| `POST /cart/apply-coupon` `{ code }` · `/cart/remove-coupon` `{ code }` | coupons on the cart | +| `POST /checkout` `{ billing_address, shipping_address, payment_method }` | place an order | + +**Store API auth for writes** (cart/checkout): send the **`Nonce`** header (read it from the `Nonce` / +`X-WC-Store-API-Nonce` response header of a prior Store API GET and echo it back) **and** the +**`Cart-Token`** header (a JWT the Store API returns on the first cart response — resend it on every +cart call so a headless client keeps one cart; persist it in `localStorage`). Reads need neither. + +**Pricing / minor units.** The Store API returns **integer-string** prices in minor units plus the +divisor: `prices.price` (e.g. `"1999"`) with `prices.currency_minor_unit` (e.g. `2`) and +`prices.currency_code`. Display value = `Number(price) / 10 ** currency_minor_unit`. **Pagination:** +`?page=&per_page=` (max 100), with `X-WP-Total` / `X-WP-TotalPages` headers. + +--- + +## 3 — Seeding the catalog (content-model push) + +Run after provision installed + activated WooCommerce (`site.json → woocommerce.enabled: true`), inside +the authenticated per-site WP MCP session (`SKILL.md` Step 1). Seed from the prepared catalog in +`content-model.md`. **Idempotent by SKU** (products) and **code** (coupons) — list/search first, create +only if absent, so a re-run never duplicates. + +1. **Product categories** — for each category: `wc-product-categories-list` (match by `slug`/`name`, + reuse the id) else `wc-product-categories-create { name, slug }`. Keep `name → id`. +2. **Products** — for each product: `wc-products-search { sku: "<sku>" }`; a hit → reuse its id; empty → + `wc-products-create`: + ```json + { "name": "...", "type": "simple", "sku": "<unique>", "regular_price": "39.99", + "description": "...", "short_description": "...", "status": "publish", + "categories": [ { "id": <catId> } ] } + ``` + - `regular_price` is a **decimal string in major units** (`"39.99"`) on the ability side — the Store + API re-exposes it as minor units to the storefront. + - **Variable products:** create the parent (`type: "variable"`) with its attributes, then the + variations (via the product update / variations ability that discovery lists). +3. **Product images** — `media-upload-from-url { url, filename, alt_text, title }` (WordPress downloads + and stores its own copy; raster, HTTPS, direct file URL — source like the parent `SKILL.md` Unsplash + flow). Attach the returned media id to the product (`wc-products-update { id, images: [ { id } ] }`). + Idempotency: skip when the product already has images. +4. **Coupons** — for each coupon: `wc-coupons-search { code }`; empty → `wc-coupons-create`: + ```json + { "code": "WELCOME10", "discount_type": "percent", "amount": "10" } + ``` + (`discount_type`: `percent` | `fixed_cart` | `fixed_product`.) + +Record the SKU → id map, category ids and coupon codes into `content-model.md` under +`## Created in WooCommerce` so the frontend and any re-run resolve references directly. + +**Verify over the public Store API (no auth):** +- `GET {store_api}/products?per_page=100` → count matches the plan; each has `prices` (minor units + + `currency_minor_unit`) and, where seeded, an image. +- `GET {store_api}/products/categories` → every planned category present. +- Coupon: `wc-coupons-search { code }` returns it, or apply it to a throwaway cart and see the total drop. + +--- + +## 4 — Checkout is owner-completed (the one gate) + +A fresh WooCommerce install has **no payment gateway and no shipping zone enabled**, and **neither is +reachable from the headless tooling** — there is no payment/shipping MCP ability, and `wp-settings-update` +only writes standard WordPress settings (it ignores `woocommerce_*` options). So: + +- **The catalog, product pages, client-side cart and coupon application all work headlessly.** +- **Checkout (`POST /checkout`) does not work until the owner enables a payment method + a shipping zone + in wp-admin** (WooCommerce → Settings → Payments / Shipping; reach it via the one-click login in Step 9). + Cash on Delivery is the zero-config choice once enabled. +- Treat this exactly like the Hostinger Ecommerce `payment_enabled: false` gate: record + `woocommerce.payment_enabled: false`, tell the user in one line that checkout needs that one setup step, + and **never block the build on it**. The frontend renders a graceful disabled-checkout state and does not + call `/checkout` while `payment_enabled` is `false`. Flip it to `true` only after the owner confirms a + gateway is live. **Never ask the user for payment credentials.** + +Store **currency** and **address** (`woocommerce_*` options) likewise aren't settable headlessly — they +follow the install default; the owner changes them in wp-admin if needed. + +--- + +## 5 — REST API v3 (optional, owner-only — not used by this flow) + +WooCommerce also exposes the authenticated **REST API v3** at `site.json → urls.wc_rest_api` +(`/cms/wp-json/wc/v3`) for external/server-side integrations. **This build never uses it** — seeding goes +through the per-site MCP and the storefront uses the public Store API. Document it for the owner only: + +- **Auth:** `Authorization: Basic base64("<consumer_key>:<consumer_secret>")`, **HTTPS only**, server-side + only — **never ship a key to the browser.** +- **Keys:** the owner generates a key pair in **wp-admin → WooCommerce → Settings → Advanced → REST API** + (the agent cannot mint one headlessly). Store it in the owner's own environment, not in `site.json` or + the repo. +- **Endpoints** (same shapes as the abilities above): `GET/POST/PUT/DELETE /products`, `/products/<id>/variations`, + `/products/categories`, `/coupons`, `/orders`. Idempotency via `?sku=` / `?code=`. Pagination via + `?page=&per_page=` + `X-WP-Total`. + +--- + +## 6 — Idempotency & verification checklist + +- **Idempotency keys:** products → **SKU** (`wc-products-search { sku }`); coupons → **code** + (`wc-coupons-search { code }`); categories → **slug/name**; images → skip when the product already has + `images`. +- **Verify over the public Store API** (no auth) — products count + prices + categories, and a coupon via + a cart apply. Record the id/SKU map into `content-model.md`. +- **Checkout** is verified only when `payment_enabled` is `true`; while `false`, confirming the catalog + + cart over the Store API is the whole job. diff --git a/skills/hostinger-headless/steps/05-design/SKILL.md b/skills/hostinger-headless/steps/05-design/SKILL.md new file mode 100644 index 0000000..b8a42b7 --- /dev/null +++ b/skills/hostinger-headless/steps/05-design/SKILL.md @@ -0,0 +1,560 @@ +--- +name: headless-step-design +description: "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), following any logo wish the owner gave, 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). When the brief says to keep an existing look, writes design.md from that look and its own logo instead. Prepare work: runs during the provision wait and never touches the backend." +--- + +# headless-step-design + +Turn the brief into a look the user has chosen, and write it down so the frontend can't drift from +it. **One question, three options, one spec.** The output is **files** (`design.md` plus the logo), +not a conversation: the frontend step implements `design.md`, not the chat. + +**In:** `brief.md` (required), `content-model.md` (if it exists yet), reference sites and assets the +user gave. **Out:** `~/.hostinger/<domain>/design.md` and the logo files in +`~/.hostinger/<domain>/brand/`. + +> ## ⚠️ Build mode: "Bring your own frontend"? This step defers to the user +> Read `brief.md` → **Build mode** first. When it is **Bring your own frontend**, the user drives the +> design — **do not generate three directions or write a prescriptive `design.md` over their project.** +> The frontend already exists (or the user vibe-codes it with you); your job is to follow *their* +> direction, not impose one. Skip Steps 1–7 below, *Keep the current look* included, even when +> **Design direction** keeps the look of the app in this folder: their own code already carries it. +> Either write nothing, or capture only what the user explicitly asks for (a short note of their +> stated colours/vibe) so the frontend step has it, then do Step 7's `state.json` update so the flow +> moves on to frontend. The logo and token work here apply only when **Build mode is "Agent designs +> and builds"**. + +This is **prepare work**: it runs while provision settles (see *Background provision & the instance +barrier* in the top-level `SKILL.md`). Nothing here calls the Hostinger connector or the backend. + +**Keep it short.** Refine already asked about tone and look; don't ask again. The step asks the user +**one** question: either the keep/change question (Step 1 and *Keep the current look*) or the three +directions (Step 4), never both. "You decide" is a valid answer. + +**Talk like a person, not a designer.** Everything the user reads describes how the site will +**look and feel**: colours by name ("hot pink, butter yellow"), lettering by feel ("big chunky +rounded headlines"), and the details they'll notice ("photos tilted like stickers on a scrapbook"). +No hex codes, font names, or design and tech words (palette, accent, tokens, shape language, +contrast, favicon, wordmark, lockup, WCAG, 8-point grid, Next.js, SSR, `state.json`), even when +the owner's own brand guide names them. This covers every message until the step ends, including +the keep/change question, your one-line confirmation after the pick and the message you hand off +with. + +**Two hard rules apply to every direction, however loud or quiet it is:** + +- **The 8-point grid.** Every spacing, sizing and layout value is a multiple of 8 px (details in + 5.2). +- **WCAG 2.2 Level AA.** Colour, focus, motion, target size and reflow are decided here, so the + frontend can't build them wrong (details in 5.1 and 5.3). + +These rules are for you, not the user. **Never mention them**: no grid, contrast, WCAG or +accessibility talk in anything the user reads, and no "all options follow…" reassurances. The user +judges the look in the preview; the rules just make sure it works. + +--- + +## Step 1 — Read the workspace, then the resume check + +Read `brief.md`, `state.json`, and `content-model.md` if it exists. + +- **`design.md` already exists and this is not a redesign** → don't overwrite it. Describe the + current look in two or three plain sentences and ask: *keep it, or change something?* That is this + step's one question. + - **Keep** → leave the file alone and go straight to the `state.json` update at the end of Step 7. + - **Change** → edit only what they asked for, re-run the 5.x checks on what changed, then save. +- **The site is already deployed** (`deploy.json` exists, or `state.json` has `deployed_at`) and the + user asked for a different look → this is a **redesign**. Tell them plainly: their content stays + as it is, only the look changes, and they'll see a preview before it goes live. Then run Steps 2–7 + as normal. The current `design.md` becomes one more signal in Step 2, so carry over what the user + wants to keep and move away from the rest. +- **The brief says to keep the current look** (*Design direction* says so in the owner's words: + "keep the look we have", "same look, new platform") → follow *Keep the current look* below instead + of Steps 2–6. No three directions and no new logo. A live site, screenshots or a brand guide alone + are signals for Step 2, not a request to keep. + +### Keep the current look + +1. **Read the look from what the brief points at.** A live site: use Step 2's reference-site method + (computed fonts, colours by painted area, the feel), but this site is the owner's own, so take its + logo file as it is (the header `<img>` or inline `<svg>`, plus its favicon if it has one) and save + it unchanged in `brand/`. Screenshots or a brand guide: read the colours and lettering from them; + a logo seen only in a screenshot counts as no logo file, so list "logo file needed" under gaps in + the final report. The app in this folder (the path under `brief.md` → **Existing project**): + read its source; no build or browser needed. + - *Colours:* the theme tokens (Tailwind v4 `@theme`, v3 `tailwind.config.*` `theme`/`theme.extend`, + `:root` custom properties), ranked by how often the code uses them (class names, `var()`), with + the page background and body text taken from the root layout or `body`. + - *Fonts:* theme font tokens, `@fontsource` imports or dependencies, Google Fonts `<link>` or + `@import`, and the `font-family` on `body` and headings. + - *Logo:* files named `logo*` in `public/`, `static/`, `src/assets/` or `assets/`; + `favicon*`, `icon*` or `apple-icon*` in `public/`, `static/`, `app/` or `src/app/`; or the icon + declared by `<link rel="icon">` in `index.html` or the root layout's `metadata.icons`. Copy them + unchanged into `brand/`. + + Files or exact colours and fonts the owner supplied win over anything you read. +2. **Confirm in one question.** Describe the kept look in two plain sentences and ask: *keep it, or + change something?* "Keep the look we have" still gets this question; skip it only when the brief + says to change nothing at all ("keep it exactly as it is"). A skipped tool result is not Keep; see + *When you ask the user* in the top-level `SKILL.md`. Change → + apply only what they asked for; a request for a new logo runs Step 6 for the logo alone. +3. **Check it** as in Step 5. The kept colours are fixed like brand colours: a pair that fails gets a + shade of the same hue in that role only. Spacing moves to the nearest grid value, and a font + without an `@fontsource` package swaps to the closest one that resolves (5.4). +4. **Logo.** Reuse the existing logo and favicon unchanged; never redraw or recolour them. With no + existing logo, the header shows the site name as text in the kept display font, and no logo is + designed. +5. **Write `design.md`** (Step 7) from the kept look, then hand off as usual. In **Logo**: + - *Files* and *Use* name the files as saved (a `.png` stays a `.png`), or say "No favicon kept". + - *Built from* says "Kept, not rebuilt". *Measured* still gives the width at `--logo-h-sm` + (≤ 192 px; else lower `--logo-h-sm` or show the name as text on small screens), and the header + link has `alt` = the site name and a target of at least 24 px (as in Step 6). + - With no logo: *Files* says "None", *Use* says the header link holds the site name as text in + `--font-display` and the browser's default favicon stays, and the *Don't* line is dropped. + +## Step 2 — Collect the signals + +Before inventing anything, gather what already constrains the design: + +| Signal | Where | How it constrains | +|---|---|---| +| Tone, purpose, audience | `brief.md` → *Tone*, *Purpose*, *Audience* | Sets the mood all three directions must fit | +| 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 | +| 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 | +| Reference sites | URLs in the brief or the conversation | Cues to borrow (see below) | +| 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 | +| Current design (redesign only) | existing `design.md` | What to keep, what to move away from | + +**Reference sites.** Open each one in a real browser if you have one. Static fetches are often +blocked or rate-limited; without a browser, fetch the HTML and its linked CSS instead. If neither +works, say so in one line and continue from the brief alone. Pull **cues**: + +- **Fonts:** the computed `font-family` of `h1`/`h2` and of body `p`. +- **Colours:** background colours weighted by painted area (element width × height), the top 5–8. + Skip transparent and semi-transparent backgrounds and overlays (cookie banners, modals, chat + widgets). Text colours come from headings, paragraphs and links. +- **Feel:** look at the first screen and one further down (screenshots shown to you, not saved in + the workspace) and note what creates the feel: type weight, colour blocking, shapes, density. + +**Borrow cues, never assets** (the owner's own site under *Keep the current look* is the exception). +Don't use its logo, copy, images or exact layout. **The brief wins** over the live site when they +disagree (the user's words are what they want). Note the mismatch in the direction. + +**Brand colours that fail contrast** are still fixed, but only in roles where they pass. A pale brand +yellow can be a background band or a large decorative shape, never body text. Say so in the +direction. + +## Step 3 — Derive three directions + +Write three directions that **all fit the brief** but are **genuinely different**. Vary at least two +of: palette (temperature, saturation, light vs. dark), type personality (geometric, grotesk, serif, +display), and shape language (sharp vs. round, flat vs. outlined, airy vs. dense). Three shades of +one idea is not a choice. Neither is a wildcard that ignores the brief. + +Order them: **A is the recommendation**, the closest reading of the brief and references. B and C +push in different directions. + +Each direction carries exactly this: + +- **Name** — 2–3 evocative words ("Sticker-book maximal", "Quiet gallery"). +- **Mood** — one line on how the site feels. +- **Palette** — 5–7 hex colours with roles (base, text, primary accent, secondary accents, surface). + These are the core colours the user sees; Step 5 may add derived shades (link, muted, error, + focus) as tokens. +- **Type** — a display font and a body font, both self-hostable through `@fontsource`. +- **Shape language** — corners, borders, shadows and density in one phrase ("chunky 3 px ink + outlines, hard 8 px offset shadows, tilted cards"). +- **Signature element** — the one memorable move (a scrolling marquee, stickers, oversized numerals, + a duotone photo treatment). If it moves on its own, it gets a pause control (5.3). +- **Logo idea** — one line on how the name becomes a logo, using a move from Step 6 ("letters + bounce in different colours and the O is a smiley"). When the brief carries a logo wish (*Design + direction* or *Assets*), every direction's logo idea is that wish in its own style. A wish is words + describing a logo; a logo file in *Assets* is a supplied logo and wins (Step 6). + +**Only offer what can pass.** Before asking, run two quick gates on **all three**: + +1. **Contrast gate.** Every colour that will carry text (base, surfaces, and any accent used as a + text block) has a palette text colour reaching **4.5:1** on it. Two text colours are fine (dark on + light blocks, light on dark ones). Accents that only ever hold large text need 3:1. +2. **Font gate.** Each font resolves on npm, and the display font also has a static `@fontsource` + package, because the logo is built from it (5.4). + +A direction that fails either gate gets adjusted, not offered as-is. Don't offer a look that only +works by breaking the rules, such as body text on a neon accent or a marquee that can't be stopped. +Loud is fine; inaccessible is not. + +**Avoid the template look** unless the brief asks for neutral: Inter or Roboto everywhere, a +blue-to-purple gradient hero, centred hero with three feature cards, grey cards on white. Every +direction should look like *this* site, not a starter theme. + +## Step 4 — Ask once + +Ask **one** question, with A marked as the recommendation. With a structured question UI (multiple +choice plus an "other" field), each option's **label** is the direction's name and its +**description** is one or two plain sentences, under ~160 characters. Without one, write each +option as one line in the same shape. + +The description says what the site would feel like, the main colours by name, what the lettering +is like, the logo idea, and the one detail they'll remember. Hex codes and font names stay in your +notes. The question may end with one plain clause saying they can add a logo idea to their pick, but +never suggest one or give an example: that idea would end up in the logo. + +> **Sticker-book maximal** — Loud and playful, like a scrapbook: candy pink, butter yellow and lilac, +> chunky headlines, tilted sticker photos and a bouncy logo with a smiley O. + +> **Midnight disco** — A dark, glowing night-out feel: deep purple with neon pink, yellow and +> turquoise, heavy rounded headlines and a logo like a neon sign. + +Then: + +- **Pick** → use it as written, and confirm the look in one line. +- **"You decide"** → use A, and tell them in one line which look you went with. +- **Skipped tool result** → not an answer: write the three directions as plain lines + and wait for the reply (see *When you ask the user* in the top-level `SKILL.md`). +- **A mix** ("A's colours with C's fonts") → merge them. Don't ask again. +- **Something else** → build a direction from what they said, run Step 5, and use it. Don't send + three new options. + +**Logo wish.** An answer can carry a logo idea next to the pick ("A, and make the logo a little +teapot"); it replaces the chosen direction's logo idea, and wins over a wish from the brief. +Whenever the logo follows a wish, from the answer or the brief, add it once to the line that confirms +the pick: *"The logo follows your idea: a little teapot."* If the kit can't draw it as said (a +detailed illustration, a photo), build its closest kit version and name what you simplified in that +same line. Don't ask a follow-up. + +Don't ask about anything else in this step. + +## Step 5 — Check the chosen direction + +Fix any problem found here by adjusting the direction, not by asking the user. Don't report these +checks or their fixes; the user sees the result in the preview. + +### 5.1 Contrast — WCAG 2.2 SC 1.4.3 and 1.4.11 + +**Which pairs count:** walk the component inventory you're about to write (Components table). For +each component, list every foreground/background combination in every state: default, hover, +focus, active, error. Include text on coloured bands (footer links on dark, marquee text, chips), +placeholder text and photo credits. Compute the WCAG contrast ratio for each: + +| Kind | Examples | Minimum | +|---|---|---| +| Body text | paragraphs, links, footer text, input text, placeholder text, captions | **4.5:1** | +| Large text | ≥ 24 px regular, or ≥ 18.66 px (14 pt) bold | **3:1** | +| 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** | + +A pair that fails gets a darker or lighter shade of the **same hue**; never drop a brand colour. A +new shade becomes a token (for example `--color-link` or `--color-muted`). Disabled controls and +pure decoration are exempt, but say which elements those are. **Text on photos** needs a scrim or +solid backing that gets the text to 4.5:1 against the lightest part of the image. If you can't +guarantee that, don't put text on photos. Record every pair and its ratio in `design.md`. + +```js +// node -e: relative luminance + contrast ratio, hex in → ratio out +const L = h => { const c = h.match(/\w\w/g).map(x => parseInt(x, 16) / 255) + .map(v => v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); + return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; }; +const ratio = (a, b) => { const [x, y] = [L(a), L(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); }; +``` + +### 5.2 The 8-point grid + +Values are checked in px at the default 16 px root (`0.5rem` = 8 px). + +| What | Rule | +|---|---| +| 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) | +| Component sizes | Heights and fixed widths are multiples of 8. Buttons, nav items and inputs are **48 px** tall minimum | +| Radii | `8 16 24 32`, or `999px` for pills. 4 px only on elements under 32 px tall | +| 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 | +| Fluid spacing | `clamp()` for spacing or sizes uses grid values at both ends. Fluid **font sizes** are exempt | +| Shadow offsets | 4 or 8 multiples | +| Layout | Content width, gutters and breakpoints are multiples of 8 (e.g. `1200`, `768`, `1024`, `1280`) | +| 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) | + +Every value in the **Tokens** block must pass this table. + +### 5.3 WCAG 2.2 AA — what the design must decide + +These criteria are settled at design time. Write each answer into the **Accessibility** section of +`design.md`. + +| Criterion | The design must specify | +|---|---| +| 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 | +| 1.4.4 Resize text | Font sizes in `rem`; nothing breaks at 200 % zoom | +| 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 | +| 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 | +| 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 | +| 2.3.1 Three flashes | Nothing flashes more than 3 times a second | +| 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 | +| 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)` | +| 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 | +| 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) | +| 3.3.2 Labels | Every form field has a visible label; placeholders are never the only label | +| 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` | + +### 5.4 Fonts resolve + +For each family, prefer the variable package and fall back to the static one: +`npm view @fontsource-variable/<slug> version`, then `npm view @fontsource/<slug> version`. One must +answer with a version. If neither does, swap to the closest family that does. Record the exact +package names. The **display** family must also resolve as a static package +(`npm view @fontsource/<slug> version`): the static packages ship `.woff` files the logo kit can +read; the variable ones ship only `.woff2`. + +## Step 6 — Design the logo + +**The user supplied a logo** → copy it into `brand/` unchanged, note it in the **Logo** section, and +skip the rest of this step. Never redraw or recolour it. + +Otherwise, turn the name into a **designed** logo: shaped, coloured and composed. Typing the name in +the display font with an emoji or symbol next to it doesn't count. Build the chosen direction's **logo +idea** (as replaced by any logo wish, Step 4) with one or two of these moves: + +| Move | What it is | Suits | +|---|---|---| +| **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 | +| **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 | +| **Stack** | The name split over 2–3 lines on coloured tiles, or set as a tight block | Long names, bold directions | +| **Tag** | The main word large; a short second word on a pill, ribbon or tab | Two-part names ("Dopamine Den") | +| **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 | +| **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 | + +No emoji, clip-art, gradients, or details that vanish at small sizes. Colours come only from the +palette (base and surface colours count). + +### Build it with the logo kit + +The kit (`logo-kit.mjs`, next to this file) does the error-prone parts: it outlines letters into +vector paths, sets text on a circle, draws the outline-and-shadow treatment, measures, validates +the output, checks the sizes and renders a preview. You write the composition. Copy the kit into +`brand/` so the build is self-contained and a redesign can re-run it: + +```bash +mkdir -p ~/.hostinger/<domain>/brand && cd ~/.hostinger/<domain>/brand +cp <the folder this SKILL.md is in>/logo-kit.mjs . +npm init -y >/dev/null && npm i opentype.js @resvg/resvg-js @fontsource/<display-slug> +``` + +Write `brand/logo-build.mjs`, import from `./logo-kit.mjs`, and always run it from `brand/` +(packages resolve from the working directory): + +| Kit function | Use | +|---|---| +| `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 | +| `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 | +| `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` | +| `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 | +| `arcText(font, text, { cx, cy, r, size })` | Letters around a circle, for seals | +| `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`) | +| `svgDoc({ width, height, title, body })` | Root SVG with `role="img"` and a `<title>` | +| `check(svg)` | Throws on `NaN`, live `<text>`, a missing title or viewBox, or an `&` that isn't an XML entity. Run it on every file before writing | +| `tile(size, fill)` | The rounded tile for the mark and favicon; draw the symbol after it | +| `widthBudget(letterHeight, height)` | The widest viewBox that still fits 192 px on small screens, for a logo of that height; see below | +| `checkLogo({ logo, mark, favicon, letterHeight, background })` | Runs `check()` on all three files, checks their sizes and renders `brand/logo-preview.png`; see below | + +Wrap every file in `svgDoc()`, never a hand-written `<svg>` or `<title>`: it escapes the site name, +and one bare `&` (as in "Salt & Pepper") makes the browser show nothing. + +Never call opentype's `path.toPathData()` directly; the kit's serializer exists because opentype.js +2.0 writes `NaN` into some paths, and browsers silently stop drawing at the first `NaN`. + +### Deliver four files + +| File | What | Must read clearly at | +|---|---|---| +| `brand/logo.svg` | The primary, mostly horizontal logo for the header | Its header display heights (desktop and small screens) | +| `brand/logo-mark.svg` | A square icon from the same idea (initial, monogram or the swapped symbol) on a `tile()` in a palette colour | 32 px | +| `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 | +| `brand/logo-build.mjs` | The script that built them, with `package.json`, kept so a redesign can adjust it | — | + +Add `brand/logo-seal.svg` only if the direction uses a seal as a secondary mark. Delete +`brand/node_modules` when you're done; a redesign runs `npm i` in `brand/` first. + +**Compose within the budget, so the first build passes.** The small-screen logo is at most 192 px +wide with letters at least 16 px tall, so the whole logo can be only about 10–12 times as wide as +its smallest letters are tall, whatever its height. On one line that's about ten letters plus a +symbol; a longer name stacks or tags, and a second word set at half size halves the room. Size the +viewBox in `logo-build.mjs` against `widthBudget(letterHeight, viewBoxHeight)`, the exact limit at +the heights the kit picks; with your own `heights`, the FAIL line gives the limit. Draw the mark's +symbol from simple, recognisable shapes that still read at 32 px; fine natural forms like petals or +leaves turn to mush. + +**Check the sizes and look at it after every build.** `letterHeight` is in viewBox units: the +`xHeight` from `layout()` when the logo is mostly lowercase, `capHeight` when it's mostly capitals; +for mixed fonts, the smaller word's. End `logo-build.mjs` with: + +```js +checkLogo({ logo, mark, favicon, letterHeight, background: '<page background colour>' }); +``` + +It picks the header heights for you: `--logo-h-sm` is the smallest multiple of 8 that makes the +letters 16 px, and `--logo-h` the same but at least 48 px, both at most 80 px, so a stacked lockup +keeps its words large or sets the mark beside them. Pass `heights: { desktop, small }` only when the +direction needs a larger header logo. It first prints `--logo-h`, `--logo-h-sm` and the **Measured** +line for `design.md`, then PASS or FAIL for the logo's letters ≥ 16 px at both +heights, width ≤ 192 px on small screens, headroom around everything drawn and heights in multiples +of 8; for the mark and favicon sitting on a `tile()` with nothing clipped and room around +the symbol; and for no shadow on the favicon. Every FAIL names its fixes and the number to reach. It +then renders `logo-preview.png` (the logo at both heights on the page background, the mark at 32 px +and favicon at 16 px on light and dark strips, each also enlarged), and throws if anything failed. + +Then read `logo-preview.png`; don't search for another renderer or read the SVG source. Rebuild only +for a FAIL line or something in the preview that reads wrong, such as collisions, a clipped or +clearly off-centre mark, or a favicon detail that turns to mush, and fix everything you saw in one +rebuild. Optical nudges of a few pixels and the header height aren't reasons to rebuild. A symbol +gets one redraw: if it still reads wrong, replace it with a simpler form of the idea, such as the +initial, in the next build. If the kit says "preview not rendered", rely on the size check and list +"logo not visually checked" under gaps in the final report. + +The logo is exempt from text-contrast minimums, but the header link around it needs `alt` = the site +name and a target of at least 24 px. + +Don't show the logo to the user in this step; they see it in the preview. + +## Step 7 — Write `design.md` and hand off + +Write `design.md` from the template below. Fill every section. The **Tokens** block is copied +verbatim into the frontend's global stylesheet, so it must be valid CSS. Every colour, font, type +size, line height, space, radius and shadow used elsewhere in the file must exist in it as a token. +The template's token list is the **minimum**: add what the design needs (`--color-error`, +`--color-on-accent`, `--leading-h3`…) under the same naming scheme. +**Breakpoints are the one exception**: CSS custom properties don't work inside `@media`, so they are +listed in *Grid, shape & motion* as plain px values. + +Then update `state.json`: set `"stage": "frontend"` (also for a redesign: restyle, then validate and +deploy) and `updated_at` to the current UTC time (ISO 8601). Don't touch `provision`, `run` or +`history` (`run-log.mjs` owns those), or add other keys. + +If content prepare hasn't sourced images yet, it now follows `design.md` → **Imagery** (see +`04-content-model/SKILL.md`). + +Hand off in one or two plain sentences, as *Talk like a person* says: "The look is set: warm cream, +brown lettering, berry buttons and your loaf logo." Nothing after it: no font names, file names or +stages, and gaps such as a missing icon file or a font that may not load go in the final report. + +## `design.md` template + +````markdown +# Design — <domain> + +> Direction **<name>**, selected on <YYYY-MM-DD> from three options (others offered: <B name>, +> <C name>). <"Picked by the user." | "User said 'you decide'; recommendation A used." | +> "Merged by the user: <what>."> — or, when kept: "Kept the owner's current look from <site URL | +> screenshots | brand guide>, confirmed on <YYYY-MM-DD>." + +## Direction +<2–3 sentences: the mood, what it borrows from the references (and any brief/reference mismatch), +the signature element.> + +## Stack +**<chosen by the frontend step>** — design doesn't decide the stack. The frontend step (06) picks and +confirms the framework (default Next.js SSR; ecommerce must be SSR) and records it in +`state.json → frontend`. Leave this as a placeholder for the frontend step to fill in. + +## Tokens +```css +:root { + /* colour — semantic names, never "--pink-2" */ + --color-base: #…; --color-text: #…; --color-surface: #…; --color-muted: #…; + --color-primary: #…; --color-accent-1: #…; --color-accent-2: #…; --color-link: #…; + --color-focus: #…; --color-focus-alt: #…; /* for surfaces where --color-focus is < 3:1 */ + /* type */ + --font-display: '<Family> Variable', <fallback stack>; + --font-body: '<Family> Variable', <fallback stack>; + --text-body: …rem; --leading-body: …rem; /* 8-multiple in px */ + --text-small: …rem; --leading-small: …rem; /* 4-multiple in px */ + --text-h1: clamp(…rem, …, …rem); --text-h2: clamp(…); --text-h3: …rem; + --leading-display: 1.05; /* unitless; fluid headings are grid-exempt */ + /* 8-point grid */ + --space-half: 4px; --space-1: 8px; --space-2: 16px; --space-3: 24px; --space-4: 32px; + --space-6: 48px; --space-8: 64px; --space-12: 96px; --space-16: 128px; + --target-min: 48px; + /* shape */ + --radius-sm: 8px; --radius-card: …; --radius-pill: 999px; + --border: …; --shadow: …; --shadow-hover: …; + --focus-ring: 3px solid var(--color-focus); --focus-offset: 2px; + /* layout */ + --content-max: …px; /* multiple of 8 */ + --header-h: …px; /* header band height, multiple of 8; scroll-padding-top uses it if sticky */ + --logo-h: …px; /* header logo height on desktop, multiple of 8 */ + --logo-h-sm: …px; /* on small screens; logo ≤ 192 px wide at this height */ +} +``` + +## Palette roles +| Token | Hex | Used for | +|---|---|---| + +## Contrast +| Foreground | Background | Kind (body text / large text / non-text) | Ratio | Needs | Passes | +|---|---|---|---|---|---| + +## Type +- **Display:** <family> (`@fontsource-variable/<slug>@<version>`), weights …, tracking …; sizes `--text-h1…h3`, `--leading-display` +- **Body:** <family> (`@fontsource-variable/<slug>@<version>`); `--text-body` / `--leading-body`, measure ≤ 68ch + +## Logo +- **Concept:** <the move(s), and what any swapped letter or symbol stands for, plus "From the owner: + '<their words>'" only when it follows their wish | "Supplied by the user" | "Kept from <source>" | + "No logo: owner kept the current look" (header shows the site name as text in `--font-display`)> +- **Built from:** `@fontsource/<display-slug>@<version>`, weight(s)/style(s) … +- **Files:** `brand/logo.svg` (header, `--logo-h` desktop / `--logo-h-sm` small screens), + `brand/logo-mark.svg`, `brand/favicon.svg`<, `brand/logo-seal.svg`>; source `brand/logo-build.mjs` +- **Measured:** letters … px tall at `--logo-h-sm`; width … px at `--logo-h-sm` +- **Use:** the frontend copies `brand/*.svg` into `public/`. Header: `<a href="/"><img src="/logo.svg" + alt="<Site name>" style="height: var(--logo-h)"></a>` (switch to `--logo-h-sm` on small screens). + Favicon: `<link rel="icon" href="/favicon.svg" type="image/svg+xml">` +- **Don't:** recolour, stretch, add effects, or retype the logo as live text. + +## Grid, shape & motion +<section padding desktop/mobile (space tokens), gutters, breakpoints in px (multiples of 8), borders, +radii, shadows, hover behaviour, every animation and its pause control, and "no motion under +prefers-reduced-motion".> + +## Accessibility (WCAG 2.2 AA) +- **Focus:** <style; which colour on which surface> +- **Targets:** <48 px controls; inline exceptions> +- **Links & states:** <underline rule; how active/error states differ beyond colour> +- **Motion:** <what moves, where its pause control is> +- **Sticky elements:** <none | header uses --header-h + scroll-padding-top> +- **Help:** <where the contact link sits on every page> +- **Text on images:** <none | scrim spec and resulting ratio> +- **Reflow:** <anything special at 320 px> +- **Exempt from contrast:** <disabled controls / decorative shapes, named> + +## Components +| Component | Renders (from content model) | Look | ≤ 320 px | +|---|---|---|---| +| Header + nav | Navigation items | … | … | +| Post card | Article: image, category, title, excerpt | … | … | +| … | | | | + +## Page composition +- **<Page>** — <sections top to bottom, each naming its component and band colour> +- … + +## Imagery +<subjects, mood and colour fit for photos; search keywords; treatment (frame, crop ratio, radius, +duotone…); alt text written from what the photo shows.> + +## Don'ts +- <3–6 site-specific rules, e.g. "no grey cards", "never put body text on the primary accent"> +```` + +## Invariants + +- **One question.** Refine owns discovery; this step only chooses among directions (or keeps the + existing look). +- **8-point grid, always.** No spacing, size, radius or breakpoint off the grid except the listed + exemptions. +- **WCAG 2.2 AA is the floor.** No direction is offered, chosen or written that fails it. Brand + colours keep their hue but only appear in roles where they pass. +- **Brand assets are fixed.** A supplied logo, colour or font appears in every direction, unchanged. +- **A logo is designed, not typed.** Outlined SVG paths built with the logo kit, never live text, + emoji or clip-art, and always readable at its smallest use. The one exception: a kept look with no + logo shows the site name as text and gets no new logo. +- **Self-hosted fonts only** (`@fontsource`). No Google Fonts or other font CDN links in the output. +- **The tokens block is the only source of values** (breakpoints excepted). Components and pages + reference tokens, never raw hex codes, pixel values or font names. +- **No backend calls.** Design never waits on provision and never writes to the instance. +- **Never overwrite an existing `design.md` silently.** Keep or change, as in Step 1. diff --git a/skills/hostinger-headless/steps/05-design/logo-kit.mjs b/skills/hostinger-headless/steps/05-design/logo-kit.mjs new file mode 100644 index 0000000..0ef79b1 --- /dev/null +++ b/skills/hostinger-headless/steps/05-design/logo-kit.mjs @@ -0,0 +1,430 @@ +// Hostinger Headless — logo kit (step 05-design). +// +// Primitives for building a designed logo as outlined SVG: the letters become vector paths, so the +// logo needs no web font and renders identically everywhere. The agent composes the design; this +// file only does the parts that are easy to get wrong. +// +// Workflow (from ~/.hostinger/<domain>/brand/, so packages and this kit sit next to the build): +// cp <installed skill>/steps/05-design/logo-kit.mjs . +// npm init -y && npm i opentype.js @resvg/resvg-js @fontsource/<display-family-slug> +// node logo-build.mjs // your script: import { … } from './logo-kit.mjs' +// // then read logo-preview.png +// rm -rf node_modules // package.json keeps the deps; a redesign runs `npm i` again +// +// Packages resolve from the current working directory, so run the build from brand/. + +import { createRequire } from 'node:module'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const requireFromCwd = createRequire(join(process.cwd(), 'logo-build.js')); +const opentype = requireFromCwd('opentype.js'); +// Optional: without resvg the kit still builds logos, it just can't measure what's drawn or render. +const Resvg = (() => { + try { + return requireFromCwd('@resvg/resvg-js').Resvg; + } catch { + return null; + } +})(); + +// @fontsource splits each weight into unicode subsets; `latin` alone has no ą č ę ė į š ų ū ž, +// ő, ł, đ… so load every subset that exists and look each character up across them. +const SUBSETS = ['latin', 'latin-ext', 'vietnamese', 'cyrillic', 'cyrillic-ext', 'greek', 'greek-ext']; + +function parseWoff(file) { + const buf = readFileSync(file); + return opentype.parse(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)); +} + +/** + * Load a static @fontsource weight (the static packages ship .woff, which opentype.js reads; + * variable packages ship only .woff2, which it can't). The returned font is the `latin` subset + * with `charToGlyph` widened to every other subset in the package, so accented letters resolve. + * @param {string} slug family slug, e.g. "unbounded" + * @param {number} weight e.g. 900 + */ +export function loadFont(slug, weight = 400, style = 'normal') { + const fonts = []; + for (const subset of SUBSETS) { + let file; + try { + file = requireFromCwd.resolve(`@fontsource/${slug}/files/${slug}-${subset}-${weight}-${style}.woff`); + } catch { + continue; + } + fonts.push(parseWoff(file)); + } + if (!fonts.length) throw new Error(`logo-kit: @fontsource/${slug} has no ${weight} ${style} .woff files`); + const [primary] = fonts; + const own = primary.charToGlyph.bind(primary); + primary.charToGlyph = (ch) => { + for (const font of fonts) { + const glyph = font === primary ? own(ch) : font.charToGlyph(ch); + if (glyph.index) { + glyph.kitFont = font; + return glyph; + } + } + return own(ch); + }; + return primary; +} + +const VIEWBOX = /viewBox="0 0 ([\d.]+) ([\d.]+)"/; + +const num = (v) => { + if (!Number.isFinite(v)) throw new Error(`logo-kit: non-finite coordinate ${v}`); + return +v.toFixed(2); +}; + +/** + * Serialize an opentype Path to SVG path data. Use this instead of path.toPathData(): + * opentype.js 2.0 emits "NaN" when a coordinate's fraction is a tiny float residue + * (its roundDecimal builds strings like "5.6e-14e+2"), and the browser silently stops drawing + * the path at the first NaN. + */ +export function toD(path) { + return path.commands + .map((c) => { + switch (c.type) { + case 'Z': + return 'Z'; + case 'Q': + return `Q${num(c.x1)} ${num(c.y1)} ${num(c.x)} ${num(c.y)}`; + case 'C': + return `C${num(c.x1)} ${num(c.y1)} ${num(c.x2)} ${num(c.y2)} ${num(c.x)} ${num(c.y)}`; + default: + return `${c.type}${num(c.x)} ${num(c.y)}`; + } + }) + .join(''); +} + +/** + * Lay out a string with kerning. Positions are relative to x = 0 on the baseline. + * One glyph per character: no ligatures or other substitutions, so `char` always matches its glyph + * (and fonts whose substitution tables opentype.js can't run still work). + * @returns {{ glyphs: { glyph, char: string, x: number, width: number }[], width: number, ascent: number, capHeight: number, xHeight: number }} + */ +export function layout(font, text, size, { tracking = 0 } = {}) { + const scale = size / font.unitsPerEm; + const chars = [...text]; + const glyphs = chars.map((ch) => font.charToGlyph(ch)); + glyphs.forEach((glyph, i) => { + if (!glyph.index && chars[i].trim()) { + 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`); + } + }); + let x = 0; + const out = glyphs.map((glyph, i) => { + // Kerning pairs only exist inside one subset file; across subsets the indexes don't match. + if (i && (glyphs[i - 1].kitFont ?? font) === (glyph.kitFont ?? font)) { + const kern = font.getKerningValue(glyphs[i - 1], glyph); + x += (Number.isFinite(kern) ? kern : 0) * scale + tracking; + } + const item = { glyph, char: chars[i] ?? '', x, width: glyph.advanceWidth * scale }; + x += item.width; + return item; + }); + const capHeight = (font.tables.os2?.sCapHeight || font.ascender * 0.7) * scale; + const xHeight = (font.tables.os2?.sxHeight || font.ascender * 0.5) * scale; + return { glyphs: out, width: x, ascent: font.ascender * scale, capHeight, xHeight }; +} + +/** + * Tight bounding box of a word's outlines at (x, baselineY), before any transform. Use it to size + * the viewBox; add room for outlines, shadows and rotations yourself. + * @returns {{ x1: number, y1: number, x2: number, y2: number }} + */ +export function pathBox(font, text, x, baselineY, size, opts) { + const path = new opentype.Path(); + for (const g of layout(font, text, size, opts).glyphs) path.extend(g.glyph.getPath(x + g.x, baselineY, size)); + return path.getBoundingBox(); +} + +/** Path data for one glyph with its origin at (x, baselineY). */ +export const glyphPath = (glyph, x, baselineY, size) => toD(glyph.getPath(x, baselineY, size)); + +/** Path data for a whole string starting at (x, baselineY). */ +export function textPath(font, text, x, baselineY, size, opts) { + return layout(font, text, size, opts) + .glyphs.map((g) => glyphPath(g.glyph, x + g.x, baselineY, size)) + .join(''); +} + +/** + * Set text around a circle (seals, badges). Each glyph is centred on its arc position and rotated + * to stand on the circle. `fill: true` stretches the spacing so the text closes the full ring. + * @returns {{ d: string, transform: string, char: string }[]} style each glyph however you like + */ +export function arcText(font, text, { cx, cy, r, size, startDeg = -90, fill = true }) { + const { glyphs, width, capHeight } = layout(font, text, size); + const k = fill ? (2 * Math.PI * r) / width : 1; + const offset = fill ? 0 : -width / 2; // centred on startDeg when not filling + return glyphs.map((g) => { + const along = (g.x + g.width / 2 + offset) * k; + const angle = along / r + (startDeg * Math.PI) / 180; + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); + const deg = (angle * 180) / Math.PI + 90; + return { + d: glyphPath(g.glyph, -g.width / 2, capHeight / 2, size), + transform: `translate(${num(px)} ${num(py)}) rotate(${num(deg)})`, + char: g.char, + }; + }); +} + +/** + * The outlined "cut-out" treatment: an ink outline drawn behind the fill (paint-order) plus an + * optional hard offset shadow. `shadowColor` defaults to the ink; set it separately on dark + * backgrounds, where a dark ink outline vanishes but a coloured shadow still reads. + * Returns SVG markup. + */ +export function sticker(d, { fill, ink = '#000', strokeWidth = 8, shadow = 8, shadowColor = ink, transform = '' }) { + const t = transform ? ` transform="${transform}"` : ''; + const shade = shadow + ? `<path data-kit="shadow" d="${d}" fill="${shadowColor}" stroke="${shadowColor}" stroke-width="${strokeWidth}" stroke-linejoin="round" transform="translate(${shadow} ${shadow})"/>` + : ''; + return `<g${t}>${shade}<path d="${d}" fill="${fill}" stroke="${ink}" stroke-width="${strokeWidth}" stroke-linejoin="round" paint-order="stroke"/></g>`; +} + +/** The solid rounded tile behind `logo-mark.svg` and `favicon.svg`; draw the symbol after it. */ +export function tile(size, fill) { + return `<rect data-kit="tile" width="${num(size)}" height="${num(size)}" rx="${num(size / 4)}" fill="${fill}"/>`; +} + +/** Root SVG with an accessible name. Pass a distinct `id` if two logos are inlined on one page. */ +export function svgDoc({ width, height, title, body, id = 'logo-title' }) { + const esc = String(title).replace(/&/g, '&').replace(/</g, '<'); + 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}${body}`; +} + +/** + * Validate a finished SVG string before writing it. Throws on: NaN/undefined/Infinity in the + * markup, live (the logo must be outlined), a missing , a missing viewBox, or an "&" that + * doesn't start an XML entity (a browser shows nothing for an SVG file that isn't well-formed XML). + * @returns {{ bytes: number }} + */ +export function check(svg) { + const problems = markupProblems(svg); + if (problems.length) throw new Error(`logo-kit check failed: ${problems.join('; ')}`); + return { bytes: Buffer.byteLength(svg) }; +} + +function markupProblems(svg) { + const problems = []; + if (/NaN|undefined|Infinity/.test(svg)) problems.push('contains NaN/undefined/Infinity'); + if (/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[\dA-Fa-f]+);)/.test(svg)) { + problems.push('contains a bare "&" or an HTML-only entity such as  ; build the file with svgDoc(), which escapes the title'); + } + if (/<text[\s>]/.test(svg)) problems.push('contains <text>; outline the letters with textPath/glyphPath'); + if (!/<title[^>]*>[^<]+<\/title>/.test(svg)) problems.push('missing <title>'); + if (!VIEWBOX.test(svg)) problems.push('missing or invalid viewBox'); + return problems; +} + +const MIN_LETTER_PX = 16; +const MAX_SMALL_WIDTH_PX = 192; +const MIN_DESKTOP_PX = 48; +const MAX_HEADER_PX = 80; // automatic heights only: taller header logos crowd the nav, and stacked lockups reached 144 +const HEADROOM = 0.02; // of the viewBox height, on every edge +const CLIP_SLACK = 0.5; // viewBox units of anti-aliasing a "nothing outside the viewBox" check allows + +function viewBoxOf(svg) { + const m = svg.match(VIEWBOX); + if (!m) throw new Error('logo-kit: missing or invalid viewBox'); + return { width: +m[1], height: +m[2] }; +} + +/** Gap between everything drawn (strokes, shadows, transforms) and each viewBox edge, smallest first. */ +function edgeGaps(svg, vb) { + const b = new Resvg(svg).getBBox(); + if (!b) return []; + const gaps = { top: b.y, bottom: vb.height - b.y - b.height, left: b.x, right: vb.width - b.x - b.width }; + return Object.entries(gaps).sort((a, b) => a[1] - b[1]); +} + +const px = (v) => +v.toFixed(1); +const ceil1 = (v) => Math.ceil(v * 10) / 10; +const ceil8 = (v) => Math.ceil(v / 8) * 8; +const floor8 = (v) => Math.floor(v / 8) * 8; + +const minHeaderPx = (letterHeight, height) => ceil8((MIN_LETTER_PX * height) / letterHeight); +const maxWidthAt = (height, smallPx) => Math.floor((MAX_SMALL_WIDTH_PX * height) / smallPx); + +function autoHeights(letterHeight, height) { + const small = Math.min(minHeaderPx(letterHeight, height), MAX_HEADER_PX); + return { desktop: Math.max(small, MIN_DESKTOP_PX), small }; +} + +/** + * The widest viewBox a logo of this height can have and still fit 192 px on small screens at the + * height `checkLogo` picks. Call it while composing, before the first build: it is roughly + * 10–12 × `letterHeight`, so a long name stacks or tags instead of running on one line. + * @param {number} letterHeight viewBox units, as passed to `checkLogo` + * @param {number} height the viewBox height + */ +export function widthBudget(letterHeight, height) { + return maxWidthAt(height, autoHeights(letterHeight, height).small); +} + +/** Collects PASS/FAIL/SKIP lines; `print` returns how many failed. */ +function sizeReport(label, vb) { + const lines = []; + return { + rule(ok, text, fix) { + lines.push(ok ? `PASS ${text}` : `FAIL ${text} → ${fix}`); + }, + skip(text) { + lines.push(`SKIP ${text}`); + }, + headroom(svg, of) { + if (!Resvg) return this.skip('headroom: install @resvg/resvg-js to measure it'); + const [edge, gap] = edgeGaps(svg, vb)[0] ?? []; + if (edge === undefined) return this.skip(`headroom: nothing drawn on ${of}`); + const min = HEADROOM * vb.height; + this.rule(gap >= min, `headroom ${px(gap)} at the ${edge} of ${of} (≥ ${px(min)} viewBox units)`, + `add ${ceil1(min - gap)} viewBox units at the ${edge}: grow the viewBox on that side or move the drawing in; accents, descenders, tilt, outline and shadow all need room`); + }, + print() { + console.log(`${label} (viewBox ${px(vb.width)} × ${px(vb.height)}):`); + for (const line of lines) console.log(` ${line}`); + return lines.filter((l) => l.startsWith('FAIL')).length; + }, + }; +} + +function logoSizes(svg, { letterHeight, heights }) { + if (!letterHeight || (heights && (!heights.desktop || !heights.small))) { + throw new TypeError('logo-kit checkLogo: pass letterHeight, and heights: { desktop, small } only to set both'); + } + const vb = viewBoxOf(svg); + const report = sizeReport('logo.svg', vb); + const minHeaderH = minHeaderPx(letterHeight, vb.height); + const maxSmall = floor8((MAX_SMALL_WIDTH_PX * vb.height) / vb.width); + const { desktop, small } = heights ?? autoHeights(letterHeight, vb.height); + const lettersAt = (h) => (letterHeight / vb.height) * h; + const width = (vb.width / vb.height) * small; + const canLowerSmall = small > minHeaderH && minHeaderH <= maxSmall; + const widthFixes = [ + ...(canLowerSmall ? [`--logo-h-sm ${minHeaderH === maxSmall ? minHeaderH : `${minHeaderH}–${maxSmall}`} px passes both`] : []), + `make the viewBox at most ${maxWidthAt(vb.height, small)} units wide (now ${px(vb.width)}) at this height: stack or tag the words, tighten gaps, or show logo-mark.svg with the name beside it on small screens`, + ]; + + report.rule(desktop % 8 === 0 && small % 8 === 0, `heights ${desktop} / ${small} px are multiples of 8`, 'round them to multiples of 8'); + const capped = !heights && minHeaderH > MAX_HEADER_PX; + const minLetterAtCap = ceil1((MIN_LETTER_PX * vb.height) / MAX_HEADER_PX); + const maxHeightAtCap = Math.floor((letterHeight * MAX_HEADER_PX) / MIN_LETTER_PX); + const lettersFixes = (name) => capped + ? [`the header logo stops at ${MAX_HEADER_PX} px: make letterHeight at least ${minLetterAtCap} viewBox units (now ${px(letterHeight)})`, + `make the viewBox at most ${maxHeightAtCap} units tall (now ${px(vb.height)}): trim empty space or set the mark beside the words instead of above them`] + : [`raise ${name} to ${minHeaderH} px or more`, 'enlarge the letters inside the viewBox', 'trim empty viewBox space']; + const letterChecks = desktop === small ? [['--logo-h and --logo-h-sm', small]] : [['--logo-h', desktop], ['--logo-h-sm', small]]; + for (const [name, h] of letterChecks) { + report.rule(lettersAt(h) >= MIN_LETTER_PX, `letters ${px(lettersAt(h))} px tall at ${name} ${h} px (≥ ${MIN_LETTER_PX})`, + lettersFixes(name).join(', or ')); + } + report.rule(width <= MAX_SMALL_WIDTH_PX, `width ${px(width)} px at --logo-h-sm ${small} px (≤ ${MAX_SMALL_WIDTH_PX})`, + widthFixes.join(', or ')); + report.headroom(svg, 'the viewBox'); + const measured = `- **Measured:** letters ${px(lettersAt(small))} px tall at \`--logo-h-sm\`; width ${px(width)} px at \`--logo-h-sm\``; + return { report, heights: { desktop, small }, measured }; +} + +function tileChecks(svg, { favicon = false } = {}) { + const vb = viewBoxOf(svg); + const report = sizeReport(favicon ? 'favicon.svg' : 'logo-mark.svg', vb); + const tileTag = svg.match(/<rect data-kit="tile"[^>]*\/>/)?.[0]; + report.rule(Boolean(tileTag), 'drawn on a tile()', "draw the tile with the kit's tile(size, fill)"); + if (!Resvg) report.skip('clipping: install @resvg/resvg-js to measure it'); + else { + const gap = edgeGaps(svg, vb)[0]?.[1] ?? 0; + report.rule(gap >= -CLIP_SLACK, 'nothing drawn outside the viewBox', 'shrink or move the drawing in; the tile already fills the viewBox'); + } + if (tileTag) report.headroom(svg.replace(tileTag, ''), 'the tile'); + if (favicon) { + report.rule(!/data-kit="shadow"|<filter|drop-shadow/.test(svg), 'no shadow', 'pass shadow: 0 to sticker() and draw no other shadow'); + } + return report; +} + +function renderPreview({ logo, mark, favicon, heights, background, out }) { + const render = (svg, h, zoom) => { + const img = new Resvg(svg, { fitTo: { mode: 'height', value: h } }).render(); + return { w: img.width * zoom, h: img.height * zoom, href: `data:image/png;base64,${img.asPng().toString('base64')}` }; + }; + const tiles = (fill) => ({ fill, items: [render(mark, 32, 1), render(mark, 32, 4), render(favicon, 16, 1), render(favicon, 16, 8)] }); + const bands = [ + { + label: `logo.svg at --logo-h ${heights.desktop} px and --logo-h-sm ${heights.small} px, then 2×, on the page background`, + fill: background, + items: [render(logo, heights.desktop, 1), render(logo, heights.small, 1), render(logo, heights.desktop, 2), render(logo, heights.small, 2)], + }, + { label: 'light: logo-mark.svg at 32 px and 4×, favicon.svg at 16 px and 8×', ...tiles('#f1f3f4') }, + { label: 'dark: the same', ...tiles('#202124') }, + ]; + const PAD = 16; + const LABEL = 28; + let body = ''; + let y = 0; + let width = 480; + for (const { label, fill, items } of bands) { + const rowH = Math.max(...items.map((i) => i.h)) + PAD * 2; + body += `<text x="${PAD}" y="${y + 19}" font-family="sans-serif" font-size="13" fill="#555">${label}</text>`; + y += LABEL; + body += `<rect y="${y}" width="100%" height="${rowH}" fill="${fill}"/>`; + let x = PAD; + for (const i of items) { + body += `<image href="${i.href}" x="${x}" y="${y + (rowH - i.h) / 2}" width="${i.w}" height="${i.h}" image-rendering="optimizeSpeed"/>`; + x += i.w + PAD * 2; + } + width = Math.max(width, x - PAD); + y += rowH; + } + const sheet = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${y}"><rect width="100%" height="100%" fill="#fff"/>${body}</svg>`; + writeFileSync(out, new Resvg(sheet).render().asPng()); +} + +/** + * Check the three finished files and render one PNG to look at instead of the SVG source. + * Throws first if any file fails `check()`. + * - logo.svg: letters ≥ 16 px at both header heights, width ≤ 192 px on small screens, 2% headroom + * around everything drawn, heights in multiples of 8. Without `heights`, `--logo-h-sm` is the + * smallest multiple of 8 that makes the letters 16 px, and `--logo-h` the same but at least 48 px; + * both stop at 80 px, so letters too small for 80 px fail. + * - logo-mark.svg, favicon.svg: drawn on a `tile()`, nothing outside the viewBox, 2% headroom + * around the symbol; the favicon has no shadow. + * - logo-preview.png: the logo at both heights on the page background, the mark at 32 px and the + * favicon at 16 px on a light and a dark strip, each also enlarged with hard pixels. + * Prints the heights and the Measured line first, then every PASS/FAIL line, each failure with its + * fixes, renders the preview, then throws if anything failed. + * @param {object} o + * @param {number} o.letterHeight viewBox units: `layout().xHeight` (mostly lowercase) or + * `.capHeight` (capitals) of the smaller word + * @param {{ desktop: number, small: number }} [o.heights] set both header heights yourself + * @param {string} [o.background] the page background colour + * @returns {{ heights: { desktop: number, small: number }, measured: string }} heights for + * `--logo-h` / `--logo-h-sm`, and the design.md → Logo → **Measured** line + */ +export function checkLogo({ logo, mark, favicon, letterHeight, heights, background = '#ffffff', out = 'logo-preview.png' }) { + // resvg can't measure or render a file check() rejects, so these fail before any size line. + const broken = Object.entries({ 'logo.svg': logo, 'logo-mark.svg': mark, 'favicon.svg': favicon }) + .flatMap(([name, svg]) => markupProblems(svg).map((p) => `${name}: ${p}`)); + if (broken.length) throw new Error(`logo-kit checkLogo: ${broken.join('; ')}`); + const sizes = logoSizes(logo, { letterHeight, heights }); + // First, so piping the output through `head` can't cut them off. + console.log(`--logo-h: ${sizes.heights.desktop}px; --logo-h-sm: ${sizes.heights.small}px`); + console.log(sizes.measured); + const reports = [sizes.report, tileChecks(mark), tileChecks(favicon, { favicon: true })]; + const failed = reports.reduce((n, r) => n + r.print(), 0); + if (Resvg) { + renderPreview({ logo, mark, favicon, heights: sizes.heights, background, out }); + console.log(`preview: read ${out}`); + } else { + console.log('preview not rendered: @resvg/resvg-js is not installed; list "logo not visually checked" under gaps'); + } + if (failed) throw new Error(`logo-kit checkLogo: ${failed} check(s) failed, see the FAIL lines above`); + return { heights: sizes.heights, measured: sizes.measured }; +} diff --git a/skills/hostinger-headless/steps/06-frontend/SKILL.md b/skills/hostinger-headless/steps/06-frontend/SKILL.md new file mode 100644 index 0000000..9248cb3 --- /dev/null +++ b/skills/hostinger-headless/steps/06-frontend/SKILL.md @@ -0,0 +1,191 @@ +--- +name: headless-step-frontend +description: "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." +--- + +# headless-step-frontend + +Build the site's frontend against the backend from `site.json`, implementing the look the design step +already chose and wrote to **`design.md`** (plus the logo in `brand/`). The design is decided; this step +is where it becomes real code. There's no per-framework recipe — use good judgement, follow `design.md` +as the contract, and ask only for the clarifications you genuinely need. **Out:** source in the site +workspace `~/.hostinger/<domain>/src/`. + +> ## ⚠️ Build mode: "Bring your own frontend"? Read this first (any backend) +> Read `brief.md` → **Build mode**. When it is **Bring your own frontend**, the user already has a +> frontend (the project in the folder the command was run in) and this step **wires it to the backend +> from `site.json` — it does not scaffold a new project and there is usually no `design.md` to +> implement** (the design step deferred to the user). **Use their project as the source**: copy or +> point `~/.hostinger/<domain>/src/` at it, keep its framework and UI, and collaborate on changes at +> the user's direction (vibe-code with them). Your job is to connect it to the live backend (WordPress +> `wp/v2` reads, the WooCommerce/Ecommerce Store API, env/keys) and make it deployable (set +> `engines.node` to `22`, remove any `.htaccess`), then hand off to deploy. This generalises the Webapp +> "existing app" case below to WordPress and Ecommerce too. When **Build mode is "Agent designs and +> builds"**, ignore this box and proceed normally (scaffold + implement `design.md`). + +> ## ⚠️ Webapp backend? Read this first +> A `webapp` brief has **no CMS/store and no `content-model.md`** — the app owns its own data (its own +> API, or the MySQL database recorded in `site.json → database`). It comes in two shapes, decided back in +> **refine**: +> +> - **An existing app under Build mode "Bring your own frontend"** (refine routes "deploy my app" here). **Use their +> project as the source** — copy/point `~/.hostinger/<domain>/src/` at it and keep its own framework and +> UI. **Do not scaffold a new project, and do not re-implement `design.md` over it** (so **the design +> step does not apply** to an app that brings its own UI). Your job is only to make it deployable: set +> `engines.node` to `22`, remove any `.htaccess`, wire its data/env, then hand off to deploy. **Skip the +> Step 2 design pass**, and ignore the `content-model.md` requirement in Step 3. +> - **A new app built from the brief.** Scaffold and build it from `brief.md` (and `design.md` if the +> design step produced one — design *does* run for a from-scratch app). Its data is owned by the app, so +> there is still **no `content-model.md`** to bind against. An existing app under **Build mode "Agent +> designs and builds"** takes this shape too, but starts from its source: copy the project at +> `brief.md` → **Existing project** into `~/.hostinger/<domain>/src/`, keep its framework, screens, +> features and data wiring, and rebuild its UI to `design.md` instead of scaffolding a new project. +> +> Framework choice (Step 1), the build, and hand-off (Step 4) apply to both. The per-backend bullets in +> Step 3 are **WordPress / WooCommerce / Ecommerce only** — for a webapp follow the Webapp bullet there. + +## Step 1 — Pick the framework (guess → default → confirm) + +**The rendering is already decided in `brief.md` → Rendering** (SSR or SSG, set during refine). Honour +it, and the backend constraint: + +- **Ecommerce → SSR required** (carts/search need per-request rendering). Any SSR framework works — + Next.js is the default, not a requirement (Express, Nuxt, SvelteKit, etc. are all fine). +- **WordPress + WooCommerce → SSR required** (same reason — cart/checkout need per-request rendering). +- **WordPress → SSG (Astro)** when the brief says SSG, otherwise **SSR (Next.js default)**. +- **Webapp → the app's own stack.** An **existing app** keeps its current framework (don't impose one), including + one rebuilt under **Agent designs and builds**; a + **new app** uses whatever fits the brief (SSR or SPA). Either way it deploys as a Node.js app (Step 8) — + an SSR app runs a server, a SPA builds to static output. +- Map rendering → framework: **`ssr` → Next.js**, **`ssg` → Astro** — unless the brief/prompt explicitly + names another supported framework, in which case use that. + +Supported frameworks (what the Hostinger build pipeline can deploy — the `app_type` in Step 8 deploy): + +- **Server/SSR-capable:** Next.js, Nuxt.js, Astro, SvelteKit, Express, NestJS, Fastify. +- **Static / SPA:** React, Vue, Angular, Vite, Parcel, Preact, Svelte, (Astro / SvelteKit static too). + +Confirm the framework with the user in one line only if the brief left it open. Record the choice in +`state.json`: + +```json +{ "frontend": { "framework": "nextjs", "rendering": "ssr" } } +``` + +If the brief is silent, default to **Next.js SSR**. Ecommerce must be SSR (never SSG) — but the SSR +framework itself is free to be any supported one, not just Next.js. + +## Step 2 — Implement `design.md` (don't re-design) + +The look was already chosen and written by the design step. **Read `~/.hostinger/<domain>/design.md` and +implement it faithfully** — tokens (colour, type, spacing), components, per-page composition, imagery and +accessibility — plus the logo files in `brand/`. Don't re-run design questions or invent a new direction here. + +- Treat `design.md` as the contract: the built pages should match its tokens, type scale, components and + per-page composition, and the 8-point grid / WCAG 2.2 AA rules it carries. +- Use the **logo** from `brand/` as-is (wordmark, icon mark, favicon); never recolour or redraw it. +- If `design.md` is **missing** (e.g. a resumed older build), fall back to `brief.md → Design direction` + (reference site, colours, brand, adjectives) and a tasteful, accessible default — but prefer `design.md` + whenever it exists. +- **Don't gate on approval here** — the result is shown at **validate** (Step 7). + +**Only ask about the code, and only if it matters** (design decisions belong in the design step): +- Where the **source should live** — default `~/.hostinger/<domain>/src/`; ask only if they want a + specific folder or an existing repo. +- **Git** — initialise / connect / push (default: a local project, no remote). +- A specific **component library** or technical must-have, if they implied one. + +## Step 3 — Build against the backend + +Two phases around the instance barrier (see the top-level `SKILL.md`): scaffold + build the shell while +provision settles, then wire live data once `provision.status == "completed"`. + +Read the backend from `site.json`: + +- **WordPress** → `urls.rest_api` (`wp/v2`), public read. Content is in the posts/pages/categories/media + the content-model push created; nav/plan come from `content-model.md`. Map each item's **SEO** from its + `yoast_head_json` into the page `<head>` (title, description, Open Graph, JSON-LD schema) — Yoast is + installed by default at provision (`site.json → seo.enabled: true`). If `seo.enabled` is `false` + (Yoast absent), fall back to generating per-page title/description/OG tags from each item's content. + - **Decode WordPress text.** WordPress returns titles and names HTML-encoded (`Tea & Cake`, + `don’t`). Add the `entities` package and, in the one data module, run every title, name + and SEO field you render as plain text (posts, pages, terms, Yoast, Store API products and + categories) through `decodeHTML` once; strip an excerpt's tags first if you show it as text. + Leave HTML fields (`content.rendered`, an excerpt shown as HTML) as they are, and never inject a + decoded field as raw HTML. + - **Contact form** (`site.json → contact_form.enabled: true`, any WordPress backend) → render the + contact page's form and submit it **client-side** to Contact Form 7's public feedback endpoint. Put a + `submitContactForm(data)` helper in the one data module: build a `FormData` with the field names from + `contact_form.fields` (e.g. `your-name`, `your-email`, `your-subject`, `your-message`) **plus CF7's + hidden fields** — `_wpcf7` = `contact_form.form_id`, `_wpcf7_version`, `_wpcf7_locale` (e.g. `en_US`), + `_wpcf7_unit_tag` (any stable string), `_wpcf7_container_post` = `0` — and `POST` it to + `contact_form.feedback_url` **without setting `Content-Type`** (let the browser add the multipart + boundary). `/cms` is same-origin, so no CORS and no secret. Branch on the JSON `status`: `mail_sent` + → success state; `validation_failed` → map `invalid_fields[].field` to per-input errors; + `spam`/`aborted`/`mail_failed` → a generic "couldn't send, try again" message. Include the honeypot + decoy field named in `contact_form.honeypot_field` (e.g. `your_website`), rendered hidden/off-screen, + **empty**, `autocomplete="off"`, `tabindex="-1"` — the deployed spam guard rejects it if filled. A user + validation error is a **normal UI state, not a build error**. When `contact_form.enabled` is `false` or absent, render the contact page with + plain contact details and **no form** — graceful, like disabled checkout; never half-build. Full + contract: `../04-content-model/wordpress/contact-form.md` §3. +- **WordPress + WooCommerce** (`site.json → woocommerce.enabled: true`) → the WordPress bullet above **plus** + the store. Read the catalog over the **public WooCommerce Store API** (`site.json → urls.store_api`, + `/cms/wp-json/wc/store/v1`, no auth, open CORS): `GET /products` (prices are **integer minor units** on + `prices.price` with `prices.currency_minor_unit` — divide to display), `GET /products/categories`. Cart, + coupons and checkout are **client-side** against the Store API with the `Nonce` + `Cart-Token` headers + (`GET /cart`, `POST /cart/add-item`, `/cart/apply-coupon`, `POST /checkout`) — see + `../04-content-model/wordpress/woocommerce.md`. **Never embed a consumer key client-side**; the + storefront is fully public. Blog/pages on the same site still come from `urls.rest_api` (`wp/v2`) + Yoast. + - **Honour `site.json → woocommerce.payment_enabled`.** It is `false` until the owner enables a payment + gateway + shipping in wp-admin (that config isn't reachable headlessly — see the provision note). While + `false`, build the full catalog, product pages and the client-side cart as normal, but render a + graceful disabled-checkout state (e.g. a disabled "Checkout" with "Checkout isn't set up yet"), **don't + call `/checkout`**, and don't treat it as a build error. When `true`, wire checkout (`payment_method` + `"cod"` works out of the box). +- **Ecommerce** → the **public Hostinger Storefront Core V2 API** (`https://api-ecommerce.hostinger.com/v2`, + no auth, open CORS), keyed on the **custom sales channel** (`site.json → ecommerce.sales_channel.id`): + `GET /channels/<scha_*>/products` (prices are **minor units** on variants), `POST /channels/<scha_*>/checkout` + for buying. Cart is client-side; never embed a token. Read + `ecommerce_miscellaneous_custom-storefront-setup-instructions` for the authoritative, current guide. + - **Honour `site.json → ecommerce.payment_enabled`.** When it is `false` (free account / no paid + Ecommerce plan — see the provision gate), there is **no checkout**: build the full catalog, product + pages and the client-side cart as normal, but **do not call the checkout endpoint**. Instead render + a clear, graceful state at the checkout step — e.g. a disabled "Checkout" button with a short note + like "Online checkout isn't available on this plan yet" (and/or a contact/enquiry fallback). A + missing checkout is **not** a build error here — never leave the storefront half-built, throw, or + retry checkout because payment is off. When `payment_enabled` is `true`, wire checkout normally. +- **Webapp** → **no `content-model.md` and no headless backend to read**. The app uses its own data source: + the MySQL database from `site.json → database` (through the `DB_*` / `DATABASE_URL` env vars — set at + provision/deploy, see `../03-provision/webapp/SKILL.md` Step 3; `127.0.0.1` only resolves on the server, + not locally), or whatever external API the app calls. For an existing app, keep its data layer as-is. + +Guidelines (framework-agnostic): + +- **One data module** is the single place that talks to the backend (e.g. `src/lib/data.ts`) — one file + to inspect, one base URL to switch. Keep it swappable so the shell can build against stubs first. +- **Fetch at request time for SSR** (fresh content, no rebuild); a static build fetches at build time. +- **A failed or incomplete fetch is an error, not a silent empty/placeholder.** Planned content missing + from the API is a build error too — a site that builds with holes ships with holes. *(WordPress/Ecommerce; + a webapp has no planned content to check.)* +- **`content-model.md` is the source for what should exist** (pages, nav, collections mapping) — for + WordPress/Ecommerce only; a **webapp has no `content-model.md`**. +- **No `.htaccess`** in the project (deploy would overwrite the live one); don't commit `node_modules` + or build output. +- Set `engines.node` to `22` (the deploy builds on Node 22). +- **Next.js 16+ only:** set the `build` script to `next build --webpack`, not plain `next build`. Next.js + 16 defaults `next build` to **Turbopack**, whose native compiler needs a newer glibc than the Hostinger + build server has, so a Turbopack build fails on deploy and won't fall back to webpack. Webpack builds + clean there. The `--webpack` flag exists only on Next.js 16+; on **Next.js 15.x** leave the build script + as plain `next build` (it already defaults to webpack). (Alternative: pin Next.js to `15.x`, keeping + plain `next build`.) + +## Step 4 — Hand off + +Confirm the project builds clean locally against the live backend, no placeholder/fixture content +remains, and `state.json → frontend` matches what you built. Update `state.json` (`stage: "validate"`). +Validate (Step 7) owns the clean rebuild + user preview; deploy (Step 8) ships it with the `app_type` +and output directory that match the chosen framework + rendering. + +> **Webapp:** just confirm the app builds clean and `state.json → frontend` is right — there's no +> `content-model.md` or headless backend to check. A DB-backed app can't reach its database locally +> (`127.0.0.1` is server-only), so its data layer is verified **after** deploy, not here. diff --git a/skills/hostinger-headless/steps/07-validate/SKILL.md b/skills/hostinger-headless/steps/07-validate/SKILL.md new file mode 100644 index 0000000..a1d4b03 --- /dev/null +++ b/skills/hostinger-headless/steps/07-validate/SKILL.md @@ -0,0 +1,69 @@ +--- +name: headless-step-validate +description: "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." +--- + +# headless-step-validate + +> **Optional / fast.** Every stage before this one already verifies its own work (provision checks the +> backend, content-model push checks the REST counts, frontend builds clean), and **deploy verifies the +> live result**. So validate is **not** a re-run of all that — it's a quick confirmation, plus the +> optional human preview/sign-off. **Skip Steps 3–4 (rebuild + approval) entirely** on an autonomous or +> temp-domain run where no one is signing off; just do the two 10-second checks below and move on. Spend +> time here only when a person wants to see it before it goes live. + +**In:** project in `~/.hostinger/<domain>/src/`, `site.json`, `state.json`. + +## 1 — Instance status (always, quick) + +`provision.status == "completed"` **and** re-verify against the connector: +`hosting_websites_list-setups { "domain": "<domain>" }`, exact-match the row → `completed`. Anything +else → not ready; return to the instance barrier, don't deploy. + +## 2 — Backend API status (WordPress / WooCommerce / Ecommerce, always, quick) + +Hit the live API from `site.json` and confirm it answers **with the content the push created** — a 200 +with an empty catalog means the push didn't land: + +- **WordPress:** `GET {urls.rest_api}/posts?per_page=1` → 200 JSON array; check `X-WP-Total` against the + planned post count, and `GET {urls.rest_api}/media?per_page=1` → `X-WP-Total` matches the planned + image count (more = duplicates). +- **WordPress + WooCommerce** (`site.json → woocommerce.enabled`): the WordPress check above **plus** + `GET {urls.store_api}/products?per_page=100` → 200 with the seeded products and `prices` (minor units); + count matches the plan. Don't test checkout while `woocommerce.payment_enabled` is `false`. +- **Ecommerce:** query `urls.storefront_api` for the sales channel → 200 with the seeded products and + prices *(confirm against the store)*. +- **Contact form** (`site.json → contact_form.enabled`): POST one throwaway test submission (dummy + name/email/subject/message + CF7's hidden `_wpcf7*` fields — see + `../04-content-model/wordpress/contact-form.md` §3) to `contact_form.feedback_url` and assert the JSON + `status` is **`mail_sent`**. This is the one place a real send happens, so one test email reaches the + owner — not several. `mail_failed` is a real finding: surface it to the user (CF7 is wired, but + `wp_mail` delivery/SPF needs attention — a Hostinger mailbox on the domain is the most reliable + recipient) and log it as an issue. A `validation_failed` means the test payload didn't match the form's + fields — fix the field names in `site.json`, not the plugin. + **Then check the honeypot:** POST a **second** throwaway submission with the same valid fields **plus** + `contact_form.honeypot_field` **filled**, and assert the `status` is **`spam`** (no mail sent). Keep the + real fields valid — CF7 validates **before** running the spam filter, so an invalid payload would return + `validation_failed` and never reach the honeypot check. A `mail_sent` here means the + `cf7-headless-spamguard` plugin isn't active — surface it and re-check its deploy. + +Fail or counts off → fix content-model push before building against live data. + +## 3 — Clean rebuild against the live API (optional — skip unless signing off) + +Frontend already produced a clean build; only redo this if you want a from-scratch guarantee before a +human sign-off. Remove the build output and rebuild; a failed or incomplete build is a stop (no +fixtures, placeholder copy or `.htaccess` in the output). + +- **Astro (SSG):** `rm -rf dist && npm run build && npm run check:dist`. +- **Next.js (SSR):** `rm -rf .next && npm run check && npm run build`. + +## 4 — Preview & approval (optional — only when a person is signing off) + +When someone wants to see it first: start the local preview (Astro: `npm run preview`; Next.js: +`npx next start`), check desktop and mobile widths, navigation and a couple of key routes, and present +it for **explicit approval**. If preview tooling is unavailable, give the user the command and URL. +On an autonomous or temp-domain run, skip this — deploy will verify the live result anyway. + +Then (after approval, or immediately if 3–4 were skipped): `state.json` → `stage: "deploy"`, fresh +`updated_at`; leave `provision` unchanged. diff --git a/skills/hostinger-headless/steps/08-deploy/SKILL.md b/skills/hostinger-headless/steps/08-deploy/SKILL.md new file mode 100644 index 0000000..d990dbd --- /dev/null +++ b/skills/hostinger-headless/steps/08-deploy/SKILL.md @@ -0,0 +1,207 @@ +--- +name: headless-step-deploy +description: "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." +--- + +# headless-step-deploy + +Ship the frontend to the live domain through the Hostinger connector. You hand the connector a zip of +the project **source** and it uploads it, runs `npm install` + the build script on the server, and +serves the result. **Out:** `deploy.json`. + +This is an **instance-barrier** step: run it only after `provision.status == "completed"` and validate +(if run) has passed. All operations go through the connector (`execute`). + +> **Connected to the hosted server (`mcp.hostinger.com`)?** Its `hosting_deploy` deploys Git +> repositories only and cannot read a local `path`. Run this step through the local +> `hostinger-api-mcp` server instead (see the connect step: +> `npx --yes --package=@hostinger/mcp@latest hostinger-api-mcp`). + +> **With a shell and the Hostinger CLI installed**, the same deploy is one command, with the build +> settings passed exactly as the table below says. Run it from the project, since it links the current directory: +> `cd ~/.hostinger/<domain>/src && hostinger up --domain <domain> --type nodejs --app-type <appType> --output-directory <dir> --build-script build --node-version 22 --package-manager npm --yes --format json`. +> It uploads, builds, waits and returns `data.buildUuid` and `data.buildState`; add `--no-wait` to +> return at once and poll with `hostinger hosting nodejs build <domain> <uuid>`. Then continue at +> "Verify" below. + +**In:** the project in `~/.hostinger/<domain>/src/`, `site.json` (`username`, `urls`), `state.json` +(`frontend.framework`, `frontend.rendering`). + +> ## ⚠️ The one rule that makes this work: pass build settings explicitly +> If you let `hosting_deploy` **auto-detect** build settings, for a headless site that detection returns +> **null** (`appType:null`, `outputDirectory:null`, Node 20) — the build "completes" but the **root +> serves 403** (no `appType`/output → the vhost serves a directory with no entry). This is reproducible. +> **Always pass `appType`, `outputDirectory`, `nodeVersion`, `buildScript`, `packageManager` and +> `rootDirectory` on the `hosting_deploy` call** with the framework's values; the deploy then resolves +> them and serves correctly, and `/cms` is preserved. + +## Build configurations + +Map `frontend.framework` (from `state.json`) to its `appType` + build output directory, and pass those +on the `hosting_deploy` call. `appType` is the framework's value, e.g. `next`, `nuxt`, `astro`, +`svelte-kit`, `nest`, `express`, `fastify`, `react`, `create-react-app`, `gatsby`, `vite`, `vue`, +`angular`, `parcel`, `svelte`, `other`. + +Common cases (all `nodeVersion: 22`, `buildScript: "build"`, `packageManager: "npm"`, `rootDirectory: "."`): + +| Framework / mode | Local config | `appType` | `outputDirectory` | +|---|---|---|---| +| **Next.js — SSR** *(default)* | `output: 'standalone'` | `next` | `.next` | +| **Nuxt — SSR** | default | `nuxt` | `.output` | +| **SvelteKit — SSR** | node adapter | `svelte-kit` | `build` | +| **Astro — static** | `output: 'static'` | `astro` | `dist` | +| **Vite / React / Vue — static (SPA)** | — | `vite` (or `react`/`vue`) | `dist` | +| **Express / NestJS / Fastify — server** | — | `express`/`nest`/`fastify` | build output dir | + +SSR/server frameworks run a persistent Node process (root sends `x-powered-by` / `cache-control: no-store`); +static frameworks serve their output dir as files. If unsure of a framework's output dir, check the +framework's own build output, but **always pass `appType`/`outputDirectory` on the deploy call with the +framework's correct values** — don't rely on auto-detection (it returns null → 403). + +> ## ⚠️ Next.js 16+: build with webpack, not Turbopack (recurring deploy failure) +> Next.js 16 makes **Turbopack** the default bundler for `next build`. Turbopack's native (Rust) +> compiler needs a newer **glibc** than the Hostinger build server ships, so the server build aborts +> with a "glibc too old" error — and Turbopack **does not fall back** to webpack on its own, so the +> deploy fails every time. **Fix: build with webpack.** On **Next.js 16+** the project's `build` script +> in `package.json` must be `next build --webpack` (not plain `next build`); `buildScript` on the deploy +> call stays `"build"` — that's what runs it. The `--webpack` flag exists only on Next.js 16+; on +> **Next.js 15.x** leave the build script as plain `next build` (it already defaults to webpack). +> **Confirm this in `package.json` before deploying** (the frontend step scaffolds it this way — see +> `../06-frontend`). Alternative, only if you must avoid the `--webpack` flag: pin Next.js to `15.x` +> (keeping plain `next build`), whose `next build` still defaults to webpack. Next.js only — other +> frameworks are unaffected. + +## Step 1 — Pre-flight + +1. **Instance live.** `provision.status == "completed"`, re-verified with + `hosting_websites_list-setups { "domain": "<domain>" }`. +2. **Build output exists locally** (the framework's output dir — e.g. `.next/`, `dist/`, `.output/`) and + the project builds clean (frontend/validate already did this). If not, go back. + - **Next.js 16+:** `package.json`'s `build` script is `next build --webpack`, not plain `next build` — + the server's glibc can't run Turbopack's native compiler (see the box above). On Next.js 15.x leave it + as plain `next build`. Fix it here if it isn't. +3. **Env vars (only if used).** Build-time vars (e.g. `NEXT_PUBLIC_*`) must be set **before** the build + with `hosting_nodejs_replace-environment-variables` (full replace). Never put secrets in the repo or + the archive. **Webapp with a database:** that full replace wipes the `DB_*`/`DATABASE_URL` that + `hosting_databases_setup-website` injected (the generated password reads back masked), so set all app + vars in the same replace and, if you can't, first reset the password with + `hosting_databases_change-password` and resend every `DB_*`/`DATABASE_URL` — see + `../03-provision/webapp/SKILL.md` Step 3. + +## Step 2 — Archive the source + +Zip the project **source** with `package.json` at the archive root. **Include** the app/source, +`public/`, the framework config (`next.config.mjs` / `astro.config.mjs`), `tsconfig.json`, +`package.json`, `package-lock.json`, `.nvmrc`. **Exclude** `node_modules/`, the build output +(`.next/`, `dist/`, `out/`), `.git/`, and any `.htaccess` — the server installs and builds from source. + +```bash +cd ~/.hostinger/<domain>/src +zip -r -X ../app.zip . -x 'node_modules/*' '.next/*' 'dist/*' 'out/*' '.git/*' '**/.htaccess' +``` + +## Step 3 — Deploy (one shot: settings + upload + build) + +Pass the framework's build settings **inline** on the deploy call — there is no separate settings step. +`path` is the archive from Step 2 (or the source directory itself; `hosting_deploy` archives a directory +for you, skipping `node_modules/`, `.git/` and `.env`, but it does **not** read `.gitignore`, so the +build output must already be absent). + +```json +{ "operation": "hosting_deploy", + "params": { + "domain": "<domain>", + "path": "/absolute/path/to/.hostinger/<domain>/app.zip", + "type": "nodejs", + "appType": "<framework appType, e.g. next>", + "outputDirectory": "<framework output dir, e.g. .next>", + "nodeVersion": 22, + "buildScript": "build", + "packageManager": "npm", + "rootDirectory": "." } } +``` + +`hosting_deploy` uploads the source and triggers `npm install` + the build **end-to-end** — do not call +any upload or build operation yourself. It returns a build `buildUuid` at once. This **overwrites the +website root** (expected) and keeps `/cms` intact. Passing `appType`/`outputDirectory` explicitly is what +keeps the root from serving 403 (see the rule above). + +## Step 4 — Poll to completion + +Poll `hosting_get-deployment { "domain": "<domain>", "buildUuid": "<buildUuid>" }` with backoff until +`build.state` is `completed`. On `failed`, the same response carries the end of the build log and the +failure analysis — read it, fix locally, re-archive and redeploy (pass `fromLine`, the line count from +the previous call, to read only new log output). `hosting_get-deployment` also returns the build settings +stored for the site: `appType`/`outputDirectory`/`nodeVersion` should echo what you passed; if they read +`null`, the settings didn't take — fix the deploy call before continuing. For an SSR app that built but +hangs, `hosting_nodejs_restart-application { username, domain }` recovers the server without a rebuild. + +## Step 5 — Verify the live site (all must pass) + +| Check | How | Expect | +|---|---|---| +| Root serves | `GET https://<domain>/` | **200** (not 403 / 404) | +| SSR is live *(server frameworks)* | response headers | `x-powered-by` present, `cache-control: no-store` | +| Static serves *(static frameworks)* | `GET https://<domain>/` | 200 HTML with real home content | +| Key routes | a couple of planned routes + one deep route | 200, real content | +| **Backend survived** *(WordPress / WooCommerce)* | `GET https://<domain>/cms/wp-json/wp/v2/posts?per_page=1` | 200 | + +> **Only WordPress (including WordPress + WooCommerce) has a `/cms` on the domain**, so the *Backend +> survived* check is WordPress-only. +> - **WordPress + WooCommerce:** also confirm the store survived the deploy — +> `GET https://<domain>/cms/wp-json/wc/store/v1/products?per_page=1` → 200. There is nothing external to +> link (no sales-channel step — that's Ecommerce only). **Checkout** stays owner-completed: when +> `site.json → woocommerce.payment_enabled` is `false`, don't test `/checkout`; a catalog that serves 200 +> is a passing, complete deploy. +> - **Ecommerce:** the backend is the **Hostinger Ecommerce store**, read over the external Storefront API +> (`api-ecommerce.hostinger.com`) — nothing a deploy touches lives on this domain. There is no `/cms`; +> confirm the storefront still fetches products as a frontend check if you like. **Only check that +> checkout can be reached when `site.json → ecommerce.payment_enabled` is `true`** — on a free account +> (`payment_enabled: false`) there is no checkout, so **skip that check entirely**; a catalog that serves +> 200 is a passing, complete deploy. Never fail or hold the deploy because checkout is unavailable. +> - **Webapp:** no content backend at all — the whole root is the app. Skip the check; `/cms` won't resolve. + +A `completed` build whose root 403s almost always means the build settings weren't passed to +`hosting_deploy` (null settings) — pass them and redeploy. Caching can briefly serve a stale root; +re-check after ~30 s. + +## Step 6 — Record `deploy.json` + +```json +{ + "build_uuid": "…", + "stack": "nextjs", + "rendering": "ssr", + "app_type": "next", + "output_directory": ".next", + "node_version": 22, + "method": "hosting_deploy (type: nodejs, settings passed inline)", + "deployed_at": "…", + "verified_at": "…", + "urls": { "live": "https://<domain>", "cms": "https://<domain>/cms" }, + "checks": { "root": 200, "ssr_headers": true, "backend_preserved": true } +} +``` + +No secrets in `deploy.json`. The `urls.cms` field and the `backend_preserved` check above are +**WordPress-only** — adjust the record to the backend: + +- **WordPress:** keep `urls.cms` and `checks.backend_preserved` as shown. +- **WordPress + WooCommerce:** keep `urls.cms` + `checks.backend_preserved` (it *is* WordPress), and add + `urls.store_api` from `site.json`; no external link step. +- **Ecommerce:** omit `urls.cms`/`backend_preserved`; record the store URLs instead (`urls.storefront_api`, + `urls.store_admin` from `site.json`) and link the custom sales channel to the live domain — + `ecommerce_sales-channels_update { store_id, sales_channel_id, url }` (see the ecommerce provision step). +- **Webapp:** omit `urls.cms` and `backend_preserved` entirely — `urls` is just `{ "live": "https://<domain>" }`. + +Update `state.json` (`stage` → `done`), then continue to Step 9 (done). + +--- + +## Note — one deploy path, and never `type: "static"` + +`hosting_deploy` on the local server is the only path: give its `path` either the source **directory** (it archives it for +you) or a local **archive** (`zip`, `tar`, `tar.gz`, `tgz`, `7z`) — there is no separate upload or build +step to fall back to. Either way pass the explicit `appType`/`outputDirectory`/`nodeVersion`/ +`buildScript`/`packageManager`/`rootDirectory` from the table. Never deploy a headless site with +`type: "static"` — it serves the directory as plain files and can overwrite `/cms`. diff --git a/skills/hostinger-headless/steps/09-done/SKILL.md b/skills/hostinger-headless/steps/09-done/SKILL.md new file mode 100644 index 0000000..baa8db4 --- /dev/null +++ b/skills/hostinger-headless/steps/09-done/SKILL.md @@ -0,0 +1,161 @@ +--- +name: headless-step-done +description: "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." +--- + +# headless-step-done + +Verify the live site, then hand over the URLs (success page, main site, WordPress admin, dashboard), a +summary of what was built, and what the user can do next. **On a successful run, open the success page +for the user** (see *Open the success page*). + +## Verify the live site (brief) + +On a run that deployed this session, re-confirm what you're about to hand over actually works (deploy +already checked this — this is a quick re-confirm, not a full re-run): + +- **Main site:** `GET https://<domain>/` → 200 (Next.js SSR: `x-powered-by: Next.js`; Astro SSG: static + HTML with real content). +- **WordPress admin:** `GET {urls.wp_admin}` → reachable (a login page, 200/302), not 404. +- **Backend API:** `GET {urls.rest_api}/posts?per_page=1` → 200 (WordPress); for a **WooCommerce** store + (`site.json → woocommerce.enabled`) also `GET {urls.store_api}/products?per_page=1` → 200; or the + storefront API for the sales channel → 200 (Ecommerce). +- **Contact form** (`site.json → contact_form.enabled`): validate already sent a test submission, so + don't send another here. In the handover, tell the owner the contact page is live and enquiries email + the **WordPress admin address** (via `wp_mail`) — and that for the most reliable delivery that address + should be a Hostinger mailbox on the domain. If validate reported `mail_failed`, say so plainly instead + of claiming the form sends. + +If a check fails, say so in the outcome paragraph and don't claim the site is live. + +## Final report + +Every run ends with this report, whether it finishes here or stops at an earlier stage (a dummy or +missing step, a failed provision, the user pausing). Build it from `state.json`, `site.json`, +`deploy.json` and what you actually did and checked in this run. Report only what you verified. Don't +claim a stage passed if you didn't check it. + +Write it in this order: + +1. **One-paragraph outcome.** What was created and on which domain (bold it). If the run stopped + early, name the stage it stopped at, why, and what the user will see right now (e.g. "the site + root returns 403 until the frontend is deployed"). + +2. **Steps completed** — a table with one row per stage that ran: + + | Stage | Result | + |---|---| + | connect | How the connector was reached, MCP version, how sign-in happened. | + | 1 check state | New build / resumed at `<stage>` / adopted an existing site. | + | 2 refine | What the brief is and which backend it chose (say if a sample brief was used). | + | 3 provision | Plan and order used, how long setup took, which verification checks passed. | + | 4 content model | What was created (counts of pages, posts, terms, menus, media, SEO) and how it was verified. | + | … | One row for each further stage that ran (design, frontend, validate, deploy, done). | + + Stages that didn't run are left out of the table and named in the outcome paragraph instead. + Use the stage numbers and names from the orchestrator's flow table. + +3. **Links** — every URL that exists at this point. Build the hPanel URLs from `site.json` + (`order_id`, `username`, `domain`) and the onboarding slug; take the rest from `site.json` / + `deploy.json`. Lead with the one the user cares about most — the **main site** — then the admin + and dashboards. **Don't list the success page here:** it opens on its own (see *Open the success + page*), so printing its URL in the report is just noise — the only exception is when a browser + can't be opened (handled in that section): + + - **Main site (live):** `urls.site` (`https://<domain>`) — only once deployed. + - **WordPress admin (one-click auto-login):** for a WordPress backend, **mint and hand over the + auto-login redirect URL** — `wordpress_login_create-links { username, software: <site.json + wordpress.install_id> }` returns a temporary link that logs the user straight into wp-admin. Give + that URL as the admin link (it lands on `/cms/wp-admin`), so the user never needs a password. Also + note the plain `urls.wp_admin` as a fallback. **Never print or store a password.** For a + **WordPress + WooCommerce** store, this *same* wp-admin login is the store admin (wp-admin → + WooCommerce → Products/Orders) — do **not** use the Ecommerce `store_admin` path. **Call out the one + setup step under *Gaps / follow-ups*:** when `site.json → woocommerce.payment_enabled` is `false`, + the owner must enable a payment method + a shipping zone in WooCommerce → Settings before checkout + works (the catalog and cart already work). For a Hostinger **Ecommerce** store, link the store admin + from `urls.store_admin` instead. + - **hPanel site dashboard:** `https://hpanel.hostinger.com/websites/<domain>` (the headless website + is installed by now). + - **Onboarding (resume):** `https://hpanel.hostinger.com/headless-onboarding/<order_id>/<username>/` + - **REST API** (for developers): `urls.rest_api`. + + > URL shape note: `<username>` may be dropped from the onboarding/success URLs in a future hPanel + > release. If a built URL 404s, retry the same path without the `/<username>` segment before + > reporting it as broken. + +4. **Where state lives** — the workspace path `~/.hostinger/<domain>/`, the `stage` that a + resumed run will start from, and the build report `~/.hostinger/<domain>/report.html`. + +5. **Gaps / follow-ups** (only if any) — a short numbered list of anything skipped, approximated or + left for the user (e.g. no images generated, default WordPress content left in place). + +Never include passwords, JWTs or other secrets in the report. For wp-admin access, give the URL and +offer a one-click login link (`wordpress_login_create-links`), not credentials. + +## Open the success page + +On a **successful** run (the deploy succeeded and the live checks passed), open the success page in the +user's **main / system browser** right after presenting the report, so they land on the hPanel +celebration/next-steps screen for their new site. This is a side effect only — **don't print the +success URL in the report output** (it just opens); the one exception is the can't-open-a-browser +fallback below. Build the URL with `<tool>` as the running tool's onboarding slug (see *State and +artifacts* in the top-level `SKILL.md`): + +```bash +# Build the URL first, then open it with the OS default (main) browser — not an embedded one. +# <tool> = the onboarding slug +URL="https://hpanel.hostinger.com/headless-onboarding/<order_id>/<username>/success/<domain>/<tool>" +open "$URL" # macOS +# xdg-open "$URL" # Linux +# start "" "$URL" # Windows +``` + +- **Main browser only.** Use the OS opener above (`open` / `xdg-open` / `start`) so the page lands in + Chrome, Safari, Edge, or whatever the user actually uses. **Do not** open it in an embedded or + sandboxed browser (Cursor IDE browser / `cursor-ide-browser`, Playwright, Puppeteer, WebView, or + any in-agent browser tab). Those stay inside the tool session and the user won't see the + celebration page where they expect it. +- Open it **once**, and only on a successful run — never on an early stop or a failed deploy. +- If your tool can't open a browser (the command isn't available, or `open`/`xdg-open`/`start` + fails), don't treat it as a failure and **don't fall back to an embedded browser**. This is the + **one** case where you print the success URL — show it once in the report and tell the user to + open it themselves in their main browser. + +## Build report + +Right after the final report, save the build report for this run. It turns the run log (stage times +from `state.json`, events from `events.jsonl`) into `reports/<run>.json` and `.html`, and copies the +HTML to `report.html` in the workspace: + +```bash +node <skill>/run-log.mjs <domain> event good '{"text":"<something that went well>"}' # one per thing, if not logged yet +node <skill>/run-log.mjs <domain> stage-end done ok +node <skill>/run-log.mjs <domain> report --outcome <live|stopped|failed> [--tokens '<json>'] +``` + +Then send it to Hostinger: pass `reports/<run>.json` unchanged to `hosting_headless_submit-build-report` +and run `report-sent <run>` (or `report-rejected <run>` on a 422). The `report` command prints the +exact path and run id. Don't ask first; see *Run log and build report* in the top-level `SKILL.md` +for failures. Then tell the user in one line: "Sent the build report to Hostinger to help improve +the flow." On a 422 say it's saved locally only; on any other failure say it'll be sent next session. + +If the user asks for more changes after this report in the same session, call +`run-log.mjs <domain> run-resume --reason "<what they asked for>"` before the work. Then log as +usual and run `report` again when it's done. It updates this run's report in place, and the report +lists the follow-up. Send the updated report the same way. + +- **Outcome:** `live` when the deploy succeeded this run, `failed` when a stage failed and you + couldn't recover, `stopped` for everything else (the user paused, a dummy or missing step). Leave + `--outcome` out and the script decides from the log. +- **Before you write it,** check that every issue, correction and skill gap from this run is in + `events.jsonl`. Log any that are missing now; the report only shows what was logged. +- **After you write it,** read the `warning:` lines `report` prints. They list logging gaps: a stage + that never ended, a start that was estimated, a stage logged with no duration, or (on a live build) + a stage that was never logged. Fix what you can: `stage-end` an open stage, or correct a start with + `stage-fix <stage> --started-ago <time>`, then run `report` again. What you can't fix stays in the report's + *Logging gaps* section. +- **Tokens:** only what your tool tells you about your own usage (see *Run log and build report* in + the top-level `SKILL.md`). +- In the final report's *Where state lives*, add the path to `report.html` so the user can open it. + +Do this on every run, including the early stops: those reports are the most useful ones. diff --git a/skills/migrate-to-hosting/SKILL.md b/skills/migrate-to-hosting/SKILL.md index d18f365..7fe72e6 100644 --- a/skills/migrate-to-hosting/SKILL.md +++ b/skills/migrate-to-hosting/SKILL.md @@ -41,11 +41,11 @@ Create the Hostinger website on the domain being migrated, not on a free subdoma ## 3. Import -`hosting_import-wordpress-website`, `hosting_deploy-static-website` and `agency-hosting_deploy-php-application` read files from this machine and exist only in the local `hostinger-api-mcp` server. On the hosted server (`mcp.hostinger.com`), upload the files first as described in the `deploy-to-hosting` skill ("Without the local deploy operations") and use the operation named in brackets below. +`hosting_deploy` with `path` and `agency-hosting_deploy-php-application` read files from this machine, so only the local `hostinger-api-mcp` server has them; the hosted server's `hosting_deploy` deploys Git repositories only. On the hosted server (`mcp.hostinger.com`), upload the files first as described in the `deploy-to-hosting` skill ("Without the local deploy operations") and use the operation named in brackets below. -**WordPress, Shared and Cloud:** `hosting_import-wordpress-website` with `domain`, `archivePath` and `databaseDump` uploads both, extracts the files and imports the database; large sites take several minutes. [Upload `site.zip` and `dump.sql`, then `wordpress_installations_import-website` with `archive_path` and `sql_path`.] Then `wordpress_installations_detect` with `username` and poll `wordpress_installations_list` with `domain` until the install appears with `is_valid: true`. +**WordPress, Shared and Cloud:** `hosting_deploy` with `domain`, `path` (the site directory or its archive), `databaseDump` and `type: "wordpress-site"` uploads both, extracts the files and imports the database; large sites take several minutes. Always pass `type`: left to detection, a directory without `wp-config.php` or `wp-content` at its top level is taken for a plain site and the dump is not imported. [Upload `site.zip` and `dump.sql`, then `wordpress_installations_import-website` with `archive_path` and `sql_path`.] Then `wordpress_installations_detect` with `username` and poll `wordpress_installations_list` with `domain` until the install appears with `is_valid: true`. -**Static, or PHP without a database:** `hosting_deploy-static-website` [upload, then `hosting_websites_deploy-static-site-archive`]; Agency: `agency-hosting_deploy-php-application` [upload to `.h5g/`, then `agency-hosting_files_import-website-from-archive`]. +**Static, or PHP without a database:** `hosting_deploy` with `domain` and `path` [upload, then `hosting_websites_deploy-static-site-archive`]; Agency: `agency-hosting_deploy-php-application` [upload to `.h5g/`, then `agency-hosting_files_import-website-from-archive`]. **PHP with a database, Shared and Cloud:** diff --git a/skills/troubleshoot-website/SKILL.md b/skills/troubleshoot-website/SKILL.md index 2357f0e..866f502 100644 --- a/skills/troubleshoot-website/SKILL.md +++ b/skills/troubleshoot-website/SKILL.md @@ -44,9 +44,9 @@ dig +short NS DOMAIN; dig +short A DOMAIN; dig +short CAA DOMAIN ### Node.js (`nodejs`) -Batch `hosting_nodejs_list-builds` (`per_page: 3`), `hosting_nodejs_runtime-logs` (`period: "1h"`, `levels: ["ERROR", "WARN"]`) and `hosting_nodejs_list-environment-variables` (keys only; values always come back masked). +Batch `hosting_get-deployment` (the latest build with its log, failure analysis and stored settings), `hosting_nodejs_runtime-logs` (`period: "1h"`, `levels: ["ERROR", "WARN"]`) and `hosting_nodejs_list-environment-variables` (keys only; values always come back masked). -- Latest build `failed`: call `hosting_nodejs_analyse-failed-build` once for that build — every call re-runs the analysis and the limit is 5 a minute. When it returns nulls, read `hosting_nodejs_build-logs`. A wrongly detected framework, output directory or missing `entry_file` is the usual cause: compare `hosting_nodejs_build-settings` with the project, rebuild with corrected values through `hosting_nodejs_start-build`, and store the same values with `hosting_nodejs_update-build-settings` so Git pushes build correctly too. +- Latest build `failed`: `hosting_get-deployment` already holds the `analysis` (fetched once per build — the API re-runs it on every call and allows 5 a minute). A wrongly detected framework, output directory or missing `entry_file` is the usual cause: compare the returned `buildSettings` with the project and deploy again with corrected `appType`, `outputDirectory`, `buildScript` or `entryFile`; a Git deploy stores them, so pushes build correctly too. A site deployed on the hosted server from uploaded files has no `hosting_deploy` to repeat: rebuild through `hosting_nodejs_start-build` with the corrected values and store them with `hosting_nodejs_update-build-settings`. - Build `completed` but the app errors: read the runtime logs; entries older than `last_deployed_at` belong to the previous deploy. A variable the code reads but the key list lacks is fixed with the `deploy-to-hosting` skill's environment step. `Access denied for user '…'@'::1'` means the app connects to `localhost` — `DB_HOST` must be `127.0.0.1`. - Hung process: `hosting_nodejs_restart-application` (restarts without rebuilding).