@trawlme/cli 1.16.0 → 1.18.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.
@@ -1,11 +1,26 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import ora from 'ora';
3
+ import { oraPromise } from 'ora';
4
4
  import { api } from '../lib/api.js';
5
5
  import { table, json } from '../lib/format.js';
6
6
  import { promptPassword } from '../lib/prompt.js';
7
7
  import { validateObjectId } from '../lib/validate.js';
8
+ import { classifyError } from '../lib/errors.js';
8
9
  import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix } from './doctor.js';
10
+ /**
11
+ * Print a usage/validation error consistently: human text to stderr always;
12
+ * when the invoking command supports --json, ALSO emit a machine envelope on
13
+ * stdout instead of leaving stdout silent/prose. Sets exit code 2 (usage) —
14
+ * distinct from a business-logic refusal (which stays 1) or an unmapped
15
+ * ApiError/NetworkError (handled centrally in index.ts). (#71)
16
+ */
17
+ function usageError(message, opts = {}) {
18
+ console.error(chalk.red(`✗ ${message}`));
19
+ if (opts.json) {
20
+ console.log(JSON.stringify({ error: { message, kind: 'usage' } }));
21
+ }
22
+ process.exitCode = 2;
23
+ }
9
24
  function lastStatus(scrap) {
10
25
  const last = scrap.history?.[0];
11
26
  if (!last || last.status === null || last.status === undefined)
@@ -61,41 +76,46 @@ scraps
61
76
  .action(async (opts) => {
62
77
  // Guard: --limit and --page are mutually exclusive
63
78
  if (opts.limit !== undefined && opts.page !== undefined) {
64
- console.log(chalk.red('--limit and --page are mutually exclusive. Use one or the other.'));
65
- process.exitCode = 1;
79
+ usageError('--limit and --page are mutually exclusive. Use one or the other.', { json: opts.json });
66
80
  return;
67
81
  }
68
- const spinner = ora('Fetching scraps…').start();
69
82
  let data;
70
83
  try {
71
- if (opts.page !== undefined) {
72
- // Single-page mode: explicit page requested, no loop
73
- const pageNum = opts.page;
74
- data = await api.get(`/api/scraps?perPage=50&page=${pageNum}`);
75
- }
76
- else {
84
+ data = await oraPromise(async () => {
85
+ if (opts.page !== undefined) {
86
+ // Single-page mode: explicit page requested, no loop
87
+ const pageNum = opts.page;
88
+ return api.get(`/api/scraps?perPage=50&page=${pageNum}`);
89
+ }
77
90
  // Fetch-all mode: paginate until a page returns < 200 items
78
91
  const perPage = 200;
79
- data = [];
92
+ let result = [];
80
93
  let page = 1;
81
94
  while (true) {
82
95
  const batch = await api.get(`/api/scraps?perPage=${perPage}&page=${page}`);
83
- data = data.concat(batch);
96
+ result = result.concat(batch);
84
97
  if (batch.length < perPage)
85
98
  break;
86
99
  page++;
87
100
  }
88
- }
101
+ return result;
102
+ }, 'Fetching scraps…');
89
103
  }
90
104
  catch (err) {
91
- spinner.stop();
92
- console.log(chalk.red(`✗ Failed to fetch scraps: ${err.message}`));
93
- process.exitCode = 1;
105
+ // Spinner already failed by oraPromise — report with the scrap-specific
106
+ // prefix kept, but route through the shared classifier so exit code +
107
+ // --json envelope stay consistent with every other command. (#71)
108
+ const { exitCode, envelope } = classifyError(err);
109
+ const message = `Failed to fetch scraps: ${envelope.message}`;
110
+ if (opts.json) {
111
+ console.log(JSON.stringify({ error: { ...envelope, message } }));
112
+ }
113
+ else {
114
+ console.error(chalk.red(`✗ ${message}`));
115
+ }
116
+ process.exitCode = exitCode;
94
117
  return;
95
118
  }
96
- finally {
97
- spinner.stop();
98
- }
99
119
  if (opts.status)
100
120
  data = data.filter((s) => lastStatus(s) === opts.status);
101
121
  const totalMatched = data.length;
@@ -146,19 +166,16 @@ scraps
146
166
  .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
147
167
  .action(async (opts) => {
148
168
  if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
149
- console.log(chalk.red(`✗ Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`));
150
- process.exitCode = 1;
169
+ usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`);
151
170
  return;
152
171
  }
153
- const spinner = ora('Creating scrap…').start();
154
- const data = await api.post('/api/scraps', {
172
+ const data = await oraPromise(() => api.post('/api/scraps', {
155
173
  title: opts.title,
156
174
  ...(opts.url && { url: opts.url }),
157
175
  request: opts.request || '',
158
176
  ...(opts.description !== undefined && { description: opts.description }),
159
177
  ...(opts.tier !== undefined && { proxyTier: opts.tier }),
160
- });
161
- spinner.succeed(`Scrap created: ${chalk.bold(data._id)}`);
178
+ }), { text: 'Creating scrap…', successText: (d) => `Scrap created: ${chalk.bold(d._id)}` });
162
179
  console.log(chalk.dim(` Title: ${data.title}`));
163
180
  });
164
181
  // update
@@ -178,11 +195,15 @@ scraps
178
195
  .option('-p, --params <json>', 'Runtime params as JSON array of objects (e.g. \'[{"TRAWL.paramName":"value"}]\')')
179
196
  .option('--params-file <path>', 'Runtime params from a JSON file')
180
197
  .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
198
+ .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
199
  .action(async (id, opts) => {
182
200
  validateObjectId(id);
183
201
  if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
184
- console.log(chalk.red(`✗ Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`));
185
- process.exitCode = 1;
202
+ usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`);
203
+ return;
204
+ }
205
+ if (opts.forceTier !== undefined && !VALID_TIERS.includes(opts.forceTier)) {
206
+ usageError(`Invalid --force-tier "${opts.forceTier}" (allowed: ${VALID_TIERS.join(', ')})`);
186
207
  return;
187
208
  }
188
209
  const body = {};
@@ -220,28 +241,66 @@ scraps
220
241
  parsed = JSON.parse(raw);
221
242
  }
222
243
  catch (e) {
223
- console.log(chalk.red(`✗ Invalid JSON for --params: ${e.message}`));
224
- process.exitCode = 1;
244
+ usageError(`Invalid JSON for --params: ${e.message}`);
225
245
  return;
226
246
  }
227
247
  if (!Array.isArray(parsed)) {
228
- console.log(chalk.red('--params must be a JSON array of objects'));
229
- process.exitCode = 1;
248
+ usageError('--params must be a JSON array of objects');
230
249
  return;
231
250
  }
232
251
  body.params = parsed;
233
252
  }
234
253
  if (opts.tier !== undefined)
235
254
  body.proxyTier = opts.tier;
255
+ if (opts.forceTier !== undefined) {
256
+ // Raise the ceiling; also start the run at that tier unless --tier says otherwise.
257
+ body.proxyMaxTier = opts.forceTier;
258
+ if (opts.tier === undefined)
259
+ body.proxyTier = opts.forceTier;
260
+ }
236
261
  if (Object.keys(body).length === 0) {
237
262
  console.log(chalk.yellow('Nothing to update. Provide at least one option.'));
238
263
  return;
239
264
  }
240
- const spinner = ora('Updating scrap…').start();
241
- const data = await api.put(`/api/scraps/${id}`, body);
242
- spinner.succeed(`Scrap updated: ${chalk.bold(data._id)}`);
265
+ const data = await oraPromise(() => api.put(`/api/scraps/${id}`, body), {
266
+ text: 'Updating scrap…',
267
+ successText: (d) => `Scrap updated: ${chalk.bold(d._id)}`,
268
+ });
269
+ // #1559 — surface the effective tier + clamp/refuse reason (fixes the
270
+ // silent-clamp: the server may persist a lower tier than requested).
271
+ const ov = data._tierOverride;
272
+ const shown = data;
243
273
  for (const key of Object.keys(body)) {
244
- console.log(chalk.dim(` ${key}: `) + String(data[key] ?? '—'));
274
+ // Only suppress the raw proxyTier/proxyMaxTier echo when _tierOverride is
275
+ // present to report the truth. Against an older server (no _tierOverride)
276
+ // fall back to echoing the requested value from the body, so the tier is
277
+ // never silently dropped (worse than a misleading echo).
278
+ if ((key === 'proxyTier' || key === 'proxyMaxTier') && ov)
279
+ continue;
280
+ const src = key in shown ? shown[key] : body[key];
281
+ console.log(chalk.dim(` ${key}: `) + String(src ?? '—'));
282
+ }
283
+ if (ov) {
284
+ if (ov.refused) {
285
+ console.error(chalk.red(` ✗ tier ceiling override refused: ${ov.reason ?? 'unknown'}`)
286
+ + chalk.dim(` (requested ${ov.requestedMaxTier ?? '—'}; kept the registry cap)`));
287
+ process.exitCode = 1;
288
+ }
289
+ else if (ov.effectiveMaxTier) {
290
+ console.log(chalk.green(` ✓ tier ceiling: ${ov.effectiveMaxTier}`)
291
+ + chalk.dim(` (${ov.reason ?? ''}${ov.provider ? `, ${ov.provider}` : ''})`));
292
+ if (ov.warning)
293
+ console.log(chalk.yellow(` ⚠ ${ov.warning}`));
294
+ }
295
+ if (ov.proxyTier) {
296
+ if (ov.proxyTier.clamped) {
297
+ console.log(chalk.yellow(` ⚠ proxyTier requested ${ov.proxyTier.requested} → applied ${ov.proxyTier.effective}`)
298
+ + chalk.dim(` (${ov.proxyTier.reason ?? 'capped'})`));
299
+ }
300
+ else {
301
+ console.log(chalk.dim(` proxyTier: `) + ov.proxyTier.effective);
302
+ }
303
+ }
245
304
  }
246
305
  });
247
306
  // run
@@ -251,51 +310,123 @@ scraps
251
310
  .option('-w, --watch', 'Stream activities after launching')
252
311
  .action(async (id, opts) => {
253
312
  validateObjectId(id);
254
- const spinner = ora('Launching scrap…').start();
255
- await api.get(`/api/scraps/load/${id}`);
256
- spinner.succeed('Scrap launched');
313
+ await oraPromise(() => api.get(`/api/scraps/load/${id}`), {
314
+ text: 'Launching scrap…',
315
+ successText: 'Scrap launched',
316
+ });
257
317
  if (opts.watch) {
258
318
  await watchActivities(id);
259
319
  }
260
320
  });
321
+ // #70 — render an items array either as a table summary or --json. Shared by
322
+ // both the default (persisted read) and --fresh (live execute) paths of `data`.
323
+ function renderScrapItems(items, asJson) {
324
+ if (asJson) {
325
+ json(items);
326
+ return;
327
+ }
328
+ console.log(chalk.bold('Last run data:'));
329
+ console.log(chalk.dim(` Items: ${items.length}`));
330
+ if (items.length > 0 && typeof items[0] === 'object' && items[0] !== null) {
331
+ console.log(chalk.dim(` First item keys: ${Object.keys(items[0]).join(', ')}`));
332
+ }
333
+ console.log(chalk.dim(' Use --json for full output.'));
334
+ }
261
335
  // data
262
336
  scraps
263
337
  .command('data <id>')
264
- .description('Get scrap data (latest run payload)')
338
+ .description('Get scrap data (last persisted run — read-only, no quota). Use --fresh to launch a new run instead.')
265
339
  .option('--json', 'Output as JSON')
266
340
  .option('--errors', 'Show failure diagnostics when the last run failed')
341
+ .option('--fresh', 'Launch a fresh run instead of reading the last persisted payload (consumes execute quota, same as `scraps run`)')
267
342
  .action(async (id, opts) => {
268
343
  validateObjectId(id);
269
- // --errors: fetch full run + fix detail via fetchRunAndFix (DRY with doctor)
344
+ // --errors: fetch full run + fix detail via fetchRunAndFix (DRY with doctor).
345
+ // Read-only: GET /api/scraps/:id + GET /api/historys/:hid, no quota, no run lock.
270
346
  if (opts.errors) {
271
347
  const result = await fetchRunAndFix(id);
272
348
  if (!result) {
349
+ if (opts.json) {
350
+ json(null);
351
+ return;
352
+ }
273
353
  console.log(chalk.dim('No runs yet.'));
274
354
  return;
275
355
  }
356
+ // --json is honored for BOTH outcomes (success or failure) — an agent
357
+ // parsing `data --errors --json` must always get the flat run object,
358
+ // never prose gated behind a status check. (#71 finding 13)
359
+ if (opts.json)
360
+ return json(pickRun(result.run));
276
361
  if (result.run.status === true) {
277
362
  console.log(chalk.green('✓ Last run succeeded. No errors to show.'));
278
363
  return;
279
364
  }
280
- if (opts.json)
281
- return json(pickRun(result.run));
282
365
  console.log(formatDoctor(result.scrap.title, result.run, result.fix, id));
283
366
  return;
284
367
  }
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.'));
368
+ // #70 --fresh is the explicit opt-in for a LIVE run. This is what the
369
+ // default path used to do silently: GET /api/scraps/load/:id executes a
370
+ // fresh scrap run server-side (requireQuota('scraps','execute') + a
371
+ // distributed run lock trawl_node modules/scraps/routes/scraps.routes.js),
372
+ // burning execute quota and 429ing if a run is already in flight. A user
373
+ // or agent "just reading data" must never trigger that by accident.
374
+ if (opts.fresh) {
375
+ const loaded = await oraPromise(() => api.get(`/api/scraps/load/${id}`), {
376
+ text: 'Launching a fresh scrap run (consumes execute quota)…',
377
+ successText: 'Fresh run complete',
378
+ });
379
+ const items = loaded?.result?.data;
380
+ if (!Array.isArray(items)) {
381
+ // --json always returns an array from `data` — [] is the honest
382
+ // "no items" signal instead of prose breaking JSON parsing. (#71)
383
+ if (opts.json) {
384
+ json([]);
385
+ return;
386
+ }
387
+ console.log(chalk.dim('No data yet. Run the scrap first.'));
388
+ return;
389
+ }
390
+ renderScrapItems(items, opts.json);
289
391
  return;
290
392
  }
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(', ')}`));
393
+ // Default: read the last PERSISTED run payload — no quota, no run lock.
394
+ // History.data (trawl_node modules/historys/services/historys.service.js
395
+ // `update()`) is a JSON-stringified clone of the worker result, so it
396
+ // carries the same `.data` items array the live load endpoint returns —
397
+ // it's just pulled from the most recent history row instead of a fresh
398
+ // run. Retention keeps this only for the newest row per (scrap, status)
399
+ // bucket (config.trawl.keepData, default 1); older rows null it out.
400
+ const scrap = await api.get(`/api/scraps/${id}`);
401
+ const hid = scrap.history?.[0]?._id;
402
+ if (!hid) {
403
+ if (opts.json) {
404
+ json([]);
405
+ return;
406
+ }
407
+ console.log(chalk.dim('No data yet. Run the scrap first, or pass --fresh to launch one now.'));
408
+ return;
297
409
  }
298
- console.log(chalk.dim(' Use --json for full output.'));
410
+ const detail = await api.get(`/api/historys/${hid}`);
411
+ let items;
412
+ if (typeof detail?.data === 'string' && detail.data) {
413
+ try {
414
+ items = JSON.parse(detail.data)?.data;
415
+ }
416
+ catch {
417
+ items = undefined;
418
+ }
419
+ }
420
+ if (!Array.isArray(items)) {
421
+ if (opts.json) {
422
+ json([]);
423
+ return;
424
+ }
425
+ console.log(chalk.dim('No persisted data for the last run (it may have failed, or aged out of retention). '
426
+ + 'Pass --fresh to launch a new run (consumes execute quota).'));
427
+ return;
428
+ }
429
+ renderScrapItems(items, opts.json);
299
430
  });
300
431
  // history — list past runs for a scrap
301
432
  scraps
@@ -307,8 +438,7 @@ scraps
307
438
  validateObjectId(id);
308
439
  const limit = Number(opts.limit);
309
440
  if (!Number.isInteger(limit) || limit < 1) {
310
- console.log(chalk.red(`✗ Invalid --limit "${opts.limit}" (expected a positive integer)`));
311
- process.exitCode = 1;
441
+ usageError(`Invalid --limit "${opts.limit}" (expected a positive integer)`, { json: opts.json });
312
442
  return;
313
443
  }
314
444
  const scrap = await api.get(`/api/scraps/${id}`);
@@ -378,9 +508,7 @@ scraps
378
508
  return;
379
509
  }
380
510
  }
381
- const spinner = ora('Deleting…').start();
382
- await api.delete(`/api/scraps/${id}`);
383
- spinner.succeed('Scrap deleted');
511
+ await oraPromise(() => api.delete(`/api/scraps/${id}`), { text: 'Deleting…', successText: 'Scrap deleted' });
384
512
  });
385
513
  // banner
386
514
  scraps
@@ -392,8 +520,7 @@ scraps
392
520
  const { readFileSync, existsSync } = await import('fs');
393
521
  const { basename } = await import('path');
394
522
  if (!existsSync(opts.file)) {
395
- console.log(chalk.red(`✗ File not found: ${opts.file}`));
396
- process.exitCode = 1;
523
+ usageError(`File not found: ${opts.file}`);
397
524
  return;
398
525
  }
399
526
  const fileBuffer = readFileSync(opts.file);
@@ -409,9 +536,10 @@ scraps
409
536
  const blob = new Blob([fileBuffer], { type: mimeType });
410
537
  const formData = new FormData();
411
538
  formData.append('banner', blob, filename);
412
- const spinner = ora('Uploading banner…').start();
413
- await api.upload(`/api/scraps/${id}/banner`, formData);
414
- spinner.succeed(`Banner uploaded for scrap ${chalk.bold(id)}`);
539
+ await oraPromise(() => api.upload(`/api/scraps/${id}/banner`, formData), {
540
+ text: 'Uploading banner…',
541
+ successText: `Banner uploaded for scrap ${chalk.bold(id)}`,
542
+ });
415
543
  });
416
544
  // watch (stream activities)
417
545
  scraps
@@ -429,13 +557,14 @@ scraps
429
557
  .option('--wait', 'Run synchronously and wait for the result (legacy behaviour)')
430
558
  .action(async (id, opts) => {
431
559
  validateObjectId(id);
432
- const spinner = ora(opts.wait ? 'Running worker…' : 'Triggering worker…').start();
433
560
  // #50 — default async: the backend (#1313) kicks off the run and returns a
434
561
  // 'queued' envelope immediately instead of holding the connection for the
435
562
  // whole run. --wait restores the old synchronous round-trip.
436
563
  const path = opts.wait ? `/api/scraps/worker/${id}` : `/api/scraps/worker/${id}?wait=false`;
437
- await api.post(path);
438
- spinner.succeed(opts.wait ? 'Worker run complete' : 'Worker triggered');
564
+ await oraPromise(() => api.post(path), {
565
+ text: opts.wait ? 'Running worker…' : 'Triggering worker…',
566
+ successText: opts.wait ? 'Worker run complete' : 'Worker triggered',
567
+ });
439
568
  if (opts.watch)
440
569
  await watchActivities(id);
441
570
  });
@@ -472,22 +601,18 @@ account
472
601
  if (!username) {
473
602
  username = await promptLine('Username: ');
474
603
  if (!username) {
475
- console.log(chalk.red('Username is required.'));
476
- process.exitCode = 1;
604
+ usageError('Username is required.');
477
605
  return;
478
606
  }
479
607
  }
480
608
  if (!password) {
481
609
  password = await promptPassword('Password: ');
482
610
  if (!password) {
483
- console.log(chalk.red('Password is required.'));
484
- process.exitCode = 1;
611
+ usageError('Password is required.');
485
612
  return;
486
613
  }
487
614
  }
488
- const spinner = ora('Saving credentials…').start();
489
- const data = await api.put(`/api/scraps/${id}/account`, { username, password });
490
- spinner.succeed('Credentials saved');
615
+ const data = await oraPromise(() => api.put(`/api/scraps/${id}/account`, { username, password }), { text: 'Saving credentials…', successText: 'Credentials saved' });
491
616
  const acc = data.account;
492
617
  console.log(chalk.dim(' Credentials: ') + (acc.hasCredentials ? chalk.green('✓ configured') : chalk.dim('not set')));
493
618
  console.log(chalk.dim(' Session: ') + (acc.hasSession ? chalk.green('active') : chalk.dim('none')));
@@ -516,9 +641,10 @@ account
516
641
  return;
517
642
  }
518
643
  }
519
- const spinner = ora('Deleting credentials…').start();
520
- await api.delete(`/api/scraps/${id}/account`);
521
- spinner.succeed('Account credentials deleted');
644
+ await oraPromise(() => api.delete(`/api/scraps/${id}/account`), {
645
+ text: 'Deleting credentials…',
646
+ successText: 'Account credentials deleted',
647
+ });
522
648
  });
523
649
  // account clear-session
524
650
  account
@@ -526,9 +652,10 @@ account
526
652
  .description('Clear the saved session for a scrap account')
527
653
  .action(async (id) => {
528
654
  validateObjectId(id);
529
- const spinner = ora('Clearing session…').start();
530
- await api.delete(`/api/scraps/${id}/account/session`);
531
- spinner.succeed('Session cleared');
655
+ await oraPromise(() => api.delete(`/api/scraps/${id}/account/session`), {
656
+ text: 'Clearing session…',
657
+ successText: 'Session cleared',
658
+ });
532
659
  });
533
660
  // account session subcommand group
534
661
  const accountSession = account
@@ -543,8 +670,7 @@ accountSession
543
670
  validateObjectId(id);
544
671
  const { existsSync, readFileSync } = await import('fs');
545
672
  if (!existsSync(opts.cookies)) {
546
- console.log(chalk.red(`✗ File not found: ${opts.cookies}`));
547
- process.exitCode = 1;
673
+ usageError(`File not found: ${opts.cookies}`);
548
674
  return;
549
675
  }
550
676
  let cookies;
@@ -553,28 +679,22 @@ accountSession
553
679
  cookies = JSON.parse(raw);
554
680
  }
555
681
  catch (e) {
556
- console.log(chalk.red(`✗ Failed to parse cookies file: ${e.message}`));
557
- process.exitCode = 1;
682
+ usageError(`Failed to parse cookies file: ${e.message}`);
558
683
  return;
559
684
  }
560
685
  if (!Array.isArray(cookies)) {
561
- console.log(chalk.red('Cookies file must contain a JSON array'));
562
- process.exitCode = 1;
686
+ usageError('Cookies file must contain a JSON array');
563
687
  return;
564
688
  }
565
689
  if (cookies.length === 0) {
566
- console.log(chalk.red('Cookies array must not be empty'));
567
- process.exitCode = 1;
690
+ usageError('Cookies array must not be empty');
568
691
  return;
569
692
  }
570
693
  if (!cookies.every((c) => c && typeof c.name === 'string' && typeof c.value === 'string')) {
571
- console.log(chalk.red('Each cookie must have a name (string) and value (string)'));
572
- process.exitCode = 1;
694
+ usageError('Each cookie must have a name (string) and value (string)');
573
695
  return;
574
696
  }
575
- const spinner = ora('Uploading session cookies…').start();
576
- const data = await api.put(`/api/scraps/${id}/account/session`, { cookies });
577
- spinner.succeed(`Session cookies saved for scrap ${chalk.bold(id)}`);
697
+ const data = await oraPromise(() => api.put(`/api/scraps/${id}/account/session`, { cookies }), { text: 'Uploading session cookies…', successText: `Session cookies saved for scrap ${chalk.bold(id)}` });
578
698
  const acc = data.account;
579
699
  console.log(chalk.dim(' Session: ') + (acc.hasSession ? chalk.green('✓ active') : chalk.dim('none')));
580
700
  });
@@ -585,9 +705,7 @@ account
585
705
  .option('--json', 'Output as JSON')
586
706
  .action(async (id, opts) => {
587
707
  validateObjectId(id);
588
- const spinner = ora('Fetching scrap…').start();
589
- const data = await api.get(`/api/scraps/${id}`);
590
- spinner.stop();
708
+ const data = await oraPromise(() => api.get(`/api/scraps/${id}`), 'Fetching scrap…');
591
709
  const acc = data.account;
592
710
  if (opts.json) {
593
711
  const { json: jsonFn } = await import('../lib/format.js');
@@ -629,6 +747,10 @@ scraps
629
747
  validateObjectId(id);
630
748
  const result = await fetchRunAndFix(id);
631
749
  if (!result) {
750
+ if (opts.json) {
751
+ json({ status: 'no_runs' });
752
+ return;
753
+ }
632
754
  console.log(chalk.dim('No runs yet.'));
633
755
  return;
634
756
  }
@@ -648,10 +770,18 @@ scraps
648
770
  validateObjectId(id);
649
771
  const result = await fetchRunAndFix(id);
650
772
  if (!result) {
773
+ if (opts.json) {
774
+ json(null);
775
+ return;
776
+ }
651
777
  console.log(chalk.dim('No runs yet.'));
652
778
  return;
653
779
  }
654
780
  if (!result.fix) {
781
+ if (opts.json) {
782
+ json(null);
783
+ return;
784
+ }
655
785
  console.log(chalk.dim('No auto-fix attempt on the last run.'));
656
786
  return;
657
787
  }
@@ -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>;