genexus-mcp 2.8.2 → 2.8.4

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.
@@ -2,18 +2,18 @@ const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
4
  const https = require('https');
5
+ const { spawn } = require('child_process');
5
6
  const {
6
7
  getGatewayExePath,
7
- getClientConfigTargets,
8
- filterClientTargets,
9
- readClientCommandEntry,
8
+ clientsStatus,
10
9
  normalizeExePath
11
10
  } = require('./config');
12
11
 
13
12
  const REPO = 'lennix1337/Genexus18MCP';
14
13
  const NPM_PACKAGE = 'genexus-mcp';
15
14
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
16
- const FETCH_TIMEOUT_MS = 2000;
15
+ const FETCH_TIMEOUT_MS = 2500;
16
+ const INSTALL_ONE_LINER = 'iex (irm https://raw.githubusercontent.com/lennix1337/Genexus18MCP/main/scripts/install.ps1)';
17
17
 
18
18
  function getPackageVersion() {
19
19
  try {
@@ -25,9 +25,21 @@ function getPackageVersion() {
25
25
  }
26
26
 
27
27
  function getCacheFile() {
28
+ // Share the cache with the gateway (UpdateNotifier.cs) so a check by either
29
+ // side serves the other. On Windows that's %LOCALAPPDATA%\GenexusMCP; on other
30
+ // platforms fall back to ~/.genexus-mcp (the gateway is Windows-only).
31
+ if (process.platform === 'win32') {
32
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
33
+ return path.join(base, 'GenexusMCP', 'update-check.json');
34
+ }
28
35
  return path.join(os.homedir(), '.genexus-mcp', 'update-check.json');
29
36
  }
30
37
 
38
+ function releaseUrlForVersion(version) {
39
+ const v = stripV(version);
40
+ return v ? `https://github.com/${REPO}/releases/tag/v${v}` : null;
41
+ }
42
+
31
43
  function readCache() {
32
44
  try {
33
45
  const raw = fs.readFileSync(getCacheFile(), 'utf8');
@@ -69,51 +81,62 @@ function compareSemver(a, b) {
69
81
  return 0;
70
82
  }
71
83
 
72
- function fetchLatestRelease() {
84
+ function httpGetJson(url) {
73
85
  return new Promise((resolve) => {
74
- const options = {
75
- hostname: 'api.github.com',
76
- path: `/repos/${REPO}/releases/latest`,
77
- method: 'GET',
78
- headers: {
79
- 'User-Agent': `${NPM_PACKAGE}-cli`,
80
- 'Accept': 'application/vnd.github+json'
81
- }
82
- };
83
-
84
- const req = https.request(options, (res) => {
85
- if (res.statusCode !== 200) {
86
- res.resume();
87
- resolve(null);
88
- return;
89
- }
90
- let body = '';
91
- res.setEncoding('utf8');
92
- res.on('data', (chunk) => { body += chunk; });
93
- res.on('end', () => {
94
- try {
95
- const json = JSON.parse(body);
96
- const tag = stripV(json.tag_name || '');
97
- const url = typeof json.html_url === 'string' ? json.html_url : null;
98
- if (!tag) { resolve(null); return; }
99
- resolve({ latestVersion: tag, releaseUrl: url });
100
- } catch {
101
- resolve(null);
102
- }
86
+ let req;
87
+ try {
88
+ req = https.request(url, { method: 'GET', headers: { 'User-Agent': `${NPM_PACKAGE}-cli`, Accept: 'application/json' } }, (res) => {
89
+ if (res.statusCode !== 200) { res.resume(); resolve(null); return; }
90
+ let body = '';
91
+ res.setEncoding('utf8');
92
+ res.on('data', (c) => { body += c; });
93
+ res.on('end', () => {
94
+ try { resolve(JSON.parse(body)); } catch { resolve(null); }
95
+ });
103
96
  });
104
- });
105
-
106
- req.on('error', () => resolve(null));
107
- req.setTimeout(FETCH_TIMEOUT_MS, () => {
108
- req.destroy();
97
+ } catch {
109
98
  resolve(null);
110
- });
111
-
99
+ return;
100
+ }
101
+ req.on('error', () => resolve(null));
102
+ req.setTimeout(FETCH_TIMEOUT_MS, () => { req.destroy(); resolve(null); });
112
103
  req.end();
113
104
  if (typeof req.unref === 'function') req.unref();
114
105
  });
115
106
  }
116
107
 
108
+ // Resolve the latest version for a dist-tag channel. Authority is the npm
109
+ // registry — that's exactly what `npm install -g <pkg>@<channel>` resolves, so
110
+ // we never advertise a version npm can't install yet (the GitHub-release-before-
111
+ // npm-publish window) and we work on networks that allow npm but block
112
+ // api.github.com. Falls back to the GitHub releases API only for the default
113
+ // channel. The release URL is derived from the version (no API call needed).
114
+ async function fetchLatestRelease(opts = {}) {
115
+ const channel = (opts && opts.channel) || 'latest';
116
+
117
+ // 1. npm registry dist-tags (lightweight: just the tag → version map).
118
+ const tags = await httpGetJson(`https://registry.npmjs.org/-/package/${NPM_PACKAGE}/dist-tags`);
119
+ if (tags && typeof tags === 'object') {
120
+ const v = stripV(tags[channel] || '');
121
+ if (v) return { latestVersion: v, releaseUrl: releaseUrlForVersion(v), source: 'npm' };
122
+ // Channel not found on npm — for non-default channels, that's a definitive "no".
123
+ if (channel !== 'latest') return null;
124
+ }
125
+
126
+ // 2. GitHub releases fallback (default channel only).
127
+ if (channel === 'latest') {
128
+ const rel = await httpGetJson(`https://api.github.com/repos/${REPO}/releases/latest`);
129
+ if (rel && typeof rel === 'object') {
130
+ const tag = stripV(rel.tag_name || '');
131
+ if (tag) {
132
+ const url = typeof rel.html_url === 'string' ? rel.html_url : releaseUrlForVersion(tag);
133
+ return { latestVersion: tag, releaseUrl: url, source: 'github' };
134
+ }
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+
117
140
  function formatBanner(current, latest, releaseUrl) {
118
141
  const lines = [
119
142
  `[genexus-mcp] update available: v${current} -> v${latest}`,
@@ -155,7 +178,8 @@ function scheduleBackgroundFetch() {
155
178
  writeCache({
156
179
  checkedAt: Date.now(),
157
180
  latestVersion: result.latestVersion,
158
- releaseUrl: result.releaseUrl
181
+ releaseUrl: result.releaseUrl,
182
+ source: result.source || null
159
183
  });
160
184
  }).catch(() => {});
161
185
  }
@@ -166,21 +190,23 @@ function startBackgroundUpdateCheck(opts) {
166
190
  scheduleBackgroundFetch();
167
191
  }
168
192
 
193
+ // Drift = a client points at a gateway launcher (exe/bat/...) that is NOT this
194
+ // npm package's gateway, so `npm install -g` won't update it. Reuses the
195
+ // broadened command resolution from config.js rather than re-implementing the
196
+ // (formerly .exe-only) match.
169
197
  function detectClientExeDrift() {
170
198
  try {
171
199
  const packageNorm = normalizeExePath(getGatewayExePath());
172
- const targets = filterClientTargets(getClientConfigTargets(), { platform: process.platform });
173
200
  const mismatches = [];
174
- for (const client of targets) {
175
- if (!fs.existsSync(client.path)) continue;
176
- const entry = readClientCommandEntry(client);
177
- if (!entry || !entry.command) continue;
178
- const cmd = entry.command;
179
- // Skip launchers that resolve via npm at runtime.
180
- if (/(^|[\\/])(npx|npx\.cmd|node|node\.exe)$/i.test(cmd) || /[\\/]genexus-mcp(\.cmd)?$/i.test(cmd)) continue;
181
- if (!/\.exe$/i.test(cmd)) continue;
201
+ for (const c of clientsStatus()) {
202
+ if (!c.registered || !c.command) continue;
203
+ const cmd = c.command;
204
+ // npx/node/genexus-mcp shims resolve via npm at runtime — not drift.
205
+ if (/(^|[\\/])(npx|npx\.cmd|node|node\.exe|genexus-mcp|genexus-mcp\.cmd)$/i.test(cmd)) continue;
206
+ // Only an explicit-path launcher can "drift" from the package exe.
207
+ if (!/[\\/]/.test(cmd)) continue;
182
208
  if (normalizeExePath(cmd) !== packageNorm) {
183
- mismatches.push({ client: client.name, configured: cmd });
209
+ mismatches.push({ client: c.name, configured: cmd });
184
210
  }
185
211
  }
186
212
  return mismatches;
@@ -189,41 +215,161 @@ function detectClientExeDrift() {
189
215
  }
190
216
  }
191
217
 
192
- async function handleUpdate(_options, ctx) {
218
+ // How is the gateway actually launched by the registered clients? That decides
219
+ // the right upgrade action (npx@latest auto-updates on restart; npm-global needs
220
+ // `npm i -g`; a fixed exe path needs the installer). Returns the dominant method
221
+ // plus per-method evidence.
222
+ function detectInstallMethod() {
223
+ // Corporate/fixed-path env override wins — the gateway runs from a pinned exe.
224
+ if (process.env.GENEXUS_MCP_GATEWAY_EXE) {
225
+ return { method: 'fixed-path', detail: process.env.GENEXUS_MCP_GATEWAY_EXE, evidence: [] };
226
+ }
227
+ const counts = { 'npx-latest': 0, 'npm-global': 0, 'fixed-path': 0 };
228
+ const evidence = [];
229
+ try {
230
+ for (const c of clientsStatus()) {
231
+ if (!c.registered || !c.command) continue;
232
+ const cmd = String(c.command);
233
+ let m;
234
+ if (/(^|[\\/])npx(\.cmd)?$/i.test(cmd)) m = 'npx-latest';
235
+ else if (/(^|[\\/])genexus-mcp(\.cmd)?$/i.test(cmd)) m = 'npm-global';
236
+ else if (/[\\/]/.test(cmd)) m = 'fixed-path';
237
+ else m = 'npm-global';
238
+ counts[m] = (counts[m] || 0) + 1;
239
+ evidence.push({ client: c.name, method: m, command: cmd });
240
+ }
241
+ } catch { /* ignore */ }
242
+ // Pick the most common; default to npx-latest (the recommended/auto path).
243
+ let method = 'npx-latest';
244
+ let best = -1;
245
+ for (const k of Object.keys(counts)) {
246
+ if (counts[k] > best) { best = counts[k]; method = k; }
247
+ }
248
+ if (best <= 0) method = 'npx-latest';
249
+ return { method, counts, evidence };
250
+ }
251
+
252
+ // The method-appropriate upgrade plan. `auto` means no manual install step is
253
+ // needed (the npx launcher fetches @latest on the next client start).
254
+ function upgradePlanFor(method, channel) {
255
+ const tag = channel && channel !== 'latest' ? `@${channel}` : '@latest';
256
+ if (method === 'npx-latest') {
257
+ return {
258
+ method,
259
+ auto: true,
260
+ steps: [
261
+ 'Your clients launch via `npx genexus-mcp@latest`, which fetches the newest version on each start.',
262
+ 'Just fully restart your AI client — it will pick up the new version automatically.'
263
+ ],
264
+ // --apply busts a stale npx cache so the next spawn is guaranteed fresh.
265
+ applyCommand: { exe: process.platform === 'win32' ? 'npm.cmd' : 'npm', args: ['cache', 'clean', '--force'] },
266
+ restartRequired: true
267
+ };
268
+ }
269
+ if (method === 'fixed-path') {
270
+ return {
271
+ method,
272
+ auto: false,
273
+ steps: [
274
+ 'Your install runs the gateway from a fixed path (corporate install).',
275
+ `Re-run the installer to update in place: ${INSTALL_ONE_LINER}`,
276
+ 'Then fully restart your AI client.'
277
+ ],
278
+ applyCommand: null, // self-stage is a future enhancement; installer is the path
279
+ restartRequired: true
280
+ };
281
+ }
282
+ // npm-global
283
+ return {
284
+ method: 'npm-global',
285
+ auto: false,
286
+ steps: [
287
+ `Run: npm install -g ${NPM_PACKAGE}${tag}`,
288
+ 'Then fully restart your AI client.'
289
+ ],
290
+ applyCommand: { exe: process.platform === 'win32' ? 'npm.cmd' : 'npm', args: ['install', '-g', `${NPM_PACKAGE}${tag}`] },
291
+ restartRequired: true
292
+ };
293
+ }
294
+
295
+ function runCommand(exe, args) {
296
+ return new Promise((resolve) => {
297
+ let child;
298
+ try {
299
+ child = spawn(exe, args, { stdio: 'inherit', windowsHide: true });
300
+ } catch (err) {
301
+ resolve({ ok: false, code: null, error: err && err.message ? err.message : 'spawn failed' });
302
+ return;
303
+ }
304
+ child.on('error', (err) => resolve({ ok: false, code: null, error: err && err.message ? err.message : 'spawn failed' }));
305
+ child.on('exit', (code) => resolve({ ok: code === 0, code }));
306
+ });
307
+ }
308
+
309
+ async function handleUpdate(options, ctx) {
310
+ const opts = options || {};
311
+ const channel = opts.channel || 'latest';
193
312
  const current = getPackageVersion();
194
- const result = await fetchLatestRelease();
313
+ const result = await fetchLatestRelease({ channel });
195
314
  const mismatches = detectClientExeDrift();
315
+ const install = detectInstallMethod();
196
316
 
197
317
  const driftHelp = mismatches.length
198
- ? [`WARNING: ${mismatches.length} AI client(s) point at a gateway exe that is NOT this npm package — \`npm install -g ${NPM_PACKAGE}@latest\` will NOT update them. Mismatches: ${mismatches.map((m) => `${m.client} -> ${m.configured}`).join('; ')}. Re-run scripts/install.ps1 (or genexus-mcp init --write-clients) to resync.`]
318
+ ? [`WARNING: ${mismatches.length} AI client(s) point at a gateway launcher that is NOT this npm package — updating npm will NOT update them. Mismatches: ${mismatches.map((m) => `${m.client} -> ${m.configured}`).join('; ')}. Re-run scripts/install.ps1 (or genexus-mcp clients add) to resync.`]
199
319
  : [];
200
320
 
201
321
  if (!result) {
322
+ const reason = channel !== 'latest'
323
+ ? `No '${channel}' version found — the npm dist-tag '${channel}' is absent (or the registry is unreachable).`
324
+ : 'Could not resolve the latest version (npm registry + GitHub both unreachable). Check connectivity/proxy or retry later.';
202
325
  return {
203
326
  exitCode: ctx.EXIT_CODES.OK,
204
327
  envelope: {
205
- ok: {
206
- current,
207
- latest: null,
208
- updateAvailable: false,
209
- fetched: false,
210
- clientDrift: mismatches
211
- },
212
- help: ['Could not reach GitHub releases API. Check connectivity or retry later.', ...driftHelp]
328
+ ok: { current, latest: null, channel, updateAvailable: false, fetched: false, installMethod: install.method, clientDrift: mismatches },
329
+ help: [reason, ...driftHelp]
213
330
  }
214
331
  };
215
332
  }
216
333
 
217
- writeCache({
218
- checkedAt: Date.now(),
219
- latestVersion: result.latestVersion,
220
- releaseUrl: result.releaseUrl
221
- });
334
+ writeCache({ checkedAt: Date.now(), latestVersion: result.latestVersion, releaseUrl: result.releaseUrl, source: result.source || null });
222
335
 
223
336
  const updateAvailable = compareSemver(result.latestVersion, current || '0.0.0') > 0;
224
- const baseHelp = updateAvailable
225
- ? [`Run: npm install -g ${NPM_PACKAGE}@latest`, result.releaseUrl ? `Release: ${result.releaseUrl}` : null].filter(Boolean)
226
- : ['Already on latest version.'];
337
+ const plan = upgradePlanFor(install.method, channel);
338
+
339
+ // --apply: actually perform the method-appropriate upgrade.
340
+ let applied = null;
341
+ if (opts.apply && updateAvailable) {
342
+ if (!plan.applyCommand) {
343
+ applied = { ran: false, reason: 'No automatic apply for this install method; follow the steps above.' };
344
+ } else if (!opts.yes && (!process.stderr || !process.stderr.isTTY)) {
345
+ applied = { ran: false, reason: 'Refusing to run an unattended install without --yes (no interactive terminal).' };
346
+ } else {
347
+ if (!opts.yes) {
348
+ ctx.stderr.write(`\n[genexus-mcp update] About to run: ${plan.applyCommand.exe} ${plan.applyCommand.args.join(' ')}\n`);
349
+ }
350
+ const proceed = opts.yes ? true : await confirmTty(ctx, 'Proceed?');
351
+ if (!proceed) {
352
+ applied = { ran: false, reason: 'Cancelled by user.' };
353
+ } else {
354
+ const r = await runCommand(plan.applyCommand.exe, plan.applyCommand.args);
355
+ applied = { ran: true, ok: r.ok, exitCode: r.code, command: `${plan.applyCommand.exe} ${plan.applyCommand.args.join(' ')}`, error: r.error || null };
356
+ }
357
+ }
358
+ }
359
+
360
+ const help = [];
361
+ if (updateAvailable) {
362
+ if (plan.auto) help.push('Auto-update path: restart your AI client and it fetches the new version (via npx @latest).');
363
+ help.push(...plan.steps);
364
+ if (result.releaseUrl) help.push(`Release notes: ${result.releaseUrl}`);
365
+ if (!opts.apply && plan.applyCommand) help.push('Or run `genexus-mcp update --apply` to do it now.');
366
+ } else {
367
+ help.push(`Already on the latest ${channel} version.`);
368
+ }
369
+ if (applied && applied.ran && applied.ok) help.push('Update applied. Fully restart your AI client to load it.');
370
+ if (applied && applied.ran && !applied.ok) help.push(`Apply command exited non-zero (${applied.exitCode}). ${applied.error || ''}`.trim());
371
+ if (applied && !applied.ran) help.push(applied.reason);
372
+ help.push(...driftHelp);
227
373
 
228
374
  return {
229
375
  exitCode: ctx.EXIT_CODES.OK,
@@ -231,21 +377,42 @@ async function handleUpdate(_options, ctx) {
231
377
  ok: {
232
378
  current,
233
379
  latest: result.latestVersion,
380
+ channel,
234
381
  releaseUrl: result.releaseUrl,
235
382
  updateAvailable,
236
- installCommand: `npm install -g ${NPM_PACKAGE}@latest`,
383
+ source: result.source || null,
384
+ installMethod: install.method,
385
+ autoUpdates: plan.auto,
386
+ installCommand: plan.applyCommand ? `${plan.applyCommand.exe} ${plan.applyCommand.args.join(' ')}` : INSTALL_ONE_LINER,
387
+ applied,
237
388
  fetched: true,
238
389
  clientDrift: mismatches
239
390
  },
240
- help: [...baseHelp, ...driftHelp]
391
+ help
241
392
  }
242
393
  };
243
394
  }
244
395
 
396
+ function confirmTty(ctx, question) {
397
+ return new Promise((resolve) => {
398
+ if (!process.stdin || !process.stdin.isTTY) { resolve(false); return; }
399
+ const readline = require('readline');
400
+ const rl = readline.createInterface({ input: process.stdin, output: ctx.stderr });
401
+ rl.question(`${question} [y/N]: `, (a) => {
402
+ rl.close();
403
+ const t = (a || '').trim().toLowerCase();
404
+ resolve(t === 'y' || t === 'yes');
405
+ });
406
+ });
407
+ }
408
+
245
409
  module.exports = {
246
410
  startBackgroundUpdateCheck,
247
411
  handleUpdate,
248
412
  compareSemver,
249
413
  parseSemver,
250
- getPackageVersion
414
+ getPackageVersion,
415
+ detectInstallMethod,
416
+ upgradePlanFor,
417
+ fetchLatestRelease
251
418
  };