memtrace 0.3.38 → 0.3.39

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/bin/memtrace.js CHANGED
@@ -9,6 +9,7 @@ const readline = require("readline");
9
9
  const { getBinaryPath } = require("../install.js");
10
10
  const { platformBinary, spawnOptionsForPlatform } = require("../lib/spawn-helper");
11
11
  const { shouldPromptForUpgrade, isPromptDisabled } = require("../lib/update-prompt");
12
+ const { fetchLatestVersion, readCachedVersion } = require("../lib/update-check");
12
13
 
13
14
  // ── Handle `memtrace uninstall` before delegating to the Rust binary ────────
14
15
  // npm v7+ does NOT fire preuninstall hooks for global packages (npm/cli#3042).
@@ -129,58 +130,27 @@ try {
129
130
  }
130
131
 
131
132
  // ── Update checker ────────────────────────────────────────────────────────────
132
- // Checks registry.npmjs.org at most once every 24 h (cached in ~/.memtrace/).
133
- // Completely non-blocking: errors are silently swallowed.
134
-
135
- const CACHE_FILE = path.join(os.homedir(), ".memtrace", "update-check.json");
136
- const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 h in ms
137
-
138
- function readUpdateCache() {
139
- try {
140
- return JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
141
- } catch {
142
- return null;
143
- }
144
- }
133
+ //
134
+ // Policy (v0.3.39+): always TRY a fresh HTTP fetch first (1.5s timeout —
135
+ // invisible to humans). On any failure (timeout, network, 5xx, parse
136
+ // error), fall back to the on-disk cache. The cache also serves as the
137
+ // Node Rust IPC handoff for the MCP banner: the Rust binary reads
138
+ // `~/.memtrace/update-check.json` to decide whether to surface an
139
+ // "update available" line in serverInfo.instructions for agents.
140
+ //
141
+ // We dropped the previous 24h TTL because it directly contradicted the
142
+ // visibility goal: a user installing v0.3.36 the hour we shipped v0.3.37
143
+ // would have waited 23 hours before their banner reflected reality.
144
+ //
145
+ // Implementation lives in lib/update-check.js and is unit + property
146
+ // tested with injectable httpGet / cache path.
145
147
 
146
- function writeUpdateCache(data) {
147
- try {
148
- fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
149
- fs.writeFileSync(CACHE_FILE, JSON.stringify(data), "utf-8");
150
- } catch {
151
- // non-fatal
152
- }
153
- }
148
+ const CACHE_FILE = path.join(os.homedir(), ".memtrace", "update-check.json");
154
149
 
155
150
  function checkForUpdate(currentVersion) {
156
- return new Promise((resolve) => {
157
- // Return cached result if it's fresh enough
158
- const cache = readUpdateCache();
159
- if (cache && Date.now() - cache.checkedAt < CHECK_INTERVAL) {
160
- resolve(cache.latestVersion !== currentVersion ? cache.latestVersion : null);
161
- return;
162
- }
163
-
164
- const https = require("https");
165
- const req = https.get(
166
- "https://registry.npmjs.org/memtrace/latest",
167
- { headers: { Accept: "application/json" }, timeout: 3000 },
168
- (res) => {
169
- let body = "";
170
- res.on("data", (chunk) => (body += chunk));
171
- res.on("end", () => {
172
- try {
173
- const latest = JSON.parse(body).version;
174
- writeUpdateCache({ checkedAt: Date.now(), latestVersion: latest });
175
- resolve(latest !== currentVersion ? latest : null);
176
- } catch {
177
- resolve(null);
178
- }
179
- });
180
- }
181
- );
182
- req.on("error", () => resolve(null));
183
- req.on("timeout", () => { req.destroy(); resolve(null); });
151
+ return fetchLatestVersion({ cachePath: CACHE_FILE }).then((latest) => {
152
+ if (!latest) return null;
153
+ return latest !== currentVersion ? latest : null;
184
154
  });
185
155
  }
186
156
 
@@ -218,8 +188,10 @@ async function maybePromptForUpgrade(command) {
218
188
  new Promise((r) => setTimeout(r, 1500)),
219
189
  ]);
220
190
 
221
- const cache = readUpdateCache();
222
- const cachedLatest = cache && cache.latestVersion ? cache.latestVersion : null;
191
+ // After awaiting the in-flight check, the cache file holds whatever
192
+ // the fresh fetch produced (or last-known-good if it failed). Use
193
+ // readCachedVersion as the single source of truth here.
194
+ const cachedLatest = readCachedVersion(CACHE_FILE);
223
195
 
224
196
  const decision = shouldPromptForUpgrade({
225
197
  command,
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+
3
+ // Update-check with cache-as-fallback semantics.
4
+ //
5
+ // Pre-v0.3.39 we cached the npm-registry result for 24h to avoid
6
+ // hammering the registry. The downside: a user installing v0.3.36 the
7
+ // hour we shipped v0.3.37 wouldn't see the upgrade banner for 23 more
8
+ // hours. That defeats the entire visibility goal.
9
+ //
10
+ // New policy:
11
+ // 1. Always TRY a fresh HTTP fetch (1.5s timeout — invisible to humans).
12
+ // 2. On success, overwrite the cache and return fresh value.
13
+ // 3. On any failure (timeout, network error, non-2xx, parse error),
14
+ // fall back to whatever the cache last contained.
15
+ // 4. The cache still serves as the Node ↔ Rust IPC handoff: the Rust
16
+ // MCP banner reads `~/.memtrace/update-check.json` to decide
17
+ // whether to surface "update available" in `serverInfo.instructions`.
18
+ //
19
+ // All IO is dependency-injected so tests can drive arbitrary network
20
+ // failure modes deterministically.
21
+
22
+ const fs = require("fs");
23
+ const path = require("path");
24
+ const https = require("https");
25
+
26
+ const NPM_LATEST_URL = "https://registry.npmjs.org/memtrace/latest";
27
+ const DEFAULT_TIMEOUT_MS = 1500;
28
+
29
+ /**
30
+ * Read the JSON cache file. Returns the parsed object, or null on any
31
+ * IO/parse error. Pure (only file IO at the path you give it).
32
+ */
33
+ function readUpdateCache(cachePath) {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(cachePath, "utf-8"));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Write the cache atomically-ish (mkdir + writeFile). Errors are
43
+ * swallowed — the cache is opportunistic; failing to write must not
44
+ * break the user's command. Returns true if write succeeded, false on
45
+ * any error.
46
+ */
47
+ function writeUpdateCache(cachePath, data) {
48
+ try {
49
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
50
+ fs.writeFileSync(cachePath, JSON.stringify(data), "utf-8");
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Resolve the latest memtrace version on npm with cache fallback.
59
+ *
60
+ * @param {object} opts
61
+ * @param {string} opts.cachePath absolute path to cache file
62
+ * @param {function(string): Promise<{body: string}>} [opts.httpGet] injectable for tests
63
+ * @param {number} [opts.timeoutMs] fetch timeout (default 1500)
64
+ * @param {function(): number} [opts.now] time source (default Date.now)
65
+ * @returns {Promise<string|null>} latest version string, or null when
66
+ * fresh failed AND cache is empty/missing
67
+ */
68
+ function fetchLatestVersion(opts = {}) {
69
+ if (!opts.cachePath) {
70
+ return Promise.reject(new Error("fetchLatestVersion: opts.cachePath is required"));
71
+ }
72
+ const httpGet = opts.httpGet || defaultHttpGet;
73
+ const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
74
+ const now = opts.now || Date.now;
75
+ const cachePath = opts.cachePath;
76
+
77
+ return new Promise((resolve) => {
78
+ let settled = false;
79
+ const finish = (value) => {
80
+ if (settled) return;
81
+ settled = true;
82
+ clearTimeout(timer);
83
+ resolve(value);
84
+ };
85
+ const fallback = () => finish(readCachedVersion(cachePath));
86
+
87
+ const timer = setTimeout(fallback, timeoutMs);
88
+
89
+ Promise.resolve()
90
+ .then(() => httpGet(NPM_LATEST_URL))
91
+ .then((result) => {
92
+ if (!result || typeof result.body !== "string") {
93
+ fallback();
94
+ return;
95
+ }
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(result.body);
99
+ } catch {
100
+ fallback();
101
+ return;
102
+ }
103
+ if (!parsed || typeof parsed.version !== "string" || !parsed.version) {
104
+ fallback();
105
+ return;
106
+ }
107
+ writeUpdateCache(cachePath, {
108
+ checkedAt: now(),
109
+ latestVersion: parsed.version,
110
+ });
111
+ finish(parsed.version);
112
+ })
113
+ .catch(fallback);
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Read the cached version string only (returns null if cache absent
119
+ * or shape is wrong). Useful when callers want to read the IPC handoff
120
+ * without triggering a network fetch.
121
+ */
122
+ function readCachedVersion(cachePath) {
123
+ const cache = readUpdateCache(cachePath);
124
+ if (!cache || typeof cache.latestVersion !== "string" || !cache.latestVersion) {
125
+ return null;
126
+ }
127
+ return cache.latestVersion;
128
+ }
129
+
130
+ /**
131
+ * Default HTTP fetcher used in production. Resolves with `{body}` on
132
+ * 2xx, rejects on any other status, network error, or socket close.
133
+ */
134
+ function defaultHttpGet(url) {
135
+ return new Promise((resolve, reject) => {
136
+ const req = https.get(
137
+ url,
138
+ { headers: { Accept: "application/json" } },
139
+ (res) => {
140
+ if (res.statusCode < 200 || res.statusCode >= 300) {
141
+ res.resume();
142
+ reject(new Error(`HTTP ${res.statusCode}`));
143
+ return;
144
+ }
145
+ let body = "";
146
+ res.on("data", (chunk) => (body += chunk));
147
+ res.on("end", () => resolve({ body }));
148
+ },
149
+ );
150
+ req.on("error", reject);
151
+ });
152
+ }
153
+
154
+ module.exports = {
155
+ NPM_LATEST_URL,
156
+ DEFAULT_TIMEOUT_MS,
157
+ fetchLatestVersion,
158
+ readUpdateCache,
159
+ writeUpdateCache,
160
+ readCachedVersion,
161
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memtrace",
3
- "version": "0.3.38",
3
+ "version": "0.3.39",
4
4
  "description": "Code intelligence graph — MCP server + AI agent skills + visualization UI",
5
5
  "keywords": [
6
6
  "mcp",
@@ -39,9 +39,9 @@
39
39
  "fs-extra": "^11.0.0"
40
40
  },
41
41
  "optionalDependencies": {
42
- "@memtrace/darwin-arm64": "0.3.38",
43
- "@memtrace/linux-x64": "0.3.38",
44
- "@memtrace/win32-x64": "0.3.38"
42
+ "@memtrace/darwin-arm64": "0.3.39",
43
+ "@memtrace/linux-x64": "0.3.39",
44
+ "@memtrace/win32-x64": "0.3.39"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=18"