@trawlme/cli 1.17.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
@@ -182,13 +199,11 @@ scraps
182
199
  .action(async (id, opts) => {
183
200
  validateObjectId(id);
184
201
  if (opts.tier !== undefined && !VALID_TIERS.includes(opts.tier)) {
185
- console.log(chalk.red(`✗ Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`));
186
- process.exitCode = 1;
202
+ usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`);
187
203
  return;
188
204
  }
189
205
  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;
206
+ usageError(`Invalid --force-tier "${opts.forceTier}" (allowed: ${VALID_TIERS.join(', ')})`);
192
207
  return;
193
208
  }
194
209
  const body = {};
@@ -226,13 +241,11 @@ scraps
226
241
  parsed = JSON.parse(raw);
227
242
  }
228
243
  catch (e) {
229
- console.log(chalk.red(`✗ Invalid JSON for --params: ${e.message}`));
230
- process.exitCode = 1;
244
+ usageError(`Invalid JSON for --params: ${e.message}`);
231
245
  return;
232
246
  }
233
247
  if (!Array.isArray(parsed)) {
234
- console.log(chalk.red('--params must be a JSON array of objects'));
235
- process.exitCode = 1;
248
+ usageError('--params must be a JSON array of objects');
236
249
  return;
237
250
  }
238
251
  body.params = parsed;
@@ -249,9 +262,10 @@ scraps
249
262
  console.log(chalk.yellow('Nothing to update. Provide at least one option.'));
250
263
  return;
251
264
  }
252
- const spinner = ora('Updating scrap…').start();
253
- const data = await api.put(`/api/scraps/${id}`, body);
254
- 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
+ });
255
269
  // #1559 — surface the effective tier + clamp/refuse reason (fixes the
256
270
  // silent-clamp: the server may persist a lower tier than requested).
257
271
  const ov = data._tierOverride;
@@ -268,7 +282,7 @@ scraps
268
282
  }
269
283
  if (ov) {
270
284
  if (ov.refused) {
271
- console.log(chalk.red(` ✗ tier ceiling override refused: ${ov.reason ?? 'unknown'}`)
285
+ console.error(chalk.red(` ✗ tier ceiling override refused: ${ov.reason ?? 'unknown'}`)
272
286
  + chalk.dim(` (requested ${ov.requestedMaxTier ?? '—'}; kept the registry cap)`));
273
287
  process.exitCode = 1;
274
288
  }
@@ -296,9 +310,10 @@ scraps
296
310
  .option('-w, --watch', 'Stream activities after launching')
297
311
  .action(async (id, opts) => {
298
312
  validateObjectId(id);
299
- const spinner = ora('Launching scrap…').start();
300
- await api.get(`/api/scraps/load/${id}`);
301
- spinner.succeed('Scrap launched');
313
+ await oraPromise(() => api.get(`/api/scraps/load/${id}`), {
314
+ text: 'Launching scrap…',
315
+ successText: 'Scrap launched',
316
+ });
302
317
  if (opts.watch) {
303
318
  await watchActivities(id);
304
319
  }
@@ -331,15 +346,22 @@ scraps
331
346
  if (opts.errors) {
332
347
  const result = await fetchRunAndFix(id);
333
348
  if (!result) {
349
+ if (opts.json) {
350
+ json(null);
351
+ return;
352
+ }
334
353
  console.log(chalk.dim('No runs yet.'));
335
354
  return;
336
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));
337
361
  if (result.run.status === true) {
338
362
  console.log(chalk.green('✓ Last run succeeded. No errors to show.'));
339
363
  return;
340
364
  }
341
- if (opts.json)
342
- return json(pickRun(result.run));
343
365
  console.log(formatDoctor(result.scrap.title, result.run, result.fix, id));
344
366
  return;
345
367
  }
@@ -350,11 +372,18 @@ scraps
350
372
  // burning execute quota and 429ing if a run is already in flight. A user
351
373
  // or agent "just reading data" must never trigger that by accident.
352
374
  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');
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
+ });
356
379
  const items = loaded?.result?.data;
357
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
+ }
358
387
  console.log(chalk.dim('No data yet. Run the scrap first.'));
359
388
  return;
360
389
  }
@@ -371,6 +400,10 @@ scraps
371
400
  const scrap = await api.get(`/api/scraps/${id}`);
372
401
  const hid = scrap.history?.[0]?._id;
373
402
  if (!hid) {
403
+ if (opts.json) {
404
+ json([]);
405
+ return;
406
+ }
374
407
  console.log(chalk.dim('No data yet. Run the scrap first, or pass --fresh to launch one now.'));
375
408
  return;
376
409
  }
@@ -385,6 +418,10 @@ scraps
385
418
  }
386
419
  }
387
420
  if (!Array.isArray(items)) {
421
+ if (opts.json) {
422
+ json([]);
423
+ return;
424
+ }
388
425
  console.log(chalk.dim('No persisted data for the last run (it may have failed, or aged out of retention). '
389
426
  + 'Pass --fresh to launch a new run (consumes execute quota).'));
390
427
  return;
@@ -401,8 +438,7 @@ scraps
401
438
  validateObjectId(id);
402
439
  const limit = Number(opts.limit);
403
440
  if (!Number.isInteger(limit) || limit < 1) {
404
- console.log(chalk.red(`✗ Invalid --limit "${opts.limit}" (expected a positive integer)`));
405
- process.exitCode = 1;
441
+ usageError(`Invalid --limit "${opts.limit}" (expected a positive integer)`, { json: opts.json });
406
442
  return;
407
443
  }
408
444
  const scrap = await api.get(`/api/scraps/${id}`);
@@ -472,9 +508,7 @@ scraps
472
508
  return;
473
509
  }
474
510
  }
475
- const spinner = ora('Deleting…').start();
476
- await api.delete(`/api/scraps/${id}`);
477
- spinner.succeed('Scrap deleted');
511
+ await oraPromise(() => api.delete(`/api/scraps/${id}`), { text: 'Deleting…', successText: 'Scrap deleted' });
478
512
  });
479
513
  // banner
480
514
  scraps
@@ -486,8 +520,7 @@ scraps
486
520
  const { readFileSync, existsSync } = await import('fs');
487
521
  const { basename } = await import('path');
488
522
  if (!existsSync(opts.file)) {
489
- console.log(chalk.red(`✗ File not found: ${opts.file}`));
490
- process.exitCode = 1;
523
+ usageError(`File not found: ${opts.file}`);
491
524
  return;
492
525
  }
493
526
  const fileBuffer = readFileSync(opts.file);
@@ -503,9 +536,10 @@ scraps
503
536
  const blob = new Blob([fileBuffer], { type: mimeType });
504
537
  const formData = new FormData();
505
538
  formData.append('banner', blob, filename);
506
- const spinner = ora('Uploading banner…').start();
507
- await api.upload(`/api/scraps/${id}/banner`, formData);
508
- 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
+ });
509
543
  });
510
544
  // watch (stream activities)
511
545
  scraps
@@ -523,13 +557,14 @@ scraps
523
557
  .option('--wait', 'Run synchronously and wait for the result (legacy behaviour)')
524
558
  .action(async (id, opts) => {
525
559
  validateObjectId(id);
526
- const spinner = ora(opts.wait ? 'Running worker…' : 'Triggering worker…').start();
527
560
  // #50 — default async: the backend (#1313) kicks off the run and returns a
528
561
  // 'queued' envelope immediately instead of holding the connection for the
529
562
  // whole run. --wait restores the old synchronous round-trip.
530
563
  const path = opts.wait ? `/api/scraps/worker/${id}` : `/api/scraps/worker/${id}?wait=false`;
531
- await api.post(path);
532
- 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
+ });
533
568
  if (opts.watch)
534
569
  await watchActivities(id);
535
570
  });
@@ -566,22 +601,18 @@ account
566
601
  if (!username) {
567
602
  username = await promptLine('Username: ');
568
603
  if (!username) {
569
- console.log(chalk.red('Username is required.'));
570
- process.exitCode = 1;
604
+ usageError('Username is required.');
571
605
  return;
572
606
  }
573
607
  }
574
608
  if (!password) {
575
609
  password = await promptPassword('Password: ');
576
610
  if (!password) {
577
- console.log(chalk.red('Password is required.'));
578
- process.exitCode = 1;
611
+ usageError('Password is required.');
579
612
  return;
580
613
  }
581
614
  }
582
- const spinner = ora('Saving credentials…').start();
583
- const data = await api.put(`/api/scraps/${id}/account`, { username, password });
584
- spinner.succeed('Credentials saved');
615
+ const data = await oraPromise(() => api.put(`/api/scraps/${id}/account`, { username, password }), { text: 'Saving credentials…', successText: 'Credentials saved' });
585
616
  const acc = data.account;
586
617
  console.log(chalk.dim(' Credentials: ') + (acc.hasCredentials ? chalk.green('✓ configured') : chalk.dim('not set')));
587
618
  console.log(chalk.dim(' Session: ') + (acc.hasSession ? chalk.green('active') : chalk.dim('none')));
@@ -610,9 +641,10 @@ account
610
641
  return;
611
642
  }
612
643
  }
613
- const spinner = ora('Deleting credentials…').start();
614
- await api.delete(`/api/scraps/${id}/account`);
615
- spinner.succeed('Account credentials deleted');
644
+ await oraPromise(() => api.delete(`/api/scraps/${id}/account`), {
645
+ text: 'Deleting credentials…',
646
+ successText: 'Account credentials deleted',
647
+ });
616
648
  });
617
649
  // account clear-session
618
650
  account
@@ -620,9 +652,10 @@ account
620
652
  .description('Clear the saved session for a scrap account')
621
653
  .action(async (id) => {
622
654
  validateObjectId(id);
623
- const spinner = ora('Clearing session…').start();
624
- await api.delete(`/api/scraps/${id}/account/session`);
625
- spinner.succeed('Session cleared');
655
+ await oraPromise(() => api.delete(`/api/scraps/${id}/account/session`), {
656
+ text: 'Clearing session…',
657
+ successText: 'Session cleared',
658
+ });
626
659
  });
627
660
  // account session subcommand group
628
661
  const accountSession = account
@@ -637,8 +670,7 @@ accountSession
637
670
  validateObjectId(id);
638
671
  const { existsSync, readFileSync } = await import('fs');
639
672
  if (!existsSync(opts.cookies)) {
640
- console.log(chalk.red(`✗ File not found: ${opts.cookies}`));
641
- process.exitCode = 1;
673
+ usageError(`File not found: ${opts.cookies}`);
642
674
  return;
643
675
  }
644
676
  let cookies;
@@ -647,28 +679,22 @@ accountSession
647
679
  cookies = JSON.parse(raw);
648
680
  }
649
681
  catch (e) {
650
- console.log(chalk.red(`✗ Failed to parse cookies file: ${e.message}`));
651
- process.exitCode = 1;
682
+ usageError(`Failed to parse cookies file: ${e.message}`);
652
683
  return;
653
684
  }
654
685
  if (!Array.isArray(cookies)) {
655
- console.log(chalk.red('Cookies file must contain a JSON array'));
656
- process.exitCode = 1;
686
+ usageError('Cookies file must contain a JSON array');
657
687
  return;
658
688
  }
659
689
  if (cookies.length === 0) {
660
- console.log(chalk.red('Cookies array must not be empty'));
661
- process.exitCode = 1;
690
+ usageError('Cookies array must not be empty');
662
691
  return;
663
692
  }
664
693
  if (!cookies.every((c) => c && typeof c.name === 'string' && typeof c.value === 'string')) {
665
- console.log(chalk.red('Each cookie must have a name (string) and value (string)'));
666
- process.exitCode = 1;
694
+ usageError('Each cookie must have a name (string) and value (string)');
667
695
  return;
668
696
  }
669
- const spinner = ora('Uploading session cookies…').start();
670
- const data = await api.put(`/api/scraps/${id}/account/session`, { cookies });
671
- 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)}` });
672
698
  const acc = data.account;
673
699
  console.log(chalk.dim(' Session: ') + (acc.hasSession ? chalk.green('✓ active') : chalk.dim('none')));
674
700
  });
@@ -679,9 +705,7 @@ account
679
705
  .option('--json', 'Output as JSON')
680
706
  .action(async (id, opts) => {
681
707
  validateObjectId(id);
682
- const spinner = ora('Fetching scrap…').start();
683
- const data = await api.get(`/api/scraps/${id}`);
684
- spinner.stop();
708
+ const data = await oraPromise(() => api.get(`/api/scraps/${id}`), 'Fetching scrap…');
685
709
  const acc = data.account;
686
710
  if (opts.json) {
687
711
  const { json: jsonFn } = await import('../lib/format.js');
@@ -723,6 +747,10 @@ scraps
723
747
  validateObjectId(id);
724
748
  const result = await fetchRunAndFix(id);
725
749
  if (!result) {
750
+ if (opts.json) {
751
+ json({ status: 'no_runs' });
752
+ return;
753
+ }
726
754
  console.log(chalk.dim('No runs yet.'));
727
755
  return;
728
756
  }
@@ -742,10 +770,18 @@ scraps
742
770
  validateObjectId(id);
743
771
  const result = await fetchRunAndFix(id);
744
772
  if (!result) {
773
+ if (opts.json) {
774
+ json(null);
775
+ return;
776
+ }
745
777
  console.log(chalk.dim('No runs yet.'));
746
778
  return;
747
779
  }
748
780
  if (!result.fix) {
781
+ if (opts.json) {
782
+ json(null);
783
+ return;
784
+ }
749
785
  console.log(chalk.dim('No auto-fix attempt on the last run.'));
750
786
  return;
751
787
  }
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { telemetry } from './commands/telemetry.js';
11
11
  import { token } from './commands/token.js';
12
12
  import { autoUpdateInstalledSkills } from './lib/skills.js';
13
13
  import { initPostHog, captureCommand, shutdown, registerAllowedCommands } from './lib/posthog.js';
14
+ import { classifyError } from './lib/errors.js';
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
16
17
  /**
@@ -91,19 +92,31 @@ export async function runCli(argv = process.argv) {
91
92
  await program.parseAsync(argv);
92
93
  }
93
94
  catch (err) {
95
+ // Map the error to a distinct exit code + machine envelope instead of a
96
+ // uniform 1 — agents driving this CLI unattended need to tell
97
+ // auth-expired (3) from not-found (4) from network-down (5) from a bad
98
+ // flag (2) apart from an arbitrary bug (1). (#71)
99
+ const { exitCode, envelope } = classifyError(err);
94
100
  // Capture error telemetry from the resolved command only — never argv.
95
101
  void captureCommand(resolveCommandName(currentCommand), {
96
- exit_code: 1,
102
+ exit_code: exitCode,
97
103
  error: err.name,
98
104
  });
99
105
  const { debug } = program.opts();
100
- if (debug || process.env['DEBUG']) {
106
+ const isDebug = Boolean(debug || process.env['DEBUG']);
107
+ // A --json subcommand must keep stdout pure JSON even on failure — read
108
+ // the resolved command's own --json flag (never argv) so the error
109
+ // envelope lands on the same channel the success path would have used.
110
+ const wantsJson = Boolean(currentCommand?.opts()?.json);
111
+ if (isDebug)
101
112
  console.error(err);
113
+ if (wantsJson) {
114
+ console.log(JSON.stringify({ error: envelope }));
102
115
  }
103
- else {
104
- console.error(chalk.red('✗ ' + err.message));
116
+ else if (!isDebug) {
117
+ console.error(chalk.red('✗ ' + envelope.message));
105
118
  }
106
- process.exitCode = 1;
119
+ process.exitCode = exitCode;
107
120
  }
108
121
  finally {
109
122
  // Flush + close telemetry before the process exits. A `process.on('exit')`
package/dist/lib/api.d.ts CHANGED
@@ -1,3 +1,16 @@
1
+ export declare class ApiError extends Error {
2
+ status: number;
3
+ constructor(status: number, message: string);
4
+ }
5
+ /**
6
+ * A fetch-level failure — the request never got a response at all (DNS,
7
+ * connection refused, timeout, TLS, …). Distinguished from ApiError (which
8
+ * always carries a real HTTP status) so the top-level handler can map it to
9
+ * its own exit code instead of the generic uniform 1. (#71 findings 4/58)
10
+ */
11
+ export declare class NetworkError extends Error {
12
+ constructor(message: string);
13
+ }
1
14
  export declare const api: {
2
15
  get: <T>(path: string) => Promise<T>;
3
16
  getText: (path: string) => Promise<string>;
package/dist/lib/api.js CHANGED
@@ -5,7 +5,7 @@ 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}`;
8
- class ApiError extends Error {
8
+ export class ApiError extends Error {
9
9
  status;
10
10
  constructor(status, message) {
11
11
  super(message);
@@ -13,7 +13,57 @@ class ApiError extends Error {
13
13
  this.name = 'ApiError';
14
14
  }
15
15
  }
16
- function extractErrorMessage(raw) {
16
+ /**
17
+ * A fetch-level failure — the request never got a response at all (DNS,
18
+ * connection refused, timeout, TLS, …). Distinguished from ApiError (which
19
+ * always carries a real HTTP status) so the top-level handler can map it to
20
+ * its own exit code instead of the generic uniform 1. (#71 findings 4/58)
21
+ */
22
+ export class NetworkError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = 'NetworkError';
26
+ }
27
+ }
28
+ const DEFAULT_TIMEOUT_MS = 30_000;
29
+ /** Effective fetch timeout — TRAWL_TIMEOUT env override (ms), default 30s. (#71) */
30
+ function getTimeoutMs() {
31
+ const raw = process.env['TRAWL_TIMEOUT']?.trim();
32
+ if (!raw)
33
+ return DEFAULT_TIMEOUT_MS;
34
+ const n = Number(raw);
35
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_TIMEOUT_MS;
36
+ }
37
+ /**
38
+ * Wrap a `fetch()` call so connection-level failures (ECONNREFUSED, DNS,
39
+ * timeout, …) surface as a NetworkError carrying the effective URL + the
40
+ * unwrapped `err.cause` detail, instead of a bare "fetch failed" with no
41
+ * actionable information. (#71 findings 4/58)
42
+ */
43
+ async function safeFetch(url, options) {
44
+ try {
45
+ return await fetch(url, options);
46
+ }
47
+ catch (err) {
48
+ const e = err;
49
+ if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
50
+ throw new NetworkError(`Request to ${url} timed out after ${getTimeoutMs()}ms (override with TRAWL_TIMEOUT env var, ms)`);
51
+ }
52
+ const cause = e?.cause;
53
+ const causeDetail = cause?.code ? ` (${cause.code})` : cause?.message ? ` (${cause.message})` : '';
54
+ throw new NetworkError(`Network error reaching ${url}${causeDetail}: ${e?.message ?? String(err)}`);
55
+ }
56
+ }
57
+ /**
58
+ * Extract the honest client-facing error string from a raw response body.
59
+ * The server envelope (lib/helpers/responses.js) shape is
60
+ * `{ type, message, code, status, errorCode, description, error? }` — where
61
+ * `message` is sometimes a bare HTTP reason phrase (e.g. "Payment Required")
62
+ * duplicating `res.statusText`, producing a tautology like
63
+ * "402 Payment Required: Payment Required". When that happens, prefer the
64
+ * richer `description` field instead. (#71 finding 76 — error-copy part only)
65
+ */
66
+ function extractErrorMessage(raw, statusText) {
17
67
  if (!raw)
18
68
  return '';
19
69
  try {
@@ -34,8 +84,15 @@ function extractErrorMessage(raw) {
34
84
  return nested;
35
85
  }
36
86
  }
37
- if (typeof env.message === 'string')
38
- return env.message;
87
+ const message = typeof env.message === 'string' ? env.message : undefined;
88
+ const description = typeof env.description === 'string' && env.description ? env.description : undefined;
89
+ if (message && description && statusText && message.toLowerCase() === statusText.toLowerCase()) {
90
+ return description;
91
+ }
92
+ if (message)
93
+ return message;
94
+ if (description)
95
+ return description;
39
96
  }
40
97
  }
41
98
  catch {
@@ -43,17 +100,67 @@ function extractErrorMessage(raw) {
43
100
  }
44
101
  return raw;
45
102
  }
103
+ /**
104
+ * Best-effort extraction of an upgrade URL from a 402 response body. In
105
+ * production the envelope rarely carries it directly (billing.quota.service
106
+ * nests `upgradeUrl` inside AppError.details, which `responses.error` only
107
+ * serializes to the dev-only `error` string) — so this checks the top-level
108
+ * field, `details.upgradeUrl`, and the dev-only nested `error` JSON string,
109
+ * and returns null (never fabricates) when none are present. (#71 finding 76)
110
+ */
111
+ function extractUpgradeUrl(raw) {
112
+ try {
113
+ const parsed = JSON.parse(raw);
114
+ if (typeof parsed.upgradeUrl === 'string')
115
+ return parsed.upgradeUrl;
116
+ const details = parsed.details;
117
+ if (details && typeof details === 'object' && typeof details.upgradeUrl === 'string') {
118
+ return details.upgradeUrl;
119
+ }
120
+ if (typeof parsed.error === 'string') {
121
+ try {
122
+ const inner = JSON.parse(parsed.error);
123
+ if (typeof inner.upgradeUrl === 'string')
124
+ return inner.upgradeUrl;
125
+ const innerDetails = inner.details;
126
+ if (innerDetails &&
127
+ typeof innerDetails === 'object' &&
128
+ typeof innerDetails.upgradeUrl === 'string') {
129
+ return innerDetails.upgradeUrl;
130
+ }
131
+ }
132
+ catch {
133
+ // dev-only nested string wasn't JSON — nothing to extract
134
+ }
135
+ }
136
+ }
137
+ catch {
138
+ // not JSON — nothing to extract
139
+ }
140
+ return null;
141
+ }
46
142
  async function throwIfError(res, isPublic = false) {
47
143
  if (res.status === 401 && !isPublic) {
48
144
  throw new ApiError(401, 'Session expired or invalid. Run: trawl login');
49
145
  }
50
146
  if (!res.ok) {
51
147
  const raw = await res.text();
52
- const message = extractErrorMessage(raw);
148
+ const message = extractErrorMessage(raw, res.statusText);
53
149
  if (res.status === 401 && isPublic) {
54
150
  throw new ApiError(401, `Invalid credentials${message ? `: ${message}` : ''}`);
55
151
  }
56
- throw new ApiError(res.status, `${res.status} ${res.statusText}: ${message}`);
152
+ let full = message;
153
+ if (res.status === 402) {
154
+ const upgradeUrl = extractUpgradeUrl(raw);
155
+ if (upgradeUrl)
156
+ full += ` — upgrade: ${upgradeUrl}`;
157
+ }
158
+ if (res.status === 429) {
159
+ const retryAfter = res.headers?.get?.('retry-after');
160
+ if (retryAfter)
161
+ full += ` (retry after ${retryAfter}s)`;
162
+ }
163
+ throw new ApiError(res.status, `${res.status} ${res.statusText}: ${full}`);
57
164
  }
58
165
  }
59
166
  async function request(path, options = {}) {
@@ -61,8 +168,9 @@ async function request(path, options = {}) {
61
168
  if (!token)
62
169
  throw new Error('Not logged in. Run: trawl login');
63
170
  const url = `${getApiUrl()}${path}`;
64
- const res = await fetch(url, {
171
+ const res = await safeFetch(url, {
65
172
  ...options,
173
+ signal: AbortSignal.timeout(getTimeoutMs()),
66
174
  headers: {
67
175
  'Content-Type': 'application/json',
68
176
  'User-Agent': USER_AGENT,
@@ -92,9 +200,10 @@ async function upload(path, formData) {
92
200
  throw new Error('Not logged in. Run: trawl login');
93
201
  const url = `${getApiUrl()}${path}`;
94
202
  // Do NOT set Content-Type — fetch sets it automatically with the correct multipart boundary
95
- const res = await fetch(url, {
203
+ const res = await safeFetch(url, {
96
204
  method: 'POST',
97
205
  body: formData,
206
+ signal: AbortSignal.timeout(getTimeoutMs()),
98
207
  headers: {
99
208
  'User-Agent': USER_AGENT,
100
209
  Cookie: `TOKEN=${token}`,
@@ -118,10 +227,11 @@ async function upload(path, formData) {
118
227
  }
119
228
  async function publicPost(path, body, baseUrlOverride) {
120
229
  const url = `${baseUrlOverride ?? getApiUrl()}${path}`;
121
- const res = await fetch(url, {
230
+ const res = await safeFetch(url, {
122
231
  method: 'POST',
123
232
  headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
124
233
  body: body ? JSON.stringify(body) : undefined,
234
+ signal: AbortSignal.timeout(getTimeoutMs()),
125
235
  });
126
236
  await throwIfError(res, true);
127
237
  const text = await res.text();
@@ -138,11 +248,12 @@ async function getText(path) {
138
248
  if (!token)
139
249
  throw new Error('Not logged in. Run: trawl login');
140
250
  const url = `${getApiUrl()}${path}`;
141
- const res = await fetch(url, {
251
+ const res = await safeFetch(url, {
142
252
  headers: {
143
253
  'User-Agent': USER_AGENT,
144
254
  Cookie: `TOKEN=${token}`,
145
255
  },
256
+ signal: AbortSignal.timeout(getTimeoutMs()),
146
257
  });
147
258
  await throwIfError(res);
148
259
  return res.text();
@@ -166,7 +277,10 @@ export const api = {
166
277
  if (!token)
167
278
  throw new Error('Not logged in. Run: trawl login');
168
279
  const url = `${getApiUrl()}${path}`;
169
- const res = await fetch(url, {
280
+ // No AbortSignal.timeout here a long-running `watch`/`--watch` stream is
281
+ // expected to sit open indefinitely; only connection-level failures
282
+ // (never a timeout) should surface via safeFetch's cause-unwrapping. (#71)
283
+ const res = await safeFetch(url, {
170
284
  headers: {
171
285
  Accept: 'text/event-stream',
172
286
  'User-Agent': USER_AGENT,
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Thrown for CLI usage / input-validation failures (bad flag value, malformed
3
+ * JSON, invalid ObjectId, missing required prompt input, …). Distinguished
4
+ * from ApiError/NetworkError so the top-level handler can map it to its own
5
+ * exit code (2) instead of the generic uniform 1 every other bug collapses
6
+ * into. (#71)
7
+ */
8
+ export declare class UsageError extends Error {
9
+ constructor(message: string);
10
+ }
11
+ export interface ErrorEnvelope {
12
+ message: string;
13
+ status?: number;
14
+ kind: string;
15
+ }
16
+ export interface ClassifiedError {
17
+ exitCode: number;
18
+ envelope: ErrorEnvelope;
19
+ }
20
+ /**
21
+ * Central status → exit-code map (#71 findings 13/14/60). Agents driving this
22
+ * CLI unattended need to tell "you're not logged in" (3) from "that id
23
+ * doesn't exist" (4) from "the network/API is unreachable" (5) from "you
24
+ * passed a bad flag" (2) — a uniform exit 1 collapses all of these into one
25
+ * undifferentiable signal.
26
+ */
27
+ export declare function classifyError(err: unknown): ClassifiedError;
28
+ /**
29
+ * Print a classified error to the correct stream and return its exit code.
30
+ * stdout is reserved for payload — under --json the error itself IS the
31
+ * payload (`{"error":{message,status,kind}}`); otherwise the human-readable
32
+ * line goes to stderr, never stdout. (#71 findings 13/14/60)
33
+ *
34
+ * `quiet` skips the human-readable stderr line (used when the caller already
35
+ * printed a fuller diagnostic, e.g. a raw stack trace under --debug) while
36
+ * still emitting the --json payload when requested.
37
+ */
38
+ export declare function reportError(err: unknown, opts?: {
39
+ json?: boolean;
40
+ quiet?: boolean;
41
+ }): number;
@@ -0,0 +1,59 @@
1
+ import chalk from 'chalk';
2
+ import { ApiError, NetworkError } from './api.js';
3
+ /**
4
+ * Thrown for CLI usage / input-validation failures (bad flag value, malformed
5
+ * JSON, invalid ObjectId, missing required prompt input, …). Distinguished
6
+ * from ApiError/NetworkError so the top-level handler can map it to its own
7
+ * exit code (2) instead of the generic uniform 1 every other bug collapses
8
+ * into. (#71)
9
+ */
10
+ export class UsageError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = 'UsageError';
14
+ }
15
+ }
16
+ /**
17
+ * Central status → exit-code map (#71 findings 13/14/60). Agents driving this
18
+ * CLI unattended need to tell "you're not logged in" (3) from "that id
19
+ * doesn't exist" (4) from "the network/API is unreachable" (5) from "you
20
+ * passed a bad flag" (2) — a uniform exit 1 collapses all of these into one
21
+ * undifferentiable signal.
22
+ */
23
+ export function classifyError(err) {
24
+ const message = err instanceof Error ? err.message : String(err);
25
+ if (err instanceof ApiError) {
26
+ if (err.status === 401)
27
+ return { exitCode: 3, envelope: { message, status: 401, kind: 'auth' } };
28
+ if (err.status === 404)
29
+ return { exitCode: 4, envelope: { message, status: 404, kind: 'not_found' } };
30
+ return { exitCode: 1, envelope: { message, status: err.status, kind: 'api' } };
31
+ }
32
+ if (err instanceof NetworkError) {
33
+ return { exitCode: 5, envelope: { message, kind: 'network' } };
34
+ }
35
+ if (err instanceof UsageError) {
36
+ return { exitCode: 2, envelope: { message, kind: 'usage' } };
37
+ }
38
+ return { exitCode: 1, envelope: { message, kind: 'unknown' } };
39
+ }
40
+ /**
41
+ * Print a classified error to the correct stream and return its exit code.
42
+ * stdout is reserved for payload — under --json the error itself IS the
43
+ * payload (`{"error":{message,status,kind}}`); otherwise the human-readable
44
+ * line goes to stderr, never stdout. (#71 findings 13/14/60)
45
+ *
46
+ * `quiet` skips the human-readable stderr line (used when the caller already
47
+ * printed a fuller diagnostic, e.g. a raw stack trace under --debug) while
48
+ * still emitting the --json payload when requested.
49
+ */
50
+ export function reportError(err, opts = {}) {
51
+ const { exitCode, envelope } = classifyError(err);
52
+ if (opts.json) {
53
+ console.log(JSON.stringify({ error: envelope }));
54
+ }
55
+ else if (!opts.quiet) {
56
+ console.error(chalk.red('✗ ' + envelope.message));
57
+ }
58
+ return exitCode;
59
+ }
@@ -1,12 +1,13 @@
1
1
  import { decodeExp } from './jwt.js';
2
+ import { UsageError } from './errors.js';
2
3
  export function validateObjectId(id) {
3
4
  if (!/^[0-9a-fA-F]{24}$/.test(id)) {
4
- throw new Error(`Invalid scrap ID: "${id}" — expected a 24-char hex ObjectId`);
5
+ throw new UsageError(`Invalid scrap ID: "${id}" — expected a 24-char hex ObjectId`);
5
6
  }
6
7
  }
7
8
  export function requireString(value, name) {
8
9
  if (typeof value !== 'string' || value.trim() === '') {
9
- throw new Error(`${name} is required and must be a non-empty string`);
10
+ throw new UsageError(`${name} is required and must be a non-empty string`);
10
11
  }
11
12
  return value.trim();
12
13
  }
@@ -15,20 +16,20 @@ export function requireUrl(value, name) {
15
16
  try {
16
17
  const url = new URL(str);
17
18
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
18
- throw new Error(`${name} must use http or https protocol`);
19
+ throw new UsageError(`${name} must use http or https protocol`);
19
20
  }
20
21
  }
21
22
  catch (err) {
22
23
  if (err instanceof Error && err.message.startsWith(name))
23
24
  throw err;
24
- throw new Error(`${name} must be a valid URL`);
25
+ throw new UsageError(`${name} must be a valid URL`);
25
26
  }
26
27
  return str;
27
28
  }
28
29
  export function requireJwt(value, name) {
29
30
  const str = requireString(value, name);
30
31
  if (str.split('.').length !== 3) {
31
- throw new Error(`${name} must be a valid JWT token`);
32
+ throw new UsageError(`${name} must be a valid JWT token`);
32
33
  }
33
34
  return str;
34
35
  }
@@ -45,7 +46,7 @@ export function requireFreshJwt(value, name) {
45
46
  const exp = decodeExp(jwt);
46
47
  const nowSeconds = Math.floor(Date.now() / 1000);
47
48
  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
+ throw new UsageError(`${name} is an expired JWT (exp has already passed) — obtain a fresh token and retry`);
49
50
  }
50
51
  return jwt;
51
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {