scalix 1.0.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.
@@ -12,7 +12,6 @@ async function getPlaywright() {
12
12
  if (playwrightAvailable === false)
13
13
  return null;
14
14
  try {
15
- // @ts-expect-error playwright is an optional peer dependency
16
15
  const pw = await import('playwright');
17
16
  playwrightAvailable = true;
18
17
  return pw;
@@ -95,20 +94,62 @@ export const browserNavigateTool = tool({
95
94
  }
96
95
  },
97
96
  });
97
+ const ANNOTATION_SCRIPT = `(() => {
98
+ const selectors = 'a, button, input, select, textarea, [role="button"], [onclick], [tabindex]:not([tabindex="-1"])';
99
+ const elements = [...document.querySelectorAll(selectors)].filter(el => {
100
+ const rect = el.getBoundingClientRect();
101
+ const style = window.getComputedStyle(el);
102
+ return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
103
+ });
104
+ const overlay = document.createElement('div');
105
+ overlay.id = '__scalix_annotations__';
106
+ overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:2147483647';
107
+ const annotations = [];
108
+ elements.forEach((el, i) => {
109
+ const rect = el.getBoundingClientRect();
110
+ const idx = i + 1;
111
+ const box = document.createElement('div');
112
+ box.style.cssText = 'position:fixed;border:2px solid red;pointer-events:none;box-sizing:border-box;'
113
+ + 'left:' + rect.left + 'px;top:' + rect.top + 'px;width:' + rect.width + 'px;height:' + rect.height + 'px';
114
+ const label = document.createElement('span');
115
+ label.textContent = String(idx);
116
+ label.style.cssText = 'position:absolute;top:-14px;left:-2px;background:red;color:white;font:bold 10px sans-serif;padding:1px 3px;border-radius:2px;line-height:12px';
117
+ box.appendChild(label);
118
+ overlay.appendChild(box);
119
+ annotations.push({
120
+ index: idx,
121
+ tag: el.tagName.toLowerCase(),
122
+ text: (el.textContent || el.getAttribute('placeholder') || el.getAttribute('aria-label') || '').trim().slice(0, 60),
123
+ x: Math.round(rect.left),
124
+ y: Math.round(rect.top),
125
+ width: Math.round(rect.width),
126
+ height: Math.round(rect.height),
127
+ centerX: Math.round(rect.left + rect.width / 2),
128
+ centerY: Math.round(rect.top + rect.height / 2),
129
+ });
130
+ });
131
+ document.body.appendChild(overlay);
132
+ return annotations;
133
+ })()`;
98
134
  export const browserScreenshotTool = tool({
99
135
  name: 'browser_screenshot',
100
- description: 'Take a screenshot of the current browser page. Returns the image as a base64 data URL for vision analysis.',
136
+ description: 'Take a screenshot of the current browser page. Returns the image as a base64 data URL for vision analysis. Use annotate=true to overlay numbered markers on all interactive elements with their coordinates.',
101
137
  inputSchema: z.object({
102
138
  fullPage: z.boolean().optional().describe('Capture full scrollable page (default: false, viewport only)'),
103
139
  selector: z.string().max(500).optional().describe('CSS selector to screenshot a specific element'),
104
140
  outputPath: z.string().max(500).optional().describe('Save screenshot to file (default: temporary)'),
141
+ annotate: z.boolean().optional().describe('Overlay numbered markers on interactive elements with coordinates (default: false)'),
105
142
  }),
106
- execute: async ({ fullPage, selector, outputPath }) => {
143
+ execute: async ({ fullPage, selector, outputPath, annotate }) => {
107
144
  const session = await ensureBrowser();
108
145
  if (!session) {
109
146
  return { error: 'Browser not running. Use browser_navigate first.' };
110
147
  }
111
148
  try {
149
+ let annotations;
150
+ if (annotate) {
151
+ annotations = await session.page.evaluate(ANNOTATION_SCRIPT);
152
+ }
112
153
  const outPath = outputPath
113
154
  ? sandboxPath(outputPath)
114
155
  : path.join(process.cwd(), '.scalix', `browser-${Date.now()}.png`);
@@ -122,17 +163,31 @@ export const browserScreenshotTool = tool({
122
163
  else {
123
164
  await session.page.screenshot({ path: outPath, fullPage: fullPage ?? false });
124
165
  }
125
- const dataUrl = await encodeImageToDataUrl(outPath);
126
- const stat = await fs.stat(outPath);
127
- if (!outputPath) {
128
- await fs.unlink(outPath).catch(() => { });
166
+ let dataUrl;
167
+ let size;
168
+ try {
169
+ dataUrl = await encodeImageToDataUrl(outPath);
170
+ size = (await fs.stat(outPath)).size;
171
+ }
172
+ finally {
173
+ if (!outputPath) {
174
+ await fs.unlink(outPath).catch(() => { });
175
+ }
176
+ }
177
+ if (annotate) {
178
+ await session.page.evaluate(() => {
179
+ const overlay = document.getElementById('__scalix_annotations__');
180
+ if (overlay)
181
+ overlay.remove();
182
+ });
129
183
  }
130
184
  return {
131
185
  url: session.page.url(),
132
186
  title: await session.page.title(),
133
187
  path: outputPath || undefined,
134
- size: stat.size,
188
+ size,
135
189
  dataUrl,
190
+ annotations,
136
191
  };
137
192
  }
138
193
  catch (error) {
@@ -142,35 +197,92 @@ export const browserScreenshotTool = tool({
142
197
  });
143
198
  export const browserClickTool = tool({
144
199
  name: 'browser_click',
145
- description: 'Click an element on the current browser page by CSS selector or text content.',
200
+ description: 'Click an element on the current browser page by CSS selector, text content, or pixel coordinates. Use coordinates after taking an annotated screenshot to click at precise positions.',
146
201
  inputSchema: z.object({
147
202
  selector: z.string().max(500).optional().describe('CSS selector to click'),
148
203
  text: z.string().max(200).optional().describe('Click element containing this text'),
204
+ x: z.number().optional().describe('X pixel coordinate to click at'),
205
+ y: z.number().optional().describe('Y pixel coordinate to click at'),
149
206
  button: z.enum(['left', 'right', 'middle']).optional().describe('Mouse button (default: left)'),
150
207
  }),
151
- execute: async ({ selector, text, button }) => {
208
+ execute: async ({ selector, text, x, y, button }) => {
152
209
  const session = await ensureBrowser();
153
210
  if (!session) {
154
211
  return { error: 'Browser not running. Use browser_navigate first.' };
155
212
  }
156
213
  try {
157
- if (text) {
158
- await session.page.getByText(text, { exact: false }).first().click({ button: button || 'left', timeout: 10_000 });
214
+ const btn = button || 'left';
215
+ if (typeof x === 'number' && typeof y === 'number') {
216
+ await session.page.mouse.click(x, y, { button: btn });
217
+ }
218
+ else if (text) {
219
+ await session.page.getByText(text, { exact: false }).first().click({ button: btn, timeout: 10_000 });
159
220
  }
160
221
  else if (selector) {
161
- await session.page.click(selector, { button: button || 'left', timeout: 10_000 });
222
+ await session.page.click(selector, { button: btn, timeout: 10_000 });
162
223
  }
163
224
  else {
164
- return { error: 'Provide either selector or text to click' };
225
+ return { error: 'Provide selector, text, or x/y coordinates to click' };
165
226
  }
166
227
  await session.page.waitForTimeout(500);
167
- return { clicked: selector || `text:"${text}"`, url: session.page.url() };
228
+ const target = typeof x === 'number' ? `coords:(${x},${y})` : selector || `text:"${text}"`;
229
+ return { clicked: target, url: session.page.url() };
168
230
  }
169
231
  catch (error) {
170
232
  return { error: `Click failed: ${error.message}` };
171
233
  }
172
234
  },
173
235
  });
236
+ export const browserMouseTool = tool({
237
+ name: 'browser_mouse',
238
+ description: 'Perform mouse operations at specific pixel coordinates. Use after taking a screenshot to identify target positions. Supports hover, double-click, drag, and scroll.',
239
+ inputSchema: z.object({
240
+ action: z.enum(['hover', 'doubleclick', 'drag', 'scroll']).describe('Mouse action to perform'),
241
+ x: z.number().describe('X pixel coordinate'),
242
+ y: z.number().describe('Y pixel coordinate'),
243
+ toX: z.number().optional().describe('Drag destination X coordinate'),
244
+ toY: z.number().optional().describe('Drag destination Y coordinate'),
245
+ scrollX: z.number().optional().describe('Horizontal scroll amount in pixels'),
246
+ scrollY: z.number().optional().describe('Vertical scroll amount in pixels'),
247
+ }),
248
+ execute: async ({ action, x, y, toX, toY, scrollX, scrollY }) => {
249
+ const session = await ensureBrowser();
250
+ if (!session) {
251
+ return { error: 'Browser not running. Use browser_navigate first.' };
252
+ }
253
+ try {
254
+ switch (action) {
255
+ case 'hover':
256
+ await session.page.mouse.move(x, y);
257
+ break;
258
+ case 'doubleclick':
259
+ await session.page.mouse.dblclick(x, y);
260
+ await session.page.waitForTimeout(500);
261
+ break;
262
+ case 'drag': {
263
+ if (typeof toX !== 'number' || typeof toY !== 'number') {
264
+ return { error: 'Drag requires toX and toY coordinates' };
265
+ }
266
+ await session.page.mouse.move(x, y);
267
+ await session.page.mouse.down();
268
+ await session.page.mouse.move(toX, toY, { steps: 10 });
269
+ await session.page.mouse.up();
270
+ await session.page.waitForTimeout(500);
271
+ break;
272
+ }
273
+ case 'scroll':
274
+ await session.page.mouse.move(x, y);
275
+ await session.page.mouse.wheel(scrollX ?? 0, scrollY ?? 0);
276
+ await session.page.waitForTimeout(300);
277
+ break;
278
+ }
279
+ return { action, x, y, url: session.page.url() };
280
+ }
281
+ catch (error) {
282
+ return { error: `Mouse ${action} failed: ${error.message}` };
283
+ }
284
+ },
285
+ });
174
286
  export const browserTypeTool = tool({
175
287
  name: 'browser_type',
176
288
  description: 'Type text into an input field on the current browser page.',
@@ -253,21 +365,170 @@ export const browserGetTextTool = tool({
253
365
  }
254
366
  },
255
367
  });
256
- export const browserCloseTool = tool({
257
- name: 'browser_close',
258
- description: 'Close the browser instance and free resources.',
368
+ const SESSION_DIR = '.scalix/browser-sessions';
369
+ async function saveSessionState(name) {
370
+ if (!browserContext || !browserPage) {
371
+ throw new Error('No active browser session to save');
372
+ }
373
+ const storageState = await browserContext.storageState();
374
+ const url = browserPage.url();
375
+ const sessionDir = path.join(process.cwd(), SESSION_DIR);
376
+ await fs.mkdir(sessionDir, { recursive: true });
377
+ const sessionPath = path.join(sessionDir, `${name}.json`);
378
+ let existing = null;
379
+ try {
380
+ existing = JSON.parse(await fs.readFile(sessionPath, 'utf-8'));
381
+ }
382
+ catch { }
383
+ const session = {
384
+ name,
385
+ url,
386
+ storageState,
387
+ createdAt: existing?.createdAt ?? Date.now(),
388
+ updatedAt: Date.now(),
389
+ viewport: { width: 1280, height: 720 },
390
+ };
391
+ await fs.writeFile(sessionPath, JSON.stringify(session, null, 2));
392
+ return session;
393
+ }
394
+ export const browserSessionSaveTool = tool({
395
+ name: 'browser_session_save',
396
+ description: 'Save the current browser session (cookies, localStorage, URL) to disk for later restoration.',
397
+ inputSchema: z.object({
398
+ name: z.string().max(100).describe('Session name (used to restore later)'),
399
+ }),
400
+ execute: async ({ name }) => {
401
+ try {
402
+ const session = await saveSessionState(name);
403
+ return { saved: name, url: session.url, updatedAt: session.updatedAt };
404
+ }
405
+ catch (error) {
406
+ return { error: `Session save failed: ${error.message}` };
407
+ }
408
+ },
409
+ });
410
+ export const browserSessionRestoreTool = tool({
411
+ name: 'browser_session_restore',
412
+ description: 'Restore a previously saved browser session. Recreates the browser context with saved cookies/localStorage and navigates to the saved URL.',
413
+ inputSchema: z.object({
414
+ name: z.string().max(100).describe('Session name to restore'),
415
+ }),
416
+ execute: async ({ name }) => {
417
+ const pw = await getPlaywright();
418
+ if (!pw) {
419
+ return { error: 'Playwright not available. Install it: npm install playwright && npx playwright install chromium' };
420
+ }
421
+ try {
422
+ const sessionPath = path.join(process.cwd(), SESSION_DIR, `${name}.json`);
423
+ const raw = await fs.readFile(sessionPath, 'utf-8');
424
+ let session;
425
+ try {
426
+ session = JSON.parse(raw);
427
+ }
428
+ catch {
429
+ return { error: `Session restore failed: corrupt session file for "${name}"` };
430
+ }
431
+ if (!session.storageState || typeof session.storageState !== 'object' ||
432
+ !session.viewport || typeof session.viewport !== 'object' ||
433
+ typeof session.viewport.width !== 'number' || typeof session.viewport.height !== 'number') {
434
+ return { error: `Session restore failed: invalid session data for "${name}" (missing or malformed storageState/viewport)` };
435
+ }
436
+ if (browserContext) {
437
+ try {
438
+ await browserContext.close();
439
+ }
440
+ catch { }
441
+ browserContext = null;
442
+ browserPage = null;
443
+ }
444
+ if (!browserInstance) {
445
+ browserInstance = await pw.chromium.launch({ headless: true });
446
+ }
447
+ browserContext = await browserInstance.newContext({
448
+ viewport: session.viewport,
449
+ storageState: session.storageState,
450
+ });
451
+ browserPage = await browserContext.newPage();
452
+ if (session.url && session.url !== 'about:blank') {
453
+ await browserPage.goto(session.url, { waitUntil: 'load', timeout: 30_000 });
454
+ }
455
+ const title = await browserPage.title();
456
+ return {
457
+ restored: name,
458
+ url: browserPage.url(),
459
+ title,
460
+ savedAt: session.updatedAt,
461
+ };
462
+ }
463
+ catch (error) {
464
+ return { error: `Session restore failed: ${error.message}` };
465
+ }
466
+ },
467
+ });
468
+ export const browserSessionListTool = tool({
469
+ name: 'browser_session_list',
470
+ description: 'List all saved browser sessions.',
259
471
  inputSchema: z.object({}),
260
472
  execute: async () => {
473
+ try {
474
+ const sessionDir = path.join(process.cwd(), SESSION_DIR);
475
+ let files;
476
+ try {
477
+ files = await fs.readdir(sessionDir);
478
+ }
479
+ catch {
480
+ return { sessions: [] };
481
+ }
482
+ const sessions = [];
483
+ for (const file of files) {
484
+ if (!file.endsWith('.json'))
485
+ continue;
486
+ try {
487
+ const raw = await fs.readFile(path.join(sessionDir, file), 'utf-8');
488
+ const session = JSON.parse(raw);
489
+ sessions.push({
490
+ name: session.name,
491
+ url: session.url,
492
+ createdAt: session.createdAt,
493
+ updatedAt: session.updatedAt,
494
+ });
495
+ }
496
+ catch { }
497
+ }
498
+ return { sessions };
499
+ }
500
+ catch (error) {
501
+ return { error: `Session list failed: ${error.message}` };
502
+ }
503
+ },
504
+ });
505
+ export const browserCloseTool = tool({
506
+ name: 'browser_close',
507
+ description: 'Close the browser instance and free resources. Optionally save the session before closing.',
508
+ inputSchema: z.object({
509
+ saveSession: z.string().max(100).optional().describe('Save session state under this name before closing'),
510
+ }),
511
+ execute: async ({ saveSession } = {}) => {
512
+ try {
513
+ if (saveSession && browserContext && browserPage) {
514
+ await saveSessionState(saveSession);
515
+ }
516
+ }
517
+ catch { }
261
518
  await closeBrowser();
262
- return { status: 'closed' };
519
+ return { status: 'closed', sessionSaved: saveSession || undefined };
263
520
  },
264
521
  });
265
522
  export const browserTools = [
266
523
  browserNavigateTool,
267
524
  browserScreenshotTool,
268
525
  browserClickTool,
526
+ browserMouseTool,
269
527
  browserTypeTool,
270
528
  browserEvalTool,
271
529
  browserGetTextTool,
530
+ browserSessionSaveTool,
531
+ browserSessionRestoreTool,
532
+ browserSessionListTool,
272
533
  browserCloseTool,
273
534
  ];
@@ -172,6 +172,13 @@ export const searchFilesTool = tool({
172
172
  try {
173
173
  const flags = `${caseInsensitive ? 'i' : ''}${multiline ? 'ms' : ''}`;
174
174
  regex = new RegExp(pattern, flags);
175
+ // Probe with a short string to catch catastrophic patterns early
176
+ const probe = 'a'.repeat(50);
177
+ const start = performance.now();
178
+ regex.test(probe);
179
+ if (performance.now() - start > 100) {
180
+ return { error: 'Regex pattern is too complex (possible catastrophic backtracking). Simplify the pattern.' };
181
+ }
175
182
  }
176
183
  catch (e) {
177
184
  return { error: `Invalid regex pattern: ${e.message}` };
@@ -346,13 +353,7 @@ export const gitCommitTool = tool({
346
353
  files: z.array(z.string().max(500)).optional().describe('Files to stage (default: all changed files)'),
347
354
  }),
348
355
  execute: async ({ message, files }) => {
349
- const gitEnv = {
350
- ...process.env,
351
- GIT_AUTHOR_NAME: GIT_BOT_IDENTITY.name,
352
- GIT_AUTHOR_EMAIL: GIT_BOT_IDENTITY.email,
353
- GIT_COMMITTER_NAME: GIT_BOT_IDENTITY.name,
354
- GIT_COMMITTER_EMAIL: GIT_BOT_IDENTITY.email,
355
- };
356
+ const fullMessage = message + `\n\nCo-Authored-By: ${GIT_BOT_IDENTITY.name} <${GIT_BOT_IDENTITY.email}>`;
356
357
  try {
357
358
  if (files && files.length > 0) {
358
359
  const safePaths = files.map((f) => sandboxPath(f));
@@ -365,7 +366,7 @@ export const gitCommitTool = tool({
365
366
  if (!staged.trim()) {
366
367
  return { error: 'Nothing to commit. No changes are staged.' };
367
368
  }
368
- const { stdout } = await execFileAsync('git', ['commit', '-m', message], { timeout: _limits.gitTimeoutMs, env: gitEnv });
369
+ const { stdout } = await execFileAsync('git', ['commit', '-m', fullMessage], { timeout: _limits.gitTimeoutMs });
369
370
  return { success: true, output: stdout.trim() };
370
371
  }
371
372
  catch (error) {
@@ -2,7 +2,7 @@ import { tool } from './tool-helper.js';
2
2
  import { z } from 'zod';
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
- import { sandboxPath } from './sandbox.js';
5
+ import { sandboxPath, getMaxImageSize } from './sandbox.js';
6
6
  const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']);
7
7
  const MIME_TYPES = {
8
8
  '.png': 'image/png',
@@ -38,8 +38,9 @@ export const readImageTool = tool({
38
38
  return { error: `Not a supported image format. Supported: ${[...IMAGE_EXTENSIONS].join(', ')}` };
39
39
  }
40
40
  const stat = await fs.stat(safePath);
41
- if (stat.size > 20 * 1024 * 1024) {
42
- return { error: `Image too large (${Math.round(stat.size / 1024 / 1024)}MB). Max 20MB.` };
41
+ const maxSize = getMaxImageSize();
42
+ if (stat.size > maxSize) {
43
+ return { error: `Image too large (${Math.round(stat.size / 1024 / 1024)}MB). Max ${Math.round(maxSize / 1024 / 1024)}MB.` };
43
44
  }
44
45
  const dataUrl = await encodeImageToDataUrl(safePath);
45
46
  return {
package/dist/tools.js CHANGED
@@ -134,7 +134,13 @@ export const runCommandTool = tool({
134
134
  child.stderr?.on('error', () => { });
135
135
  child.on('error', (err) => {
136
136
  entry.exitCode = -1;
137
- entry.stderr += `\nProcess error: ${err.message}`;
137
+ const errMsg = `\nProcess error: ${err.message}`;
138
+ if (entry.stderr.length + errMsg.length <= _limits.bgBufferMax) {
139
+ entry.stderr += errMsg;
140
+ }
141
+ else if (entry.stderr.length < _limits.bgBufferMax) {
142
+ entry.stderr += errMsg.slice(0, _limits.bgBufferMax - entry.stderr.length);
143
+ }
138
144
  });
139
145
  child.on('close', (code) => {
140
146
  entry.exitCode = code ?? 1;
@@ -211,8 +217,12 @@ export const readFileTool = tool({
211
217
  return { error: `File too large (${Math.round(stat.size / 1024)}KB, max ${Math.round(_limits.readFileMaxSize / 1024)}KB). Use read_file_lines to read specific sections.` };
212
218
  const fd = await fs.open(safePath, 'r');
213
219
  const header = Buffer.alloc(BINARY_DETECT_BUFFER_SIZE);
214
- await fd.read(header, 0, Math.min(BINARY_DETECT_BUFFER_SIZE, stat.size), 0);
215
- await fd.close();
220
+ try {
221
+ await fd.read(header, 0, Math.min(BINARY_DETECT_BUFFER_SIZE, stat.size), 0);
222
+ }
223
+ finally {
224
+ await fd.close();
225
+ }
216
226
  if (header.slice(0, Math.min(BINARY_DETECT_BUFFER_SIZE, stat.size)).includes(0x00)) {
217
227
  return { error: 'File appears to be binary. Use a specialized tool to inspect binary files.' };
218
228
  }
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs/promises';
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { execFile } from 'node:child_process';
5
+ import { VERSION, UPDATE_DEFAULTS, USER_CONFIG_DIR } from './constants.js';
6
+ function semverGt(a, b) {
7
+ const pa = a.replace(/^v/, '').split('.').map(Number);
8
+ const pb = b.replace(/^v/, '').split('.').map(Number);
9
+ for (let i = 0; i < 3; i++) {
10
+ if ((pa[i] ?? 0) > (pb[i] ?? 0))
11
+ return true;
12
+ if ((pa[i] ?? 0) < (pb[i] ?? 0))
13
+ return false;
14
+ }
15
+ return false;
16
+ }
17
+ function isValidSemver(v) {
18
+ return /^\d+\.\d+\.\d+/.test(v.replace(/^v/, ''));
19
+ }
20
+ function getCacheFile() {
21
+ return path.join(process.env.HOME || process.env.USERPROFILE || '.', USER_CONFIG_DIR, 'update-check.json');
22
+ }
23
+ function readCacheSync() {
24
+ try {
25
+ const raw = readFileSync(getCacheFile(), 'utf-8');
26
+ const parsed = JSON.parse(raw);
27
+ if (typeof parsed.lastCheck === 'number' && typeof parsed.latestVersion === 'string') {
28
+ return parsed;
29
+ }
30
+ }
31
+ catch { }
32
+ return null;
33
+ }
34
+ async function writeCache(cache) {
35
+ const dir = path.dirname(getCacheFile());
36
+ await fs.mkdir(dir, { recursive: true });
37
+ await fs.writeFile(getCacheFile(), JSON.stringify(cache), { encoding: 'utf-8', mode: 0o600 });
38
+ }
39
+ export async function checkForUpdate() {
40
+ if (process.env.NO_UPDATE_NOTIFIER)
41
+ return null;
42
+ const cached = readCacheSync();
43
+ const now = Date.now();
44
+ if (cached && (now - cached.lastCheck) < UPDATE_DEFAULTS.checkIntervalMs) {
45
+ return {
46
+ current: VERSION,
47
+ latest: cached.latestVersion,
48
+ updateAvailable: semverGt(cached.latestVersion, VERSION),
49
+ };
50
+ }
51
+ try {
52
+ const res = await fetch(UPDATE_DEFAULTS.registryUrl, {
53
+ signal: AbortSignal.timeout(UPDATE_DEFAULTS.fetchTimeoutMs),
54
+ headers: { 'Accept': 'application/json' },
55
+ });
56
+ if (!res.ok)
57
+ return null;
58
+ const data = await res.json();
59
+ const latest = data.version;
60
+ if (!latest || !isValidSemver(latest))
61
+ return null;
62
+ await writeCache({ lastCheck: now, latestVersion: latest }).catch(() => { });
63
+ return {
64
+ current: VERSION,
65
+ latest,
66
+ updateAvailable: semverGt(latest, VERSION),
67
+ };
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ export function getUpdateBanner() {
74
+ if (process.env.NO_UPDATE_NOTIFIER)
75
+ return null;
76
+ const cached = readCacheSync();
77
+ if (!cached)
78
+ return null;
79
+ if (!semverGt(cached.latestVersion, VERSION))
80
+ return null;
81
+ const current = VERSION;
82
+ const latest = cached.latestVersion;
83
+ const line1 = ` Update available: ${current} → ${latest}`;
84
+ const line2 = ` Run /update or:`;
85
+ const line3 = ` npm install -g ${UPDATE_DEFAULTS.npmPackageName}@latest`;
86
+ const maxLen = Math.max(line1.length, line2.length, line3.length) + 2;
87
+ const pad = (s) => s + ' '.repeat(Math.max(0, maxLen - s.length));
88
+ return [
89
+ `╭${'─'.repeat(maxLen)}╮`,
90
+ `│${pad(line1)}│`,
91
+ `│${pad(line2)}│`,
92
+ `│${pad(line3)}│`,
93
+ `╰${'─'.repeat(maxLen)}╯`,
94
+ ].join('\n');
95
+ }
96
+ export function performUpdate() {
97
+ return new Promise((resolve) => {
98
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
99
+ execFile(npmCmd, ['install', '-g', `${UPDATE_DEFAULTS.npmPackageName}@latest`], {
100
+ timeout: 120_000,
101
+ env: { ...process.env },
102
+ }, (error, stdout, stderr) => {
103
+ if (error) {
104
+ resolve({ success: false, output: stderr || error.message });
105
+ }
106
+ else {
107
+ resolve({ success: true, output: stdout.trim() });
108
+ }
109
+ });
110
+ });
111
+ }