trackly-cli 0.2.1 → 0.2.3

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/bin/trackly CHANGED
@@ -466,13 +466,30 @@ async function cmdStats() {
466
466
  }
467
467
 
468
468
  const ACTION_TO_COMMAND = { applied: 'apply', saved: 'save', dismissed: 'dismiss' };
469
+
470
+ // CLI surface accepts applied|saved|dismissed (legacy human-friendly terms).
471
+ // Backend `/api/jobscout/tracker/jobs/:id/stage` expects stage values:
472
+ // applied → applied, saved → backlog, dismissed → discarded
473
+ // The old `/api/jobscout-tracker/status` endpoint was removed; the new stage
474
+ // endpoint is what production actually serves (and what hosted MCP uses).
475
+ const ACTION_TO_STAGE = { applied: 'applied', saved: 'backlog', dismissed: 'discarded' };
476
+
469
477
  async function cmdUpdateStatus(id, action) {
470
478
  const jobId = parseJobId(id);
471
479
  if (jobId === null) failNow(`Job ID must be a positive integer. Usage: trackly ${ACTION_TO_COMMAND[action] || action} <id>`);
472
480
  if (!hasAuth()) failNow(AUTH_HELP_TEXT);
473
481
 
482
+ // action is already validated by the command dispatcher (apply/save/dismiss subcommands
483
+ // pass literal strings). No `|| action` fallback — if ACTION_TO_STAGE is ever widened
484
+ // without updating the dispatcher (or vice versa), fail loud rather than silently POST
485
+ // an unintended stage value (e.g., posting stage='snoozed' when backend only knows the
486
+ // stage column's allowlist). Mirrors the defensive throw in mcp/server.js.
487
+ const stage = ACTION_TO_STAGE[action];
488
+ if (!stage) {
489
+ failNow(`Internal error: unknown action "${action}" — expected applied|saved|dismissed.`);
490
+ }
474
491
  try {
475
- const result = await apiRequest('POST', '/api/jobscout-tracker/status', { jobId, action });
492
+ const result = await apiRequest('POST', `/api/jobscout/tracker/jobs/${jobId}/stage`, { stage });
476
493
  if (isJSON()) return printJson(result);
477
494
  console.log(color('green', `Job ${jobId} marked as ${STATUS_LABELS[action]}.`));
478
495
  } catch (e) {
@@ -767,7 +784,7 @@ ${color('bold', 'Filter values:')}
767
784
  --remote Flag (no value). Filters for remote jobs (equivalent to usStates=REMOTE).
768
785
  --status new, applying, applied_confirmed, check_later, not_interested, all
769
786
  (filters by YOUR application pipeline state)
770
- --sort newest, oldest, company
787
+ --sort newest (default), match (highest match score first, requires resume)
771
788
 
772
789
  ${color('bold', 'MCP Setup:')}
773
790
  Claude Code: claude mcp add --scope user trackly -- trackly mcp
@@ -790,6 +807,29 @@ async function main() {
790
807
 
791
808
  if (args.command !== 'config' && args['api-key']) {
792
809
  process.env.TRACKLY_API_KEY = String(args['api-key']);
810
+ // Warn once per invocation: --api-key on the command line is visible in `ps auxww` to
811
+ // any local user AND lands in shell history (~/.zsh_history, ~/.bash_history). This
812
+ // warning is a nudge, not a block — scripted pipelines can suppress via TRACKLY_NO_WARN=1.
813
+ // Printed to stderr (not stdout) so `--json` consumers parsing stdout are unaffected.
814
+ if (process.env.TRACKLY_NO_WARN !== '1') {
815
+ process.stderr.write(
816
+ '\x1b[33mWarning:\x1b[0m --api-key on the command line is visible in `ps` and shell history.\n' +
817
+ ' Prefer: TRACKLY_API_KEY=trk_... trackly ... (env var)\n' +
818
+ ' or: trackly config --api-key trk_... (persisted to ~/.trackly/config.json, mode 0600)\n' +
819
+ ' Silence this warning: TRACKLY_NO_WARN=1\n'
820
+ );
821
+ }
822
+ // Scrub the flag value from argv so subsequent inspection (diagnostics, crash reporters,
823
+ // child processes that inherit argv) cannot recover the secret. The `ps` vector is
824
+ // already lost the moment the process started — this only protects against in-process
825
+ // leaks (e.g. an MCP tool accidentally logging process.argv).
826
+ for (let i = 0; i < process.argv.length; i++) {
827
+ if (process.argv[i] === '--api-key' && i + 1 < process.argv.length) {
828
+ process.argv[i + 1] = '***';
829
+ } else if (process.argv[i].startsWith('--api-key=')) {
830
+ process.argv[i] = '--api-key=***';
831
+ }
832
+ }
793
833
  }
794
834
 
795
835
  if (args.command !== 'config' && args['base-url']) {
@@ -75,7 +75,7 @@ You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
75
75
  - `jobModality`: `full_time`, `internship`, `all` — employment type, NOT work-location style. For remote/hybrid/onsite filtering, use the `remote` boolean or `locationFilter: "remote"`; hybrid and onsite are not exposed as filters.
76
76
  - `remote` (boolean): filter to remote jobs only. Maps to `usStates=REMOTE`.
77
77
  - `status`: your application pipeline state. Values: `new`, `applying`, `applied_confirmed`, `check_later`, `not_interested`, `all`.
78
- - `sort`: `newest`, `oldest`, `company`.
78
+ - `sort`: `newest` (default) or `match` (highest match score first; requires a resume on file). Backend rejects the deprecated values `oldest` and `company` with HTTP 400.
79
79
  - `keywords`, `companyId`, `limit`, `offset`.
80
80
  - **trackly_get_job** — Get full job details by ID
81
81
  - **trackly_search_companies** — Semantic company search
package/lib/client.js CHANGED
@@ -54,7 +54,20 @@ function ensureConfigDir() {
54
54
  function saveConfig(config) {
55
55
  ensureConfigDir();
56
56
  const { file } = getConfigPaths();
57
- fs.writeFileSync(file, JSON.stringify(config, null, 2), { mode: 0o600 });
57
+ // Atomic write: stage to a sibling temp file (same dir → same filesystem → rename is atomic
58
+ // on POSIX) then rename into place. Without this, a crash or SIGKILL mid-write can leave
59
+ // a zero-byte or partial config.json, and the next CLI invocation silently loses all auth.
60
+ // Temp file is created mode 0600 so tokens are never world-readable, even briefly.
61
+ const tmp = `${file}.tmp.${process.pid}`;
62
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 });
63
+ try { fs.chmodSync(tmp, 0o600); } catch (e) {}
64
+ try {
65
+ fs.renameSync(tmp, file);
66
+ } catch (e) {
67
+ // Best-effort cleanup of the temp file if rename fails; re-raise the original error.
68
+ try { fs.unlinkSync(tmp); } catch (_) {}
69
+ throw e;
70
+ }
58
71
  try { fs.chmodSync(file, 0o600); } catch (e) {}
59
72
  }
60
73
 
@@ -63,6 +76,28 @@ function clearConfig() {
63
76
  try { fs.unlinkSync(file); } catch (e) {}
64
77
  }
65
78
 
79
+ // Surgical alternative to clearConfig() for the refresh-failure path. Deletes ONLY the
80
+ // OAuth credentials (token + refreshToken) while preserving everything else on disk
81
+ // (baseUrl, apiKey, timeout overrides, future keys). A refresh token being invalidated
82
+ // is not a reason to reset the user's `trackly config --base-url` choice — use this
83
+ // instead of clearConfig() when the only thing known to be dead is the session pair.
84
+ // Bug caught by Cursor Bugbot on PR #20: clearConfig() in refresh failure paths
85
+ // silently wiped baseUrl alongside the dead tokens.
86
+ function clearOAuthTokens() {
87
+ const config = loadConfig();
88
+ delete config.token;
89
+ delete config.refreshToken;
90
+ // If nothing else is persisted (e.g. user only ever logged in via OAuth and never set
91
+ // a custom baseUrl), unlink the file so getConfigPaths().file doesn't linger as an
92
+ // empty {} on disk. Otherwise preserve the remaining keys.
93
+ if (Object.keys(config).length === 0) {
94
+ const { file } = getConfigPaths();
95
+ try { fs.unlinkSync(file); } catch (e) {}
96
+ return;
97
+ }
98
+ saveConfig(config);
99
+ }
100
+
66
101
  function getBaseUrl() {
67
102
  return process.env.TRACKLY_BASE_URL || loadConfig().baseUrl || DEFAULT_BASE_URL;
68
103
  }
@@ -169,7 +204,25 @@ async function refreshAccessToken() {
169
204
  saveConfig(config);
170
205
  return result.token;
171
206
  }
172
- } catch (e) {}
207
+ // Backend responded 2xx but didn't return a token (e.g. { success: false, code: 'invalid_grant' }).
208
+ // Treat as an invalidated session and purge ONLY the OAuth pair so we don't retry on
209
+ // every subsequent 401. Without this, a backend that signals failure via JSON instead of
210
+ // a 4xx status would leave the user stuck in a doomed-refresh loop forever.
211
+ // Use clearOAuthTokens() — NOT clearConfig() — so the user's baseUrl, apiKey, and any
212
+ // other persisted settings survive a token expiry. (Bug caught by Cursor Bugbot on
213
+ // PR #20 post-review: the earlier clearConfig() call silently wiped baseUrl too.)
214
+ try { clearOAuthTokens(); } catch (_) {}
215
+ } catch (e) {
216
+ // A 401/invalid_grant on refresh means the token is permanently dead (rotation, manual
217
+ // revocation, or session expired). Keeping it on disk makes every subsequent request
218
+ // trigger another doomed refresh attempt — the user looks perpetually broken until they
219
+ // `trackly logout`. Network errors (ECONNREFUSED, DNS) are transient and should NOT
220
+ // purge the token. Surgical OAuth-only clear preserves baseUrl + apiKey + timeout overrides.
221
+ const status = e && typeof e.status === 'number' ? e.status : null;
222
+ if (status === 400 || status === 401 || status === 403) {
223
+ try { clearOAuthTokens(); } catch (_) {}
224
+ }
225
+ }
173
226
  return null;
174
227
  }
175
228
 
@@ -227,7 +280,12 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
227
280
  }
228
281
 
229
282
  if (res.statusCode >= 400) {
230
- reject({ status: res.statusCode, ...json });
283
+ // Spread `json` FIRST so the HTTP statusCode always wins. A backend that returns
284
+ // `{"status": "error"}` in the body would otherwise clobber the numeric status with
285
+ // a string, breaking downstream branches like `typeof e.status === 'number'` in
286
+ // refreshAccessToken — which would silently skip the 400/401/403 token-clear and
287
+ // leave dead refresh tokens on disk. (Cursor Bugbot finding #5 on PR #20.)
288
+ reject({ ...json, status: res.statusCode });
231
289
  } else {
232
290
  resolve(json);
233
291
  }
@@ -250,18 +308,21 @@ module.exports = {
250
308
  TracklyConfigError,
251
309
  apiRequest,
252
310
  clearConfig,
311
+ clearOAuthTokens,
253
312
  ensureSecureUrl,
254
313
  getApiKey,
255
314
  getAuthHeader,
256
315
  getAuthMethod,
257
316
  getBaseUrl,
258
317
  getConfigPaths,
318
+ getRefreshToken,
259
319
  getRequestHeaders,
260
320
  getRequestTimeoutMs,
261
321
  getToken,
262
322
  hasAuth,
263
323
  loadConfig,
264
324
  normalizeEndpoint,
325
+ refreshAccessToken,
265
326
  saveConfig,
266
327
  CONFIG_DIR: DEFAULT_CONFIG_DIR,
267
328
  CONFIG_FILE: path.join(DEFAULT_CONFIG_DIR, 'config.json'),
package/mcp/server.js CHANGED
@@ -36,6 +36,16 @@ const STATUS_VALUES = ['new', 'applying', 'applied_confirmed', 'check_later', 'n
36
36
  // `jobModality` enum matches `jobscout.ts:2870-2875`. Employment type, NOT work-location.
37
37
  const JOB_MODALITIES = ['full_time', 'internship', 'all'];
38
38
 
39
+ // `sort` enum matches backend handler at `jobscout.ts:3053` — NOT the pre-fix
40
+ // `newest|oldest|company` (backend rejects oldest/company with HTTP 400).
41
+ const SORT_VALUES = ['newest', 'match'];
42
+
43
+ // Maps the user-facing trackly_update_status action to the backend's tracker
44
+ // stage column. Backend `/api/jobscout/tracker/jobs/:id/stage` expects the
45
+ // stage value, NOT the legacy action name. Hoisted to module scope so it's not
46
+ // rebuilt on every tool invocation.
47
+ const ACTION_TO_STAGE = { applied: 'applied', saved: 'backlog', dismissed: 'discarded' };
48
+
39
49
  function createErrorResult(error, fallbackMessage, extra = {}) {
40
50
  const payload = {
41
51
  error: error?.error || error?.message || fallbackMessage,
@@ -111,7 +121,7 @@ function createServer() {
111
121
  status: z.enum(STATUS_VALUES).optional().describe(
112
122
  'Filter by YOUR application pipeline state. Not a generic job-posting status. Values: ' + STATUS_VALUES.join(', ')
113
123
  ),
114
- sort: z.enum(['newest', 'oldest', 'company']).optional().describe('Sort order: newest, oldest, company'),
124
+ sort: z.enum(SORT_VALUES).optional().describe('Sort order: newest (default) or match (highest match score first; requires resume). Backend rejects legacy oldest/company with HTTP 400.'),
115
125
  limit: z.number().max(50).optional().describe('Max results (default 20, max 50)'),
116
126
  offset: z.number().min(0).optional().describe('Pagination offset'),
117
127
  keywords: z.string().max(500).optional().describe('Keyword search in title, company, or description'),
@@ -194,7 +204,16 @@ function createServer() {
194
204
  action: z.enum(['applied', 'saved', 'dismissed']).describe('Status action'),
195
205
  },
196
206
  wrapTool(async ({ id, action }) => {
197
- return apiRequest('POST', '/api/jobscout-tracker/status', { jobId: id, action }, false, false, MCP_USER_AGENT);
207
+ // Backend expects the stage name, not the human-friendly action name.
208
+ // ACTION_TO_STAGE is defined at module scope (mirrors the same map in bin/trackly).
209
+ const stage = ACTION_TO_STAGE[action];
210
+ if (!stage) {
211
+ // Defensive: the z.enum above already rejects values outside applied|saved|dismissed,
212
+ // but if the enum is ever widened the mapping must be updated in lockstep — fail loud
213
+ // rather than silently sending an unintended stage.
214
+ throw new Error(`trackly_update_status: unknown action "${action}" — expected applied|saved|dismissed`);
215
+ }
216
+ return apiRequest('POST', `/api/jobscout/tracker/jobs/${id}/stage`, { stage }, false, false, MCP_USER_AGENT);
198
217
  }, 'Failed to update job status')
199
218
  );
200
219
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "mcpName": "io.github.kevinastuhuaman/trackly",
5
5
  "description": "AI job search CLI + hosted MCP server with OAuth. 128K+ jobs, 1,900+ companies, 40+ ATS. Works with Claude, ChatGPT, Cursor, Windsurf, Codex via hosted streamable-http or local stdio.",
6
6
  "bin": {
package/server.json CHANGED
@@ -17,12 +17,12 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "0.2.1",
20
+ "version": "0.2.3",
21
21
  "packages": [
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "trackly-cli",
25
- "version": "0.2.1",
25
+ "version": "0.2.3",
26
26
  "runtimeHint": "npx",
27
27
  "runtimeArguments": [
28
28
  {