@wonderwhy-er/desktop-commander 0.2.41 → 0.2.42

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.
@@ -11,7 +11,7 @@ import { attachDirectoryHandlers } from './directory-controller.js';
11
11
  import { buildDocumentLayout } from './document-layout.js';
12
12
  import { getDocumentFullscreenAvailability, parseReadRange, stripReadStatusLine } from './document-workspace.js';
13
13
  import { getFileTypeCapabilities, renderPayloadBody } from './file-type-handlers.js';
14
- import { buildOpenInEditorCommand, buildOpenInFolderCommand, detectDefaultMarkdownEditor, renderMarkdownEditorAppIcon } from './host/external-actions.js';
14
+ import { buildOpenInEditorCommand, buildOpenInFolderCommand, renderMarkdownEditorAppIcon } from './host/external-actions.js';
15
15
  import { attachSelectionContext } from './host/selection-context.js';
16
16
  import { createMarkdownController } from './markdown/controller.js';
17
17
  import { createConflictDialogController, renderConflictDialogMarkup, } from './markdown/conflict-dialog.js';
@@ -40,7 +40,9 @@ let inlinePayloadBeforeFullscreen;
40
40
  let directoryBackPayload;
41
41
  let selectionAbortController = null;
42
42
  const markdownEditorAppCache = new Map();
43
- const markdownEditorAppPending = new Set();
43
+ function getTelemetryToolName(payload) {
44
+ return typeof payload?.sourceTool === 'string' ? payload.sourceTool : 'read_file';
45
+ }
44
46
  async function callToolIfReady(name, args) {
45
47
  return rpcCallTool ? rpcCallTool(name, args) : undefined;
46
48
  }
@@ -147,7 +149,10 @@ async function readAndResolvePayload(payload, onReady) {
147
149
  try {
148
150
  const freshPayload = await markdownController.readPayload(payload.filePath);
149
151
  if (freshPayload) {
150
- onReady(freshPayload);
152
+ onReady({
153
+ ...freshPayload,
154
+ sourceTool: payload.sourceTool ?? freshPayload.sourceTool,
155
+ });
151
156
  if (freshPayload.fileType === 'markdown') {
152
157
  void markdownController.refreshFromDisk(freshPayload);
153
158
  }
@@ -212,21 +217,15 @@ export function renderApp(container, payload, htmlMode = 'rendered', expandedSta
212
217
  const canGoFullscreen = !isFullscreen && getDocumentFullscreenAvailability({
213
218
  availableDisplayModes: getAvailableDisplayModes(),
214
219
  }).canFullscreen;
220
+ if (payload.fileType === 'markdown' && payload.defaultEditorName) {
221
+ markdownEditorAppCache.set(payload.filePath, {
222
+ appName: payload.defaultEditorName,
223
+ appPath: payload.defaultEditorPath,
224
+ });
225
+ }
215
226
  const defaultMarkdownEditor = payload.fileType === 'markdown'
216
227
  ? markdownEditorAppCache.get(payload.filePath)
217
228
  : undefined;
218
- if (payload.fileType === 'markdown' && !defaultMarkdownEditor) {
219
- void detectDefaultMarkdownEditor({
220
- filePath: payload.filePath,
221
- editorAppCache: markdownEditorAppCache,
222
- editorAppPending: markdownEditorAppPending,
223
- callTool: callToolIfReady,
224
- extractToolText,
225
- onDetected: () => {
226
- rerenderCurrent?.();
227
- },
228
- });
229
- }
230
229
  const layout = buildDocumentLayout({
231
230
  payload,
232
231
  body,
@@ -415,9 +414,12 @@ export function bootstrapApp() {
415
414
  const result = await app.requestDisplayMode({ mode });
416
415
  return typeof result.mode === 'string' ? result.mode : null;
417
416
  };
418
- trackUiEvent = createUiEventTracker((name, args) => app.callServerTool({ name, arguments: args }), {
417
+ const filePreviewUiEvent = createUiEventTracker((name, args) => app.callServerTool({ name, arguments: args }), {
419
418
  component: 'file_preview',
420
- baseParams: { tool_name: 'read_file' },
419
+ });
420
+ trackUiEvent = (event, params = {}) => filePreviewUiEvent(event, {
421
+ tool_name: getTelemetryToolName(currentPayload ?? hostPayload),
422
+ ...params,
421
423
  });
422
424
  app.ontoolinput = (params) => {
423
425
  const requestedPath = typeof params.arguments?.path === 'string' ? params.arguments.path : undefined;
@@ -5,15 +5,4 @@ export declare function buildOpenInEditorCommand(filePath: string, isLikelyUrl:
5
5
  appName: string;
6
6
  appPath?: string;
7
7
  }>): string | undefined;
8
- export declare function detectDefaultMarkdownEditor(options: {
9
- filePath: string;
10
- editorAppCache: Map<string, {
11
- appName: string;
12
- appPath?: string;
13
- }>;
14
- editorAppPending: Set<string>;
15
- callTool?: (name: string, args: Record<string, unknown>) => Promise<unknown | undefined>;
16
- extractToolText: (value: unknown) => string | undefined;
17
- onDetected?: () => void;
18
- }): Promise<void>;
19
8
  export declare function renderMarkdownEditorAppIcon(): string;
@@ -50,45 +50,6 @@ export function buildOpenInEditorCommand(filePath, isLikelyUrl, editorAppCache)
50
50
  }
51
51
  return `xdg-open ${shellQuote(trimmedPath)}`;
52
52
  }
53
- export async function detectDefaultMarkdownEditor(options) {
54
- const trimmedPath = options.filePath.trim();
55
- if (!trimmedPath || options.editorAppCache.has(trimmedPath) || options.editorAppPending.has(trimmedPath)) {
56
- return;
57
- }
58
- const userAgent = navigator.userAgent.toLowerCase();
59
- if (!userAgent.includes('mac')) {
60
- return;
61
- }
62
- options.editorAppPending.add(trimmedPath);
63
- try {
64
- const detectCommand = `osascript -e ${shellQuote(`set appAlias to default application of (info for POSIX file "${trimmedPath.replace(/"/g, '\\"')}")
65
- return (name of (info for appAlias)) & linefeed & POSIX path of appAlias`)}`;
66
- const detectResult = await options.callTool?.('start_process', {
67
- command: detectCommand,
68
- timeout_ms: 12000,
69
- });
70
- const text = options.extractToolText(detectResult) ?? '';
71
- if (!text || text.toLowerCase().includes('error') || text.toLowerCase().includes('execution')) {
72
- return;
73
- }
74
- const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
75
- const appName = lines[lines.length - 2]?.replace(/\.app$/i, '') ?? '';
76
- const appPath = lines[lines.length - 1] ?? '';
77
- if (appName && appPath.startsWith('/')) {
78
- options.editorAppCache.set(trimmedPath, {
79
- appName,
80
- appPath,
81
- });
82
- options.onDetected?.();
83
- }
84
- }
85
- catch {
86
- // Fall back to generic editor label.
87
- }
88
- finally {
89
- options.editorAppPending.delete(trimmedPath);
90
- }
91
- }
92
53
  export function renderMarkdownEditorAppIcon() {
93
54
  return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4Z"/></svg>';
94
55
  }
@@ -10,10 +10,9 @@ import { version as nodeVersion } from 'process';
10
10
  import * as https from 'https';
11
11
  import { randomUUID } from 'crypto';
12
12
 
13
- // Google Analytics configuration
14
- const GA_MEASUREMENT_ID = 'G-NGGDNL0K4L'; // Replace with your GA4 Measurement ID
15
- const GA_API_SECRET = '5M0mC--2S_6t94m8WrI60A'; // Replace with your GA4 API Secret
16
- const GA_BASE_URL = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_MEASUREMENT_ID}&api_secret=${GA_API_SECRET}`;
13
+ // Telemetry proxy configuration
14
+ const TELEMETRY_PROXY_URL = 'https://telemetry.desktopcommander.app/mp/collect';
15
+ const TELEMETRY_PROXY_FALLBACK_URL = 'https://dc-telemetry-proxy-83847352264.europe-west1.run.app/mp/collect';
17
16
 
18
17
  // Read clientId and telemetry settings from existing config
19
18
  let uniqueUserId = 'unknown';
@@ -222,11 +221,6 @@ async function trackEvent(eventName, additionalProps = {}) {
222
221
  return true; // Return success since this is expected behavior
223
222
  }
224
223
 
225
- if (!GA_MEASUREMENT_ID || !GA_API_SECRET) {
226
- updateUninstallStep(trackingStep, 'skipped', new Error('GA not configured'));
227
- return;
228
- }
229
-
230
224
  const maxRetries = 2;
231
225
  let attempt = 0;
232
226
  let lastError = null;
@@ -257,43 +251,8 @@ async function trackEvent(eventName, additionalProps = {}) {
257
251
  }
258
252
  };
259
253
 
260
- const result = await new Promise((resolve, reject) => {
261
- const req = https.request(GA_BASE_URL, options);
262
-
263
- const timeoutId = setTimeout(() => {
264
- req.destroy();
265
- reject(new Error('Request timeout'));
266
- }, 5000);
267
-
268
- req.on('error', (error) => {
269
- clearTimeout(timeoutId);
270
- reject(error);
271
- });
272
-
273
- req.on('response', (res) => {
274
- clearTimeout(timeoutId);
275
- let data = '';
276
-
277
- res.on('data', (chunk) => {
278
- data += chunk;
279
- });
280
-
281
- res.on('error', (error) => {
282
- reject(error);
283
- });
284
-
285
- res.on('end', () => {
286
- if (res.statusCode >= 200 && res.statusCode < 300) {
287
- resolve({ success: true, data });
288
- } else {
289
- reject(new Error(`HTTP error ${res.statusCode}: ${data}`));
290
- }
291
- });
292
- });
293
-
294
- req.write(postData);
295
- req.end();
296
- });
254
+ const result = await postTelemetryPayload(postData, options);
255
+ if (!result.success) throw new Error('Telemetry proxy request failed');
297
256
 
298
257
  updateUninstallStep(trackingStep, 'completed');
299
258
  return result;
@@ -309,6 +268,54 @@ async function trackEvent(eventName, additionalProps = {}) {
309
268
  updateUninstallStep(trackingStep, 'failed', lastError);
310
269
  return false;
311
270
  }
271
+
272
+ async function postTelemetryPayload(postData, options) {
273
+ for (const endpoint of [TELEMETRY_PROXY_URL, TELEMETRY_PROXY_FALLBACK_URL]) {
274
+ const result = await new Promise((resolve) => {
275
+ let settled = false;
276
+ let timeoutId;
277
+ const finish = (result) => {
278
+ if (settled) return;
279
+ settled = true;
280
+ clearTimeout(timeoutId);
281
+ resolve(result);
282
+ };
283
+ const req = https.request(endpoint, options);
284
+
285
+ timeoutId = setTimeout(() => {
286
+ req.destroy();
287
+ finish({ success: false, data: '' });
288
+ }, 5000);
289
+
290
+ req.on('error', () => {
291
+ finish({ success: false, data: '' });
292
+ });
293
+
294
+ req.on('response', (res) => {
295
+ let data = '';
296
+ res.on('data', (chunk) => {
297
+ data += chunk;
298
+ });
299
+ res.on('error', () => {
300
+ finish({ success: false, data: '' });
301
+ });
302
+ res.on('end', () => {
303
+ finish({ success: res.statusCode >= 200 && res.statusCode < 300, data });
304
+ });
305
+ res.on('close', () => {
306
+ finish({ success: false, data: '' });
307
+ });
308
+ });
309
+
310
+ req.write(postData);
311
+ req.end();
312
+ });
313
+
314
+ if (result.success) return result;
315
+ }
316
+
317
+ return { success: false, data: '' };
318
+ }
312
319
  // Ensure tracking completes before process exits
313
320
  async function ensureTrackingCompleted(eventName, additionalProps = {}, timeoutMs = 6000) {
314
321
  return new Promise(async (resolve) => {
@@ -750,4 +757,4 @@ if (process.argv.length >= 2 && process.argv[1] === fileURLToPath(import.meta.ur
750
757
  logToFile(`Fatal error: ${error}`, true);
751
758
  process.exit(1);
752
759
  });
753
- }
760
+ }
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Get the exact assigned variant for a named experiment.
3
+ */
4
+ export declare function getABTestVariant(experimentName: string): Promise<string | null>;
1
5
  /**
2
6
  * Check if a feature (variant name) is enabled for current user
3
7
  */
@@ -57,6 +57,12 @@ async function getVariant(experimentName) {
57
57
  variantCache[experimentName] = variant;
58
58
  return variant;
59
59
  }
60
+ /**
61
+ * Get the exact assigned variant for a named experiment.
62
+ */
63
+ export async function getABTestVariant(experimentName) {
64
+ return getVariant(experimentName);
65
+ }
60
66
  /**
61
67
  * Check if a feature (variant name) is enabled for current user
62
68
  */
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Hard kill-switch for telemetry via environment variable.
3
+ *
4
+ * Independent of the persisted `telemetryEnabled` config so that tests, CI and
5
+ * one-off runs can suppress all analytics without mutating the user's config.
6
+ * Set DESKTOP_COMMANDER_DISABLE_TELEMETRY to 1/true/yes/on to disable.
7
+ */
8
+ export declare function isTelemetryDisabledByEnv(): boolean;
1
9
  /**
2
10
  * Sanitizes error objects to remove potentially sensitive information like file paths
3
11
  * @param error Error object or string to sanitize
@@ -8,13 +16,13 @@ export declare function sanitizeError(error: any): {
8
16
  code?: string;
9
17
  };
10
18
  /**
11
- * Send an event to Google Analytics
19
+ * Send an event to telemetry
12
20
  * @param event Event name
13
21
  * @param properties Optional event properties
14
22
  */
15
23
  export declare const captureBase: (captureURL: string, event: string, properties?: any) => Promise<void>;
16
- export declare const capture_call_tool: (event: string, properties?: any) => Promise<void>;
17
24
  export declare const capture: (event: string, properties?: any) => Promise<void>;
25
+ export declare const capture_call_tool: (event: string, properties?: any) => Promise<void>;
18
26
  export declare const capture_ui_event: (event: string, properties?: any) => Promise<void>;
19
27
  /**
20
28
  * Wrapper for capture() that automatically adds remote flag for remote-device telemetry
@@ -1,7 +1,7 @@
1
1
  import { platform } from 'os';
2
2
  import * as https from 'https';
3
3
  import { configManager, isTelemetryDisabledValue } from '../config-manager.js';
4
- import { currentClient } from '../server.js';
4
+ import { currentClient, currentCallIsRemote } from '../server.js';
5
5
  let VERSION = 'unknown';
6
6
  try {
7
7
  const versionModule = await import('../version.js');
@@ -13,8 +13,26 @@ catch {
13
13
  // Will be initialized when needed
14
14
  let uniqueUserId = 'unknown';
15
15
  // --- Telemetry Proxy (direct BigQuery ingestion) ---
16
- const TELEMETRY_PROXY_URL = 'https://dc-telemetry-proxy-83847352264.europe-west1.run.app/mp/collect';
17
- const TELEMETRY_PROXY_TOKEN = 'Od44UB_fTrVfGPGRPLr5QdVgFhuKdiGaBmvazTdxVdQ';
16
+ // TODO: Move proxy endpoints, auth header setup, request retry/fallback, and
17
+ // transport code into a dedicated telemetry utility once this migration lands.
18
+ // TODO(security): bearer token was removed, so this endpoint is now unauthenticated.
19
+ // Confirm the proxy enforces rate limiting / payload validation server-side,
20
+ // otherwise anyone can POST arbitrary events straight into BigQuery ingestion.
21
+ const TELEMETRY_PROXY_URL = 'https://telemetry.desktopcommander.app/mp/collect';
22
+ const TELEMETRY_PROXY_FALLBACK_URL = 'https://dc-telemetry-proxy-83847352264.europe-west1.run.app/mp/collect';
23
+ /**
24
+ * Hard kill-switch for telemetry via environment variable.
25
+ *
26
+ * Independent of the persisted `telemetryEnabled` config so that tests, CI and
27
+ * one-off runs can suppress all analytics without mutating the user's config.
28
+ * Set DESKTOP_COMMANDER_DISABLE_TELEMETRY to 1/true/yes/on to disable.
29
+ */
30
+ export function isTelemetryDisabledByEnv() {
31
+ const raw = process.env.DESKTOP_COMMANDER_DISABLE_TELEMETRY;
32
+ if (!raw)
33
+ return false;
34
+ return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
35
+ }
18
36
  /**
19
37
  * Sanitizes error objects to remove potentially sensitive information like file paths
20
38
  * @param error Error object or string to sanitize
@@ -47,12 +65,19 @@ export function sanitizeError(error) {
47
65
  };
48
66
  }
49
67
  /**
50
- * Send an event to Google Analytics
68
+ * Send an event to telemetry
51
69
  * @param event Event name
52
70
  * @param properties Optional event properties
53
71
  */
72
+ // TODO(cleanup): captureBase is now dead code — no caller remains after the GA
73
+ // removal (only referenced in a comment). It still carries the full GA4-flavored
74
+ // send path. Remove it, or repurpose it as the shared proxy transport.
54
75
  export const captureBase = async (captureURL, event, properties) => {
55
76
  try {
77
+ // Env kill-switch takes precedence over config (tests/CI).
78
+ if (isTelemetryDisabledByEnv()) {
79
+ return;
80
+ }
56
81
  // Check if telemetry is enabled in config (defaults to true if not set)
57
82
  const telemetryEnabled = await configManager.getValue('telemetryEnabled');
58
83
  // If telemetry is explicitly disabled or GA credentials are missing, don't send
@@ -178,9 +203,13 @@ export const captureBase = async (captureURL, event, properties) => {
178
203
  const eventProperties = {
179
204
  ...baseProperties,
180
205
  ...clientContext,
206
+ // Attribute events to the remote path when the in-flight tool call
207
+ // came from a remote device. Placed before sanitizedProperties so an
208
+ // explicit `remote` passed by the caller (e.g. captureRemote) wins.
209
+ ...(currentCallIsRemote ? { remote: String(true) } : {}),
181
210
  ...sanitizedProperties
182
211
  };
183
- // Prepare GA4 payload
212
+ // Prepare telemetry payload
184
213
  const payload = {
185
214
  client_id: uniqueUserId,
186
215
  non_personalized_ads: false,
@@ -190,7 +219,7 @@ export const captureBase = async (captureURL, event, properties) => {
190
219
  params: eventProperties
191
220
  }]
192
221
  };
193
- // Send data to Google Analytics
222
+ // Send data to telemetry endpoint
194
223
  const postData = JSON.stringify(payload);
195
224
  const options = {
196
225
  method: 'POST',
@@ -209,7 +238,7 @@ export const captureBase = async (captureURL, event, properties) => {
209
238
  const success = res.statusCode === 200 || res.statusCode === 204;
210
239
  if (!success) {
211
240
  // Optional debug logging
212
- // console.debug(`GA tracking error: ${res.statusCode} ${data}`);
241
+ // console.debug(`Telemetry tracking error: ${res.statusCode} ${data}`);
213
242
  }
214
243
  });
215
244
  });
@@ -229,7 +258,7 @@ export const captureBase = async (captureURL, event, properties) => {
229
258
  }
230
259
  };
231
260
  /**
232
- * Build the standard event properties used by both GA4 and the telemetry proxy.
261
+ * Build the standard event properties used by the telemetry proxy.
233
262
  * Extracted from captureBase so both paths get identical data.
234
263
  */
235
264
  const buildEventProperties = async (properties) => {
@@ -325,16 +354,21 @@ const buildEventProperties = async (properties) => {
325
354
  app_version: VERSION,
326
355
  engagement_time_msec: "100",
327
356
  ...clientContext,
357
+ // Attribute events to the remote path when the in-flight tool call
358
+ // came from a remote device. Placed before sanitizedProperties so an
359
+ // explicit `remote` passed by the caller (e.g. captureRemote) wins.
360
+ ...(currentCallIsRemote ? { remote: String(true) } : {}),
328
361
  ...sanitizedProperties,
329
362
  };
330
363
  };
331
364
  /**
332
365
  * Send event to the telemetry proxy (direct BigQuery ingestion).
333
- * Runs in parallel with GA4 used for high-volume events to avoid
334
- * the 1M/day GA4 BigQuery export limit.
366
+ * Uses the custom domain first and retries the generated Cloud Run URL on failure.
335
367
  */
336
368
  const sendToTelemetryProxy = async (event, eventProperties) => {
337
369
  try {
370
+ if (isTelemetryDisabledByEnv())
371
+ return;
338
372
  const telemetryEnabled = await configManager.getValue('telemetryEnabled');
339
373
  if (isTelemetryDisabledValue(telemetryEnabled))
340
374
  return;
@@ -346,7 +380,18 @@ const sendToTelemetryProxy = async (event, eventProperties) => {
346
380
  params: eventProperties
347
381
  }]
348
382
  });
349
- const url = new URL(TELEMETRY_PROXY_URL);
383
+ const sent = await postTelemetryPayload(TELEMETRY_PROXY_URL, payload);
384
+ if (!sent) {
385
+ await postTelemetryPayload(TELEMETRY_PROXY_FALLBACK_URL, payload);
386
+ }
387
+ }
388
+ catch {
389
+ // Silent fail — telemetry should never break functionality
390
+ }
391
+ };
392
+ const postTelemetryPayload = async (endpoint, payload) => {
393
+ return await new Promise((resolve) => {
394
+ const url = new URL(endpoint);
350
395
  const options = {
351
396
  hostname: url.hostname,
352
397
  port: 443,
@@ -354,59 +399,40 @@ const sendToTelemetryProxy = async (event, eventProperties) => {
354
399
  method: 'POST',
355
400
  headers: {
356
401
  'Content-Type': 'application/json',
357
- 'Authorization': `Bearer ${TELEMETRY_PROXY_TOKEN}`,
358
402
  'Content-Length': Buffer.byteLength(payload)
359
403
  }
360
404
  };
361
405
  const req = https.request(options, (res) => {
362
- res.resume(); // drain response
406
+ res.resume();
407
+ res.on('end', () => resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 300));
408
+ });
409
+ req.on('error', () => resolve(false));
410
+ req.setTimeout(3000, () => {
411
+ req.destroy();
412
+ resolve(false);
363
413
  });
364
- req.on('error', () => { }); // silent fail
365
- req.setTimeout(3000, () => req.destroy());
366
414
  req.write(payload);
367
415
  req.end();
368
- }
369
- catch {
370
- // Silent fail — telemetry should never break functionality
371
- }
372
- };
373
- export const capture_call_tool = async (event, properties) => {
374
- // Old property (G-8L163XZ1CE) — keeps lower-volume tool events
375
- const GA_OLD_ID = 'G-8L163XZ1CE';
376
- const GA_OLD_SECRET = 'hNxh4TK2TnSy4oLZn4RwTA';
377
- const GA_OLD_URL = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_OLD_ID}&api_secret=${GA_OLD_SECRET}`;
378
- // New property (dc_high_volume) — receives highest-volume tool events to avoid 1M/day BQ export limit
379
- const GA_NEW_ID = 'G-ZDF1M5403Z';
380
- const GA_NEW_SECRET = 'cUEilpa0SpWfc2UjblDtKQ';
381
- const GA_NEW_URL = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_NEW_ID}&api_secret=${GA_NEW_SECRET}`;
382
- // Route highest-volume tools to new property, rest to old
383
- const HIGH_VOLUME_TOOLS = ['start_process', 'track_ui_event'];
384
- const toolName = properties?.tool_name ?? properties?.name;
385
- const gaUrl = HIGH_VOLUME_TOOLS.includes(toolName) ? GA_NEW_URL : GA_OLD_URL;
386
- // Build properties once, send to GA4 + telemetry proxy in parallel
387
- const eventProperties = await buildEventProperties(properties);
388
- await Promise.all([
389
- captureBase(gaUrl, event, properties), // GA4 (routed by tool name)
390
- sendToTelemetryProxy(event, eventProperties), // direct BigQuery (all events)
391
- ]);
416
+ });
392
417
  };
418
+ // TODO(behavior): capture() is now fire-and-forget — every `await capture(...)`
419
+ // call site resolves before the network send completes. Fine for the long-running
420
+ // MCP server, but events fired right before process exit (e.g. opt-out, feedback)
421
+ // can be silently dropped. If we need delivery guarantees on short-lived paths,
422
+ // expose an awaitable variant or flush-before-exit hook.
393
423
  export const capture = async (event, properties) => {
394
- const GA_MEASUREMENT_ID = 'G-F3GK01G39Y';
395
- const GA_API_SECRET = 'SqdcIAweSQS1RQErURMdEA';
396
- const GA_BASE_URL = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_MEASUREMENT_ID}&api_secret=${GA_API_SECRET}`;
397
- // Build properties once, send to both GA4 and telemetry proxy in parallel
398
- const eventProperties = await buildEventProperties(properties);
399
- await Promise.all([
400
- captureBase(GA_BASE_URL, event, properties), // existing GA4
401
- sendToTelemetryProxy(event, eventProperties), // new: direct BigQuery
402
- ]);
403
- };
404
- export const capture_ui_event = async (event, properties) => {
405
- const GA_MEASUREMENT_ID = 'G-MPFSWEGQ0T';
406
- const GA_API_SECRET = 'BeK3uyAOQ6-TK6wnaDG2Ww';
407
- const GA_BASE_URL = `https://www.google-analytics.com/mp/collect?measurement_id=${GA_MEASUREMENT_ID}&api_secret=${GA_API_SECRET}`;
408
- return await captureBase(GA_BASE_URL, event, properties);
424
+ void (async () => {
425
+ try {
426
+ const eventProperties = await buildEventProperties(properties);
427
+ await sendToTelemetryProxy(event, eventProperties);
428
+ }
429
+ catch {
430
+ // Silent fail telemetry should never break functionality
431
+ }
432
+ })();
409
433
  };
434
+ export const capture_call_tool = capture;
435
+ export const capture_ui_event = capture;
410
436
  /**
411
437
  * Wrapper for capture() that automatically adds remote flag for remote-device telemetry
412
438
  * Also adds additional privacy filtering to remove sensitive identity information
@@ -0,0 +1,13 @@
1
+ export declare const MCP_UI_EXPERIMENT_NAME = "McpUiPreviews";
2
+ export declare const MCP_UI_SHOW_VARIANT = "showMCPUi";
3
+ export declare const MCP_UI_HIDE_VARIANT = "notShowMCPUi";
4
+ export interface McpUiPreviewDecisionDeps {
5
+ getExistingAssignment: () => Promise<unknown>;
6
+ isFirstRun: () => boolean;
7
+ wasLoadedFromCache: () => boolean;
8
+ waitForFreshFlags: () => Promise<void>;
9
+ getABTestVariant: (experimentName: string) => Promise<string | null>;
10
+ capture: (event: string, properties?: Record<string, unknown>) => Promise<unknown> | unknown;
11
+ }
12
+ export declare function resolveMcpUiPreviewDecision(deps: McpUiPreviewDecisionDeps): Promise<boolean>;
13
+ export declare function shouldShowMcpUiPreviews(): Promise<boolean>;
@@ -0,0 +1,62 @@
1
+ import { configManager } from '../config-manager.js';
2
+ import { getABTestVariant } from './ab-test.js';
3
+ import { capture } from './capture.js';
4
+ import { featureFlagManager } from './feature-flags.js';
5
+ export const MCP_UI_EXPERIMENT_NAME = 'McpUiPreviews';
6
+ export const MCP_UI_SHOW_VARIANT = 'showMCPUi';
7
+ export const MCP_UI_HIDE_VARIANT = 'notShowMCPUi';
8
+ function variantEnablesMcpUi(variant) {
9
+ if (variant === MCP_UI_HIDE_VARIANT)
10
+ return false;
11
+ if (variant === MCP_UI_SHOW_VARIANT)
12
+ return true;
13
+ return null;
14
+ }
15
+ export async function resolveMcpUiPreviewDecision(deps) {
16
+ try {
17
+ const existingAssignment = await deps.getExistingAssignment();
18
+ const existingDecision = variantEnablesMcpUi(existingAssignment);
19
+ if (existingDecision !== null) {
20
+ if (!deps.wasLoadedFromCache()) {
21
+ await deps.waitForFreshFlags();
22
+ }
23
+ const currentVariant = await deps.getABTestVariant(MCP_UI_EXPERIMENT_NAME);
24
+ return variantEnablesMcpUi(currentVariant) ?? existingDecision;
25
+ }
26
+ if (!deps.isFirstRun()) {
27
+ return true;
28
+ }
29
+ if (!deps.wasLoadedFromCache()) {
30
+ await deps.waitForFreshFlags();
31
+ }
32
+ const variant = await deps.getABTestVariant(MCP_UI_EXPERIMENT_NAME);
33
+ const decision = variantEnablesMcpUi(variant);
34
+ if (decision === null) {
35
+ return true;
36
+ }
37
+ try {
38
+ await deps.capture('server_mcp_ui_ab_decision', {
39
+ experiment: MCP_UI_EXPERIMENT_NAME,
40
+ variant,
41
+ mcp_ui_enabled: decision,
42
+ });
43
+ }
44
+ catch {
45
+ // Telemetry must not change the assigned product experience.
46
+ }
47
+ return decision;
48
+ }
49
+ catch {
50
+ return true;
51
+ }
52
+ }
53
+ export async function shouldShowMcpUiPreviews() {
54
+ return resolveMcpUiPreviewDecision({
55
+ getExistingAssignment: () => configManager.getValue(`abTest_${MCP_UI_EXPERIMENT_NAME}`),
56
+ isFirstRun: () => configManager.isFirstRun(),
57
+ wasLoadedFromCache: () => featureFlagManager.wasLoadedFromCache(),
58
+ waitForFreshFlags: () => featureFlagManager.waitForFreshFlags(),
59
+ getABTestVariant,
60
+ capture,
61
+ });
62
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.2.41";
1
+ export declare const VERSION = "0.2.42";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.2.41';
1
+ export const VERSION = '0.2.42';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonderwhy-er/desktop-commander",
3
- "version": "0.2.41",
3
+ "version": "0.2.42",
4
4
  "description": "MCP server for terminal operations and file editing",
5
5
  "mcpName": "io.github.wonderwhy-er/desktop-commander",
6
6
  "license": "MIT",
@@ -41,6 +41,7 @@
41
41
  "prepare": "npm run build",
42
42
  "clean": "shx rm -rf dist",
43
43
  "test": "npm run build && node test/run-all-tests.js",
44
+ "test:integration": "npm run build && node test/integration/run-all-integration-tests.js",
44
45
  "test:debug": "node --inspect test/run-all-tests.js",
45
46
  "validate:tools": "npm run build && node scripts/validate-tools-sync.js",
46
47
  "link:local": "npm run build && npm link",