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..bbe4c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.3.2] - 2026-10-21 + +### Changed + +- Synced with `@hostinger/mcp` 3.4.0: 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..aca1664 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.4.0", + "total": 427, "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", @@ -155,6 +160,10 @@ "hosting_cache_clear-website", "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 +179,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 +195,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", @@ -226,7 +242,8 @@ "hosting_websites_delete", "hosting_websites_deploy-static-site-archive", "hosting_websites_list", - "hosting_websites_list-setups" + "hosting_websites_list-setups", + "hosting_websites_start-setup" ], "mail": [ "mail_aliases_create-alias", @@ -293,11 +310,15 @@ "reach_forms_delete", "reach_forms_get", "reach_forms_list", + "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", @@ -370,6 +391,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 +413,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..c39c907 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`; on the hosted server use `hosting_nodejs_list-builds` with `per_page: 1`) 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..1eebdf2 100644 --- a/skills/deploy-to-hosting/SKILL.md +++ b/skills/deploy-to-hosting/SKILL.md @@ -24,27 +24,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 | -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. +- 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. -Archive rules (name archives `name_YYYYMMDD_HHMMSS.zip`): +**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 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` and the `agency-hosting_deploy-*` operations read files from this machine, so only the local server has them. On the hosted server (`mcp.hostinger.com`) `search` does not find them — upload the files yourself as below. -### Without the local deploy operations +### Without the local deploy operations (hosted server; on the local server only for Agency) 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,15 +72,29 @@ 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 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. + +On the hosted server, which has no `hosting_deploy` or `hosting_get-deployment`: +- After the upload steps below, start the build and track it with `hosting_nodejs_list-builds` and `hosting_nodejs_build` (by `uuid`), polling every 10–20 s. - 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`. +- Wrong detection: 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`. - 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. +On the local 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. A Git deploy has nothing to detect from, so when none are stored pass `appType` and `nodeVersion` (from `engines` in `package.json`), plus `outputDirectory` and `buildScript` if the app has a build step; they are stored for those pushes. + +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`. + +On the hosted server, by hand: + +1. `hosting_git_list-installations`. With no `active` installation (check `status: "pending"` and `"suspended"` too), the user connects GitHub or GitLab once in hPanel, as above. 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. diff --git a/skills/hostinger-headless/SKILL.md b/skills/hostinger-headless/SKILL.md index d709179..a149bba 100644 --- a/skills/hostinger-headless/SKILL.md +++ b/skills/hostinger-headless/SKILL.md @@ -1,56 +1,265 @@ --- 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`. +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.** -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. +--- + +## 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. + +### 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 a build is discoverable and 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, and `~/.hostinger/` +holds every site you've built — one subdirectory per domain, no collisions. + +| 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. + +--- + +## 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. -## Paths +**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..c05a95f --- /dev/null +++ b/skills/hostinger-headless/steps/01-check-state/SKILL.md @@ -0,0 +1,209 @@ +--- +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. + +--- + +## 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, +`~/.hostinger/` holds every site the user has built and each one is discoverable and resumable from +any directory — no collisions. + +`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 + +Look at `~/.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`. +- **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. + +--- + +## 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. +- **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..4e82948 --- /dev/null +++ b/skills/hostinger-headless/steps/02-refine/SKILL.md @@ -0,0 +1,273 @@ +--- +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.** The only thing you must know is *what they are building* — enough to guess the +backend. Every other question is skippable: 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. + +> **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). + +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. If the folder has nothing site-like, assume they don't + and lean toward "agent builds it". +- 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 + +If Step 2 already answers *what are you building* and *will they sell*, skip the Round 1–3 questions +below, but still ask **Build mode** before moving on. + +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", "you decide", or a skipped question means *use defaults*, not *ask +again*. + +**Round 1 — what it is (the only required round):** + +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. *This decides the backend.* Skip it only when the answer is already unmistakable + ("an online shop for my candles", "my personal blog"); when in doubt, ask. +3. **Who is it for?** The audience, and how they'll find the site. *(skippable)* + +**Round 2 — what goes on it (optional, adapt to Round 1):** + +- **Content they'll publish over time** — blog posts, articles, news, recipes, portfolio pieces, + case studies? How often? Or is it a handful of pages that rarely change? +- **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. + +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. + +### Build mode — ask once (this one *is* a user choice) + +The backend is your call, but **how the frontend gets built is the user's** — because most of the time +the user already has their own frontend and just needs a Hostinger backend to wire it to. After the +folder + memory inspection (Step 2), ask **one** question (a structured choice is ideal here), seeding +the default from what you found: + +- **Bring your own frontend** *(default when Step 2 found a real project in the folder)* — "I already + have a frontend / I'll drive the design and we vibe-code it together." 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. +- **Agent designs and builds it** *(default when the folder has nothing site-like)* — "Give me ideas + — you design and code it." Today's flow: the design step proposes directions and the frontend step + scaffolds and implements them. + +Record the answer as the brief's **Build mode**. 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. +- **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 silence-equivalent like "go") → write +`brief.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. 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.> + +## Pages +- **** — +- … + +## Products + + +## Content + + +## Must-haves +- … + +## Out of scope +- … + +## Assets + + +## 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..0d97e27 --- /dev/null +++ b/skills/hostinger-headless/steps/03-provision/ecommerce/SKILL.md @@ -0,0 +1,142 @@ +--- +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 (don't call a separate + subdomain op first — passing a generated free subdomain back as `domain` 422s). The response carries + the generated domain; if it differs from the workspace dir, move `~/.hostinger//` → `/` 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..972344c --- /dev/null +++ b/skills/hostinger-headless/steps/03-provision/webapp/SKILL.md @@ -0,0 +1,154 @@ +--- +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. Don't call a separate subdomain op to make one + first: there's no need, and passing a generated free subdomain back as `domain` fails with + `422 … Free subdomains cannot be provided`. The response then carries the generated domain: if it + differs from the workspace directory name, move `~/.hostinger//` to `~/.hostinger//`, + 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). +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..f8ddaa2 --- /dev/null +++ b/skills/hostinger-headless/steps/03-provision/wordpress/SKILL.md @@ -0,0 +1,249 @@ +--- +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. Don't call a separate subdomain op to make one + first: there's no need, and passing a generated free subdomain back as `domain` fails with + `422 … Free subdomains cannot be provided`. The response then carries the generated domain: + if it differs from the workspace directory name, move `~/.hostinger//` to `~/.hostinger//`, + 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 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" + } +} +``` + +`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..1c598b8 --- /dev/null +++ b/skills/hostinger-headless/steps/04-content-model/SKILL.md @@ -0,0 +1,228 @@ +--- +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 (a skipped answer / "you decide" = use the default): + +| 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 / web search | 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), that's a third path: put the files in `media/` +and record alt text — 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/`. + - Don't call `unsplash.com/napi/…`. It sits behind a bot challenge and returns 401. +2. **Resolve a direct file URL.** `GET https://unsplash.com/photos//download?force=true` (don't + follow the redirect) answers `302` with `Location: https://images.unsplash.com/photo-<…>?…`. The + `dl=` parameter in it also carries the photographer's name. Drop the query string and request a + web size: `https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max`. The upload ability + needs this direct file URL, which returns 200 with `image/jpeg`. A photo *page* URL won't work. +3. **Look before you keep it.** Download a small preview (`?w=500`) and view it. Search snippets + only show captions, and a photo 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 + ``` + +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..2fcbe70 --- /dev/null +++ b/skills/hostinger-headless/steps/04-content-model/wordpress/SKILL.md @@ -0,0 +1,223 @@ +--- +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. + +## 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`. A generic +`RestEndpointTool` can reach any `wp/v2` route *through the authenticated session* if no dedicated tool +fits. + +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 (or `RestEndpointTool` → `GET /wp/v2/posts?slug=…&status=any`): 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 + +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. + +## 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/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..6f05740 --- /dev/null +++ b/skills/hostinger-headless/steps/05-design/SKILL.md @@ -0,0 +1,464 @@ +--- +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), then writes design.md — tokens, type, logo, components, per-page composition, accessibility and imagery — as the contract the frontend implements (the framework/stack is chosen later by the frontend step). Prepare work: runs during the provision wait and never touches the backend." +--- + +# 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: 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. The logo +> and token work here apply only when **Build mode is "Agent designs and builds"**. (This mirrors the +> frontend step's existing "existing app the user brought" handling, now generalised to any backend.) + +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) 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 jargon (palette, accent, tokens, shape language, contrast). + +**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. + +## 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.** 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"). + +**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. + +> **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. +- **"You decide" / skipped** → use A, and tell them in one line which look you went with. +- **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. + +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 **logo idea** from the +chosen direction 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, and validates +the output. 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 @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. Run it on every file before writing | + +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 solid rounded tile (corner radius ≈ ¼ of the side) 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. + +**Check the sizes.** Display heights are multiples of 8, with one for desktop and one for small +screens (they may be equal). Work them out from the viewBox: + +- **Letter size:** letter height on screen = letter height ÷ viewBox height × display height, and it + must be ≥ 16 px. Use `xHeight` when the logo is mostly lowercase and `capHeight` when it's mostly + capitals; for mixed fonts, take the smaller word. A two-line lockup usually needs 64 px. +- **Headroom:** `pathBox` covers the letters, not what you add. Leave room above for accents (Ė, Ž, Å sit above cap height) and below for descenders, plus whatever bounce, tilt, outline and shadow add, so nothing touches the viewBox edge. +- **Width:** width on screen = viewBox width ÷ viewBox height × display height. At the small-screen + height it must be **≤ 192 px**, which leaves room for the menu button in a 320 px header. If it + doesn't fit, lower the small-screen height (letters still ≥ 16 px), simplify the logo, or show + `logo-mark.svg` with the name beside it on small screens. +- **Look at it.** If you have a browser, render the files at both header heights, 32 px and 16 px, + the favicon on a light and a dark background, and fix anything that reads wrong. Without a + browser, rely on the numbers 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`). + +## `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>."> + +## 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 | "Supplied by the user"> +- **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 one). +- **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. +- **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..49efe2b --- /dev/null +++ b/skills/hostinger-headless/steps/05-design/logo-kit.mjs @@ -0,0 +1,201 @@ +// 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 @fontsource/<display-family-slug> +// node logo-build.mjs // your script: import { … } from './logo-kit.mjs' +// 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 } from 'node:fs'; +import { join } from 'node:path'; + +const requireFromCwd = createRequire(join(process.cwd(), 'logo-build.js')); +const opentype = requireFromCwd('opentype.js'); + +// @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 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 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>`; +} + +/** 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 , or a missing viewBox. + * @returns {{ bytes: number }} + */ +export function check(svg) { + const problems = []; + if (/NaN|undefined|Infinity/.test(svg)) problems.push('contains NaN/undefined/Infinity'); + 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="0 0 [\d.]+ [\d.]+"/.test(svg)) problems.push('missing or invalid viewBox'); + if (problems.length) throw new Error(`logo-kit check failed: ${problems.join('; ')}`); + return { bytes: Buffer.byteLength(svg) }; +} 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..3c0dff5 --- /dev/null +++ b/skills/hostinger-headless/steps/06-frontend/SKILL.md @@ -0,0 +1,160 @@ +--- +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 the user brought to deploy** (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. +> +> 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); 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. +- **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). + +## 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..f44e1d9 --- /dev/null +++ b/skills/hostinger-headless/steps/07-validate/SKILL.md @@ -0,0 +1,56 @@ +--- +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)*. + +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..43b1dff --- /dev/null +++ b/skills/hostinger-headless/steps/08-deploy/SKILL.md @@ -0,0 +1,179 @@ +--- +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`). + +**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). + +## 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. +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` 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..67988fa --- /dev/null +++ b/skills/hostinger-headless/steps/09-done/SKILL.md @@ -0,0 +1,157 @@ +--- +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). + +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 `state.json` (`tool`); 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 (`claude` for +Claude Code, else `cursor`, `codex`, `vscode`, `antigravity`, `devin`), mapped from +`state.json → tool` (e.g. `claude-code` → `claude`): + +```bash +# Build the URL first, then open it with the OS default (main) browser — not an embedded one. +# <tool> = the onboarding slug (claude for Claude Code, else cursor/codex/vscode/antigravity/devin) +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..f047dd3 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` 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. -**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..8d4abe2 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). On the hosted server, which has no `hosting_get-deployment`, use `hosting_nodejs_list-builds` (`per_page: 3`) in its place. -- 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. On the hosted server: `hosting_nodejs_analyse-failed-build` once for that build, then `hosting_nodejs_build-logs`; compare `hosting_nodejs_build-settings`, rebuild through `hosting_nodejs_start-build` and store the values 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).