trackly-cli 0.13.4 → 0.14.1

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/lib/client.js CHANGED
@@ -10,6 +10,7 @@ const { version: PACKAGE_VERSION } = require('../package.json');
10
10
  const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.trackly');
11
11
  const DEFAULT_BASE_URL = 'https://closeai.mba';
12
12
  const DEFAULT_HTTP_TIMEOUT_MS = 30000;
13
+ const ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS = 1500;
13
14
  const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
14
15
  const MAINTENANCE_CODE = 'maintenance_mode';
15
16
  const LEGACY_MAINTENANCE_CODE = 'planned_maintenance';
@@ -106,10 +107,180 @@ function saveConfig(config) {
106
107
  }
107
108
 
108
109
  function clearConfig() {
110
+ let config = {};
111
+ try {
112
+ const loaded = loadConfig();
113
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) config = loaded;
114
+ } catch {
115
+ // Logout must still remove an invalid or unreadable credential file. The
116
+ // opt-out can be preserved only when the existing config is recoverable.
117
+ }
118
+ // A logout clears credentials and endpoint overrides, but the content-free
119
+ // analytics opt-out must continue suppressing anonymous MCP lifecycle events.
120
+ if (config.mcpAnalyticsOptOut === true) {
121
+ try {
122
+ saveConfig({ mcpAnalyticsOptOut: true });
123
+ return;
124
+ } catch {
125
+ // Preserving the marker is best-effort. A disk/write failure must never
126
+ // leave credentials behind, so fall through to unlinking the old file.
127
+ }
128
+ }
109
129
  const { file } = getConfigPaths();
110
130
  try { fs.unlinkSync(file); } catch (e) {}
111
131
  }
112
132
 
133
+ function isRecord(value) {
134
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
135
+ }
136
+
137
+ function hasAuthenticationInConfig(config) {
138
+ return Boolean(
139
+ (process.env.TRACKLY_API_KEY || '').trim()
140
+ || (isRecord(config) && (config.apiKey || config.token || config.refreshToken))
141
+ );
142
+ }
143
+
144
+ function configAfterCredentialRemoval(config, scope) {
145
+ const next = isRecord(config) ? { ...config } : {};
146
+ if (scope === 'all' || scope === 'apiKey') delete next.apiKey;
147
+ if (scope === 'all' || scope === 'oauth') {
148
+ delete next.token;
149
+ delete next.refreshToken;
150
+ }
151
+ return next;
152
+ }
153
+
154
+ async function resolveAnalyticsOptOutBeforeCredentialLoss(
155
+ request = apiRequest,
156
+ timeoutMs = ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS
157
+ ) {
158
+ const controller = new AbortController();
159
+ let timeout;
160
+ try {
161
+ const preference = await Promise.race([
162
+ Promise.resolve().then(() => request(
163
+ 'GET',
164
+ '/api/jobscout/analytics-preference',
165
+ null,
166
+ false,
167
+ true,
168
+ null,
169
+ null,
170
+ { signal: controller.signal, timeoutMs }
171
+ )),
172
+ new Promise((_, reject) => {
173
+ timeout = setTimeout(() => {
174
+ controller.abort();
175
+ reject(new Error('analytics_preference_sync_timeout'));
176
+ }, timeoutMs);
177
+ }),
178
+ ]);
179
+ if (typeof preference?.shareUsageAnalytics !== 'boolean') {
180
+ throw new Error('analytics_preference_invalid');
181
+ }
182
+ return preference.shareUsageAnalytics === false;
183
+ } catch {
184
+ // Fail closed: if the authoritative preference cannot be read before the
185
+ // final credential disappears, suppress future anonymous MCP capture.
186
+ return true;
187
+ } finally {
188
+ clearTimeout(timeout);
189
+ }
190
+ }
191
+
192
+ async function synchronizeAnalyticsPreferenceBeforeLogout(
193
+ request = apiRequest,
194
+ options = {}
195
+ ) {
196
+ const timeoutMs = options.timeoutMs ?? ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS;
197
+ try {
198
+ if (!hasAuth()) return;
199
+ } catch {
200
+ // A corrupt or unreadable config must never prevent logout. clearConfig()
201
+ // owns the best-effort removal path and will run immediately afterward.
202
+ return;
203
+ }
204
+ const optedOut = await resolveAnalyticsOptOutBeforeCredentialLoss(request, timeoutMs);
205
+ let config = {};
206
+ try {
207
+ const loaded = loadConfig();
208
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) config = loaded;
209
+ } catch {
210
+ // Environment-provided authentication can still make the preference
211
+ // request valid when the on-disk config is corrupt. Replace only the
212
+ // recoverable analytics marker and let clearConfig() remove credentials.
213
+ }
214
+ if (optedOut) config.mcpAnalyticsOptOut = true;
215
+ else delete config.mcpAnalyticsOptOut;
216
+ try {
217
+ saveConfig(config);
218
+ } catch {
219
+ // Analytics synchronization is best-effort and must never block logout.
220
+ }
221
+ }
222
+
223
+ async function removeCredentials({
224
+ scope = 'all',
225
+ request = apiRequest,
226
+ timeoutMs = ANALYTICS_PREFERENCE_SYNC_TIMEOUT_MS,
227
+ preferenceUnavailable = false,
228
+ } = {}) {
229
+ let config = {};
230
+ try {
231
+ const loaded = loadConfig();
232
+ if (isRecord(loaded)) config = loaded;
233
+ } catch {
234
+ // Corrupt or unreadable config still belongs to the best-effort removal
235
+ // path below. There is no safe credential state to preserve from it.
236
+ }
237
+
238
+ let next = configAfterCredentialRemoval(config, scope);
239
+ const removesEphemeralCredential = scope === 'all'
240
+ && Boolean((process.env.TRACKLY_API_KEY || '').trim());
241
+ const removesFinalCredential = removesEphemeralCredential
242
+ || (hasAuthenticationInConfig(config) && !hasAuthenticationInConfig(next));
243
+
244
+ if (removesFinalCredential) {
245
+ const optedOut = preferenceUnavailable
246
+ ? true
247
+ : await resolveAnalyticsOptOutBeforeCredentialLoss(request, timeoutMs);
248
+ if (optedOut) next.mcpAnalyticsOptOut = true;
249
+ else delete next.mcpAnalyticsOptOut;
250
+ }
251
+
252
+ if (scope === 'all') {
253
+ // Preserve logout's historical behavior of clearing endpoint overrides and
254
+ // all future config keys, with only the anonymous opt-out allowed through.
255
+ if (next.mcpAnalyticsOptOut === true) {
256
+ try {
257
+ saveConfig({ mcpAnalyticsOptOut: true });
258
+ return;
259
+ } catch {
260
+ // Credential removal wins over marker preservation on write failure.
261
+ }
262
+ }
263
+ const { file } = getConfigPaths();
264
+ try { fs.unlinkSync(file); } catch (_) {}
265
+ return;
266
+ }
267
+
268
+ if (Object.keys(next).length === 0) {
269
+ const { file } = getConfigPaths();
270
+ try { fs.unlinkSync(file); } catch (_) {}
271
+ return;
272
+ }
273
+ try {
274
+ saveConfig(next);
275
+ } catch (error) {
276
+ // If the surgical rewrite cannot be persisted, do not leave known-invalid
277
+ // credentials in the old file. Best-effort unlink is the safer fallback.
278
+ const { file } = getConfigPaths();
279
+ try { fs.unlinkSync(file); } catch (_) {}
280
+ throw error;
281
+ }
282
+ }
283
+
113
284
  // Surgical alternative to clearConfig() for the refresh-failure path. Deletes ONLY the
114
285
  // OAuth credentials (token + refreshToken) while preserving everything else on disk
115
286
  // (baseUrl, apiKey, timeout overrides, future keys). A refresh token being invalidated
@@ -488,10 +659,10 @@ async function _doRefresh() {
488
659
  // Treat as an invalidated session and purge ONLY the OAuth pair so we don't retry on
489
660
  // every subsequent 401. Without this, a backend that signals failure via JSON instead of
490
661
  // a 4xx status would leave the user stuck in a doomed-refresh loop forever.
491
- // Use clearOAuthTokens() — NOT clearConfig() — so the user's baseUrl, apiKey, and any
492
- // other persisted settings survive a token expiry. (Bug caught by Cursor Bugbot on
493
- // PR #20 post-review: the earlier clearConfig() call silently wiped baseUrl too.)
494
- try { clearOAuthTokens(); } catch (_) {}
662
+ // Use the centralized OAuth-only removal — NOT clearConfig() — so the user's
663
+ // baseUrl, apiKey, and other persisted settings survive a token expiry while
664
+ // final-credential loss still preserves the analytics preference safely.
665
+ try { await removeCredentials({ scope: 'oauth', preferenceUnavailable: true }); } catch (_) {}
495
666
  } catch (e) {
496
667
  if (isMaintenanceError(e)) {
497
668
  throw e;
@@ -503,7 +674,7 @@ async function _doRefresh() {
503
674
  // purge the token. Surgical OAuth-only clear preserves baseUrl + apiKey + timeout overrides.
504
675
  const status = e && typeof e.status === 'number' ? e.status : null;
505
676
  if (status === 400 || status === 401 || status === 403) {
506
- try { clearOAuthTokens(); } catch (_) {}
677
+ try { await removeCredentials({ scope: 'oauth', preferenceUnavailable: true }); } catch (_) {}
507
678
  }
508
679
  }
509
680
  return null;
@@ -516,7 +687,8 @@ function apiRequest(
516
687
  skipAuth = false,
517
688
  _isRetry = false,
518
689
  userAgent = null,
519
- additionalHeaders = null
690
+ additionalHeaders = null,
691
+ requestOptions = null
520
692
  ) {
521
693
  return new Promise((resolve, reject) => {
522
694
  let url;
@@ -552,7 +724,15 @@ function apiRequest(
552
724
  headers['Idempotency-Key'] = idempotencyEntry[1];
553
725
  }
554
726
  const authMethod = getAuthMethod();
555
- const timeoutMs = getRequestTimeoutMs();
727
+ const requestedTimeoutMs = Number(requestOptions?.timeoutMs);
728
+ const timeoutMs = Number.isFinite(requestedTimeoutMs) && requestedTimeoutMs > 0
729
+ ? Math.min(getRequestTimeoutMs(), requestedTimeoutMs)
730
+ : getRequestTimeoutMs();
731
+ const signal = requestOptions?.signal;
732
+ if (signal?.aborted) {
733
+ reject(new Error('Trackly request aborted.'));
734
+ return;
735
+ }
556
736
  const hasSensitivePayload = Boolean(data && typeof data === 'object' && 'refreshToken' in data);
557
737
 
558
738
  try {
@@ -640,7 +820,8 @@ function apiRequest(
640
820
  false,
641
821
  true,
642
822
  userAgent,
643
- additionalHeaders
823
+ additionalHeaders,
824
+ requestOptions
644
825
  );
645
826
  return resolve(retried);
646
827
  } catch (e) { return reject(e); }
@@ -671,6 +852,9 @@ function apiRequest(
671
852
  req.on('timeout', () => {
672
853
  req.destroy(new Error(`Trackly request timed out after ${timeoutMs}ms`));
673
854
  });
855
+ const abortRequest = () => req.destroy(new Error('Trackly request aborted.'));
856
+ signal?.addEventListener('abort', abortRequest, { once: true });
857
+ req.on('close', () => signal?.removeEventListener('abort', abortRequest));
674
858
  req.on('error', reject);
675
859
  if (data) req.write(JSON.stringify(data));
676
860
  req.end();
@@ -827,7 +1011,9 @@ module.exports = {
827
1011
  normalizeMaintenance,
828
1012
  normalizeEndpoint,
829
1013
  refreshAccessToken,
1014
+ removeCredentials,
830
1015
  saveConfig,
1016
+ synchronizeAnalyticsPreferenceBeforeLogout,
831
1017
  CONFIG_DIR: DEFAULT_CONFIG_DIR,
832
1018
  CONFIG_FILE: path.join(DEFAULT_CONFIG_DIR, 'config.json'),
833
1019
  };