poltertab 1.1.0 → 1.2.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 +35 -0
- package/bin/doctor.js +174 -0
- package/bin/poltertab.js +14 -1
- package/mcp-server/index.js +67 -2
- package/mcp-server/update-check.js +242 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -229,6 +229,41 @@ poltertab/
|
|
|
229
229
|
└── README.md
|
|
230
230
|
```
|
|
231
231
|
|
|
232
|
+
## Staying up to date
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
poltertab doctor
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Reports the installed version, whether npm has a newer one, and — the part that
|
|
239
|
+
matters — whether the two halves agree:
|
|
240
|
+
|
|
241
|
+
```
|
|
242
|
+
server 1.1.0
|
|
243
|
+
extension 1.0.0 SKEW last seen 3m ago
|
|
244
|
+
bridge listening on port 7822
|
|
245
|
+
state ~/.poltertab
|
|
246
|
+
npm 1.2.0 available
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The npm package updates when you run `npm update -g poltertab`. The extension,
|
|
250
|
+
loaded unpacked, **never updates on its own** — Chrome only auto-updates what came
|
|
251
|
+
from the Web Store. So the interesting failure is not a missing update, it is
|
|
252
|
+
skew: a newer server sending a command an older extension does not implement,
|
|
253
|
+
which surfaces as an element mysteriously not being found.
|
|
254
|
+
|
|
255
|
+
Both halves ship as one version, so a mismatch is unambiguous. The extension
|
|
256
|
+
reports its version when it connects; the server compares, logs it, and shows it
|
|
257
|
+
in the extension popup. If either half is behind, the first tool response of a
|
|
258
|
+
session says so once, so you hear about it without having to think to check.
|
|
259
|
+
|
|
260
|
+
Update checks hit `registry.npmjs.org` at most once a day, cached in
|
|
261
|
+
`~/.poltertab/update-check.json`, and never block anything. Set
|
|
262
|
+
`POLTERTAB_NO_UPDATE_CHECK=1` to turn them off — skew detection is local and
|
|
263
|
+
keeps working either way.
|
|
264
|
+
|
|
265
|
+
`poltertab doctor` exits non-zero on skew, so CI or a shell prompt can act on it.
|
|
266
|
+
|
|
232
267
|
## Where your data lives
|
|
233
268
|
|
|
234
269
|
Nothing the server accumulates is stored inside the package:
|
package/bin/doctor.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// `poltertab doctor` — what is installed, what is available, and whether the
|
|
2
|
+
// two halves agree.
|
|
3
|
+
//
|
|
4
|
+
// Deliberately works with no server running and no network. Someone runs this
|
|
5
|
+
// because something is already broken, which is the worst time to demand that
|
|
6
|
+
// more things be working.
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const net = require("net");
|
|
10
|
+
const os = require("os");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
|
|
13
|
+
const PKG = path.join(__dirname, "..");
|
|
14
|
+
const updates = require(path.join(PKG, "mcp-server", "update-check.js"));
|
|
15
|
+
|
|
16
|
+
const c = {
|
|
17
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
18
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
19
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
20
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
21
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
22
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const home = () =>
|
|
26
|
+
process.env.POLTERTAB_HOME || path.join(os.homedir(), ".poltertab");
|
|
27
|
+
|
|
28
|
+
function port() {
|
|
29
|
+
const i = process.argv.indexOf("--port");
|
|
30
|
+
if (i !== -1 && process.argv[i + 1]) return Number(process.argv[i + 1]);
|
|
31
|
+
return Number(process.env.MCP_BROWSER_WS_PORT) || 7822;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A listening port means a server holds the bridge. Cheaper and more reliable
|
|
35
|
+
// than speaking the protocol just to answer "is anything running".
|
|
36
|
+
function probePort(p, timeout = 700) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const s = net.connect({ port: p, host: "127.0.0.1" });
|
|
39
|
+
const done = (v) => {
|
|
40
|
+
s.destroy();
|
|
41
|
+
resolve(v);
|
|
42
|
+
};
|
|
43
|
+
s.setTimeout(timeout);
|
|
44
|
+
s.once("connect", () => done(true));
|
|
45
|
+
s.once("timeout", () => done(false));
|
|
46
|
+
s.once("error", () => done(false));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ago = (ts) => {
|
|
51
|
+
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
|
52
|
+
if (s < 90) return `${s}s ago`;
|
|
53
|
+
const m = Math.round(s / 60);
|
|
54
|
+
if (m < 90) return `${m}m ago`;
|
|
55
|
+
const h = Math.round(m / 60);
|
|
56
|
+
return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
async function run(argv = []) {
|
|
60
|
+
const H = home();
|
|
61
|
+
const own = require(path.join(PKG, "package.json")).version;
|
|
62
|
+
const row = (k, v) => console.log(` ${k.padEnd(12)}${v}`);
|
|
63
|
+
|
|
64
|
+
console.log(`\n ${c.bold("PolterTab")} ${c.dim("doctor")}\n`);
|
|
65
|
+
|
|
66
|
+
// Start the registry lookup before printing anything, then report everything
|
|
67
|
+
// local while it is in flight. A cold lookup costs seconds, and blocking the
|
|
68
|
+
// whole report on it makes doctor look hung exactly when someone is already
|
|
69
|
+
// suspicious that something is broken.
|
|
70
|
+
const checking = updates
|
|
71
|
+
.checkForUpdate({ current: own, home: H, force: argv.includes("--force") })
|
|
72
|
+
.catch(() => ({ latest: null, updateAvailable: false }));
|
|
73
|
+
|
|
74
|
+
// --- versions
|
|
75
|
+
const seen = updates.readExtensionState(H);
|
|
76
|
+
const ext = seen && seen.extensionVersion;
|
|
77
|
+
const sk = updates.skew(own, ext);
|
|
78
|
+
|
|
79
|
+
row("server", own);
|
|
80
|
+
|
|
81
|
+
if (!ext) {
|
|
82
|
+
row("extension", c.dim("never seen — has it connected to a server yet?"));
|
|
83
|
+
} else {
|
|
84
|
+
row(
|
|
85
|
+
"extension",
|
|
86
|
+
`${ext} ${sk ? c.red("SKEW") : c.green("matches server")}` +
|
|
87
|
+
c.dim(` last seen ${ago(seen.seenAt || 0)}`),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const listening = await probePort(port());
|
|
92
|
+
row(
|
|
93
|
+
"bridge",
|
|
94
|
+
listening
|
|
95
|
+
? `${c.green("listening")} ${c.dim(`on port ${port()}`)}`
|
|
96
|
+
: `${c.dim(`nothing on port ${port()}`)}`,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
row("state", c.dim(H));
|
|
100
|
+
|
|
101
|
+
// Now collect the answer we started with.
|
|
102
|
+
const check = await checking;
|
|
103
|
+
if (updates.disabled() && !check.latest) {
|
|
104
|
+
row("npm", c.dim("update check disabled by POLTERTAB_NO_UPDATE_CHECK"));
|
|
105
|
+
} else if (check.latest) {
|
|
106
|
+
row(
|
|
107
|
+
"npm",
|
|
108
|
+
check.updateAvailable
|
|
109
|
+
? `${c.yellow(check.latest)} ${c.dim("available")}`
|
|
110
|
+
: `${c.green("up to date")} ${c.dim(`(latest is ${check.latest})`)}`,
|
|
111
|
+
);
|
|
112
|
+
} else {
|
|
113
|
+
row("npm", c.dim("could not reach the registry"));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- what to do about it
|
|
117
|
+
const todo = [];
|
|
118
|
+
if (sk && sk.kind === "extension-behind") {
|
|
119
|
+
todo.push(
|
|
120
|
+
`${c.red("The extension is older than the server.")} Browser commands can fail\n` +
|
|
121
|
+
` in ways that look like a missing element. Download and reload it:\n` +
|
|
122
|
+
` ${c.cyan(updates.EXTENSION_URL)}`,
|
|
123
|
+
);
|
|
124
|
+
} else if (sk && sk.kind === "server-behind") {
|
|
125
|
+
todo.push(
|
|
126
|
+
`${c.yellow("The extension is newer than the server.")} Update the package:\n` +
|
|
127
|
+
` ${c.cyan("npm update -g poltertab")}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (check.updateAvailable) {
|
|
131
|
+
todo.push(
|
|
132
|
+
`PolterTab ${c.bold(check.latest)} is available:\n` +
|
|
133
|
+
` ${c.cyan("npm update -g poltertab")}\n` +
|
|
134
|
+
` ${c.dim("then reload the extension from")} ${c.cyan(updates.EXTENSION_URL)}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (!listening) {
|
|
138
|
+
todo.push(
|
|
139
|
+
`No bridge on port ${port()}. That is expected unless an MCP client has\n` +
|
|
140
|
+
` PolterTab running — the server starts when the client starts it.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (todo.length) {
|
|
145
|
+
console.log(`\n ${c.bold("To do")}\n`);
|
|
146
|
+
for (const t of todo) console.log(` • ${t}`);
|
|
147
|
+
} else if (!ext) {
|
|
148
|
+
// Claiming "both halves agree" having never seen one of them is the kind of
|
|
149
|
+
// all-clear that sends someone looking for the fault somewhere else.
|
|
150
|
+
console.log(
|
|
151
|
+
`\n ${c.yellow("Nothing wrong found, but the extension has never checked in.")}\n` +
|
|
152
|
+
` ${c.dim("Install it, open a page, and run doctor again to compare versions:")}\n` +
|
|
153
|
+
` ${c.cyan(updates.EXTENSION_URL)}`,
|
|
154
|
+
);
|
|
155
|
+
} else {
|
|
156
|
+
console.log(`\n ${c.green("Nothing to do — both halves agree and are current.")}`);
|
|
157
|
+
}
|
|
158
|
+
console.log("");
|
|
159
|
+
|
|
160
|
+
// Non-zero on skew only: an available update is information, but two halves
|
|
161
|
+
// that disagree is a real fault, and a script may want to act on it.
|
|
162
|
+
return sk ? 1 : 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { run, probePort, ago };
|
|
166
|
+
|
|
167
|
+
if (require.main === module) {
|
|
168
|
+
run(process.argv.slice(3))
|
|
169
|
+
.then((code) => process.exit(code))
|
|
170
|
+
.catch((err) => {
|
|
171
|
+
console.error(`\n doctor failed: ${err.message}\n`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
});
|
|
174
|
+
}
|
package/bin/poltertab.js
CHANGED
|
@@ -15,6 +15,14 @@ if (arg === "setup") {
|
|
|
15
15
|
console.error(`\n setup failed: ${err.message}\n`);
|
|
16
16
|
process.exit(1);
|
|
17
17
|
});
|
|
18
|
+
} else if (arg === "doctor") {
|
|
19
|
+
require("./doctor.js")
|
|
20
|
+
.run(process.argv.slice(3))
|
|
21
|
+
.then((code) => process.exit(code))
|
|
22
|
+
.catch((err) => {
|
|
23
|
+
console.error(`\n doctor failed: ${err.message}\n`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
18
26
|
} else if (arg === "--version" || arg === "-v") {
|
|
19
27
|
console.log(require("../package.json").version);
|
|
20
28
|
} else if (arg === "--help" || arg === "-h") {
|
|
@@ -23,9 +31,14 @@ if (arg === "setup") {
|
|
|
23
31
|
|
|
24
32
|
poltertab start the MCP server (stdio)
|
|
25
33
|
poltertab setup install skill, CLAUDE.md section, and MCP registration
|
|
34
|
+
poltertab doctor versions, update check, and extension/server skew
|
|
26
35
|
poltertab --version
|
|
27
36
|
|
|
28
|
-
setup flags:
|
|
37
|
+
setup flags: --global | --project (skips the prompt)
|
|
38
|
+
doctor flags: --force (ignore the cached update check), --port <n>
|
|
39
|
+
|
|
40
|
+
POLTERTAB_HOME where memory and downloads live
|
|
41
|
+
POLTERTAB_NO_UPDATE_CHECK set to disable the npm version check
|
|
29
42
|
`);
|
|
30
43
|
} else {
|
|
31
44
|
require("../mcp-server/index.js");
|
package/mcp-server/index.js
CHANGED
|
@@ -30,6 +30,26 @@ const DOWNLOADS_DIR = path.join(POLTERTAB_HOME, "downloads");
|
|
|
30
30
|
|
|
31
31
|
fs.mkdirSync(MEMORY_DIR, { recursive: true });
|
|
32
32
|
|
|
33
|
+
const updates = require("./update-check.js");
|
|
34
|
+
const OWN_VERSION = require("./../package.json").version;
|
|
35
|
+
|
|
36
|
+
// Populated when the extension connects; stays null until then, which is itself
|
|
37
|
+
// worth reporting — a skew warning must not fire on "no extension yet".
|
|
38
|
+
let extensionVersion = null;
|
|
39
|
+
let updateState = { latest: null, updateAvailable: false };
|
|
40
|
+
let noticeDelivered = false;
|
|
41
|
+
|
|
42
|
+
// Fire and forget at startup so the answer is ready by the first tool call.
|
|
43
|
+
// A rejected promise here must never reach the top level.
|
|
44
|
+
if (!updates.disabled()) {
|
|
45
|
+
updates
|
|
46
|
+
.checkForUpdate({ current: OWN_VERSION, home: POLTERTAB_HOME })
|
|
47
|
+
.then((r) => {
|
|
48
|
+
updateState = r;
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {});
|
|
51
|
+
}
|
|
52
|
+
|
|
33
53
|
// Installs predating the move kept memory beside the code. Copy it forward once
|
|
34
54
|
// so an upgrade does not look like the agent forgot everything it learned.
|
|
35
55
|
// Never overwrite: if both sides have a note for a domain, the one already in
|
|
@@ -370,6 +390,27 @@ function setupPrimaryWss(wss) {
|
|
|
370
390
|
extensionSocket = ws;
|
|
371
391
|
console.error("[PolterTab MCP] Chrome extension connected.");
|
|
372
392
|
}
|
|
393
|
+
// The extension has always sent its version here and we always
|
|
394
|
+
// dropped it, so the one mismatch that actually breaks commands was
|
|
395
|
+
// invisible to both halves.
|
|
396
|
+
if (msg.version && msg.version !== extensionVersion) {
|
|
397
|
+
extensionVersion = msg.version;
|
|
398
|
+
updates.recordExtension(POLTERTAB_HOME, msg.version);
|
|
399
|
+
const sk = updates.skew(OWN_VERSION, msg.version);
|
|
400
|
+
console.error(
|
|
401
|
+
`[PolterTab MCP] Extension v${msg.version} (server v${OWN_VERSION})` +
|
|
402
|
+
(sk ? ` — VERSION SKEW: ${sk.kind}` : ""),
|
|
403
|
+
);
|
|
404
|
+
// Let the extension show the mismatch in its popup; it cannot
|
|
405
|
+
// know the server's version otherwise.
|
|
406
|
+
try {
|
|
407
|
+
ws.send(
|
|
408
|
+
JSON.stringify({ type: "server_version", version: OWN_VERSION }),
|
|
409
|
+
);
|
|
410
|
+
} catch (_) {
|
|
411
|
+
// A socket that died mid-handshake reconnects on its own.
|
|
412
|
+
}
|
|
413
|
+
}
|
|
373
414
|
}
|
|
374
415
|
return;
|
|
375
416
|
}
|
|
@@ -554,7 +595,9 @@ async function sendCommand(action, params) {
|
|
|
554
595
|
const server = new Server(
|
|
555
596
|
{
|
|
556
597
|
name: "poltertab-browser-mcp",
|
|
557
|
-
|
|
598
|
+
// Read, not hardcoded: this said "1.0.0" through every release, so the
|
|
599
|
+
// version the client reported had nothing to do with what was installed.
|
|
600
|
+
version: OWN_VERSION,
|
|
558
601
|
},
|
|
559
602
|
{
|
|
560
603
|
capabilities: {
|
|
@@ -867,7 +910,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
867
910
|
};
|
|
868
911
|
});
|
|
869
912
|
|
|
870
|
-
|
|
913
|
+
const handleToolCall = async (request) => {
|
|
871
914
|
const { name, arguments: args } = request.params;
|
|
872
915
|
|
|
873
916
|
if (!name.startsWith("browser_")) {
|
|
@@ -1064,6 +1107,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1064
1107
|
],
|
|
1065
1108
|
};
|
|
1066
1109
|
}
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
// Update and skew notices ride out on the first tool response and never again.
|
|
1113
|
+
// Doctor and the extension popup both require the user to already suspect
|
|
1114
|
+
// something is wrong; the agent's reply is the one place they are certainly
|
|
1115
|
+
// looking. Appended as its own content block so it cannot corrupt a payload
|
|
1116
|
+
// something downstream is parsing.
|
|
1117
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1118
|
+
const result = await handleToolCall(request);
|
|
1119
|
+
if (noticeDelivered) return result;
|
|
1120
|
+
|
|
1121
|
+
const text = updates.notice({
|
|
1122
|
+
current: OWN_VERSION,
|
|
1123
|
+
latest: updateState.latest,
|
|
1124
|
+
updateAvailable: updateState.updateAvailable,
|
|
1125
|
+
skew: updates.skew(OWN_VERSION, extensionVersion),
|
|
1126
|
+
});
|
|
1127
|
+
if (!text) return result;
|
|
1128
|
+
|
|
1129
|
+
noticeDelivered = true;
|
|
1130
|
+
if (!result || !Array.isArray(result.content)) return result;
|
|
1131
|
+
return { ...result, content: [...result.content, { type: "text", text }] };
|
|
1067
1132
|
});
|
|
1068
1133
|
|
|
1069
1134
|
// Start the server
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Update and version-skew checking.
|
|
2
|
+
//
|
|
3
|
+
// Two halves ship as one version but update on completely different schedules.
|
|
4
|
+
// The npm package updates when someone remembers to run `npm update -g`; the
|
|
5
|
+
// extension, loaded unpacked, never updates at all — Chrome only auto-updates
|
|
6
|
+
// what came from the Web Store. So the interesting failure is not "an update
|
|
7
|
+
// exists", it is skew: a 1.2 server sending a command a 1.0 extension does not
|
|
8
|
+
// implement. That surfaces as an element mysteriously not being found, which is
|
|
9
|
+
// indistinguishable from the page genuinely not having it.
|
|
10
|
+
//
|
|
11
|
+
// Nothing here may block startup or throw into a caller. A failed update check
|
|
12
|
+
// is a non-event; the browser tools have to keep working with no network.
|
|
13
|
+
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
|
|
17
|
+
const CACHE_FILE = "update-check.json";
|
|
18
|
+
const STATE_FILE = "state.json";
|
|
19
|
+
const OK_TTL_MS = 24 * 60 * 60 * 1000; // a day between successful checks
|
|
20
|
+
const FAIL_TTL_MS = 60 * 60 * 1000; // retry sooner after a failure
|
|
21
|
+
|
|
22
|
+
// Measured, not guessed: a cold connection to registry.npmjs.org costs ~4s here
|
|
23
|
+
// (DNS + TLS; the body itself arrives in ~5ms). Every check is cold, because it
|
|
24
|
+
// runs once at startup in a fresh process. An earlier 3s value aborted 100% of
|
|
25
|
+
// real lookups while every unit test passed, since those inject a fake fetch —
|
|
26
|
+
// so keep a wide margin over the observed cost. Nothing waits on this: the
|
|
27
|
+
// server fires it and forgets, and doctor prints local state first.
|
|
28
|
+
const FETCH_TIMEOUT_MS = 15000;
|
|
29
|
+
|
|
30
|
+
// --- semver ----------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
function parse(version) {
|
|
33
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(
|
|
34
|
+
String(version || "").trim().replace(/^v/, ""),
|
|
35
|
+
);
|
|
36
|
+
if (!m) return null;
|
|
37
|
+
return {
|
|
38
|
+
nums: [Number(m[1]), Number(m[2]), Number(m[3])],
|
|
39
|
+
pre: m[4] ? m[4].split(".") : [],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Full semver precedence, because the shortcuts are all wrong in ways that
|
|
44
|
+
// matter here: string compare puts "1.10.0" below "1.9.0", and ignoring the
|
|
45
|
+
// prerelease tail would tell someone on 1.2.0-beta.1 to "update" to 1.1.0.
|
|
46
|
+
function compareVersions(a, b) {
|
|
47
|
+
const A = parse(a);
|
|
48
|
+
const B = parse(b);
|
|
49
|
+
if (!A || !B) return 0;
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < 3; i++) {
|
|
52
|
+
if (A.nums[i] !== B.nums[i]) return A.nums[i] < B.nums[i] ? -1 : 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 1.2.0 outranks every 1.2.0-anything.
|
|
56
|
+
if (!A.pre.length && B.pre.length) return 1;
|
|
57
|
+
if (A.pre.length && !B.pre.length) return -1;
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < Math.max(A.pre.length, B.pre.length); i++) {
|
|
60
|
+
const x = A.pre[i];
|
|
61
|
+
const y = B.pre[i];
|
|
62
|
+
if (x === undefined) return -1; // a shorter prerelease ranks lower
|
|
63
|
+
if (y === undefined) return 1;
|
|
64
|
+
const xNum = /^\d+$/.test(x);
|
|
65
|
+
const yNum = /^\d+$/.test(y);
|
|
66
|
+
if (xNum && yNum) {
|
|
67
|
+
if (Number(x) !== Number(y)) return Number(x) < Number(y) ? -1 : 1;
|
|
68
|
+
} else if (xNum !== yNum) {
|
|
69
|
+
return xNum ? -1 : 1; // numeric identifiers rank below alphanumeric
|
|
70
|
+
} else if (x !== y) {
|
|
71
|
+
return x < y ? -1 : 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isNewer = (candidate, current) => compareVersions(candidate, current) > 0;
|
|
78
|
+
|
|
79
|
+
// --- on-disk state ---------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function readJson(file) {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
84
|
+
} catch (_) {
|
|
85
|
+
return null; // absent, unreadable, or truncated — all mean "no state"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function writeJson(file, data) {
|
|
90
|
+
try {
|
|
91
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
92
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
|
|
93
|
+
return true;
|
|
94
|
+
} catch (_) {
|
|
95
|
+
return false; // a read-only home is not worth failing a browser command
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The extension only ever talks to a running server, so `poltertab doctor` has
|
|
100
|
+
// no way to ask it directly. Recording the version here means doctor can report
|
|
101
|
+
// it — honestly labelled as last-seen — without any new protocol, and even when
|
|
102
|
+
// no server is running.
|
|
103
|
+
const readExtensionState = (home) => readJson(path.join(home, STATE_FILE));
|
|
104
|
+
|
|
105
|
+
function recordExtension(home, version, now = Date.now()) {
|
|
106
|
+
if (!version) return false;
|
|
107
|
+
return writeJson(path.join(home, STATE_FILE), {
|
|
108
|
+
extensionVersion: version,
|
|
109
|
+
seenAt: now,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- registry --------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
function disabled() {
|
|
116
|
+
const v = process.env.POLTERTAB_NO_UPDATE_CHECK;
|
|
117
|
+
return v !== undefined && v !== "" && v !== "0" && v !== "false";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Resolves to a version string, or null for any failure at all — offline,
|
|
121
|
+
// 404 (the package is not published yet), timeout, garbage response.
|
|
122
|
+
async function fetchLatest(pkg = "poltertab", fetchImpl = globalThis.fetch) {
|
|
123
|
+
if (typeof fetchImpl !== "function") return null;
|
|
124
|
+
const ac = new AbortController();
|
|
125
|
+
const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
|
|
126
|
+
try {
|
|
127
|
+
const res = await fetchImpl(
|
|
128
|
+
`https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`,
|
|
129
|
+
{ signal: ac.signal, headers: { accept: "application/json" } },
|
|
130
|
+
);
|
|
131
|
+
if (!res || !res.ok) return null;
|
|
132
|
+
const body = await res.json();
|
|
133
|
+
return parse(body && body.version) ? body.version : null;
|
|
134
|
+
} catch (_) {
|
|
135
|
+
return null;
|
|
136
|
+
} finally {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Cached so a per-session server start does not mean a per-session request.
|
|
142
|
+
// Returns the cached answer without touching the network when it is fresh.
|
|
143
|
+
async function checkForUpdate({
|
|
144
|
+
current,
|
|
145
|
+
home,
|
|
146
|
+
pkg = "poltertab",
|
|
147
|
+
now = Date.now(),
|
|
148
|
+
fetchImpl = globalThis.fetch,
|
|
149
|
+
force = false,
|
|
150
|
+
} = {}) {
|
|
151
|
+
const cacheFile = path.join(home, CACHE_FILE);
|
|
152
|
+
const cached = readJson(cacheFile);
|
|
153
|
+
|
|
154
|
+
if (!force && cached && typeof cached.checkedAt === "number") {
|
|
155
|
+
const ttl = cached.latest ? OK_TTL_MS : FAIL_TTL_MS;
|
|
156
|
+
if (now - cached.checkedAt < ttl) {
|
|
157
|
+
return {
|
|
158
|
+
latest: cached.latest || null,
|
|
159
|
+
updateAvailable: !!cached.latest && isNewer(cached.latest, current),
|
|
160
|
+
fromCache: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!force && disabled()) {
|
|
166
|
+
return { latest: null, updateAvailable: false, disabled: true };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const latest = await fetchLatest(pkg, fetchImpl);
|
|
170
|
+
writeJson(cacheFile, { checkedAt: now, latest });
|
|
171
|
+
return {
|
|
172
|
+
latest,
|
|
173
|
+
updateAvailable: !!latest && isNewer(latest, current),
|
|
174
|
+
fromCache: false,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- skew ------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
// Compares only major.minor. A patch-level difference between the two halves is
|
|
181
|
+
// normal — someone updates the package and reloads the extension a minute later
|
|
182
|
+
// — and warning about it would train people to ignore the warning.
|
|
183
|
+
function skew(serverVersion, extensionVersion) {
|
|
184
|
+
if (!extensionVersion) return null;
|
|
185
|
+
const s = parse(serverVersion);
|
|
186
|
+
const e = parse(extensionVersion);
|
|
187
|
+
if (!s || !e) return null;
|
|
188
|
+
if (s.nums[0] === e.nums[0] && s.nums[1] === e.nums[1]) return null;
|
|
189
|
+
const behind = compareVersions(extensionVersion, serverVersion) < 0;
|
|
190
|
+
return {
|
|
191
|
+
kind: behind ? "extension-behind" : "server-behind",
|
|
192
|
+
server: serverVersion,
|
|
193
|
+
extension: extensionVersion,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- the one-line notice ---------------------------------------------------
|
|
198
|
+
|
|
199
|
+
const EXTENSION_URL = "https://github.com/abhinav162/poltertab/releases/latest";
|
|
200
|
+
|
|
201
|
+
// Surfaced once per server process on a tool response, because it is the only
|
|
202
|
+
// channel the user reliably reads: doctor and the popup both require them to
|
|
203
|
+
// already suspect something is wrong.
|
|
204
|
+
function notice({ current, latest, updateAvailable, skew: sk }) {
|
|
205
|
+
const parts = [];
|
|
206
|
+
if (sk && sk.kind === "extension-behind") {
|
|
207
|
+
parts.push(
|
|
208
|
+
`The Chrome extension (${sk.extension}) is older than this server (${sk.server}) — ` +
|
|
209
|
+
`browser commands may fail in ways that look like missing elements. ` +
|
|
210
|
+
`Reload it from ${EXTENSION_URL}`,
|
|
211
|
+
);
|
|
212
|
+
} else if (sk && sk.kind === "server-behind") {
|
|
213
|
+
parts.push(
|
|
214
|
+
`The Chrome extension (${sk.extension}) is newer than this server (${sk.server}). ` +
|
|
215
|
+
`Update with: npm update -g poltertab`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (updateAvailable && latest) {
|
|
219
|
+
parts.push(
|
|
220
|
+
`PolterTab ${latest} is available (running ${current}). ` +
|
|
221
|
+
`Update with: npm update -g poltertab && poltertab setup`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (!parts.length) return null;
|
|
225
|
+
return `[PolterTab] ${parts.join(" ")} (Mention this to the user once, then carry on.)`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = {
|
|
229
|
+
compareVersions,
|
|
230
|
+
isNewer,
|
|
231
|
+
parse,
|
|
232
|
+
readExtensionState,
|
|
233
|
+
recordExtension,
|
|
234
|
+
fetchLatest,
|
|
235
|
+
checkForUpdate,
|
|
236
|
+
skew,
|
|
237
|
+
notice,
|
|
238
|
+
disabled,
|
|
239
|
+
EXTENSION_URL,
|
|
240
|
+
CACHE_FILE,
|
|
241
|
+
STATE_FILE,
|
|
242
|
+
};
|
package/package.json
CHANGED