htmlhost-mcp 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/package.json +1 -1
- package/src/api.mjs +13 -4
- package/src/login.mjs +93 -0
- package/src/server.mjs +40 -11
- package/src/update.mjs +118 -0
package/README.md
CHANGED
|
@@ -12,9 +12,19 @@ Works with **Cursor**, **Claude Desktop**, **Windsurf**, **Cline**, **Zed**, and
|
|
|
12
12
|
npm install -g htmlhost-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
Or skip installing and use `npx -y htmlhost-mcp@latest` as the command, which fetches the newest version each
|
|
16
|
+
time your AI tool starts the server.
|
|
17
|
+
|
|
18
|
+
**Updates:** the server checks npm once a day. A global install updates itself in the background (restart your
|
|
19
|
+
AI tool to load it), and the first tool result of a session tells your AI tool when a newer version exists. With
|
|
20
|
+
npx, keep `@latest` in the command: a bare `npx -y htmlhost-mcp` keeps reusing its cached copy. Set
|
|
21
|
+
`HTMLHOST_NO_UPDATE_CHECK=1` to turn the check off.
|
|
22
|
+
|
|
15
23
|
### 2. Authenticate
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
Nothing to do up front: the first time your AI tool needs your account, it calls the `login` tool, which opens an
|
|
26
|
+
approval page in your browser. Check the code matches and click Approve. The server shares its token with the CLI, so
|
|
27
|
+
if you've already run `htmlhost login`, you're set. You can also sign in from a terminal:
|
|
18
28
|
|
|
19
29
|
```bash
|
|
20
30
|
npx htmlhost-cli login
|
|
@@ -82,6 +92,7 @@ htmlhost-mcp
|
|
|
82
92
|
| `get_site` | Get details about a specific site (pages, assets, version) |
|
|
83
93
|
| `pull_site` | Download a site's files to a local directory |
|
|
84
94
|
| `delete_site` | Permanently delete a site |
|
|
95
|
+
| `login` | Sign in: opens an approval page in your browser |
|
|
85
96
|
| `whoami` | Show the current authenticated user |
|
|
86
97
|
| `list_versions` | List a site's published versions and what each changed |
|
|
87
98
|
| `restore_version` | Make an earlier version live again (Pro) |
|
|
@@ -141,6 +152,11 @@ slug (required) The site slug to delete
|
|
|
141
152
|
confirm (required) Must be true — safety check
|
|
142
153
|
```
|
|
143
154
|
|
|
155
|
+
### `login`
|
|
156
|
+
|
|
157
|
+
Signs in to your htmlhost.co account. Opens an approval page in your browser and waits briefly; if you haven't
|
|
158
|
+
approved yet, it returns the link and code, and your AI tool calls it again once you have.
|
|
159
|
+
|
|
144
160
|
### `whoami`
|
|
145
161
|
|
|
146
162
|
Returns the authenticated user's email, name, handle, and plan.
|
package/package.json
CHANGED
package/src/api.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* API client for the htmlhost MCP server.
|
|
3
3
|
*
|
|
4
4
|
* Reuses the same ~/.htmlhostrc token and API base as the CLI,
|
|
5
|
-
* so `
|
|
5
|
+
* so signing in once (the `login` tool or `htmlhost login`) covers both.
|
|
6
6
|
*/
|
|
7
7
|
import { readFileSync, writeFileSync, statSync, readdirSync, mkdirSync, existsSync } from "node:fs";
|
|
8
8
|
import { join, basename, dirname, resolve } from "node:path";
|
|
@@ -13,9 +13,11 @@ import { createHash } from "node:crypto";
|
|
|
13
13
|
// Config (mirrors packages/cli/src/config.mjs)
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
const CONFIG_PATH = join(homedir(), ".htmlhostrc");
|
|
16
|
+
/** Sent with every request, so the API can tell which version is calling. Kept in step with server.mjs. */
|
|
17
|
+
export const CLIENT_VERSION = "1.4.0";
|
|
16
18
|
const DEFAULT_API = "https://htmlhost.co";
|
|
17
19
|
|
|
18
|
-
function readConfig() {
|
|
20
|
+
export function readConfig() {
|
|
19
21
|
try {
|
|
20
22
|
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
21
23
|
} catch {
|
|
@@ -23,6 +25,13 @@ function readConfig() {
|
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
/** Merge keys into ~/.htmlhostrc (shared with the CLI), keeping it readable by its owner only. */
|
|
29
|
+
export function writeConfig(data) {
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(CONFIG_PATH, JSON.stringify({ ...readConfig(), ...data }, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
32
|
+
} catch { /* a read-only home: the check just runs again next time */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
export function getToken() {
|
|
27
36
|
return readConfig().token || null;
|
|
28
37
|
}
|
|
@@ -47,8 +56,8 @@ class ApiError extends Error {
|
|
|
47
56
|
|
|
48
57
|
function headers(extra = {}) {
|
|
49
58
|
const token = getToken();
|
|
50
|
-
if (!token) throw new ApiError("Not
|
|
51
|
-
return { Authorization: `Bearer ${token}`, "X-HTMLHost-Client": "mcp", ...extra };
|
|
59
|
+
if (!token) throw new ApiError("Not signed in to htmlhost. Call the `login` tool: it opens an approval page in the user's browser.", 0);
|
|
60
|
+
return { Authorization: `Bearer ${token}`, "X-HTMLHost-Client": "mcp", "X-HTMLHost-Client-Version": CLIENT_VERSION, ...extra };
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
async function withRetry(fn, label = "request", retries = 3) {
|
package/src/login.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `login` tool: browser-approved sign-in, the same flow as `htmlhost login`
|
|
3
|
+
* in htmlhost-cli (keep the two in step). It opens the approval page in the
|
|
4
|
+
* user's browser and waits a little; if they haven't approved yet it returns
|
|
5
|
+
* the link and code for the AI to show, and the next call picks the same
|
|
6
|
+
* sign-in up. The token lands in ~/.htmlhostrc, shared with the CLI.
|
|
7
|
+
* Never writes to stdout (the MCP channel).
|
|
8
|
+
*/
|
|
9
|
+
import { hostname } from "node:os";
|
|
10
|
+
import { exec } from "node:child_process";
|
|
11
|
+
import { readConfig, writeConfig, getApi, getToken, CLIENT_VERSION } from "./api.mjs";
|
|
12
|
+
|
|
13
|
+
/** How long one call waits for the approval before handing the link back. */
|
|
14
|
+
const CALL_WAIT_MS = 25_000;
|
|
15
|
+
|
|
16
|
+
function openBrowser(url) {
|
|
17
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? 'start ""' : "xdg-open";
|
|
18
|
+
exec(`${cmd} "${url}"`, () => {});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function currentUser() {
|
|
22
|
+
const token = getToken();
|
|
23
|
+
if (!token) return null;
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`${getApi()}/api/tokens/me`, { headers: { Authorization: `Bearer ${token}`, "X-HTMLHost-Client": "mcp", "X-HTMLHost-Client-Version": CLIENT_VERSION } });
|
|
26
|
+
return res.ok ? await res.json() : null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function startLogin() {
|
|
33
|
+
const res = await fetch(`${getApi()}/api/cli-login`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "Content-Type": "application/json", "X-HTMLHost-Client": "mcp", "X-HTMLHost-Client-Version": CLIENT_VERSION },
|
|
36
|
+
body: JSON.stringify({ client: "mcp", deviceName: hostname() }),
|
|
37
|
+
});
|
|
38
|
+
if (res.status === 404 || res.status === 405) return null;
|
|
39
|
+
const data = await res.json().catch(() => ({}));
|
|
40
|
+
if (!res.ok) throw new Error(data.error || `Couldn't start sign-in (HTTP ${res.status}).`);
|
|
41
|
+
const pending = {
|
|
42
|
+
deviceCode: data.deviceCode,
|
|
43
|
+
userCode: data.userCode,
|
|
44
|
+
verifyUrl: data.verifyUrl,
|
|
45
|
+
interval: data.interval || 3,
|
|
46
|
+
expiresAt: Date.now() + (data.expiresIn || 600) * 1000,
|
|
47
|
+
api: getApi(),
|
|
48
|
+
};
|
|
49
|
+
writeConfig({ pendingLogin: pending });
|
|
50
|
+
openBrowser(pending.verifyUrl);
|
|
51
|
+
return pending;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One call of the tool: the message for the AI. */
|
|
55
|
+
export async function loginStep() {
|
|
56
|
+
const user = await currentUser();
|
|
57
|
+
if (user) return `Already signed in to htmlhost as ${user.email}.`;
|
|
58
|
+
|
|
59
|
+
const saved = readConfig().pendingLogin;
|
|
60
|
+
let pending = saved && saved.api === getApi() && saved.expiresAt > Date.now() + 5_000 ? saved : null;
|
|
61
|
+
if (!pending) {
|
|
62
|
+
pending = await startLogin();
|
|
63
|
+
if (!pending) return `This htmlhost server doesn't offer browser sign-in yet. Ask the user to run \`npx htmlhost-cli login\` in a terminal.`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const until = Math.min(pending.expiresAt, Date.now() + CALL_WAIT_MS);
|
|
67
|
+
let interval = pending.interval * 1000;
|
|
68
|
+
while (Date.now() < until) {
|
|
69
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
70
|
+
let data;
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(`${getApi()}/api/cli-login/poll`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "Content-Type": "application/json" },
|
|
75
|
+
body: JSON.stringify({ deviceCode: pending.deviceCode }),
|
|
76
|
+
});
|
|
77
|
+
data = await res.json();
|
|
78
|
+
} catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (data.status === "approved") {
|
|
82
|
+
writeConfig({ token: data.token, api: getApi(), pendingLogin: undefined });
|
|
83
|
+
return `Signed in to htmlhost as ${data.email || "the user's account"}. You can use the htmlhost tools now.`;
|
|
84
|
+
}
|
|
85
|
+
if (data.status === "slow_down") interval += 1000;
|
|
86
|
+
else if (data.status === "denied" || data.status === "expired" || data.status === "too_many_tokens") {
|
|
87
|
+
writeConfig({ pendingLogin: undefined });
|
|
88
|
+
if (data.status === "too_many_tokens") return `The user's account has 10 API keys. Ask them to remove one at ${getApi()}/settings#keys, then call login again.`;
|
|
89
|
+
return data.status === "denied" ? "The user cancelled the sign-in." : "That sign-in expired. Call login again for a new link.";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return `Not signed in yet. The approval page opened in the user's browser. Ask them to open ${pending.verifyUrl} if it didn't, check it shows the code ${pending.userCode}, and click Approve. Then call login again.`;
|
|
93
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -24,18 +24,32 @@ import {
|
|
|
24
24
|
findAllFiles,
|
|
25
25
|
pagePathForFile,
|
|
26
26
|
mimeFromExt,
|
|
27
|
+
CLIENT_VERSION,
|
|
27
28
|
} from "./api.mjs";
|
|
29
|
+
import { checkForUpdate } from "./update.mjs";
|
|
30
|
+
import { loginStep } from "./login.mjs";
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
/** What every tool says before the user has signed in: the login tool does it, no terminal needed. */
|
|
33
|
+
const NOT_SIGNED_IN = "Not signed in to htmlhost. Call the `login` tool: it opens an approval page in the user's browser.";
|
|
34
|
+
|
|
35
|
+
const VERSION = CLIENT_VERSION;
|
|
30
36
|
|
|
31
37
|
/**
|
|
32
38
|
* Helper: wraps a handler to return proper MCP content and catch errors.
|
|
33
39
|
*/
|
|
40
|
+
/** An update notice (update.mjs), shown once: with the first tool result after the check finishes. */
|
|
41
|
+
let updateNotice = null;
|
|
42
|
+
let updateNoticeShown = false;
|
|
43
|
+
|
|
34
44
|
function wrap(fn) {
|
|
35
45
|
return async (args) => {
|
|
36
46
|
try {
|
|
37
47
|
const result = await fn(args);
|
|
38
|
-
|
|
48
|
+
let text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
49
|
+
if (updateNotice && !updateNoticeShown) {
|
|
50
|
+
updateNoticeShown = true;
|
|
51
|
+
text += `\n\nNote for the user: ${updateNotice}`;
|
|
52
|
+
}
|
|
39
53
|
return { content: [{ type: "text", text }] };
|
|
40
54
|
} catch (e) {
|
|
41
55
|
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
|
|
@@ -44,6 +58,8 @@ function wrap(fn) {
|
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
export function createServer() {
|
|
61
|
+
// In the background: never delays starting up or a tool call.
|
|
62
|
+
void checkForUpdate(VERSION).then((notice) => { updateNotice = notice; }).catch(() => {});
|
|
47
63
|
const server = new McpServer({
|
|
48
64
|
name: "htmlhost",
|
|
49
65
|
version: VERSION,
|
|
@@ -69,7 +85,7 @@ export function createServer() {
|
|
|
69
85
|
}),
|
|
70
86
|
},
|
|
71
87
|
wrap(async ({ path, slug, title, ttl }) => {
|
|
72
|
-
if (!isLoggedIn()) return
|
|
88
|
+
if (!isLoggedIn()) return NOT_SIGNED_IN;
|
|
73
89
|
|
|
74
90
|
const resolvedPath = resolve(path);
|
|
75
91
|
if (!existsSync(resolvedPath)) return `Path not found: ${path}`;
|
|
@@ -111,7 +127,7 @@ export function createServer() {
|
|
|
111
127
|
inputSchema: z.object({}),
|
|
112
128
|
},
|
|
113
129
|
wrap(async () => {
|
|
114
|
-
if (!isLoggedIn()) return
|
|
130
|
+
if (!isLoggedIn()) return NOT_SIGNED_IN;
|
|
115
131
|
|
|
116
132
|
const data = await apiGet("/api/sites");
|
|
117
133
|
const sites = (data.sites || []).map((s) => ({
|
|
@@ -146,7 +162,7 @@ export function createServer() {
|
|
|
146
162
|
}),
|
|
147
163
|
},
|
|
148
164
|
wrap(async ({ slug }) => {
|
|
149
|
-
if (!isLoggedIn()) return
|
|
165
|
+
if (!isLoggedIn()) return NOT_SIGNED_IN;
|
|
150
166
|
|
|
151
167
|
const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
|
|
152
168
|
const data = await apiGet(`/api/sites/pull?slug=${encodeURIComponent(cleanSlug)}&meta=true`);
|
|
@@ -185,7 +201,7 @@ export function createServer() {
|
|
|
185
201
|
}),
|
|
186
202
|
},
|
|
187
203
|
wrap(async ({ slug, directory }) => {
|
|
188
|
-
if (!isLoggedIn()) return
|
|
204
|
+
if (!isLoggedIn()) return NOT_SIGNED_IN;
|
|
189
205
|
|
|
190
206
|
const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
|
|
191
207
|
const destDir = resolve(directory);
|
|
@@ -251,7 +267,7 @@ export function createServer() {
|
|
|
251
267
|
}),
|
|
252
268
|
},
|
|
253
269
|
wrap(async ({ slug, confirm }) => {
|
|
254
|
-
if (!isLoggedIn()) return
|
|
270
|
+
if (!isLoggedIn()) return NOT_SIGNED_IN;
|
|
255
271
|
if (!confirm) return "Deletion not confirmed. Set confirm to true to proceed.";
|
|
256
272
|
|
|
257
273
|
const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
|
|
@@ -264,6 +280,19 @@ export function createServer() {
|
|
|
264
280
|
// -------------------------------------------------------------------
|
|
265
281
|
// Tool: whoami
|
|
266
282
|
// -------------------------------------------------------------------
|
|
283
|
+
server.registerTool(
|
|
284
|
+
"login",
|
|
285
|
+
{
|
|
286
|
+
title: "Sign in to htmlhost",
|
|
287
|
+
description:
|
|
288
|
+
"Sign in to the user's htmlhost.co account. Opens an approval page in their browser and waits briefly; " +
|
|
289
|
+
"if they haven't approved yet, it returns the link and code to show them. Call it again after they approve. " +
|
|
290
|
+
"Use it when another htmlhost tool says you're not signed in.",
|
|
291
|
+
inputSchema: z.object({}),
|
|
292
|
+
},
|
|
293
|
+
wrap(async () => loginStep())
|
|
294
|
+
);
|
|
295
|
+
|
|
267
296
|
server.registerTool(
|
|
268
297
|
"whoami",
|
|
269
298
|
{
|
|
@@ -272,7 +301,7 @@ export function createServer() {
|
|
|
272
301
|
inputSchema: z.object({}),
|
|
273
302
|
},
|
|
274
303
|
wrap(async () => {
|
|
275
|
-
if (!isLoggedIn()) return
|
|
304
|
+
if (!isLoggedIn()) return NOT_SIGNED_IN;
|
|
276
305
|
|
|
277
306
|
const data = await apiGet("/api/tokens/me");
|
|
278
307
|
return {
|
|
@@ -302,7 +331,7 @@ export function createServer() {
|
|
|
302
331
|
}),
|
|
303
332
|
},
|
|
304
333
|
wrap(async ({ html, slug, title, ttl }) => {
|
|
305
|
-
if (!isLoggedIn()) return
|
|
334
|
+
if (!isLoggedIn()) return NOT_SIGNED_IN;
|
|
306
335
|
|
|
307
336
|
const body = { html };
|
|
308
337
|
if (slug) body.slug = slug;
|
|
@@ -337,7 +366,7 @@ export function createServer() {
|
|
|
337
366
|
}),
|
|
338
367
|
},
|
|
339
368
|
wrap(async ({ slug }) => {
|
|
340
|
-
if (!isLoggedIn()) return
|
|
369
|
+
if (!isLoggedIn()) return NOT_SIGNED_IN;
|
|
341
370
|
const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
|
|
342
371
|
return apiGet(`/api/sites/${encodeURIComponent(cleanSlug)}/versions`);
|
|
343
372
|
})
|
|
@@ -360,7 +389,7 @@ export function createServer() {
|
|
|
360
389
|
}),
|
|
361
390
|
},
|
|
362
391
|
wrap(async ({ slug, version, confirm }) => {
|
|
363
|
-
if (!isLoggedIn()) return
|
|
392
|
+
if (!isLoggedIn()) return NOT_SIGNED_IN;
|
|
364
393
|
if (!confirm) return "Restore not confirmed. Set confirm to true to proceed.";
|
|
365
394
|
const cleanSlug = slug.replace(/\.htmlhost\.co$/, "");
|
|
366
395
|
const data = await apiPost(`/api/sites/${encodeURIComponent(cleanSlug)}/versions/${version}/restore`, {});
|
package/src/update.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping htmlhost-mcp current, like the CLI's update check.
|
|
3
|
+
*
|
|
4
|
+
* An MCP server is a child process of the AI tool, talking over stdio, so it
|
|
5
|
+
* can't swap itself out while running and must never print to stdout. On
|
|
6
|
+
* start it checks the npm registry (at most once a day, cached in the shared
|
|
7
|
+
* ~/.htmlhostrc). When a newer version exists:
|
|
8
|
+
*
|
|
9
|
+
* - installed globally (`htmlhost-mcp` command): it installs the new version
|
|
10
|
+
* in the background, so the next start of the AI tool uses it;
|
|
11
|
+
* - run with `npx -y htmlhost-mcp@latest`: npx fetches it on the next start;
|
|
12
|
+
* - run with a bare `npx -y htmlhost-mcp`: npx keeps reusing its cached copy,
|
|
13
|
+
* so the notice asks for `@latest` in the MCP config.
|
|
14
|
+
*
|
|
15
|
+
* The first tool result of the session carries a one-line notice, so the AI
|
|
16
|
+
* tells the user. Set HTMLHOST_NO_UPDATE_CHECK to turn all of this off.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { realpathSync } from "node:fs";
|
|
20
|
+
import { sep } from "node:path";
|
|
21
|
+
import { readConfig, writeConfig } from "./api.mjs";
|
|
22
|
+
|
|
23
|
+
const PACKAGE_NAME = "htmlhost-mcp";
|
|
24
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
/** True when a is a newer x.y.z than b. */
|
|
27
|
+
export function isNewer(a, b) {
|
|
28
|
+
const pa = String(a).split(".").map(Number);
|
|
29
|
+
const pb = String(b).split(".").map(Number);
|
|
30
|
+
for (let i = 0; i < 3; i++) {
|
|
31
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
32
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** How this server was started: "npx" (from npx's cache), "global", or "local" (a project's node_modules, or a checkout). */
|
|
38
|
+
export function installKind(entry = process.argv[1] || "", globalRoot = null) {
|
|
39
|
+
let real = entry;
|
|
40
|
+
try { real = realpathSync(entry); } catch { /* keep the path as given */ }
|
|
41
|
+
if (real.includes(`${sep}_npx${sep}`)) return "npx";
|
|
42
|
+
if (globalRoot && real.startsWith(globalRoot + sep)) return "global";
|
|
43
|
+
return "local";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `npm root -g`, or null. */
|
|
47
|
+
function npmGlobalRoot() {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
try {
|
|
50
|
+
const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["root", "-g"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
51
|
+
let out = "";
|
|
52
|
+
child.stdout.on("data", (d) => (out += d));
|
|
53
|
+
child.on("error", () => resolve(null));
|
|
54
|
+
child.on("close", () => resolve(out.trim() || null));
|
|
55
|
+
setTimeout(() => { child.kill(); resolve(null); }, 5000).unref();
|
|
56
|
+
} catch {
|
|
57
|
+
resolve(null);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The newest published version, from the cache when it's less than a day old. */
|
|
63
|
+
async function latestVersion() {
|
|
64
|
+
const config = readConfig();
|
|
65
|
+
if (config.mcpLatestVersion && Date.now() - (config.mcpLastUpdateCheck || 0) < CHECK_INTERVAL_MS) {
|
|
66
|
+
return config.mcpLatestVersion;
|
|
67
|
+
}
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { signal: controller.signal, headers: { Accept: "application/json" } });
|
|
72
|
+
if (!res.ok) return null;
|
|
73
|
+
const data = await res.json();
|
|
74
|
+
if (!data?.version) return null;
|
|
75
|
+
writeConfig({ mcpLastUpdateCheck: Date.now(), mcpLatestVersion: data.version });
|
|
76
|
+
return data.version;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
} finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Install the latest version globally, detached and silent (stdout belongs to MCP). */
|
|
85
|
+
function installInBackground() {
|
|
86
|
+
try {
|
|
87
|
+
const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
|
|
88
|
+
stdio: "ignore",
|
|
89
|
+
detached: true,
|
|
90
|
+
});
|
|
91
|
+
child.on("error", () => {});
|
|
92
|
+
child.unref();
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check for a newer version and act on it. Resolves to a one-line notice for
|
|
101
|
+
* the AI to pass on, or null. Never throws, never writes to stdout.
|
|
102
|
+
*/
|
|
103
|
+
export async function checkForUpdate(currentVersion) {
|
|
104
|
+
if (process.env.HTMLHOST_NO_UPDATE_CHECK || process.env.CI) return null;
|
|
105
|
+
const latest = await latestVersion();
|
|
106
|
+
if (!latest || !isNewer(latest, currentVersion)) return null;
|
|
107
|
+
const kind = installKind(process.argv[1], await npmGlobalRoot());
|
|
108
|
+
const head = `htmlhost-mcp ${latest} is available (this is ${currentVersion}).`;
|
|
109
|
+
if (kind === "global") {
|
|
110
|
+
return installInBackground()
|
|
111
|
+
? `${head} It's being installed now; restart your AI tool (or its MCP servers) to use it.`
|
|
112
|
+
: `${head} Update with: npm install -g ${PACKAGE_NAME}@latest, then restart your AI tool.`;
|
|
113
|
+
}
|
|
114
|
+
if (kind === "npx") {
|
|
115
|
+
return `${head} Restart your AI tool to load it. If it still reports ${currentVersion}, change the MCP config to run "npx -y ${PACKAGE_NAME}@latest" so npx always fetches the newest version.`;
|
|
116
|
+
}
|
|
117
|
+
return `${head} Update the htmlhost-mcp package this server runs from, then restart your AI tool.`;
|
|
118
|
+
}
|