@trawlme/cli 1.15.0 → 1.17.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.
package/README.md CHANGED
@@ -26,27 +26,43 @@ Custom API URL: `trawl login --url https://self-hosted.example.com`
26
26
 
27
27
  ## Commands
28
28
 
29
+ All commands accept a global `--debug` flag to show full error stack traces on failure.
30
+
29
31
  ### Auth
30
32
 
31
33
  ```
32
34
  trawl login [--url <url>] [--token <jwt>] [--email <email>] [--password <pass>]
33
35
  trawl logout
36
+ trawl token Print the stored session JWT (for MCP Bearer auth)
34
37
  ```
35
38
 
36
39
  ### Scraps
37
40
 
38
41
  ```
39
- trawl scraps list [--json] [--status <success|failure|never>]
42
+ trawl scraps list [--json] [--status <success|failure|never>] [--limit <n>] [--page <n>]
40
43
  trawl scraps get <id> [--json]
41
- trawl scraps create -t <title> [-u <url>] [-r <request>] [-s <scrapper>]
42
- trawl scraps update <id> [-t title] [-u <url>] [-r request] [-s scrapper] [--cron <expr>|--no-cron] [--alert <email>|--no-alert] [-p <json>|--params-file <path>]
44
+ trawl scraps create -t <title> [-u <url>] [-r <request>] [-d <description>] [--tier <tier0|tier1|tier2|tier3|tier4>]
45
+ trawl scraps update <id> [-t <title>] [-u <url>] [-r <request>] [-d <description>] [--cron <expr>|--no-cron] [--alert <email>|--no-alert] [--autofix|--no-autofix] [-p <json>|--params-file <path>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--force-tier <tier0|tier1|tier2|tier3|tier4>]
43
46
  trawl scraps run <id> [--watch]
44
- trawl scraps trigger <id> [--watch]
47
+ trawl scraps trigger <id> [--watch] [--wait]
45
48
  trawl scraps watch <id>
46
- trawl scraps data <id> [--json]
49
+ trawl scraps data <id> [--json] [--fresh] [--errors]
50
+ trawl scraps history <id> [--json] [-n <limit>]
51
+ trawl scraps run-info <hid> [--json]
52
+ trawl scraps doctor <id> [--json] [--autofix]
53
+ trawl scraps autofix <id> [--json]
54
+ trawl scraps snapshot <id> [--error] [-o <file>]
55
+ trawl scraps banner <id> -f <file>
47
56
  trawl scraps rm <id> [--force]
48
57
  ```
49
58
 
59
+ - `--tier` forces a proxy tier; `--force-tier` raises the proxy-tier ceiling past the auto-cap (history-gated: may be refused or cost more).
60
+ - `scraps doctor` diagnoses the last run (error, failed selector, block status, page state, autofix outcome); `--autofix` includes the full autofix diff/dry-run/knowledge.
61
+ - `scraps autofix` shows the last auto-fix attempt on its own (decision, diff, dry-run, knowledge).
62
+ - `scraps snapshot --error` fetches the error-path snapshot instead of the normal one; `-o <file>` writes to a file instead of stdout.
63
+ - `scraps history` lists past runs (newest first); `scraps run-info <hid>` shows details of a single run from that history.
64
+ - `scraps data` returns the last persisted run payload (no execute quota); `--fresh` runs the scrap live instead (consumes execute quota); `--errors` shows the last run's error detail.
65
+
50
66
  ### Scrap accounts
51
67
 
52
68
  ```
@@ -54,8 +70,11 @@ trawl scraps account set <id> [-u <username>] [-p <password>]
54
70
  trawl scraps account delete <id> [--force]
55
71
  trawl scraps account clear-session <id>
56
72
  trawl scraps account status <id> [--json]
73
+ trawl scraps account session set <id> -c <file>
57
74
  ```
58
75
 
76
+ `account session set` uploads a Puppeteer cookie JSON array to bootstrap a logged-in session without storing credentials (BYO-cookies).
77
+
59
78
  ### Claude Code skills
60
79
 
61
80
  The CLI bundles a Claude Code skill that teaches Claude how to use `trawl`. Once installed, Claude can manage scraps for you via prompts.
@@ -117,9 +136,15 @@ TRAWL_TELEMETRY=0 trawl scraps list
117
136
  | Variable | Description |
118
137
  |--------------------|-------------------------------------------------------------------|
119
138
  | `TRAWL_TOKEN` | JWT token — bypasses login prompt, useful for CI/CD |
120
- | `TRAWL_API_URL` | Override the default API URL (`https://api.trawl.me`) |
139
+ | `TRAWL_API_URL` | Override the API base URL for the session (takes precedence over `trawl login --url`) |
121
140
  | `TRAWL_TELEMETRY` | Set to `0` to disable telemetry for the current session |
122
141
 
142
+ Session override (no `trawl login` mutation, ideal for CI/QA against another env):
143
+
144
+ ```bash
145
+ TRAWL_API_URL=https://dev.trawl.me TRAWL_TOKEN=<jwt> trawl scraps list --json
146
+ ```
147
+
123
148
  ## API documentation
124
149
 
125
150
  The Trawl API is documented at [trawl.me/api/spec.json](https://trawl.me/api/spec.json) (OpenAPI 3).
@@ -25,6 +25,7 @@ export interface Run {
25
25
  selectors?: Record<string, number>;
26
26
  } | null;
27
27
  blocked?: boolean;
28
+ blockType?: string | null;
28
29
  proxyTier?: string | null;
29
30
  regressionDetected?: boolean;
30
31
  baselineLength?: number | null;
@@ -33,6 +34,14 @@ export interface Run {
33
34
  time?: number | null;
34
35
  triggeredBy?: string | null;
35
36
  }
37
+ /**
38
+ * Resolve a known anti-bot vendor (or auth) name from a worker `blockType`
39
+ * string. Returns null when the run isn't blocked, or when it's blocked by
40
+ * something other than a known vendor (e.g. `proxy-domain-gate`,
41
+ * `rate_limited_per_host`) — those stay on the genuine-error path since we
42
+ * can't honestly attribute them to a specific "no reliable bypass" wall.
43
+ */
44
+ export declare function detectWallVendor(run: Pick<Run, 'blocked' | 'blockType'>): string | null;
36
45
  /**
37
46
  * Autofix activity metadata — from the persisted ai_fix_end activity.
38
47
  * aiUsage (cost) is stripped server-side; all diagnostics are kept.
@@ -1,5 +1,48 @@
1
1
  import { api } from '../lib/api.js';
2
2
  import chalk from 'chalk';
3
+ /**
4
+ * Known anti-bot vendors (+ auth) that the worker's `blockType` field may name.
5
+ * Ordered by first-match; `blockType` is a freeform worker string, not an enum
6
+ * (see the `Run.blockType` doc comment), so this is a best-effort substring
7
+ * match against the real field — never an invented/mocked value.
8
+ *
9
+ * `datadome`/`perimeterx`/`akamai`/`cloudflare` are literal substrings the
10
+ * current worker emits (detect.js). `kasada` and `auth` are forward-compatible:
11
+ * the issue (#62 / wall-registry WS-7) names them as genuine walls, but the
12
+ * current worker vocabulary does not yet emit them — these branches light up
13
+ * automatically if/when a future worker classifier does, without a CLI change.
14
+ *
15
+ * The `auth` pattern is delimiter-anchored (`^`/`$`/`-_:`) rather than a bare
16
+ * substring so it matches worker-style tokens (`auth`, `auth-wall`, `auth_wall`,
17
+ * `login:auth`) without false-matching `oauth`/`authorization`/`author`. It uses
18
+ * `[-_:]` (not `\b`) because `_` is a JS `\w` char, so `\bauth\b` would miss the
19
+ * underscore-delimited `auth_wall` form the worker's `rate_limited_per_host`-style
20
+ * naming favors.
21
+ */
22
+ const WALL_VENDOR_PATTERNS = [
23
+ [/datadome/i, 'DataDome'],
24
+ [/kasada/i, 'Kasada'],
25
+ [/perimeterx/i, 'PerimeterX'],
26
+ [/akamai/i, 'Akamai'],
27
+ [/cloudflare/i, 'Cloudflare'],
28
+ [/(?:^|[-_:])auth(?:$|[-_:])/i, 'auth'],
29
+ ];
30
+ /**
31
+ * Resolve a known anti-bot vendor (or auth) name from a worker `blockType`
32
+ * string. Returns null when the run isn't blocked, or when it's blocked by
33
+ * something other than a known vendor (e.g. `proxy-domain-gate`,
34
+ * `rate_limited_per_host`) — those stay on the genuine-error path since we
35
+ * can't honestly attribute them to a specific "no reliable bypass" wall.
36
+ */
37
+ export function detectWallVendor(run) {
38
+ if (run.blocked !== true || !run.blockType)
39
+ return null;
40
+ for (const [pattern, label] of WALL_VENDOR_PATTERNS) {
41
+ if (pattern.test(run.blockType))
42
+ return label;
43
+ }
44
+ return null;
45
+ }
3
46
  const TIER_LABELS = {
4
47
  tier0: 'Tier 0',
5
48
  tier1: 'Tier 1',
@@ -9,7 +52,7 @@ const TIER_LABELS = {
9
52
  };
10
53
  const RUN_ALLOWLIST = [
11
54
  '_id', 'status', 'statusDetail', 'length', 'errorMessage', 'errorSnapshot',
12
- 'emptyContext', 'blocked', 'proxyTier', 'regressionDetected', 'baselineLength',
55
+ 'emptyContext', 'blocked', 'blockType', 'proxyTier', 'regressionDetected', 'baselineLength',
13
56
  'fixVersionId', 'createdAt', 'time', 'triggeredBy',
14
57
  ];
15
58
  const FIX_ALLOWLIST = [
@@ -49,9 +92,15 @@ export function formatDoctor(scrapTitle, run, fix = null, scrapId) {
49
92
  : chalk.red('● failed');
50
93
  lines.push(`${chalk.bold(scrapTitle)} ${badge}${run.statusDetail ? ` (${run.statusDetail})` : ''}`);
51
94
  lines.push(chalk.dim(` Run ID: ${run._id}`));
52
- // Error message
95
+ // Error message — an honest accept-wall string for known-walled scraps
96
+ // (DataDome/Kasada/PerimeterX/Akamai/auth terminal verdict from the worker),
97
+ // otherwise the real error (genuine transient failure).
98
+ const wallVendor = detectWallVendor(run);
53
99
  const errMsg = run.errorMessage ?? run.errorSnapshot?.errorMessage;
54
- if (errMsg) {
100
+ if (wallVendor) {
101
+ lines.push(chalk.dim(' Error: ') + chalk.red(`walled: ${wallVendor} — no reliable bypass`));
102
+ }
103
+ else if (errMsg) {
55
104
  lines.push(chalk.dim(' Error: ') + chalk.red(errMsg));
56
105
  }
57
106
  // Failed selector
@@ -1,15 +1,24 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import config from '../lib/config.js';
3
+ import config, { getApiUrl } from '../lib/config.js';
4
4
  import { api } from '../lib/api.js';
5
- import { requireJwt, requireUrl } from '../lib/validate.js';
5
+ import { requireFreshJwt, requireUrl } from '../lib/validate.js';
6
6
  import { promptPassword } from '../lib/prompt.js';
7
7
  async function promptEmail() {
8
8
  const { createInterface } = await import('readline');
9
9
  const rl = createInterface({ input: process.stdin, output: process.stdout });
10
10
  try {
11
- return await new Promise((resolve) => {
12
- rl.question('Email: ', (answer) => resolve(answer.trim()));
11
+ return await new Promise((resolve, reject) => {
12
+ let answered = false;
13
+ rl.question('Email: ', (answer) => {
14
+ answered = true;
15
+ resolve(answer.trim());
16
+ });
17
+ rl.on('close', () => {
18
+ if (!answered) {
19
+ reject(new Error('No input received — pass -e/-p or set TRAWL_TOKEN for non-interactive use'));
20
+ }
21
+ });
13
22
  });
14
23
  }
15
24
  finally {
@@ -23,22 +32,32 @@ export const login = new Command('login')
23
32
  .option('-e, --email <email>', 'Email address')
24
33
  .option('-p, --password <password>', 'Password (CI only — visible in process list and shell history)')
25
34
  .action(async (opts) => {
26
- if (opts.url)
27
- config.set('apiUrl', requireUrl(opts.url, '--url'));
35
+ // Validate --url but do NOT persist it yet. A failed signin must not
36
+ // brick the config by pointing it at an unreachable/wrong host while the
37
+ // OLD token stays stored (and would then be sent to that new host on the
38
+ // next command). Target this run via `pendingUrl` and persist only once
39
+ // a token has actually been obtained. (#68)
40
+ const pendingUrl = opts.url ? requireUrl(opts.url, '--url') : undefined;
41
+ const persistUrlIfPending = () => {
42
+ if (pendingUrl)
43
+ config.set('apiUrl', pendingUrl);
44
+ };
28
45
  // Check environment variable override first
29
46
  const envToken = process.env['TRAWL_TOKEN'];
30
47
  if (envToken) {
31
- config.set('token', requireJwt(envToken, 'TRAWL_TOKEN'));
48
+ config.set('token', requireFreshJwt(envToken, 'TRAWL_TOKEN'));
49
+ persistUrlIfPending();
32
50
  console.log(chalk.green('✓ Logged in'));
33
- console.log(chalk.dim(` API: ${config.get('apiUrl')}`));
51
+ console.log(chalk.dim(` API: ${getApiUrl()}`));
34
52
  console.log(chalk.dim(` Config: ${config.path}`));
35
53
  return;
36
54
  }
37
55
  // --token flag: direct JWT (CI / retrocompat)
38
56
  if (opts.token) {
39
- config.set('token', requireJwt(opts.token, '--token'));
57
+ config.set('token', requireFreshJwt(opts.token, '--token'));
58
+ persistUrlIfPending();
40
59
  console.log(chalk.green('✓ Logged in'));
41
- console.log(chalk.dim(` API: ${config.get('apiUrl')}`));
60
+ console.log(chalk.dim(` API: ${getApiUrl()}`));
42
61
  console.log(chalk.dim(` Config: ${config.path}`));
43
62
  return;
44
63
  }
@@ -48,7 +67,7 @@ export const login = new Command('login')
48
67
  }
49
68
  const email = opts.email ?? (await promptEmail());
50
69
  const password = opts.password ?? (await promptPassword('Password: '));
51
- const { data, headers } = await api.publicPost('/api/auth/signin', { email, password });
70
+ const { data, headers } = await api.publicPost('/api/auth/signin', { email, password }, pendingUrl);
52
71
  // Try token from response body first, then fall back to Set-Cookie header
53
72
  let raw = typeof data === 'string' ? data : data.token;
54
73
  if (!raw) {
@@ -60,10 +79,11 @@ export const login = new Command('login')
60
79
  if (!raw || typeof raw !== 'string') {
61
80
  throw new Error(`Invalid response from API: no token received. Got: ${JSON.stringify(data)}`);
62
81
  }
63
- const token = requireJwt(raw, 'token');
82
+ const token = requireFreshJwt(raw, 'token');
64
83
  config.set('token', token);
84
+ persistUrlIfPending();
65
85
  console.log(chalk.green(`✓ Logged in as ${email}`));
66
- console.log(chalk.dim(` API: ${config.get('apiUrl')}`));
86
+ console.log(chalk.dim(` API: ${getApiUrl()}`));
67
87
  console.log(chalk.dim(` Config: ${config.path}`));
68
88
  });
69
89
  export const logout = new Command('logout')
@@ -178,6 +178,7 @@ scraps
178
178
  .option('-p, --params <json>', 'Runtime params as JSON array of objects (e.g. \'[{"TRAWL.paramName":"value"}]\')')
179
179
  .option('--params-file <path>', 'Runtime params from a JSON file')
180
180
  .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
181
+ .option('--force-tier <tier>', `Raise the proxy-tier ceiling PAST the auto-cap (${VALID_TIERS.join('|')}) — history-gated: may be refused or cost more`)
181
182
  .action(async (id, opts) => {
182
183
  validateObjectId(id);
183
184
  if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
@@ -185,6 +186,11 @@ scraps
185
186
  process.exitCode = 1;
186
187
  return;
187
188
  }
189
+ if (opts.forceTier !== undefined && !VALID_TIERS.includes(opts.forceTier)) {
190
+ console.log(chalk.red(`✗ Invalid --force-tier "${opts.forceTier}" (allowed: ${VALID_TIERS.join(', ')})`));
191
+ process.exitCode = 1;
192
+ return;
193
+ }
188
194
  const body = {};
189
195
  if (opts.title !== undefined)
190
196
  body.title = opts.title;
@@ -233,6 +239,12 @@ scraps
233
239
  }
234
240
  if (opts.tier !== undefined)
235
241
  body.proxyTier = opts.tier;
242
+ if (opts.forceTier !== undefined) {
243
+ // Raise the ceiling; also start the run at that tier unless --tier says otherwise.
244
+ body.proxyMaxTier = opts.forceTier;
245
+ if (opts.tier === undefined)
246
+ body.proxyTier = opts.forceTier;
247
+ }
236
248
  if (Object.keys(body).length === 0) {
237
249
  console.log(chalk.yellow('Nothing to update. Provide at least one option.'));
238
250
  return;
@@ -240,8 +252,41 @@ scraps
240
252
  const spinner = ora('Updating scrap…').start();
241
253
  const data = await api.put(`/api/scraps/${id}`, body);
242
254
  spinner.succeed(`Scrap updated: ${chalk.bold(data._id)}`);
255
+ // #1559 — surface the effective tier + clamp/refuse reason (fixes the
256
+ // silent-clamp: the server may persist a lower tier than requested).
257
+ const ov = data._tierOverride;
258
+ const shown = data;
243
259
  for (const key of Object.keys(body)) {
244
- console.log(chalk.dim(` ${key}: `) + String(data[key] ?? '—'));
260
+ // Only suppress the raw proxyTier/proxyMaxTier echo when _tierOverride is
261
+ // present to report the truth. Against an older server (no _tierOverride)
262
+ // fall back to echoing the requested value from the body, so the tier is
263
+ // never silently dropped (worse than a misleading echo).
264
+ if ((key === 'proxyTier' || key === 'proxyMaxTier') && ov)
265
+ continue;
266
+ const src = key in shown ? shown[key] : body[key];
267
+ console.log(chalk.dim(` ${key}: `) + String(src ?? '—'));
268
+ }
269
+ if (ov) {
270
+ if (ov.refused) {
271
+ console.log(chalk.red(` ✗ tier ceiling override refused: ${ov.reason ?? 'unknown'}`)
272
+ + chalk.dim(` (requested ${ov.requestedMaxTier ?? '—'}; kept the registry cap)`));
273
+ process.exitCode = 1;
274
+ }
275
+ else if (ov.effectiveMaxTier) {
276
+ console.log(chalk.green(` ✓ tier ceiling: ${ov.effectiveMaxTier}`)
277
+ + chalk.dim(` (${ov.reason ?? ''}${ov.provider ? `, ${ov.provider}` : ''})`));
278
+ if (ov.warning)
279
+ console.log(chalk.yellow(` ⚠ ${ov.warning}`));
280
+ }
281
+ if (ov.proxyTier) {
282
+ if (ov.proxyTier.clamped) {
283
+ console.log(chalk.yellow(` ⚠ proxyTier requested ${ov.proxyTier.requested} → applied ${ov.proxyTier.effective}`)
284
+ + chalk.dim(` (${ov.proxyTier.reason ?? 'capped'})`));
285
+ }
286
+ else {
287
+ console.log(chalk.dim(` proxyTier: `) + ov.proxyTier.effective);
288
+ }
289
+ }
245
290
  }
246
291
  });
247
292
  // run
@@ -258,15 +303,31 @@ scraps
258
303
  await watchActivities(id);
259
304
  }
260
305
  });
306
+ // #70 — render an items array either as a table summary or --json. Shared by
307
+ // both the default (persisted read) and --fresh (live execute) paths of `data`.
308
+ function renderScrapItems(items, asJson) {
309
+ if (asJson) {
310
+ json(items);
311
+ return;
312
+ }
313
+ console.log(chalk.bold('Last run data:'));
314
+ console.log(chalk.dim(` Items: ${items.length}`));
315
+ if (items.length > 0 && typeof items[0] === 'object' && items[0] !== null) {
316
+ console.log(chalk.dim(` First item keys: ${Object.keys(items[0]).join(', ')}`));
317
+ }
318
+ console.log(chalk.dim(' Use --json for full output.'));
319
+ }
261
320
  // data
262
321
  scraps
263
322
  .command('data <id>')
264
- .description('Get scrap data (latest run payload)')
323
+ .description('Get scrap data (last persisted run — read-only, no quota). Use --fresh to launch a new run instead.')
265
324
  .option('--json', 'Output as JSON')
266
325
  .option('--errors', 'Show failure diagnostics when the last run failed')
326
+ .option('--fresh', 'Launch a fresh run instead of reading the last persisted payload (consumes execute quota, same as `scraps run`)')
267
327
  .action(async (id, opts) => {
268
328
  validateObjectId(id);
269
- // --errors: fetch full run + fix detail via fetchRunAndFix (DRY with doctor)
329
+ // --errors: fetch full run + fix detail via fetchRunAndFix (DRY with doctor).
330
+ // Read-only: GET /api/scraps/:id + GET /api/historys/:hid, no quota, no run lock.
270
331
  if (opts.errors) {
271
332
  const result = await fetchRunAndFix(id);
272
333
  if (!result) {
@@ -282,20 +343,53 @@ scraps
282
343
  console.log(formatDoctor(result.scrap.title, result.run, result.fix, id));
283
344
  return;
284
345
  }
285
- const loaded = await api.get(`/api/scraps/load/${id}`);
286
- const items = loaded?.result?.data;
287
- if (!Array.isArray(items)) {
288
- console.log(chalk.dim('No data yet. Run the scrap first.'));
346
+ // #70 --fresh is the explicit opt-in for a LIVE run. This is what the
347
+ // default path used to do silently: GET /api/scraps/load/:id executes a
348
+ // fresh scrap run server-side (requireQuota('scraps','execute') + a
349
+ // distributed run lock trawl_node modules/scraps/routes/scraps.routes.js),
350
+ // burning execute quota and 429ing if a run is already in flight. A user
351
+ // or agent "just reading data" must never trigger that by accident.
352
+ if (opts.fresh) {
353
+ const spinner = ora('Launching a fresh scrap run (consumes execute quota)…').start();
354
+ const loaded = await api.get(`/api/scraps/load/${id}`);
355
+ spinner.succeed('Fresh run complete');
356
+ const items = loaded?.result?.data;
357
+ if (!Array.isArray(items)) {
358
+ console.log(chalk.dim('No data yet. Run the scrap first.'));
359
+ return;
360
+ }
361
+ renderScrapItems(items, opts.json);
289
362
  return;
290
363
  }
291
- if (opts.json)
292
- return json(items);
293
- console.log(chalk.bold('Last run data:'));
294
- console.log(chalk.dim(` Items: ${items.length}`));
295
- if (items.length > 0 && typeof items[0] === 'object' && items[0] !== null) {
296
- console.log(chalk.dim(` First item keys: ${Object.keys(items[0]).join(', ')}`));
364
+ // Default: read the last PERSISTED run payload — no quota, no run lock.
365
+ // History.data (trawl_node modules/historys/services/historys.service.js
366
+ // `update()`) is a JSON-stringified clone of the worker result, so it
367
+ // carries the same `.data` items array the live load endpoint returns —
368
+ // it's just pulled from the most recent history row instead of a fresh
369
+ // run. Retention keeps this only for the newest row per (scrap, status)
370
+ // bucket (config.trawl.keepData, default 1); older rows null it out.
371
+ const scrap = await api.get(`/api/scraps/${id}`);
372
+ const hid = scrap.history?.[0]?._id;
373
+ if (!hid) {
374
+ console.log(chalk.dim('No data yet. Run the scrap first, or pass --fresh to launch one now.'));
375
+ return;
297
376
  }
298
- console.log(chalk.dim(' Use --json for full output.'));
377
+ const detail = await api.get(`/api/historys/${hid}`);
378
+ let items;
379
+ if (typeof detail?.data === 'string' && detail.data) {
380
+ try {
381
+ items = JSON.parse(detail.data)?.data;
382
+ }
383
+ catch {
384
+ items = undefined;
385
+ }
386
+ }
387
+ if (!Array.isArray(items)) {
388
+ console.log(chalk.dim('No persisted data for the last run (it may have failed, or aged out of retention). '
389
+ + 'Pass --fresh to launch a new run (consumes execute quota).'));
390
+ return;
391
+ }
392
+ renderScrapItems(items, opts.json);
299
393
  });
300
394
  // history — list past runs for a scrap
301
395
  scraps
@@ -28,16 +28,20 @@ telemetry
28
28
  const enabled = config.get('telemetry') !== false;
29
29
  const userId = config.get('telemetryUserId') || '(not yet generated)';
30
30
  const envOverride = process.env['TRAWL_TELEMETRY'] === '0';
31
+ const doNotTrack = process.env['DO_NOT_TRACK'] === '1';
31
32
  console.log(chalk.bold('Telemetry status'));
32
33
  console.log(chalk.dim(' State: ') +
33
- (envOverride
34
- ? chalk.yellow('disabled (TRAWL_TELEMETRY=0 env var)')
35
- : enabled
36
- ? chalk.green('enabled')
37
- : chalk.yellow('disabled')));
34
+ (doNotTrack
35
+ ? chalk.yellow('disabled (DO_NOT_TRACK=1 env var)')
36
+ : envOverride
37
+ ? chalk.yellow('disabled (TRAWL_TELEMETRY=0 env var)')
38
+ : enabled
39
+ ? chalk.green('enabled')
40
+ : chalk.yellow('disabled')));
38
41
  console.log(chalk.dim(' Telemetry ID: ') + userId);
39
42
  console.log('');
40
43
  console.log(chalk.dim(' To opt out:'));
41
44
  console.log(chalk.dim(' trawl telemetry off'));
42
45
  console.log(chalk.dim(' TRAWL_TELEMETRY=0 (env var, disables for this session)'));
46
+ console.log(chalk.dim(' DO_NOT_TRACK=1 (cross-tool env var, disables for this session)'));
43
47
  });
@@ -1,25 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import config from '../lib/config.js';
4
- /**
5
- * Decode the exp claim from a JWT (middle segment, base64url encoded JSON).
6
- * Returns null if the payload cannot be decoded or has no exp field.
7
- */
8
- function decodeExp(jwt) {
9
- try {
10
- const parts = jwt.split('.');
11
- if (parts.length !== 3)
12
- return null;
13
- const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
14
- const parsed = JSON.parse(payload);
15
- if (typeof parsed.exp !== 'number')
16
- return null;
17
- return parsed.exp;
18
- }
19
- catch {
20
- return null;
21
- }
22
- }
4
+ import { decodeExp } from '../lib/jwt.js';
23
5
  export const token = new Command('token')
24
6
  .description('Print the stored session JWT (for MCP Bearer auth)')
25
7
  .action(() => {
@@ -47,11 +29,15 @@ export const token = new Command('token')
47
29
  const daysLeft = secsLeft / 86400;
48
30
  if (daysLeft < 1) {
49
31
  const hoursLeft = Math.floor(secsLeft / 3600);
50
- console.log(chalk.yellow(`⚠ Token expiring in ${hoursLeft}h. Run: trawl login to refresh.`));
32
+ // stderr, not stdout this is an advisory, not the payload. `trawl
33
+ // token` exists so callers can capture the raw JWT via
34
+ // `$(trawl token)` for `Authorization: Bearer …`; anything printed
35
+ // to stdout after the token corrupts that capture. (#68)
36
+ console.error(chalk.yellow(`⚠ Token expiring in ${hoursLeft}h. Run: trawl login to refresh.`));
51
37
  }
52
38
  else {
53
39
  const daysRounded = Math.floor(daysLeft);
54
- console.log(chalk.dim(` Expires in ${daysRounded}d. Renew with: trawl login`));
40
+ console.error(chalk.dim(` Expires in ${daysRounded}d. Renew with: trawl login`));
55
41
  }
56
42
  }
57
43
  });
package/dist/index.d.ts CHANGED
@@ -1,2 +1,20 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ import { Command } from 'commander';
3
+ /**
4
+ * Derive a safe telemetry event name from a *resolved* commander Command —
5
+ * NEVER from raw argv. Flag values (e.g. the string after --password/--email/
6
+ * --url) don't start with '-' and would otherwise survive an argv filter and
7
+ * leak to PostHog (#67). Falls back to 'unknown' when no command resolved
8
+ * (e.g. an error thrown before any action ran).
9
+ */
10
+ export declare function resolveCommandName(actionCommand: Command | undefined): string;
11
+ /**
12
+ * Walk the full command tree and produce every valid resolveCommandName()
13
+ * token — the allowlist registered with posthog.ts so captureCommand can
14
+ * never be handed a free-form string.
15
+ */
16
+ export declare function collectCommandNames(root: Command): string[];
17
+ export declare function createProgram(): Command;
18
+ /** True when this module is the process entrypoint (not merely imported by a test). */
19
+ export declare function isEntryPoint(argv1: string | undefined, moduleUrl: string): boolean;
20
+ export declare function runCli(argv?: string[]): Promise<void>;
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
4
  import { readFileSync } from 'node:fs';
5
- import { fileURLToPath } from 'node:url';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { login, logout } from './commands/login.js';
8
8
  import { scraps } from './commands/scraps.js';
@@ -10,49 +10,107 @@ import { skills } from './commands/skills.js';
10
10
  import { telemetry } from './commands/telemetry.js';
11
11
  import { token } from './commands/token.js';
12
12
  import { autoUpdateInstalledSkills } from './lib/skills.js';
13
- import { initPostHog, captureCommand, shutdown } from './lib/posthog.js';
14
- autoUpdateInstalledSkills();
15
- initPostHog();
13
+ import { initPostHog, captureCommand, shutdown, registerAllowedCommands } from './lib/posthog.js';
16
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
15
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
18
- const program = new Command()
19
- .name('trawl')
20
- .description('Trawl CLI manage scraps from the terminal')
21
- .version(pkg.version)
22
- .option('--debug', 'Show full error stack traces');
23
- // Track start times per command instance for duration measurement
24
- const startTimes = new WeakMap();
25
- program.hook('preAction', (thisCommand, actionCommand) => {
26
- startTimes.set(actionCommand, Date.now());
27
- });
28
- program.hook('postAction', (thisCommand, actionCommand) => {
29
- const start = startTimes.get(actionCommand);
30
- if (start !== undefined) {
31
- const name = actionCommand.parent
32
- ? `${actionCommand.parent.name()} ${actionCommand.name()}`
33
- : actionCommand.name();
34
- void captureCommand(name, { duration_ms: Date.now() - start, exit_code: 0 });
16
+ /**
17
+ * Derive a safe telemetry event name from a *resolved* commander Command —
18
+ * NEVER from raw argv. Flag values (e.g. the string after --password/--email/
19
+ * --url) don't start with '-' and would otherwise survive an argv filter and
20
+ * leak to PostHog (#67). Falls back to 'unknown' when no command resolved
21
+ * (e.g. an error thrown before any action ran).
22
+ */
23
+ export function resolveCommandName(actionCommand) {
24
+ if (!actionCommand)
25
+ return 'unknown';
26
+ return actionCommand.parent
27
+ ? `${actionCommand.parent.name()} ${actionCommand.name()}`
28
+ : actionCommand.name();
29
+ }
30
+ /**
31
+ * Walk the full command tree and produce every valid resolveCommandName()
32
+ * token the allowlist registered with posthog.ts so captureCommand can
33
+ * never be handed a free-form string.
34
+ */
35
+ export function collectCommandNames(root) {
36
+ const names = [];
37
+ const walk = (cmd) => {
38
+ for (const sub of cmd.commands) {
39
+ names.push(resolveCommandName(sub));
40
+ walk(sub);
41
+ }
42
+ };
43
+ walk(root);
44
+ return names;
45
+ }
46
+ export function createProgram() {
47
+ const program = new Command()
48
+ .name('trawl')
49
+ .description('Trawl CLI — manage scraps from the terminal')
50
+ .version(pkg.version)
51
+ .option('--debug', 'Show full error stack traces');
52
+ program.addCommand(login);
53
+ program.addCommand(logout);
54
+ program.addCommand(scraps);
55
+ program.addCommand(skills);
56
+ program.addCommand(telemetry);
57
+ program.addCommand(token);
58
+ return program;
59
+ }
60
+ /** True when this module is the process entrypoint (not merely imported by a test). */
61
+ export function isEntryPoint(argv1, moduleUrl) {
62
+ return argv1 !== undefined && moduleUrl === pathToFileURL(argv1).href;
63
+ }
64
+ export async function runCli(argv = process.argv) {
65
+ autoUpdateInstalledSkills();
66
+ initPostHog();
67
+ const program = createProgram();
68
+ registerAllowedCommands(collectCommandNames(program));
69
+ // Track start times + the currently-resolved command per instance, so the
70
+ // catch handler below can derive the exact same safe name the success path
71
+ // uses — it must never re-derive anything from argv.
72
+ const startTimes = new WeakMap();
73
+ let currentCommand;
74
+ program.hook('preAction', (_thisCommand, actionCommand) => {
75
+ currentCommand = actionCommand;
76
+ startTimes.set(actionCommand, Date.now());
77
+ });
78
+ program.hook('postAction', (_thisCommand, actionCommand) => {
79
+ const start = startTimes.get(actionCommand);
80
+ if (start !== undefined) {
81
+ // Actions can fail without throwing (process.exitCode set directly) —
82
+ // report the real outcome instead of hardcoding success.
83
+ const exitCode = typeof process.exitCode === 'number' ? process.exitCode : 0;
84
+ void captureCommand(resolveCommandName(actionCommand), {
85
+ duration_ms: Date.now() - start,
86
+ exit_code: exitCode,
87
+ });
88
+ }
89
+ });
90
+ try {
91
+ await program.parseAsync(argv);
35
92
  }
36
- });
37
- program.addCommand(login);
38
- program.addCommand(logout);
39
- program.addCommand(scraps);
40
- program.addCommand(skills);
41
- program.addCommand(telemetry);
42
- program.addCommand(token);
43
- process.on('exit', () => {
44
- void shutdown();
45
- });
46
- program.parseAsync().catch((err) => {
47
- // Capture error telemetry (best-effort: command name from process.argv)
48
- const name = process.argv.slice(2).filter((a) => !a.startsWith('-')).join(' ') || 'unknown';
49
- void captureCommand(name, { exit_code: 1, error: err.name });
50
- const { debug } = program.opts();
51
- if (debug || process.env['DEBUG']) {
52
- console.error(err);
93
+ catch (err) {
94
+ // Capture error telemetry from the resolved command only — never argv.
95
+ void captureCommand(resolveCommandName(currentCommand), {
96
+ exit_code: 1,
97
+ error: err.name,
98
+ });
99
+ const { debug } = program.opts();
100
+ if (debug || process.env['DEBUG']) {
101
+ console.error(err);
102
+ }
103
+ else {
104
+ console.error(chalk.red('✗ ' + err.message));
105
+ }
106
+ process.exitCode = 1;
53
107
  }
54
- else {
55
- console.error(chalk.red('' + err.message));
108
+ finally {
109
+ // Flush + close telemetry before the process exits. A `process.on('exit')`
110
+ // handler cannot reliably run async work, so this must happen here.
111
+ await shutdown();
56
112
  }
57
- process.exitCode = 1;
58
- });
113
+ }
114
+ if (isEntryPoint(process.argv[1], import.meta.url)) {
115
+ void runCli();
116
+ }
package/dist/lib/api.d.ts CHANGED
@@ -5,7 +5,7 @@ export declare const api: {
5
5
  put: <T>(path: string, body?: unknown) => Promise<T>;
6
6
  delete: <T>(path: string) => Promise<T>;
7
7
  upload: <T>(path: string, formData: FormData) => Promise<T>;
8
- publicPost: <T>(path: string, body?: unknown) => Promise<{
8
+ publicPost: <T>(path: string, body?: unknown, baseUrlOverride?: string) => Promise<{
9
9
  data: T;
10
10
  headers: Headers;
11
11
  }>;
package/dist/lib/api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, resolve } from 'node:path';
4
- import config from './config.js';
4
+ import { getApiUrl, getToken } from './config.js';
5
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
6
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
7
7
  const USER_AGENT = `@trawlme/cli/${pkg.version}`;
@@ -57,10 +57,10 @@ async function throwIfError(res, isPublic = false) {
57
57
  }
58
58
  }
59
59
  async function request(path, options = {}) {
60
- const token = config.get('token');
60
+ const token = getToken();
61
61
  if (!token)
62
62
  throw new Error('Not logged in. Run: trawl login');
63
- const url = `${config.get('apiUrl')}${path}`;
63
+ const url = `${getApiUrl()}${path}`;
64
64
  const res = await fetch(url, {
65
65
  ...options,
66
66
  headers: {
@@ -87,10 +87,10 @@ async function request(path, options = {}) {
87
87
  }
88
88
  }
89
89
  async function upload(path, formData) {
90
- const token = config.get('token');
90
+ const token = getToken();
91
91
  if (!token)
92
92
  throw new Error('Not logged in. Run: trawl login');
93
- const url = `${config.get('apiUrl')}${path}`;
93
+ const url = `${getApiUrl()}${path}`;
94
94
  // Do NOT set Content-Type — fetch sets it automatically with the correct multipart boundary
95
95
  const res = await fetch(url, {
96
96
  method: 'POST',
@@ -116,8 +116,8 @@ async function upload(path, formData) {
116
116
  throw new Error('Invalid JSON in server response');
117
117
  }
118
118
  }
119
- async function publicPost(path, body) {
120
- const url = `${config.get('apiUrl')}${path}`;
119
+ async function publicPost(path, body, baseUrlOverride) {
120
+ const url = `${baseUrlOverride ?? getApiUrl()}${path}`;
121
121
  const res = await fetch(url, {
122
122
  method: 'POST',
123
123
  headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
@@ -134,10 +134,10 @@ async function publicPost(path, body) {
134
134
  }
135
135
  }
136
136
  async function getText(path) {
137
- const token = config.get('token');
137
+ const token = getToken();
138
138
  if (!token)
139
139
  throw new Error('Not logged in. Run: trawl login');
140
- const url = `${config.get('apiUrl')}${path}`;
140
+ const url = `${getApiUrl()}${path}`;
141
141
  const res = await fetch(url, {
142
142
  headers: {
143
143
  'User-Agent': USER_AGENT,
@@ -160,12 +160,12 @@ export const api = {
160
160
  }),
161
161
  delete: (path) => request(path, { method: 'DELETE' }),
162
162
  upload: (path, formData) => upload(path, formData),
163
- publicPost: (path, body) => publicPost(path, body),
163
+ publicPost: (path, body, baseUrlOverride) => publicPost(path, body, baseUrlOverride),
164
164
  stream: async function* (path) {
165
- const token = config.get('token');
165
+ const token = getToken();
166
166
  if (!token)
167
167
  throw new Error('Not logged in. Run: trawl login');
168
- const url = `${config.get('apiUrl')}${path}`;
168
+ const url = `${getApiUrl()}${path}`;
169
169
  const res = await fetch(url, {
170
170
  headers: {
171
171
  Accept: 'text/event-stream',
@@ -6,4 +6,23 @@ interface TrawlConfig {
6
6
  telemetryUserId: string;
7
7
  }
8
8
  declare const config: Conf<TrawlConfig>;
9
+ /**
10
+ * Resolve the effective API base URL.
11
+ * Precedence: TRAWL_API_URL env > stored `trawl login --url` > default.
12
+ * Mirrors the TRAWL_TOKEN / TRAWL_TELEMETRY session-override pattern so scripts
13
+ * (e.g. infra /trawl-prod-qa against https://dev.trawl.me) can retarget the CLI
14
+ * without mutating the operator's persisted config. (#56)
15
+ */
16
+ export declare function getApiUrl(): string;
17
+ /**
18
+ * Resolve the effective session token.
19
+ * Precedence: TRAWL_TOKEN env > stored `trawl login` token.
20
+ * Lets CI/agents authenticate headlessly (`TRAWL_TOKEN=<jwt> trawl scraps list`)
21
+ * without ever touching the on-disk config — and without a stored token being
22
+ * silently sent to whatever TRAWL_API_URL points at instead (cross-env
23
+ * credential misuse). Every request/upload/getText/stream call site in
24
+ * api.ts must read the token through this, never through `config.get('token')`
25
+ * directly. Mirrors getApiUrl(). (#68)
26
+ */
27
+ export declare function getToken(): string;
9
28
  export default config;
@@ -1,6 +1,16 @@
1
1
  import Conf from 'conf';
2
+ /**
3
+ * TRAWL_CONFIG_DIR overrides where Conf stores the config file (its `cwd`
4
+ * option). Without this, the CLI has zero config isolation on macOS — Conf's
5
+ * env-paths dependency hardcodes `~/Library/Preferences/...` and ignores
6
+ * XDG_CONFIG_HOME — so CI/agent runs and concurrent `trawl login`s race on
7
+ * one shared on-disk file. Point at an ephemeral dir for hermetic runs, e.g.
8
+ * `TRAWL_CONFIG_DIR=$(mktemp -d) trawl login --token …`. Rescope of #59. (#68)
9
+ */
10
+ const configDir = process.env['TRAWL_CONFIG_DIR']?.trim();
2
11
  const config = new Conf({
3
12
  projectName: 'trawl-cli',
13
+ ...(configDir ? { cwd: configDir } : {}),
4
14
  defaults: {
5
15
  apiUrl: 'https://api.trawl.me',
6
16
  token: '',
@@ -8,4 +18,29 @@ const config = new Conf({
8
18
  telemetryUserId: '',
9
19
  },
10
20
  });
21
+ /**
22
+ * Resolve the effective API base URL.
23
+ * Precedence: TRAWL_API_URL env > stored `trawl login --url` > default.
24
+ * Mirrors the TRAWL_TOKEN / TRAWL_TELEMETRY session-override pattern so scripts
25
+ * (e.g. infra /trawl-prod-qa against https://dev.trawl.me) can retarget the CLI
26
+ * without mutating the operator's persisted config. (#56)
27
+ */
28
+ export function getApiUrl() {
29
+ const override = process.env['TRAWL_API_URL']?.trim();
30
+ return override ? override : config.get('apiUrl');
31
+ }
32
+ /**
33
+ * Resolve the effective session token.
34
+ * Precedence: TRAWL_TOKEN env > stored `trawl login` token.
35
+ * Lets CI/agents authenticate headlessly (`TRAWL_TOKEN=<jwt> trawl scraps list`)
36
+ * without ever touching the on-disk config — and without a stored token being
37
+ * silently sent to whatever TRAWL_API_URL points at instead (cross-env
38
+ * credential misuse). Every request/upload/getText/stream call site in
39
+ * api.ts must read the token through this, never through `config.get('token')`
40
+ * directly. Mirrors getApiUrl(). (#68)
41
+ */
42
+ export function getToken() {
43
+ const override = process.env['TRAWL_TOKEN']?.trim();
44
+ return override ? override : config.get('token');
45
+ }
11
46
  export default config;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Decode the `exp` claim from a JWT (middle segment, base64url-encoded JSON).
3
+ * Returns null if the payload cannot be decoded or has no `exp` field.
4
+ *
5
+ * Shared between `trawl token` (expiry advisory) and `trawl login` (reject
6
+ * already-expired tokens instead of silently storing them). (#68)
7
+ */
8
+ export declare function decodeExp(jwt: string): number | null;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Decode the `exp` claim from a JWT (middle segment, base64url-encoded JSON).
3
+ * Returns null if the payload cannot be decoded or has no `exp` field.
4
+ *
5
+ * Shared between `trawl token` (expiry advisory) and `trawl login` (reject
6
+ * already-expired tokens instead of silently storing them). (#68)
7
+ */
8
+ export function decodeExp(jwt) {
9
+ try {
10
+ const parts = jwt.split('.');
11
+ if (parts.length !== 3)
12
+ return null;
13
+ const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
14
+ const parsed = JSON.parse(payload);
15
+ if (typeof parsed.exp !== 'number')
16
+ return null;
17
+ return parsed.exp;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
@@ -1,4 +1,13 @@
1
1
  export declare const initPostHog: () => void;
2
+ /**
3
+ * Register the complete set of valid "<parent> <name>" telemetry tokens,
4
+ * derived from the actual commander command tree (see resolveCommandName /
5
+ * collectCommandNames in src/index.ts). captureCommand refuses any command
6
+ * string outside this set — defense in depth so free-form user input (argv
7
+ * operands, flag values) can never reach PostHog as the event name, even if a
8
+ * future change accidentally reintroduces argv-derived naming.
9
+ */
10
+ export declare const registerAllowedCommands: (names: readonly string[]) => void;
2
11
  export declare const captureCommand: (command: string, props?: Record<string, unknown>) => void;
3
12
  export declare const shutdown: () => Promise<void>;
4
13
  /** Reset singleton state — for testing only */
@@ -1,4 +1,5 @@
1
1
  import { PostHog } from 'posthog-node';
2
+ import chalk from 'chalk';
2
3
  import os from 'node:os';
3
4
  import crypto from 'node:crypto';
4
5
  import { readFileSync } from 'node:fs';
@@ -8,26 +9,66 @@ import config from './config.js';
8
9
  // public-by-design — cf memory feedback_public_keys_in_config.md
9
10
  const POSTHOG_KEY = 'phc_pGgSLivbq8rDZzXC9trWWJG7sBjNghXeTJjETzBcnEDv';
10
11
  const POSTHOG_HOST = 'https://eu.i.posthog.com';
12
+ // Fail fast: a firewalled/offline endpoint must never add tens of seconds of
13
+ // trailing latency to a CLI command. Library defaults are requestTimeout 10s x
14
+ // 3 retries at a 3s delay (~40s+ worst case per call), and a 30s shutdown
15
+ // ceiling on top of that — cap all three low.
16
+ const REQUEST_TIMEOUT_MS = 3000;
17
+ const FETCH_RETRY_COUNT = 1;
18
+ const FETCH_RETRY_DELAY_MS = 250;
19
+ // A touch above one request + one retry, so the shutdown-time flush has room
20
+ // to complete normally without inheriting the library's 30s default ceiling.
21
+ const SHUTDOWN_TIMEOUT_MS = 8000;
11
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
23
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
13
24
  let client = null;
14
25
  let distinctId = null;
26
+ let allowedCommands = new Set();
27
+ /**
28
+ * Cross-vendor opt-out (https://consoledonottrack.com) on top of the existing
29
+ * config/env gates. Any one of these disables telemetry entirely.
30
+ */
31
+ function isTelemetryDisabled() {
32
+ return (config.get('telemetry') === false ||
33
+ process.env['TRAWL_TELEMETRY'] === '0' ||
34
+ process.env['DO_NOT_TRACK'] === '1');
35
+ }
15
36
  export const initPostHog = () => {
16
- if (config.get('telemetry') === false || process.env['TRAWL_TELEMETRY'] === '0')
37
+ if (isTelemetryDisabled())
17
38
  return;
18
- if (!config.get('telemetryUserId')) {
39
+ const isFirstRun = !config.get('telemetryUserId');
40
+ if (isFirstRun) {
19
41
  config.set('telemetryUserId', `cli_${crypto.randomUUID()}`);
42
+ // One-time disclosure — telemetry is on by default and otherwise silent.
43
+ console.error(chalk.dim('ℹ Trawl CLI collects anonymous usage telemetry (no credentials or command arguments). ' +
44
+ 'Opt out: trawl telemetry off, TRAWL_TELEMETRY=0, or DO_NOT_TRACK=1.'));
20
45
  }
21
46
  distinctId = config.get('telemetryUserId');
22
47
  client = new PostHog(POSTHOG_KEY, {
23
48
  host: POSTHOG_HOST,
24
49
  flushAt: 1,
25
50
  flushInterval: 0,
51
+ requestTimeout: REQUEST_TIMEOUT_MS,
52
+ fetchRetryCount: FETCH_RETRY_COUNT,
53
+ fetchRetryDelay: FETCH_RETRY_DELAY_MS,
26
54
  });
27
55
  };
56
+ /**
57
+ * Register the complete set of valid "<parent> <name>" telemetry tokens,
58
+ * derived from the actual commander command tree (see resolveCommandName /
59
+ * collectCommandNames in src/index.ts). captureCommand refuses any command
60
+ * string outside this set — defense in depth so free-form user input (argv
61
+ * operands, flag values) can never reach PostHog as the event name, even if a
62
+ * future change accidentally reintroduces argv-derived naming.
63
+ */
64
+ export const registerAllowedCommands = (names) => {
65
+ allowedCommands = new Set(names);
66
+ };
28
67
  export const captureCommand = (command, props = {}) => {
29
68
  if (!client || !distinctId)
30
69
  return;
70
+ if (!allowedCommands.has(command))
71
+ return;
31
72
  client.capture({
32
73
  distinctId,
33
74
  event: 'cli.command.run',
@@ -42,11 +83,14 @@ export const captureCommand = (command, props = {}) => {
42
83
  });
43
84
  };
44
85
  export const shutdown = async () => {
86
+ // Explicit ceiling — client.shutdown() otherwise defaults to a 30s internal
87
+ // race, which would undercut the whole point of capping request/retry time.
45
88
  if (client)
46
- await client.shutdown();
89
+ await client.shutdown(SHUTDOWN_TIMEOUT_MS);
47
90
  };
48
91
  /** Reset singleton state — for testing only */
49
92
  export const _resetForTesting = () => {
50
93
  client = null;
51
94
  distinctId = null;
95
+ allowedCommands = new Set();
52
96
  };
@@ -1,6 +1,6 @@
1
1
  export async function promptPassword(prompt) {
2
2
  process.stderr.write(prompt);
3
- return new Promise((resolve) => {
3
+ return new Promise((resolve, reject) => {
4
4
  let password = '';
5
5
  if (process.stdin.isTTY) {
6
6
  process.stdin.setRawMode(true);
@@ -31,13 +31,24 @@ export async function promptPassword(prompt) {
31
31
  process.stdin.on('data', onData);
32
32
  }
33
33
  else {
34
- // Non-TTY fallback (e.g. piped input in CI)
34
+ // Non-TTY fallback (e.g. piped input in CI). If stdin closes (EOF)
35
+ // before an answer arrives — e.g. `trawl login < /dev/null` — the
36
+ // `question` callback never fires; without a `close` handler the
37
+ // promise hangs forever and the process exits 0 without logging in.
38
+ // Reject loudly instead. (#68)
35
39
  import('readline').then(({ createInterface }) => {
36
40
  const rl = createInterface({ input: process.stdin, output: process.stdout });
41
+ let answered = false;
37
42
  rl.question('', (answer) => {
43
+ answered = true;
38
44
  rl.close();
39
45
  resolve(answer.trim());
40
46
  });
47
+ rl.on('close', () => {
48
+ if (!answered) {
49
+ reject(new Error('No input received — pass -e/-p or set TRAWL_TOKEN for non-interactive use'));
50
+ }
51
+ });
41
52
  });
42
53
  }
43
54
  });
@@ -2,3 +2,12 @@ export declare function validateObjectId(id: string): void;
2
2
  export declare function requireString(value: unknown, name: string): string;
3
3
  export declare function requireUrl(value: unknown, name: string): string;
4
4
  export declare function requireJwt(value: unknown, name: string): string;
5
+ /**
6
+ * Like requireJwt, but also rejects a JWT whose `exp` claim has already
7
+ * passed — `trawl login --token <expired>` / `TRAWL_TOKEN=<expired>` must
8
+ * not print "✓ Logged in" for a token that can't actually authenticate.
9
+ * A JWT with an undecodable/opaque payload is let through (we can't prove
10
+ * it's expired), mirroring `trawl token`'s advisory-only handling of the
11
+ * same case. (#68)
12
+ */
13
+ export declare function requireFreshJwt(value: unknown, name: string): string;
@@ -1,3 +1,4 @@
1
+ import { decodeExp } from './jwt.js';
1
2
  export function validateObjectId(id) {
2
3
  if (!/^[0-9a-fA-F]{24}$/.test(id)) {
3
4
  throw new Error(`Invalid scrap ID: "${id}" — expected a 24-char hex ObjectId`);
@@ -31,3 +32,20 @@ export function requireJwt(value, name) {
31
32
  }
32
33
  return str;
33
34
  }
35
+ /**
36
+ * Like requireJwt, but also rejects a JWT whose `exp` claim has already
37
+ * passed — `trawl login --token <expired>` / `TRAWL_TOKEN=<expired>` must
38
+ * not print "✓ Logged in" for a token that can't actually authenticate.
39
+ * A JWT with an undecodable/opaque payload is let through (we can't prove
40
+ * it's expired), mirroring `trawl token`'s advisory-only handling of the
41
+ * same case. (#68)
42
+ */
43
+ export function requireFreshJwt(value, name) {
44
+ const jwt = requireJwt(value, name);
45
+ const exp = decodeExp(jwt);
46
+ const nowSeconds = Math.floor(Date.now() / 1000);
47
+ if (exp !== null && exp < nowSeconds) {
48
+ throw new Error(`${name} is an expired JWT (exp has already passed) — obtain a fresh token and retry`);
49
+ }
50
+ return jwt;
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "LICENSE"
13
13
  ],
14
14
  "engines": {
15
- "node": ">=18"
15
+ "node": ">=20"
16
16
  },
17
17
  "scripts": {
18
18
  "build": "tsc",