@trawlme/cli 1.17.0 → 1.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/scraps.js +123 -87
- package/dist/index.d.ts +7 -0
- package/dist/index.js +29 -6
- package/dist/lib/api.d.ts +13 -0
- package/dist/lib/api.js +125 -11
- package/dist/lib/errors.d.ts +41 -0
- package/dist/lib/errors.js +59 -0
- package/dist/lib/prompt.js +39 -21
- package/dist/lib/skills.d.ts +13 -3
- package/dist/lib/skills.js +23 -4
- package/dist/lib/validate.js +7 -6
- package/package.json +1 -1
package/dist/commands/scraps.js
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
150
|
-
process.exitCode = 1;
|
|
169
|
+
usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`);
|
|
151
170
|
return;
|
|
152
171
|
}
|
|
153
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
process.exitCode = 1;
|
|
244
|
+
usageError(`Invalid JSON for --params: ${e.message}`);
|
|
231
245
|
return;
|
|
232
246
|
}
|
|
233
247
|
if (!Array.isArray(parsed)) {
|
|
234
|
-
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
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.
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
578
|
-
process.exitCode = 1;
|
|
611
|
+
usageError('Password is required.');
|
|
579
612
|
return;
|
|
580
613
|
}
|
|
581
614
|
}
|
|
582
|
-
const
|
|
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
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
666
|
-
process.exitCode = 1;
|
|
694
|
+
usageError('Each cookie must have a name (string) and value (string)');
|
|
667
695
|
return;
|
|
668
696
|
}
|
|
669
|
-
const
|
|
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
|
|
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.d.ts
CHANGED
|
@@ -17,4 +17,11 @@ export declare function collectCommandNames(root: Command): string[];
|
|
|
17
17
|
export declare function createProgram(): Command;
|
|
18
18
|
/** True when this module is the process entrypoint (not merely imported by a test). */
|
|
19
19
|
export declare function isEntryPoint(argv1: string | undefined, moduleUrl: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* True when the invocation is a pure `--help`/`--version` query. These must not
|
|
22
|
+
* trigger the skills auto-sync (a filesystem-mutating startup side effect) — a
|
|
23
|
+
* user running `trawl --version` never expects it to rewrite their skills dirs.
|
|
24
|
+
* (#73)
|
|
25
|
+
*/
|
|
26
|
+
export declare function isHelpOrVersion(argv: string[]): boolean;
|
|
20
27
|
export declare function runCli(argv?: string[]): Promise<void>;
|
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
|
/**
|
|
@@ -61,8 +62,18 @@ export function createProgram() {
|
|
|
61
62
|
export function isEntryPoint(argv1, moduleUrl) {
|
|
62
63
|
return argv1 !== undefined && moduleUrl === pathToFileURL(argv1).href;
|
|
63
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* True when the invocation is a pure `--help`/`--version` query. These must not
|
|
67
|
+
* trigger the skills auto-sync (a filesystem-mutating startup side effect) — a
|
|
68
|
+
* user running `trawl --version` never expects it to rewrite their skills dirs.
|
|
69
|
+
* (#73)
|
|
70
|
+
*/
|
|
71
|
+
export function isHelpOrVersion(argv) {
|
|
72
|
+
return argv.some((a) => a === '-h' || a === '--help' || a === '-V' || a === '--version');
|
|
73
|
+
}
|
|
64
74
|
export async function runCli(argv = process.argv) {
|
|
65
|
-
|
|
75
|
+
if (!isHelpOrVersion(argv))
|
|
76
|
+
autoUpdateInstalledSkills();
|
|
66
77
|
initPostHog();
|
|
67
78
|
const program = createProgram();
|
|
68
79
|
registerAllowedCommands(collectCommandNames(program));
|
|
@@ -91,19 +102,31 @@ export async function runCli(argv = process.argv) {
|
|
|
91
102
|
await program.parseAsync(argv);
|
|
92
103
|
}
|
|
93
104
|
catch (err) {
|
|
105
|
+
// Map the error to a distinct exit code + machine envelope instead of a
|
|
106
|
+
// uniform 1 — agents driving this CLI unattended need to tell
|
|
107
|
+
// auth-expired (3) from not-found (4) from network-down (5) from a bad
|
|
108
|
+
// flag (2) apart from an arbitrary bug (1). (#71)
|
|
109
|
+
const { exitCode, envelope } = classifyError(err);
|
|
94
110
|
// Capture error telemetry from the resolved command only — never argv.
|
|
95
111
|
void captureCommand(resolveCommandName(currentCommand), {
|
|
96
|
-
exit_code:
|
|
112
|
+
exit_code: exitCode,
|
|
97
113
|
error: err.name,
|
|
98
114
|
});
|
|
99
115
|
const { debug } = program.opts();
|
|
100
|
-
|
|
116
|
+
const isDebug = Boolean(debug || process.env['DEBUG']);
|
|
117
|
+
// A --json subcommand must keep stdout pure JSON even on failure — read
|
|
118
|
+
// the resolved command's own --json flag (never argv) so the error
|
|
119
|
+
// envelope lands on the same channel the success path would have used.
|
|
120
|
+
const wantsJson = Boolean(currentCommand?.opts()?.json);
|
|
121
|
+
if (isDebug)
|
|
101
122
|
console.error(err);
|
|
123
|
+
if (wantsJson) {
|
|
124
|
+
console.log(JSON.stringify({ error: envelope }));
|
|
102
125
|
}
|
|
103
|
-
else {
|
|
104
|
-
console.error(chalk.red('✗ ' +
|
|
126
|
+
else if (!isDebug) {
|
|
127
|
+
console.error(chalk.red('✗ ' + envelope.message));
|
|
105
128
|
}
|
|
106
|
-
process.exitCode =
|
|
129
|
+
process.exitCode = exitCode;
|
|
107
130
|
}
|
|
108
131
|
finally {
|
|
109
132
|
// 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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/lib/prompt.js
CHANGED
|
@@ -6,26 +6,38 @@ export async function promptPassword(prompt) {
|
|
|
6
6
|
process.stdin.setRawMode(true);
|
|
7
7
|
process.stdin.resume();
|
|
8
8
|
process.stdin.setEncoding('utf8');
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
9
|
+
const restore = () => {
|
|
10
|
+
process.stdin.setRawMode(false);
|
|
11
|
+
process.stdin.pause();
|
|
12
|
+
process.stdin.removeListener('data', onData);
|
|
13
|
+
};
|
|
14
|
+
const onData = (chunk) => {
|
|
15
|
+
// A stdin chunk is NOT a single keystroke. Terminals deliver a paste as
|
|
16
|
+
// one chunk (Windows Terminal always chunk-pastes), and a paste that
|
|
17
|
+
// ends in a newline embeds a literal \r mid-chunk. Iterate per code
|
|
18
|
+
// point so an embedded Enter finalizes the password instead of being
|
|
19
|
+
// appended to it and hanging for a second Enter. (#73)
|
|
20
|
+
for (const char of chunk) {
|
|
21
|
+
if (char === '\r' || char === '\n') {
|
|
22
|
+
restore();
|
|
23
|
+
process.stderr.write('\n');
|
|
24
|
+
resolve(password);
|
|
25
|
+
return; // ignore anything after the first terminator
|
|
26
|
+
}
|
|
27
|
+
if (char === '') {
|
|
28
|
+
// Ctrl+C: restore the terminal and terminate the line before
|
|
29
|
+
// exiting so the shell prompt isn't left mid-line. 130 = 128+SIGINT.
|
|
30
|
+
restore();
|
|
31
|
+
process.stderr.write('\n');
|
|
32
|
+
process.exit(130);
|
|
33
|
+
}
|
|
34
|
+
if (char === '' || char === '\b') {
|
|
35
|
+
// Backspace
|
|
36
|
+
password = password.slice(0, -1);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
password += char;
|
|
40
|
+
}
|
|
29
41
|
}
|
|
30
42
|
};
|
|
31
43
|
process.stdin.on('data', onData);
|
|
@@ -36,8 +48,14 @@ export async function promptPassword(prompt) {
|
|
|
36
48
|
// `question` callback never fires; without a `close` handler the
|
|
37
49
|
// promise hangs forever and the process exits 0 without logging in.
|
|
38
50
|
// Reject loudly instead. (#68)
|
|
51
|
+
//
|
|
52
|
+
// No `output` is wired to the interface: readline echoes typed input
|
|
53
|
+
// whenever its output is a TTY, so `trawl login < creds` run from a real
|
|
54
|
+
// terminal would print the password. Omitting output (and terminal:false)
|
|
55
|
+
// guarantees the secret is never echoed. The prompt itself was already
|
|
56
|
+
// written to stderr above. (#73)
|
|
39
57
|
import('readline').then(({ createInterface }) => {
|
|
40
|
-
const rl = createInterface({ input: process.stdin,
|
|
58
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
41
59
|
let answered = false;
|
|
42
60
|
rl.question('', (answer) => {
|
|
43
61
|
answered = true;
|
package/dist/lib/skills.d.ts
CHANGED
|
@@ -5,8 +5,18 @@ export declare function uninstallSkill(name: string, scope: 'user' | 'local'): s
|
|
|
5
5
|
export declare function getInstalledVersion(name: string, scope: 'user' | 'local'): string | null;
|
|
6
6
|
export declare function isSkillInstalled(name: string, scope: 'user' | 'local'): boolean;
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
* Called on CLI startup to keep skills in sync with the CLI
|
|
10
|
-
* Never throws — failures are silent so they don't break unrelated
|
|
8
|
+
* Re-installs any CLI-owned skill whose installed version doesn't match the
|
|
9
|
+
* bundled one. Called on CLI startup to keep skills in sync with the CLI
|
|
10
|
+
* version. Never throws — failures are silent so they don't break unrelated
|
|
11
|
+
* commands.
|
|
12
|
+
*
|
|
13
|
+
* Ownership guard (#73): `installSkill` does `rmSync(recursive)` on the target
|
|
14
|
+
* dir, so this MUST only ever touch dirs the CLI itself installed. Proof of
|
|
15
|
+
* ownership is a `.version` marker. A user-created `.claude/skills/<name>` dir
|
|
16
|
+
* that happens to collide with a bundled skill name carries no marker, so it is
|
|
17
|
+
* left untouched instead of being silently deleted + overwritten.
|
|
18
|
+
*
|
|
19
|
+
* Opt-out: `TRAWL_SKILLS_SYNC=0` disables auto-sync entirely (mirrors
|
|
20
|
+
* `TRAWL_TELEMETRY=0`), for users who manage their skills by hand.
|
|
11
21
|
*/
|
|
12
22
|
export declare function autoUpdateInstalledSkills(): void;
|
package/dist/lib/skills.js
CHANGED
|
@@ -54,20 +54,39 @@ export function isSkillInstalled(name, scope) {
|
|
|
54
54
|
return existsSync(join(getSkillsBase(scope), name));
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
58
|
-
* Called on CLI startup to keep skills in sync with the CLI
|
|
59
|
-
* Never throws — failures are silent so they don't break unrelated
|
|
57
|
+
* Re-installs any CLI-owned skill whose installed version doesn't match the
|
|
58
|
+
* bundled one. Called on CLI startup to keep skills in sync with the CLI
|
|
59
|
+
* version. Never throws — failures are silent so they don't break unrelated
|
|
60
|
+
* commands.
|
|
61
|
+
*
|
|
62
|
+
* Ownership guard (#73): `installSkill` does `rmSync(recursive)` on the target
|
|
63
|
+
* dir, so this MUST only ever touch dirs the CLI itself installed. Proof of
|
|
64
|
+
* ownership is a `.version` marker. A user-created `.claude/skills/<name>` dir
|
|
65
|
+
* that happens to collide with a bundled skill name carries no marker, so it is
|
|
66
|
+
* left untouched instead of being silently deleted + overwritten.
|
|
67
|
+
*
|
|
68
|
+
* Opt-out: `TRAWL_SKILLS_SYNC=0` disables auto-sync entirely (mirrors
|
|
69
|
+
* `TRAWL_TELEMETRY=0`), for users who manage their skills by hand.
|
|
60
70
|
*/
|
|
61
71
|
export function autoUpdateInstalledSkills() {
|
|
72
|
+
if (process.env['TRAWL_SKILLS_SYNC'] === '0')
|
|
73
|
+
return;
|
|
62
74
|
try {
|
|
63
75
|
const bundledVersion = getBundledSkillsVersion();
|
|
64
76
|
for (const name of listBundledSkills()) {
|
|
65
77
|
for (const scope of ['user', 'local']) {
|
|
66
78
|
if (!isSkillInstalled(name, scope))
|
|
67
79
|
continue;
|
|
68
|
-
|
|
80
|
+
const installed = getInstalledVersion(name, scope);
|
|
81
|
+
// No `.version` marker → not ours → never delete it.
|
|
82
|
+
if (installed === null)
|
|
83
|
+
continue;
|
|
84
|
+
if (installed === bundledVersion)
|
|
69
85
|
continue;
|
|
70
86
|
installSkill(name, scope);
|
|
87
|
+
// One honest line so a destructive-looking re-sync is never silent.
|
|
88
|
+
// stderr keeps stdout clean for --json consumers.
|
|
89
|
+
process.stderr.write(`trawl: re-synced skill "${name}" (${scope}) ${installed} → ${bundledVersion}\n`);
|
|
71
90
|
}
|
|
72
91
|
}
|
|
73
92
|
}
|
package/dist/lib/validate.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|