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