hostinger-api-mcp 3.27.4 → 3.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/gemini-extension.json +1 -1
  3. package/package.json +1 -1
  4. package/server.json +2 -2
  5. package/skills/headless/SKILL.md +49 -21
  6. package/skills/headless/VERSION +3 -3
  7. package/skills/headless/connect/SKILL.md +51 -3
  8. package/skills/headless/connect/tools/antigravity.md +21 -19
  9. package/skills/headless/connect/tools/claude-code.md +24 -14
  10. package/skills/headless/connect/tools/codex.md +21 -15
  11. package/skills/headless/connect/tools/cursor.md +20 -14
  12. package/skills/headless/connect/tools/devin.md +23 -18
  13. package/skills/headless/connect/tools/vscode.md +24 -14
  14. package/skills/headless/run-log.mjs +21 -1
  15. package/skills/headless/steps/01-check-state/SKILL.md +12 -13
  16. package/skills/headless/steps/03-provision/SKILL.md +11 -10
  17. package/skills/headless/steps/03-provision/ecommerce/SKILL.md +15 -19
  18. package/skills/headless/steps/03-provision/webapp/SKILL.md +3 -2
  19. package/skills/headless/steps/03-provision/wordpress/SKILL.md +6 -7
  20. package/skills/headless/steps/04-content-model/SKILL.md +39 -21
  21. package/skills/headless/steps/04-content-model/ecommerce/SKILL.md +3 -3
  22. package/skills/headless/steps/04-content-model/wordpress/SKILL.md +1 -2
  23. package/skills/headless/steps/07-validate/SKILL.md +3 -3
  24. package/skills/headless/steps/08-deploy/SKILL.md +56 -22
  25. package/skills/headless/steps/09-done/SKILL.md +3 -2
  26. package/skills/headless/wait.mjs +245 -0
  27. package/src/core/skills.js +67 -60
  28. package/src/core/skills.ts +67 -60
  29. package/src/servers/agency-hosting.js +1 -1
  30. package/src/servers/agency-hosting.ts +1 -1
  31. package/src/servers/agent-readiness.js +1 -1
  32. package/src/servers/agent-readiness.ts +1 -1
  33. package/src/servers/all.js +1 -1
  34. package/src/servers/all.ts +1 -1
  35. package/src/servers/billing.js +1 -1
  36. package/src/servers/billing.ts +1 -1
  37. package/src/servers/dns.js +1 -1
  38. package/src/servers/dns.ts +1 -1
  39. package/src/servers/domains.js +1 -1
  40. package/src/servers/domains.ts +1 -1
  41. package/src/servers/ecommerce.js +1 -1
  42. package/src/servers/ecommerce.ts +1 -1
  43. package/src/servers/gpu.js +1 -1
  44. package/src/servers/gpu.ts +1 -1
  45. package/src/servers/horizons.js +1 -1
  46. package/src/servers/horizons.ts +1 -1
  47. package/src/servers/hosting.js +1 -1
  48. package/src/servers/hosting.ts +1 -1
  49. package/src/servers/mail.js +1 -1
  50. package/src/servers/mail.ts +1 -1
  51. package/src/servers/reach.js +1 -1
  52. package/src/servers/reach.ts +1 -1
  53. package/src/servers/vps.js +1 -1
  54. package/src/servers/vps.ts +1 -1
  55. package/src/servers/wordpress.js +1 -1
  56. package/src/servers/wordpress.ts +1 -1
@@ -6,6 +6,7 @@
6
6
  // node run-log.mjs <domain> run-resume [--reason "<text>"]
7
7
  // node run-log.mjs <domain> stage-start <stage> [--phase <phase>] [--started-ago 6m | --started-at <ISO time>]
8
8
  // node run-log.mjs <domain> stage-end <stage> <result> [--phase <phase>] [--note "<text>"] [--started-ago 6m | --started-at <ISO time>]
9
+ // node run-log.mjs <domain> next <stage> [--phase <phase>]
9
10
  // node run-log.mjs <domain> stage-fix <stage> [--phase <phase>] (--started-ago 6m | --started-at <ISO time>)
10
11
  // node run-log.mjs <domain> event <issue|feedback|skill_gap|good> '<json>'
11
12
  // node run-log.mjs <domain> report [--outcome live|stopped|failed|abandoned] [--tokens '<json>']
@@ -239,6 +240,22 @@ function stageEnd(ws, [stage, result], flags) {
239
240
  console.log(`${stage}${phase ? `/${phase}` : ''} ended: ${result}`);
240
241
  }
241
242
 
243
+ // One call at a stage boundary: ends the open stage (or phase) as ok and starts <stage>, unless the open
244
+ // one is <stage> itself, so re-running a chained `next X && …` doesn't close X as ok. With nothing open
245
+ // it only starts; with more than one open it can't tell which ends, so it refuses.
246
+ function next(ws, [stage], flags) {
247
+ if (!STAGES.includes(stage)) fail(`unknown stage "${stage}"; one of ${STAGES.join(', ')}`);
248
+ const log = loadLog(ws);
249
+ const run = currentRun(log);
250
+ const open = (log.history ?? []).filter((h) => h.run === run.id && !h.ended_at);
251
+ const label = (h) => `${h.stage}${h.phase ? ` --phase ${h.phase}` : ''}`;
252
+ if (open.length > 1) fail(`${open.map(label).join(' and ')} are open; end each with stage-end <stage> <result>, then call next`);
253
+ const phase = flags.phase ?? null;
254
+ const [current] = open;
255
+ if (current && !(current.stage === stage && current.phase === phase)) stageEnd(ws, [current.stage, 'ok'], { phase: current.phase ?? undefined });
256
+ stageStart(ws, [stage], { phase: flags.phase });
257
+ }
258
+
242
259
  function stageFix(ws, [stage], flags) {
243
260
  if (!STAGES.includes(stage)) fail(`unknown stage "${stage}"`);
244
261
  const log = loadLog(ws);
@@ -663,6 +680,9 @@ switch (command) {
663
680
  case 'stage-end':
664
681
  stageEnd(ws, rest, flags);
665
682
  break;
683
+ case 'next':
684
+ next(ws, rest, flags);
685
+ break;
666
686
  case 'stage-fix':
667
687
  stageFix(ws, rest, flags);
668
688
  break;
@@ -696,5 +716,5 @@ switch (command) {
696
716
  reportRejected(ws, rest);
697
717
  break;
698
718
  default:
699
- fail('command must be run-start, run-update, run-resume, stage-start, stage-end, stage-fix, event, report, unsent, report-sent or report-rejected');
719
+ fail('command must be run-start, run-update, run-resume, stage-start, stage-end, next, stage-fix, event, report, unsent, report-sent or report-rejected');
700
720
  }
@@ -32,8 +32,9 @@ present only once provision has fired):
32
32
  "updated_at": "…" }
33
33
  ```
34
34
 
35
- `provision.status` is `in_progress` or `completed` — but it is a **hint**: always re-verify it against
36
- `hosting_websites_list-setups` (Step 1) before trusting it to gate an instance-barrier step.
35
+ `provision.status` is `in_progress` or `completed` — but it is a **hint** from an earlier session:
36
+ re-verify it against `hosting_websites_list-setups` here (Step 1). See *State and artifacts* in the
37
+ top-level `SKILL.md` for when the connector is asked again.
37
38
 
38
39
  ---
39
40
 
@@ -43,18 +44,14 @@ Before anything else, start a run and the stage (see *Run log and build report*
43
44
  `SKILL.md`):
44
45
 
45
46
  ```bash
46
- node <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version>
47
- node <skill>/run-log.mjs <domain> stage-start check_state
47
+ node <skill>/run-log.mjs <domain> run-start --tool <tool> --model <model> --skill <skill version> && \
48
+ node <skill>/run-log.mjs <domain> next check_state && \
49
+ node <skill>/run-log.mjs <domain> unsent
48
50
  ```
49
51
 
50
52
  If an earlier run on this domain never wrote its report, `run-start` closes it as `abandoned` first.
51
-
52
- Then send any build reports that haven't reached Hostinger yet (that abandoned run, or a send that
53
- failed last time):
54
-
55
- ```bash
56
- node <skill>/run-log.mjs <domain> unsent
57
- ```
53
+ `unsent` lists the build reports that haven't reached Hostinger yet (that abandoned run, or a send
54
+ that failed last time).
58
55
 
59
56
  Each line is `<run> <path>`. For each, send the file through `hosting_headless_submit-build-report`
60
57
  and record the result, as described in *Run log and build report* in the top-level `SKILL.md`. One
@@ -70,7 +67,8 @@ Keep two things separate, and **never guess from a timestamp.**
70
67
  `hosting_websites_list-setups { "domain": "<domain>" }` and exact-match the domain row:
71
68
  - `status: running` → provisioning is underway **right now** (a background provision from an earlier
72
69
  turn). Don't start a second one — reconcile `state.json`'s `provision` to `in_progress`, and
73
- either resume prepare work while it finishes or poll it to `completed` at the barrier.
70
+ either resume prepare work while it finishes or wait for it at the barrier with
71
+ `wait.mjs setup <domain>` (see *Waiting on Hostinger* in the top-level `SKILL.md`).
74
72
  - `status: failed` → not running; route to provision to self-heal (Step 3).
75
73
  - `completed` → provision is done; set `provision.status` to `completed`. Instance-barrier steps
76
74
  (content push, frontend data, deploy) are now unblocked.
@@ -200,7 +198,8 @@ When the intent is ambiguous, **ask** — don't guess. A skipped tool result is
200
198
 
201
199
  Write `~/.hostinger/<domain>/state.json` with the agreed next `stage` and a fresh timestamp. If the
202
200
  route isn't a new build, record it with `run-log.mjs <domain> run-update --kind resume` (or
203
- `redesign` / `iterate`), then end the stage with `stage-end check_state ok --note "<the route>"`.
201
+ `redesign` / `iterate`) and end the stage with the route as its note, in one shell command:
202
+ `… run-update --kind resume && … stage-end check_state ok --note "<the route>"`.
204
203
  Then:
205
204
 
206
205
  - **Handing straight to the next stage in this same session?** Keep the `.lock` (you are still
@@ -10,9 +10,9 @@ matching subfolder, which provisions it.
10
10
 
11
11
  **In:** `brief.md`. **Out:** `site.json` (written by the subfolder).
12
12
 
13
- **This stage runs in the background.** Provisioning is mostly a poll wait, so it splits in two: a
14
- **fire** half (detect + create, returns straight away) and a **join** half (poll to `completed`,
15
- verify, write `site.json`). Fire it, record `provision.in_progress`, then go do the prepare work of
13
+ **This stage is asynchronous.** Provisioning is mostly a wait on Hostinger, so it splits in two: a
14
+ **fire** half (detect + create, returns straight away) and a **join** half (`wait.mjs` in the
15
+ foreground, verify, write `site.json`). Fire it, record `provision.in_progress`, then go do the prepare work of
16
16
  the other stages (content-model prepare, frontend shell) while it settles. Only cross back to
17
17
  the join half when an instance-barrier step needs the live backend. See *Background provision & the
18
18
  instance barrier* in the top-level `SKILL.md`. An agent that can't interleave simply does fire and
@@ -98,7 +98,7 @@ So provision has **two entry shapes**, and each subfolder's *detect* step picks
98
98
  you not to expect an empty shell.
99
99
 
100
100
  This only changes *detect*. Everything after the swap (verify, `site.json`, the WooCommerce tail, the
101
- background fire/join split) is identical to a fresh provision. A swap that installs WordPress is still
101
+ fire/join split) is identical to a fresh provision. A swap that installs WordPress is still
102
102
  asynchronous — fire it, record `provision.in_progress`, and join at the barrier just like a fresh create.
103
103
 
104
104
  ## Step 2 — Fire (don't block)
@@ -118,8 +118,8 @@ asynchronous — fire it, record `provision.in_progress`, and join at the barrie
118
118
  > shell from an already-provisioned backend.
119
119
 
120
120
  Follow the chosen subfolder's `SKILL.md` through **detect + create** (its `hosting_websites_create` call). The
121
- moment the create is accepted and queued, record it in `state.json` and **return** — do not sit on the
122
- readiness poll:
121
+ moment the create is accepted and queued, record it in `state.json` and **return** — do not wait for
122
+ it yet:
123
123
 
124
124
  ```json
125
125
  { "stage": "content_model",
@@ -132,7 +132,8 @@ shell). The subfolder's readiness poll, verification and `site.json` are the **j
132
132
  ## Step 3 — Join (at the instance barrier)
133
133
 
134
134
  When an instance-barrier step is about to run (content-model push, frontend data wiring, deploy) — or
135
- right away, if the agent can't interleave — finish the subfolder's `SKILL.md`: **poll to `completed`**,
136
- run its verification, and write `site.json` (which records the chosen `backend`). Then set
137
- `provision.status` to `"completed"` in `state.json` with a fresh `updated_at`. On resume, treat a
138
- stored `in_progress` as a hint only — re-poll `hosting_websites_list-setups` for the truth first.
135
+ right away, if the agent can't interleave — finish the subfolder's `SKILL.md`: run its `wait.mjs` (it
136
+ returns at once if the job is already done), run its verification, and write `site.json` (which
137
+ records the chosen `backend`). Then set `provision.status` to `"completed"` in `state.json` with a
138
+ fresh `updated_at`.
139
+
@@ -17,26 +17,19 @@ Provision a **Hostinger Ecommerce** backend for `<domain>` and prove it is ready
17
17
 
18
18
  > ## ⚠️ Prerequisite: the `hostinger-ecommerce` MCP server must be connected
19
19
  > Store management is **not** in the `hosting`/`wordpress` MCP groups — it's the separate
20
- > **`hostinger-ecommerce`** group (`ecommerce_*` ops, OAuth-backed, no API token needed). If those
21
- > tools aren't available, add the server and **reconnect the session** before continuing:
20
+ > **`hostinger-ecommerce`** group (`ecommerce_*` ops, OAuth-backed, no API token needed). Connect
21
+ > registers it alongside `hostinger`, so its tools are normally already in the session. If they
22
+ > aren't (for example, a tool connected before connect registered both servers), register it now. The
23
+ > command for each tool is in its `connect/tools/<tool>.md`; for Claude Code:
22
24
  > ```bash
23
- > 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
25
+ > claude mcp add hostinger-ecommerce --scope user -- npx --package=@hostinger/mcp@latest hostinger-ecommerce-mcp
24
26
  > ```
25
- > (For other tools, enable the **Ecommerce** MCP in the Hostinger Connector.) A newly added MCP server
26
- > only loads on a session restart/reconnect.
27
+ > (Or enable the **Ecommerce** MCP in the Hostinger Connector.) A newly added MCP server only loads
28
+ > after a restart, so pause the run with *register, restart, resume* from `connect/SKILL.md`: save
29
+ > state, ask the user to restart the tool and paste the same prompt, and end your turn. Check-state then
30
+ > resumes at provision. Never spawn the server yourself or write a JSON-RPC client to reach it.
27
31
  >
28
- > **Can't restart your own session? Don't block — drive the server directly.** A coding agent usually
29
- > can't reconnect itself, so instead of stopping, run the stdio server yourself and speak MCP to it:
30
- > spawn `npx --package=@hostinger/mcp@latest hostinger-ecommerce-mcp` (same `USER_AGENT` as above) and
31
- > send newline-delimited JSON-RPC over its stdin/stdout — `initialize` → the `notifications/initialized`
32
- > notification → `tools/list` → `tools/call`, where the tools are `search` / `execute` / `multi-execute`
33
- > (call ops exactly as in-session: `{ "name": "execute", "arguments": { "operation": "…", "params": {…} } }`).
34
- > It self-authenticates with the same stored Hostinger OAuth creds as the other connected servers, so
35
- > there is **no token to handle** (keep any secret off the command line). Your sandbox may ask the user
36
- > to approve spawning it — ask once, then proceed. Only fall back to asking the user to restart if
37
- > driving the server directly isn't permitted.
38
- >
39
- > Verify the tools work (in-session or via the direct drive) with a search — e.g. "list stores" →
32
+ > Verify the tools work in-session with a search — e.g. "list stores" →
40
33
  > `ecommerce_stores_list` — before proceeding.
41
34
  > The authoritative, always-current build guide is `ecommerce_miscellaneous_custom-storefront-setup-instructions` — read it once per build.
42
35
 
@@ -48,14 +41,17 @@ All website-lifecycle calls go through the `hosting` connector (`execute`); all
48
41
  Same lifecycle as `../wordpress/SKILL.md` Steps 1–2, but `type: "headless_ecommerce"`:
49
42
 
50
43
  1. Detect: `hosting_websites_list` / `hosting_websites_list-setups` (exact-match the domain). `completed`
51
- → adopt. `running` → poll. `failed` → stop, point at hPanel. If `website_type` is not `headless`
44
+ → adopt. `running` → record `provision.in_progress` and wait at the join. `failed` → stop, point at hPanel.
45
+ If `website_type` is not `headless`
52
46
  (plain WordPress, Node.js, …), stop and tell the user — don't overwrite another kind of site.
53
47
  2. Create (only if **no** site exists): `hosting_websites_create { order_id, type: "headless_ecommerce", domain }`
54
48
  — **omit `domain`** for a temporary subdomain: the setup generates one itself and returns it (don't
55
49
  call a separate subdomain op first — there's no need in this flow). The response carries the
56
50
  generated domain; if it differs from the workspace dir, move
57
51
  `~/.hostinger/<old>/` → `<new>/` (if `<new>/` already exists, stop and ask instead of moving
58
- into it) and update `brief.md`. Poll `list-setups` to `completed`. `website_type` will be `headless`.
52
+ into it) and update `brief.md`. Don't wait here. At the join, `node <skill>/wait.mjs setup <domain>`
53
+ returns once the setup is `completed` (see *Waiting on Hostinger* in the top-level `SKILL.md`).
54
+ `website_type` will be `headless`.
59
55
 
60
56
  > **Onboarding handoff — an empty headless shell needs no new website.** If the domain already has an
61
57
  > empty headless shell (`website_type: headless`, setup `type: headless_empty`, nothing installed —
@@ -57,8 +57,9 @@ All website/database operations below go through the Hostinger connector (`execu
57
57
  `~/.hostinger/<new>/`
58
58
  (if `<new>/` already exists, stop and ask instead of moving into it),
59
59
  update the domain in `brief.md`, and tell the user the new domain.
60
- 3. Poll `hosting_websites_list-setups { "domain": "<domain>" }` every ~15 s until `completed`
61
- (typically under a minute; allow a few minutes). `failed` → stop, point at hPanel.
60
+ 3. Don't wait here. At the join, `node <skill>/wait.mjs setup <domain>` (see *Waiting on Hostinger* in
61
+ the top-level `SKILL.md`) returns once the setup is `completed`, usually at once by then. A `failed`
62
+ setup → stop, point at hPanel.
62
63
 
63
64
  ## Step 3 — Database (only if the app needs one)
64
65
 
@@ -64,8 +64,9 @@ All operations below go through the Hostinger connector (`execute`).
64
64
  (if `<new>/` already exists, stop and ask instead of moving into it),
65
65
  update the domain in `brief.md`, and tell the user the new domain.
66
66
  Admin credentials are generated by the platform; never ask the user for a password.
67
- 3. Poll `hosting_websites_list-setups { "domain": "<domain>" }` every ~15 s until `completed`
68
- (typically under a minute; allow a few minutes). `failed` → stop, point at hPanel.
67
+ 3. Don't wait here. At the join, `node <skill>/wait.mjs setup <domain>` (see *Waiting on Hostinger* in
68
+ the top-level `SKILL.md`) returns once the setup is `completed`, usually at once by then. A `failed`
69
+ setup → stop, point at hPanel.
69
70
 
70
71
  ## Step 2b — Swap an empty headless shell to WordPress (only when onboarding already made the shell)
71
72
 
@@ -93,11 +94,9 @@ install WordPress straight into its `/cms` directory. This is the onboarding han
93
94
  - A new database is created for the install unless you pass `database.name` of an existing one; the
94
95
  generated database counts against the plan's limit.
95
96
  3. The call is **asynchronous** — a success response only means the install job was **queued**. This is
96
- the *fire*: record `provision.in_progress` and return to prepare work. Poll
97
- `wordpress_installations_list { username, domain, ownership: "all" }` (exact row) at the join; when a
98
- row with `directory: "cms"` and `is_valid: true` appears, WordPress is ready. Allow 1–2 minutes;
99
- `wordpress_installations_list` can lag a few seconds behind the job finishing and return `[]` — retry
100
- briefly before failing.
97
+ the *fire*: record `provision.in_progress` and return to prepare work. At the join,
98
+ `node <skill>/wait.mjs cms <domain> <username>` returns the `/cms` install row (`directory: "cms"`,
99
+ valid) once it exists, usually within 1–2 minutes of the install.
101
100
 
102
101
  ## Step 3 — Verify (all must pass)
103
102
 
@@ -96,7 +96,7 @@ not an answer, see *When you ask the user* in the top-level `SKILL.md`):
96
96
  | Option | Tool | Backends | Needs the live instance? |
97
97
  |---|---|---|---|
98
98
  | **A — Hostinger AI image generation** *(default for WordPress / WooCommerce)* | `wordpress_ai-content_generate-images` | WordPress, WordPress + WooCommerce | **yes** — runs at the instance barrier |
99
- | **B — Unsplash stock photos** *(default for Ecommerce / Webapp; the fallback everywhere)* | Unsplash API, or web search + web fetch | any | no — pure prepare work |
99
+ | **B — Unsplash stock photos** *(default for Ecommerce / Webapp; the fallback everywhere)* | Unsplash API, or web fetch of Unsplash search pages | any | no — pure prepare work |
100
100
 
101
101
  > **Do not silently fall back to stock on a WordPress site.** On WordPress / WooCommerce the default is
102
102
  > **Hostinger AI (Option A)** — on-brand, generated from `design.md`. Using Unsplash on a WordPress build
@@ -165,29 +165,47 @@ runs **at the instance barrier** (after `provision.status == "completed"`), usin
165
165
  This is still prepare work and needs no live backend: you pick the photos and record their URLs here,
166
166
  and the push phase uploads them.
167
167
 
168
- 1. **Find candidates.** Use one photo per article, matching its subject and destination.
169
- - With an `UNSPLASH_ACCESS_KEY` in the environment, use the official API:
170
- `GET https://api.unsplash.com/search/photos?query=<subject>&orientation=landscape` with header
171
- `Authorization: Client-ID <key>`.
172
- - Without a key, use web search limited to `unsplash.com` (e.g. "unsplash photo Lisbon tram") and
173
- take the photo id from the result URL: the last segment of `unsplash.com/photos/<slug>-<id>`, or
174
- the whole segment for `unsplash.com/photos/<id>`.
175
- 2. **Resolve a direct file URL.** With the API, the result's `urls.raw` is the file and `user.name`
176
- the photographer; for each photo you keep, also `GET` its `links.download_location` (same header),
177
- as the API terms require. Without a key, open the photo page (`https://unsplash.com/photos/<…>`)
178
- with your **web-fetch tool** and ask it for the main photo's `images.unsplash.com/photo-<…>` URL,
179
- quoted exactly, and the photographer's name. Either way, drop the query string and request a web
180
- size: `https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max`. Check that it returns
181
- 200 with `image/jpeg`: the upload ability needs this direct file URL, not the photo page.
182
- - Every other `unsplash.com` request (curl on the page, `/photos/<id>/download`, `/napi/…`,
168
+ Source every image in a few batched calls, not a search and a fetch per photo:
169
+
170
+ 1. **Find candidates, a page of them per fetch.** Group the image briefs into a few themes (e.g.
171
+ "sourdough bread", "bakery interior", "Lisbon street").
172
+ - With an `UNSPLASH_ACCESS_KEY` in the environment, use the official API, one call per theme:
173
+ `GET https://api.unsplash.com/search/photos?query=<theme>&orientation=landscape&per_page=20` with
174
+ header `Authorization: Client-ID <key>`. Each result's `urls.raw` is the file, `alt_description`
175
+ what it shows and `user.name` the photographer; for each photo you keep, also `GET` its
176
+ `links.download_location` (same header), as the API terms require.
177
+ - Without a key, open the theme's search page `https://unsplash.com/s/photos/<theme-with-dashes>`
178
+ with your **web-fetch tool**, once per theme, and ask it to list every free photo on the page with
179
+ its `images.unsplash.com/photo-<…>` URL quoted exactly (the part before `?`), the photo page
180
+ link, the alt text and the photographer. One fetch returns 10–20 candidates. Skip the
181
+ `plus.unsplash.com/premium_photo-…` results: they are Unsplash+ and not free. Fetch the themes in
182
+ parallel when your tool allows several calls at once. Fall back to a web search limited to
183
+ `unsplash.com` plus a fetch of one photo page (`https://unsplash.com/photos/<…>`) only for a
184
+ subject no search page covers.
185
+ - Every other `unsplash.com` request (curl on any page, `/photos/<id>/download`, `/napi/…`,
183
186
  `/oembed`) hits a bot challenge: a `307` to `/.within.website` (Anubis) or a 401. If your fetch
184
187
  tool gets the challenge page too, stop: no user-agent spoofing or other workarounds. On a
185
188
  WordPress backend where Option A hasn't already failed, offer it; otherwise ask the user for an
186
189
  `UNSPLASH_ACCESS_KEY` or record the gap (step 5).
187
- 3. **Look before you keep it.** Download a small preview (`?w=500`) and view it. Search snippets and
188
- fetch summaries only describe the page, and can hand you a related photo's thumbnail instead of
189
- the main one; a photo also often isn't quite what its title says. Reject anything off-topic.
190
- If nothing fits exactly, take the closest real photo and note the gap rather than mislabel it.
190
+ 2. **Check them all in one command.** Pick a candidate per image brief from the alt text, then in one
191
+ shell command request each web-size URL
192
+ (`https://images.unsplash.com/photo-<…>?w=1600&q=80&fm=jpg&fit=max`, query string replaced) and
193
+ confirm 200 `image/jpeg`, and save a small preview (`?w=500`) of each into one folder, e.g.:
194
+
195
+ ```bash
196
+ mkdir -p ~/.hostinger/<domain>/media/previews && cd ~/.hostinger/<domain>/media/previews && \
197
+ for id in photo-<a> photo-<b>; do
198
+ curl -s -o /dev/null -w "$id %{http_code} %{content_type}\n" "https://images.unsplash.com/$id?w=1600&q=80&fm=jpg&fit=max"
199
+ curl -s -o "$id.jpg" "https://images.unsplash.com/$id?w=500&q=60&fm=jpg&fit=max"
200
+ done
201
+ ```
202
+
203
+ The upload ability needs this direct file URL, not the photo page.
204
+ 3. **Look before you keep it.** View the previews, all in one turn where your tool can open several
205
+ images at once. Alt text from a search page describes the photo better than a search snippet, but
206
+ a photo often isn't quite what its title says. Reject anything off-topic and take the next
207
+ candidate from the same list. If nothing fits exactly, take the closest real photo and note the gap
208
+ rather than mislabel it.
191
209
  4. **Write alt text from what the photo shows**, not from the article's image brief. For example,
192
210
  use "Shopfronts and parked scooters on a Hanoi street" even if the article is about rain.
193
211
  5. **Record it** under each article in `content-model.md`:
@@ -212,7 +230,7 @@ Then update `state.json`: set `stage` to the next prepare stage still to do, fre
212
230
 
213
231
  ## Push phase — create it the backend's way (needs the instance)
214
232
 
215
- Only once `provision.status == "completed"` (re-verified against `hosting_websites_list-setups`),
233
+ Only once `provision.status == "completed"` (see *State and artifacts* in the top-level `SKILL.md`),
216
234
  route on the brief's **Suggested backend** and follow that subfolder, which maps the helper onto the
217
235
  live backend — for WordPress, through the per-site WP MCP's ability tools (the installation JWT
218
236
  authenticates that MCP session; `wp/v2` REST is the public read surface, not a write path for the
@@ -12,9 +12,9 @@ OAuth). Prices are **integers in minor units** (cents) and live on **variants**.
12
12
  **In:** `content-model.md` (prepared catalog), `site.json` (`ecommerce.store_id`). **Out:** products +
13
13
  images created in the store; ids recorded back into `content-model.md`.
14
14
 
15
- > If the `hostinger-ecommerce` MCP tools aren't available, don't stop — see the prerequisite in
16
- > `../../03-provision/ecommerce/SKILL.md` (add the server, and if you can't reconnect your session,
17
- > drive the stdio server directly rather than blocking).
15
+ > If the `hostinger-ecommerce` MCP tools aren't available, follow the prerequisite in
16
+ > `../../03-provision/ecommerce/SKILL.md`: register the server, then pause with *register, restart,
17
+ > resume* from `connect/SKILL.md`. Never drive the server yourself over stdio.
18
18
 
19
19
  ## Step 1 — Create products (idempotent)
20
20
 
@@ -5,8 +5,7 @@ description: "Push phase of Step 4 for a headless WordPress backend: open an aut
5
5
 
6
6
  # headless-step-content-model-wordpress
7
7
 
8
- **Push phase — needs the live instance.** Run only after `provision.status == "completed"` (re-verify
9
- against `hosting_websites_list-setups`). It maps the backend-agnostic `content-model.md` onto real
8
+ **Push phase — needs the live instance.** Run only after `provision.status == "completed"`. It maps the backend-agnostic `content-model.md` onto real
10
9
  WordPress objects.
11
10
 
12
11
  **In:** `content-model.md`, `site.json` (both prepared earlier), generated media under
@@ -16,9 +16,9 @@ description: "Step 7 (validate) of the Hostinger Headless flow. OPTIONAL quick g
16
16
 
17
17
  ## 1 — Instance status (always, quick)
18
18
 
19
- `provision.status == "completed"` **and** re-verify against the connector:
20
- `hosting_websites_list-setups { "domain": "<domain>" }`, exact-match the row → `completed`. Anything
21
- else → not ready; return to the instance barrier, don't deploy.
19
+ `provision.status == "completed"` in `state.json`. Anything else → not ready; return to the instance
20
+ barrier, don't deploy. Deploy's pre-flight asks the connector again (see *State and artifacts* in the
21
+ top-level `SKILL.md`).
22
22
 
23
23
  ## 2 — Backend API status (WordPress / WooCommerce / Ecommerce, always, quick)
24
24
 
@@ -21,7 +21,7 @@ This is an **instance-barrier** step: run it only after `provision.status == "co
21
21
  > settings passed exactly as the table below says. Run it from the project, since it links the current directory:
22
22
  > `cd ~/.hostinger/<domain>/src && hostinger up --domain <domain> --type nodejs --app-type <appType> --output-directory <dir> --build-script build --node-version 22 --package-manager npm --yes --format json`.
23
23
  > It uploads, builds, waits and returns `data.buildUuid` and `data.buildState`; add `--no-wait` to
24
- > return at once and poll with `hostinger hosting nodejs build <domain> <uuid>`. Then continue at
24
+ > return at once and wait with `node <skill>/wait.mjs build <domain> <uuid>`. Then continue at
25
25
  > "Verify" below. Recent CLI versions see that the site is headless (`data.headless: true`) and
26
26
  > deploy only its Node.js frontend, leaving `/cms` alone; still pass the settings above.
27
27
 
@@ -74,8 +74,9 @@ framework's correct values** — don't rely on auto-detection (it returns null
74
74
 
75
75
  ## Step 1 — Pre-flight
76
76
 
77
- 1. **Instance live.** `provision.status == "completed"`, re-verified with
78
- `hosting_websites_list-setups { "domain": "<domain>" }`.
77
+ 1. **Instance live (the deploy gate).** `provision.status == "completed"`, re-verified with
78
+ `hosting_websites_list-setups { "domain": "<domain>" }`: deploy overwrites the site root, so it never
79
+ trusts `state.json` alone.
79
80
  2. **Build output exists locally** (the framework's output dir — e.g. `.next/`, `dist/`, `.output/`) and
80
81
  the project builds clean (frontend/validate already did this). If not, go back.
81
82
  - **Next.js 16+:** `package.json`'s `build` script is `next build --webpack`, not plain `next build` —
@@ -96,23 +97,44 @@ Zip the project **source** with `package.json` at the archive root. **Include**
96
97
  `package.json`, `package-lock.json`, `.nvmrc`. **Exclude** `node_modules/`, the build output
97
98
  (`.next/`, `dist/`, `out/`), `.git/`, and any `.htaccess` — the server installs and builds from source.
98
99
 
100
+ > ## ⚠️ Write the archive outside `~/.hostinger/` (the deploy refuses it there)
101
+ > `hosting_deploy` refuses any `path` under a hidden folder at the top of the home directory
102
+ > (`~/.hostinger`, `~/.config`, …) with *"it is, or contains, a home, system or credentials location"*:
103
+ > a deploy publishes what it reads, so the connector protects dotfolders that can hold tokens. That
104
+ > covers both `~/.hostinger/<domain>/app.zip` **and** the `~/.hostinger/<domain>/src/` directory
105
+ > itself, so neither can be passed as `path`. Keep the source in the workspace as usual, but write the
106
+ > archive to a fresh temp directory and pass that. Don't copy the archive somewhere else after a
107
+ > refusal, and don't move `src/` out of the workspace.
108
+
99
109
  ```bash
110
+ # macOS/Linux — the OS temp dir is outside the home directory
100
111
  cd ~/.hostinger/<domain>/src
101
- zip -r -X ../app.zip . -x 'node_modules/*' '.next/*' 'dist/*' 'out/*' '.git/*' '**/.htaccess'
112
+ DEPLOY_DIR=$(mktemp -d)
113
+ zip -r -X "$DEPLOY_DIR/app.zip" . -x 'node_modules/*' '.next/*' 'dist/*' 'out/*' '.git/*' '**/.htaccess'
114
+ echo "$DEPLOY_DIR/app.zip" # the absolute path to pass as `path` in Step 3
115
+ ```
116
+
117
+ ```powershell
118
+ # Windows PowerShell — %TEMP% sits under AppData, which the deploy also refuses; use a folder on the system drive
119
+ cd $HOME\.hostinger\<domain>\src
120
+ $DeployDir = Join-Path $env:SystemDrive "hostinger-deploy\<domain>"; New-Item -ItemType Directory -Force $DeployDir | Out-Null
121
+ tar -a -cf "$DeployDir\app.zip" --exclude=node_modules --exclude=.next --exclude=dist --exclude=out --exclude=.git --exclude=.htaccess .
122
+ "$DeployDir\app.zip" # the absolute path to pass as `path` in Step 3
102
123
  ```
103
124
 
125
+ Delete the temp directory once the build in Step 4 is `completed`.
126
+
104
127
  ## Step 3 — Deploy (one shot: settings + upload + build)
105
128
 
106
129
  Pass the framework's build settings **inline** on the deploy call — there is no separate settings step.
107
- `path` is the archive from Step 2 (or the source directory itself; `hosting_deploy` archives a directory
108
- for you, skipping `node_modules/`, `.git/` and `.env`, but it does **not** read `.gitignore`, so the
109
- build output must already be absent).
130
+ `path` is the absolute path of the archive from Step 2, in its temp directory. Never pass a path under
131
+ `~/.hostinger/`, the `src/` directory included: the deploy refuses it (see Step 2).
110
132
 
111
133
  ```json
112
134
  { "operation": "hosting_deploy",
113
135
  "params": {
114
136
  "domain": "<domain>",
115
- "path": "/absolute/path/to/.hostinger/<domain>/app.zip",
137
+ "path": "<absolute path printed in Step 2, e.g. /var/folders/…/tmp.XXXX/app.zip>",
116
138
  "type": "nodejs",
117
139
  "appType": "<framework appType, e.g. next>",
118
140
  "outputDirectory": "<framework output dir, e.g. .next>",
@@ -128,15 +150,26 @@ website root** (expected) and keeps `/cms` intact. `type` is always `"nodejs"` f
128
150
  **never `"static"`** (see the gate below). Passing `appType`/`outputDirectory` explicitly is what
129
151
  keeps the root from serving 403 (see the rule above).
130
152
 
131
- ## Step 4 — Poll to completion
153
+ ## Step 4 — Wait for the build and the live root
154
+
155
+ Wait for it in one command (see *Waiting on Hostinger* in the top-level `SKILL.md`):
156
+
157
+ ```bash
158
+ node <skill>/wait.mjs build <domain> <buildUuid>
159
+ ```
160
+
161
+ It waits for `hosting_get-deployment` to report the build `completed`, then for `https://<domain>/` to
162
+ serve 200. What its result means here:
132
163
 
133
- Poll `hosting_get-deployment { "domain": "<domain>", "buildUuid": "<buildUuid>" }` with backoff until
134
- `build.state` is `completed`. On `failed`, the same response carries the end of the build log and the
135
- failure analysis — read it, fix locally, re-archive and redeploy (pass `fromLine`, the line count from
136
- the previous call, to read only new log output). `hosting_get-deployment` also returns the build settings
137
- stored for the site: `appType`/`outputDirectory`/`nodeVersion` should echo what you passed; if they read
138
- `null`, the settings didn't take — fix the deploy call before continuing. For an SSR app that built but
139
- hangs, `hosting_nodejs_restart-application { username, domain }` recovers the server without a rebuild.
164
+ - `ok: true`: the build is done and the root serves 200. Go on to Step 5.
165
+ - `ok: false` with `build.state: failed`: read `log_tail` (the end of the build log) and `analysis`, fix
166
+ locally, re-archive and redeploy (pass `fromLine` to `hosting_get-deployment` to read only new log
167
+ output).
168
+ - `ok: false` with `build settings missing`: the settings didn't take (the root would 403 for good). Fix
169
+ the deploy call and redeploy.
170
+ - `ok: false` with `timed_out`: `last_status` says whether the build was still running or the root
171
+ wasn't serving 200 yet (see Step 5's note on a 403). For an SSR app that built but hangs,
172
+ `hosting_nodejs_restart-application { username, domain }` recovers the server without a rebuild.
140
173
 
141
174
  ## Step 5 — Verify the live site (all must pass)
142
175
 
@@ -163,9 +196,10 @@ hangs, `hosting_nodejs_restart-application { username, domain }` recovers the se
163
196
  > 200 is a passing, complete deploy. Never fail or hold the deploy because checkout is unavailable.
164
197
  > - **Webapp:** no content backend at all — the whole root is the app. Skip the check; `/cms` won't resolve.
165
198
 
166
- A `completed` build whose root 403s almost always means the build settings weren't passed to
167
- `hosting_deploy` (null settings) — pass them and redeploy. Caching can briefly serve a stale root;
168
- re-check after ~30 s.
199
+ The root check is already done when `wait.mjs build` returned `ok: true`; check the rest in one shell
200
+ command (`curl` each URL). A `completed` build whose root 403s almost always means the build settings
201
+ weren't passed to `hosting_deploy` (null settings) — pass them and redeploy. Caching can briefly serve a
202
+ stale root; re-check after ~30 s.
169
203
 
170
204
  ## Step 6 — Record `deploy.json`
171
205
 
@@ -211,6 +245,6 @@ still deploys as `type: "nodejs"` with its `appType` — the framework's static
211
245
  that way, and `/cms` is preserved. If a flow ever tells you to deploy a headless site as `type:
212
246
  "static"`, you are on a stale/legacy skill copy — stop and re-pull the latest skills.
213
247
 
214
- `hosting_deploy` on the local server is the only path: give its `path` either the source **directory** (it archives it for
215
- you) or a local **archive** (`zip`, `tar`, `tar.gz`, `tgz`, `7z`) — there is no separate upload or build
216
- step to fall back to.
248
+ `hosting_deploy` on the local server is the only path: give its `path` a local **archive** (`zip`, `tar`,
249
+ `tar.gz`, `tgz`, `7z`) written outside `~/.hostinger/` as in Step 2. There is no separate upload or
250
+ build step to fall back to.
@@ -160,8 +160,9 @@ from `run-log.json`, events from `events.jsonl`) into `reports/<run>.json` and `
160
160
  HTML to `report.html` in the workspace:
161
161
 
162
162
  ```bash
163
- node <skill>/run-log.mjs <domain> event good '{"text":"<something that went well>"}' # one per thing, if not logged yet
164
- node <skill>/run-log.mjs <domain> stage-end done ok
163
+ # one shell command; one `event good` per thing that went well, if not logged yet
164
+ node <skill>/run-log.mjs <domain> event good '{"text":"<something that went well>"}' && \
165
+ node <skill>/run-log.mjs <domain> stage-end done ok && \
165
166
  node <skill>/run-log.mjs <domain> report --outcome <live|stopped|failed> [--tokens '<json>']
166
167
  ```
167
168