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.
- package/CHANGELOG.md +15 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/headless/SKILL.md +49 -21
- package/skills/headless/VERSION +3 -3
- package/skills/headless/connect/SKILL.md +51 -3
- package/skills/headless/connect/tools/antigravity.md +21 -19
- package/skills/headless/connect/tools/claude-code.md +24 -14
- package/skills/headless/connect/tools/codex.md +21 -15
- package/skills/headless/connect/tools/cursor.md +20 -14
- package/skills/headless/connect/tools/devin.md +23 -18
- package/skills/headless/connect/tools/vscode.md +24 -14
- package/skills/headless/run-log.mjs +21 -1
- package/skills/headless/steps/01-check-state/SKILL.md +12 -13
- package/skills/headless/steps/03-provision/SKILL.md +11 -10
- package/skills/headless/steps/03-provision/ecommerce/SKILL.md +15 -19
- package/skills/headless/steps/03-provision/webapp/SKILL.md +3 -2
- package/skills/headless/steps/03-provision/wordpress/SKILL.md +6 -7
- package/skills/headless/steps/04-content-model/SKILL.md +39 -21
- package/skills/headless/steps/04-content-model/ecommerce/SKILL.md +3 -3
- package/skills/headless/steps/04-content-model/wordpress/SKILL.md +1 -2
- package/skills/headless/steps/07-validate/SKILL.md +3 -3
- package/skills/headless/steps/08-deploy/SKILL.md +56 -22
- package/skills/headless/steps/09-done/SKILL.md +3 -2
- package/skills/headless/wait.mjs +245 -0
- package/src/core/skills.js +67 -60
- package/src/core/skills.ts +67 -60
- package/src/servers/agency-hosting.js +1 -1
- package/src/servers/agency-hosting.ts +1 -1
- package/src/servers/agent-readiness.js +1 -1
- package/src/servers/agent-readiness.ts +1 -1
- package/src/servers/all.js +1 -1
- package/src/servers/all.ts +1 -1
- package/src/servers/billing.js +1 -1
- package/src/servers/billing.ts +1 -1
- package/src/servers/dns.js +1 -1
- package/src/servers/dns.ts +1 -1
- package/src/servers/domains.js +1 -1
- package/src/servers/domains.ts +1 -1
- package/src/servers/ecommerce.js +1 -1
- package/src/servers/ecommerce.ts +1 -1
- package/src/servers/gpu.js +1 -1
- package/src/servers/gpu.ts +1 -1
- package/src/servers/horizons.js +1 -1
- package/src/servers/horizons.ts +1 -1
- package/src/servers/hosting.js +1 -1
- package/src/servers/hosting.ts +1 -1
- package/src/servers/mail.js +1 -1
- package/src/servers/mail.ts +1 -1
- package/src/servers/reach.js +1 -1
- package/src/servers/reach.ts +1 -1
- package/src/servers/vps.js +1 -1
- package/src/servers/vps.ts +1 -1
- package/src/servers/wordpress.js +1 -1
- package/src/servers/wordpress.ts +1 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Hostinger Headless wait: polls one slow Hostinger job to its end in a single command, so the agent
|
|
3
|
+
// doesn't spend a model turn on every sleep-and-check.
|
|
4
|
+
//
|
|
5
|
+
// node wait.mjs setup <domain> # hosting_websites_list-setups row → completed (fresh site)
|
|
6
|
+
// node wait.mjs cms <domain> <username> # wordpress_installations_list → /cms row is_valid (shell swap)
|
|
7
|
+
// node wait.mjs build <domain> <buildUuid> # hosting_get-deployment → build completed, then the root serves 200
|
|
8
|
+
// node wait.mjs live <domain> # https://<domain>/ serves 200
|
|
9
|
+
// any of them: [--timeout 10m]
|
|
10
|
+
//
|
|
11
|
+
// It talks to the local Hostinger MCP server, which reuses the sign-in from the connect step. Progress
|
|
12
|
+
// goes to stderr; stdout is one JSON line. Exit 0 when done, 1 when the job failed, timed out or hit an
|
|
13
|
+
// error retrying can't fix (the JSON says which), 2 when it couldn't run (`fallback` names the
|
|
14
|
+
// operation to poll by hand instead).
|
|
15
|
+
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
// ── platform-safe binary names (npm/npx are .cmd on Windows), as in connect/bootstrap.mjs ──────────
|
|
19
|
+
const isWin = process.platform === 'win32';
|
|
20
|
+
const bin = (name) => (isWin ? `${name}.cmd` : name);
|
|
21
|
+
const MCP = [bin('npx'), '-y', '--package=@hostinger/mcp@latest', 'hostinger-api-mcp'];
|
|
22
|
+
// Node >= 18.20.2 / 20.12.2 refuses to spawn a .cmd/.bat without a shell
|
|
23
|
+
// (CVE-2024-27980), so npx.cmd needs shell:true on Windows. Our args are all
|
|
24
|
+
// fixed literals, so enabling the shell introduces no injection risk.
|
|
25
|
+
const spawnShell = isWin;
|
|
26
|
+
|
|
27
|
+
const log = (message) => process.stderr.write(`wait: ${message}\n`);
|
|
28
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
let fallback = null;
|
|
30
|
+
const finish = (code, result) => {
|
|
31
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
32
|
+
process.exit(code);
|
|
33
|
+
};
|
|
34
|
+
const fail = (message) => finish(2, { ok: false, error: message, ...(fallback ? { fallback } : {}) });
|
|
35
|
+
|
|
36
|
+
function parseArgs(argv) {
|
|
37
|
+
const positional = [];
|
|
38
|
+
const flags = {};
|
|
39
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
40
|
+
if (argv[i].startsWith('--')) {
|
|
41
|
+
const key = argv[i].slice(2);
|
|
42
|
+
const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[(i += 1)] : true;
|
|
43
|
+
flags[key] = value;
|
|
44
|
+
} else positional.push(argv[i]);
|
|
45
|
+
}
|
|
46
|
+
return { positional, flags };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function duration(text) {
|
|
50
|
+
const m = typeof text === 'string' && text.match(/^(\d+(?:\.\d+)?)\s*([smh])$/);
|
|
51
|
+
if (!m) fail(`--timeout "${text}" should look like 90s, 10m or 1h`);
|
|
52
|
+
return Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5 }[m[2]];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// One MCP server for the whole wait, spoken to over newline-delimited JSON-RPC on stdio. Once the
|
|
56
|
+
// server dies every pending and later call rejects, so the wait ends with exit 2 rather than hanging.
|
|
57
|
+
function connect() {
|
|
58
|
+
const child = spawn(MCP[0], MCP.slice(1), { stdio: ['pipe', 'pipe', 'pipe'], shell: spawnShell });
|
|
59
|
+
const pending = new Map();
|
|
60
|
+
let buffer = '';
|
|
61
|
+
let diag = '';
|
|
62
|
+
let dead = null;
|
|
63
|
+
let nextId = 0;
|
|
64
|
+
const die = (reason) => {
|
|
65
|
+
dead ??= new Error(`the Hostinger MCP server stopped (${reason}): ${diag.trim().slice(-500)}`);
|
|
66
|
+
for (const { reject } of pending.values()) reject(dead);
|
|
67
|
+
pending.clear();
|
|
68
|
+
};
|
|
69
|
+
child.on('error', (e) => die(e.message));
|
|
70
|
+
// 'close' comes after stderr is drained, so the reason is in diag. A write to a dying server fails
|
|
71
|
+
// with EPIPE before that; 'close' still follows, so the EPIPE itself is ignored.
|
|
72
|
+
child.on('close', (code) => die(`exit code ${code}`));
|
|
73
|
+
child.stdin.on('error', () => {});
|
|
74
|
+
child.stderr.on('data', (chunk) => (diag = (diag + chunk).slice(-2000)));
|
|
75
|
+
child.stdout.on('data', (chunk) => {
|
|
76
|
+
buffer += chunk;
|
|
77
|
+
let i;
|
|
78
|
+
while ((i = buffer.indexOf('\n')) >= 0) {
|
|
79
|
+
const line = buffer.slice(0, i);
|
|
80
|
+
buffer = buffer.slice(i + 1);
|
|
81
|
+
try {
|
|
82
|
+
const message = JSON.parse(line);
|
|
83
|
+
pending.get(message.id)?.resolve(message);
|
|
84
|
+
pending.delete(message.id);
|
|
85
|
+
} catch {
|
|
86
|
+
/* not a JSON-RPC line */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
// A call can stall on a live server (a stuck upstream request), so each one has its own deadline; past
|
|
91
|
+
// it, it answers like an error reply. A stalled poll is retried; a stalled start ends the wait.
|
|
92
|
+
const rpc = (method, params) =>
|
|
93
|
+
new Promise((resolve, reject) => {
|
|
94
|
+
if (dead) return reject(dead);
|
|
95
|
+
const id = (nextId += 1);
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
pending.delete(id);
|
|
98
|
+
resolve({ error: { message: `no reply to ${method} in 60 s` } });
|
|
99
|
+
}, 60_000);
|
|
100
|
+
const settle = (fn) => (value) => {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
fn(value);
|
|
103
|
+
};
|
|
104
|
+
pending.set(id, { resolve: settle(resolve), reject: settle(reject) });
|
|
105
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
|
|
106
|
+
});
|
|
107
|
+
const ready = rpc('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'headless-wait', version: '1' } })
|
|
108
|
+
.then((reply) => {
|
|
109
|
+
if (reply.error) throw new Error(`the Hostinger MCP server didn't start: ${reply.error.message}`);
|
|
110
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`);
|
|
111
|
+
});
|
|
112
|
+
// The server keeps "HTTP <status>" in its error text. A 409 while the setup runs, a 429 or a 5xx is a
|
|
113
|
+
// poll that didn't answer; any other 4xx (expired sign-in) or an unknown domain won't fix itself by waiting.
|
|
114
|
+
const execute = async (operation, params) => {
|
|
115
|
+
await ready;
|
|
116
|
+
const reply = await rpc('tools/call', { name: 'execute', arguments: { operation, params } });
|
|
117
|
+
const text = reply.result?.content?.[0]?.text ?? reply.error?.message ?? 'unknown error';
|
|
118
|
+
if (reply.error || reply.result?.isError) {
|
|
119
|
+
const status = Number(text.match(/HTTP (\d{3})/)?.[1]);
|
|
120
|
+
const terminal = (status >= 400 && status < 500 && ![408, 409, 425, 429].includes(status)) || /No website found/.test(text);
|
|
121
|
+
return { error: text.slice(0, 300), terminal };
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
return { data: JSON.parse(text) };
|
|
125
|
+
} catch {
|
|
126
|
+
return { error: text.slice(0, 300), terminal: false };
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
return { execute, close: () => child.kill() };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function httpStatus(url) {
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(30_000) });
|
|
135
|
+
return response.status;
|
|
136
|
+
} catch {
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Polls check() until it returns a result, with pauses growing from 5 s to 30 s.
|
|
142
|
+
async function poll(label, timeoutMs, check) {
|
|
143
|
+
const started = Date.now();
|
|
144
|
+
const waited = () => Math.round((Date.now() - started) / 1000);
|
|
145
|
+
let pause = 5_000;
|
|
146
|
+
let last = null;
|
|
147
|
+
for (;;) {
|
|
148
|
+
const outcome = await check();
|
|
149
|
+
if (outcome.done) return { ...outcome.done, waited_s: waited() };
|
|
150
|
+
if (outcome.terminal) return { ok: false, error: outcome.status, waited_s: waited() };
|
|
151
|
+
if (outcome.status !== last) log(`${label}: ${outcome.status}`);
|
|
152
|
+
last = outcome.status;
|
|
153
|
+
if (Date.now() - started + pause > timeoutMs) return { ok: false, timed_out: true, last_status: last, waited_s: waited() };
|
|
154
|
+
await sleep(pause);
|
|
155
|
+
pause = Math.min(pause * 1.5, 30_000);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// List operations answer an array, or an object holding it in `data`; their domain filter is loose.
|
|
160
|
+
const rowsFor = (data, domain) =>
|
|
161
|
+
(Array.isArray(data) ? data : data?.data ?? []).filter((r) => r.domain?.toLowerCase() === domain.toLowerCase());
|
|
162
|
+
|
|
163
|
+
const waitLive = (domain, timeoutMs) =>
|
|
164
|
+
poll('live', timeoutMs, async () => {
|
|
165
|
+
const status = await httpStatus(`https://${domain}/`);
|
|
166
|
+
return status === 200 ? { done: { ok: true, live: `https://${domain}/` } } : { status: `root ${status || 'unreachable'}` };
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const KINDS = {
|
|
170
|
+
setup: {
|
|
171
|
+
args: [],
|
|
172
|
+
timeout: '10m',
|
|
173
|
+
fallback: 'hosting_websites_list-setups',
|
|
174
|
+
run: (mcp, { domain }, timeoutMs) =>
|
|
175
|
+
poll('setup', timeoutMs, async () => {
|
|
176
|
+
const { data, error, terminal } = await mcp.execute('hosting_websites_list-setups', { domain });
|
|
177
|
+
if (error) return { status: `list-setups: ${error}`, terminal };
|
|
178
|
+
const [row] = rowsFor(data, domain);
|
|
179
|
+
if (row?.status === 'completed') return { done: { ok: true, setup: row } };
|
|
180
|
+
if (row?.status === 'failed') return { done: { ok: false, setup: row } };
|
|
181
|
+
return { status: row ? row.status : 'no setup row yet' };
|
|
182
|
+
}),
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
cms: {
|
|
186
|
+
args: ['username'],
|
|
187
|
+
timeout: '5m',
|
|
188
|
+
fallback: 'wordpress_installations_list',
|
|
189
|
+
// The list can lag a few seconds behind the install finishing and answer [], so an empty list is a wait.
|
|
190
|
+
run: (mcp, { domain, username }, timeoutMs) =>
|
|
191
|
+
poll('cms', timeoutMs, async () => {
|
|
192
|
+
const { data, error, terminal } = await mcp.execute('wordpress_installations_list', { username, domain, ownership: 'all' });
|
|
193
|
+
if (error) return { status: `installations: ${error}`, terminal };
|
|
194
|
+
const rows = rowsFor(data, domain).filter((r) => r.directory === 'cms');
|
|
195
|
+
const valid = rows.find((r) => r.is_valid ?? r.valid);
|
|
196
|
+
if (valid) return { done: { ok: true, install: valid } };
|
|
197
|
+
return { status: rows.length ? `/cms install not valid yet: ${rows[0].validationError ?? 'pending'}` : 'no /cms install yet' };
|
|
198
|
+
}),
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
build: {
|
|
202
|
+
args: ['buildUuid'],
|
|
203
|
+
timeout: '15m',
|
|
204
|
+
fallback: 'hosting_get-deployment',
|
|
205
|
+
run: async (mcp, { domain, buildUuid }, timeoutMs) => {
|
|
206
|
+
const started = Date.now();
|
|
207
|
+
const build = await poll('build', timeoutMs, async () => {
|
|
208
|
+
const { data, error, terminal } = await mcp.execute('hosting_get-deployment', { domain, buildUuid });
|
|
209
|
+
if (error) return { status: `get-deployment: ${error}`, terminal };
|
|
210
|
+
const state = data?.build?.state;
|
|
211
|
+
if (state === 'failed') {
|
|
212
|
+
const logTail = String(data.logs?.logs ?? '').split('\n').slice(-40).join('\n');
|
|
213
|
+
return { done: { ok: false, build: data.build, analysis: data.analysis, log_tail: logTail } };
|
|
214
|
+
}
|
|
215
|
+
if (state === 'completed') return { done: { ok: true, build: data.build, buildSettings: data.buildSettings } };
|
|
216
|
+
return { status: `build ${state ?? 'not found yet'}` };
|
|
217
|
+
});
|
|
218
|
+
if (!build.ok) return build;
|
|
219
|
+
// Settings that didn't take make the root 403 for good, so don't wait for it to go live.
|
|
220
|
+
const missing = ['app_type', 'output_directory', 'node_version'].filter((k) => build.buildSettings?.[k] == null);
|
|
221
|
+
if (missing.length) return { ...build, ok: false, error: `build settings missing: ${missing.join(', ')}; pass them on hosting_deploy and redeploy` };
|
|
222
|
+
const live = await waitLive(domain, timeoutMs - (Date.now() - started));
|
|
223
|
+
return { ...build, ...live, waited_s: Math.round((Date.now() - started) / 1000) };
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
live: {
|
|
228
|
+
args: [],
|
|
229
|
+
timeout: '5m',
|
|
230
|
+
run: (_mcp, { domain }, timeoutMs) => waitLive(domain, timeoutMs),
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const { positional, flags } = parseArgs(process.argv.slice(2));
|
|
235
|
+
const [kind, domain, ...rest] = positional;
|
|
236
|
+
const spec = KINDS[kind];
|
|
237
|
+
const usage = Object.entries(KINDS).map(([k, s]) => `wait.mjs ${k} <domain>${s.args.map((a) => ` <${a}>`).join('')}`).join(' | ');
|
|
238
|
+
if (!spec || !domain || rest.length < spec.args.length) fail(`usage: ${usage} [--timeout 10m]`);
|
|
239
|
+
const params = { domain, ...Object.fromEntries(spec.args.map((a, i) => [a, rest[i]])) };
|
|
240
|
+
const timeoutMs = duration(flags.timeout ?? spec.timeout);
|
|
241
|
+
fallback = spec.fallback ?? null;
|
|
242
|
+
const mcp = spec.fallback ? connect() : null;
|
|
243
|
+
const result = await spec.run(mcp, params, timeoutMs).catch((e) => fail(e.message));
|
|
244
|
+
mcp?.close();
|
|
245
|
+
finish(result.ok ? 0 : 1, { wait: kind, domain, ...result });
|