@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.
- package/README.md +24 -5
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +52 -3
- package/dist/commands/login.js +29 -9
- package/dist/commands/scraps.js +225 -95
- package/dist/commands/telemetry.js +9 -5
- package/dist/commands/token.js +7 -21
- package/dist/index.d.ts +19 -1
- package/dist/index.js +113 -42
- package/dist/lib/api.d.ts +14 -1
- package/dist/lib/api.js +133 -19
- package/dist/lib/config.d.ts +11 -0
- package/dist/lib/config.js +24 -0
- package/dist/lib/errors.d.ts +41 -0
- package/dist/lib/errors.js +59 -0
- package/dist/lib/jwt.d.ts +8 -0
- package/dist/lib/jwt.js +22 -0
- package/dist/lib/posthog.d.ts +9 -0
- package/dist/lib/posthog.js +47 -3
- package/dist/lib/prompt.js +13 -2
- package/dist/lib/validate.d.ts +9 -0
- package/dist/lib/validate.js +24 -5
- package/package.json +2 -2
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
|
|
@@ -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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
224
|
-
process.exitCode = 1;
|
|
244
|
+
usageError(`Invalid JSON for --params: ${e.message}`);
|
|
225
245
|
return;
|
|
226
246
|
}
|
|
227
247
|
if (!Array.isArray(parsed)) {
|
|
228
|
-
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 (
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
484
|
-
process.exitCode = 1;
|
|
611
|
+
usageError('Password is required.');
|
|
485
612
|
return;
|
|
486
613
|
}
|
|
487
614
|
}
|
|
488
|
-
const
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
572
|
-
process.exitCode = 1;
|
|
694
|
+
usageError('Each cookie must have a name (string) and value (string)');
|
|
573
695
|
return;
|
|
574
696
|
}
|
|
575
|
-
const
|
|
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
|
|
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
|
-
(
|
|
34
|
-
? chalk.yellow('disabled (
|
|
35
|
-
:
|
|
36
|
-
? chalk.
|
|
37
|
-
:
|
|
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
|
});
|
package/dist/commands/token.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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>;
|