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/README.md +23 -5
- package/bin/trackly +10 -6
- package/contracts/trackly-apply-contract-history.json +8 -1
- package/contracts/trackly-apply-tools.json +15 -4
- package/docs/trackly-tools.md +12 -0
- package/lib/agent.js +2 -2
- package/lib/apply-tab-set.js +124 -0
- package/lib/apply-upload.js +66 -0
- package/lib/client.js +194 -8
- package/mcp/analytics.js +903 -0
- package/mcp/apply-tools.js +322 -8
- package/mcp/server.js +123 -8
- package/npm-shrinkwrap.json +56 -3
- package/package.json +4 -2
- package/server.json +2 -2
- package/skills/trackly-apply/SKILL.md +12 -4
- package/skills/trackly-apply/references/batch-orchestration.md +34 -0
- package/skills/trackly-apply/references/browser-lifecycle.md +14 -0
- package/skills/trackly-apply/references/browser-upload.md +40 -8
- package/skills/trackly-apply/references/review-handoff.md +87 -0
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
|
|
492
|
-
// other persisted settings survive a token expiry
|
|
493
|
-
//
|
|
494
|
-
try {
|
|
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 {
|
|
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
|
|
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
|
};
|