@trawlme/cli 3.7.4 → 3.8.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 +16 -6
- package/dist/commands/login.js +19 -1
- package/dist/commands/scraps.js +66 -24
- package/dist/commands/spec.d.ts +92 -0
- package/dist/commands/spec.js +83 -0
- package/dist/commands/token.js +34 -7
- package/dist/commands/upgrade.js +27 -3
- package/dist/commands/whoami.js +11 -1
- package/dist/index.d.ts +52 -0
- package/dist/index.js +85 -4
- package/dist/lib/api.d.ts +36 -2
- package/dist/lib/api.js +184 -13
- package/dist/lib/config.d.ts +40 -8
- package/dist/lib/config.js +51 -8
- package/dist/lib/errors.d.ts +113 -2
- package/dist/lib/errors.js +178 -12
- package/dist/lib/spinner.d.ts +31 -0
- package/dist/lib/spinner.js +34 -0
- package/docs/agent-quickstart.md +88 -11
- package/package.json +2 -1
package/dist/index.d.ts
CHANGED
|
@@ -64,6 +64,58 @@ export declare function isHelpOrVersion(argv: string[]): boolean;
|
|
|
64
64
|
* case so it never shows up ahead of e.g. `trawl scraps --help`.
|
|
65
65
|
*/
|
|
66
66
|
export declare function isBareInvocation(argv: string[]): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* True for `trawl spec` (with or without `--json`) — deliberately NOT folded
|
|
69
|
+
* into `isHelpOrVersion` above: that predicate's name and doc comment are
|
|
70
|
+
* about help/version queries specifically, and `spec` is a real, data-
|
|
71
|
+
* bearing command (see `isHelpOrVersion`'s own #170 test case), not a help
|
|
72
|
+
* query. This is its own narrow predicate for a DIFFERENT reason: `spec
|
|
73
|
+
* --json` is documented (docs/agent-quickstart.md) as the first call an AI
|
|
74
|
+
* agent makes against this CLI, so it must not mutate anything the caller
|
|
75
|
+
* did not ask it to — same requirement `--help`/`--version` already got
|
|
76
|
+
* from `isHelpOrVersion` (#73), extended here to cover `spec` too.
|
|
77
|
+
*
|
|
78
|
+
* Scope, stated honestly rather than aspirationally: this guard suppresses
|
|
79
|
+
* the skills auto-sync (an `rmSync(recursive)` + `cpSync` over the user's
|
|
80
|
+
* `~/.claude/skills`) and the update notifier (a config write plus a
|
|
81
|
+
* DETACHED CHILD that queries the npm registry — fatal in an
|
|
82
|
+
* egress-restricted sandbox). It does NOT suppress `initPostHog()`, which
|
|
83
|
+
* still creates the `conf` config file and mints a persistent
|
|
84
|
+
* `telemetryUserId` on first run, exactly as it does for every other
|
|
85
|
+
* command. That one is deliberate: `spec` is the signal that tells us
|
|
86
|
+
* whether agents are actually adopting the CLI, so it stays measured, and
|
|
87
|
+
* the caller's own controls (`TRAWL_TELEMETRY=0`, `DO_NOT_TRACK=1`) are the
|
|
88
|
+
* opt-out. That same first run also has `initPostHog()` write a one-time
|
|
89
|
+
* telemetry disclosure line (`ℹ Trawl CLI collects anonymous usage
|
|
90
|
+
* telemetry…`) to STDERR, ahead of the JSON payload — this guard does not
|
|
91
|
+
* suppress that either. `stdout` stays pure JSON either way, so this only
|
|
92
|
+
* bites a caller that merges the two streams (e.g. `2>&1`); whether a `spec`
|
|
93
|
+
* probe specifically should suppress the notice is a product call,
|
|
94
|
+
* deliberately not taken here. Do not upgrade this paragraph back to "no
|
|
95
|
+
* filesystem side effect" without also gating telemetry — a comment that
|
|
96
|
+
* overstates its own invariant is worse than no comment, because the next
|
|
97
|
+
* reader trusts it.
|
|
98
|
+
*
|
|
99
|
+
* Before this, `trawl spec --json` silently re-synced
|
|
100
|
+
* `~/.claude/skills` (and `./.claude/skills`) and printed `trawl: re-synced
|
|
101
|
+
* skill …` lines to stderr BEFORE the JSON payload — an agent's very first
|
|
102
|
+
* probe of the CLI mutated the user's filesystem and polluted the channel
|
|
103
|
+
* it was about to parse.
|
|
104
|
+
*
|
|
105
|
+
* Matches `spec` as the FIRST positional token, skipping any leading
|
|
106
|
+
* `-`-prefixed tokens first (a global flag ahead of the subcommand, e.g.
|
|
107
|
+
* `trawl --debug spec --json`) — the same argv-scan discipline `hasJsonFlag`
|
|
108
|
+
* below already applies. #170 review F4 — before this, a bare
|
|
109
|
+
* `argv.slice(2)[0] === 'spec'` check was defeated by ANY leading flag:
|
|
110
|
+
* `trawl --debug spec --json` read `--debug` as the first positional, missed
|
|
111
|
+
* the match entirely, and fell through to the normal startup path — running
|
|
112
|
+
* the filesystem-mutating skills auto-sync during what's documented
|
|
113
|
+
* (docs/agent-quickstart.md) as an agent's read-only first probe of the CLI.
|
|
114
|
+
* Still never a bare substring search: e.g. `trawl scraps create --title
|
|
115
|
+
* spec` (the literal string "spec" as a FLAG VALUE, not a leading flag) never
|
|
116
|
+
* matches, since `scraps` — the first non-`-`-prefixed token — isn't `spec`.
|
|
117
|
+
*/
|
|
118
|
+
export declare function isSpecQuery(argv: string[]): boolean;
|
|
67
119
|
/** Best-effort scan for a `--json` flag in raw argv, used only when parsing
|
|
68
120
|
* itself failed before any command's own `.opts()` could be resolved (a
|
|
69
121
|
* commander usage error — unknown option/command, missing required arg). Same
|
package/dist/index.js
CHANGED
|
@@ -13,9 +13,10 @@ import { upgrade } from './commands/upgrade.js';
|
|
|
13
13
|
import { create } from './commands/create.js';
|
|
14
14
|
import { whoami } from './commands/whoami.js';
|
|
15
15
|
import { ping } from './commands/ping.js';
|
|
16
|
+
import { spec } from './commands/spec.js';
|
|
16
17
|
import { autoUpdateInstalledSkills } from './lib/skills.js';
|
|
17
18
|
import { initPostHog, captureCommand, shutdown, registerAllowedCommands } from './lib/posthog.js';
|
|
18
|
-
import { classifyError, reportError, stripCommanderErrorPrefix } from './lib/errors.js';
|
|
19
|
+
import { classifyError, reportError, stripCommanderErrorPrefix, retryFieldsFor } from './lib/errors.js';
|
|
19
20
|
import { renderPinch, pinchEnabled } from './lib/pinch.js';
|
|
20
21
|
import { maybeNotifyUpdate, scheduleUpdateCheck } from './lib/updateNotifier.js';
|
|
21
22
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -126,6 +127,10 @@ export function createProgram() {
|
|
|
126
127
|
program.addCommand(whoami);
|
|
127
128
|
program.addCommand(token);
|
|
128
129
|
program.addCommand(ping);
|
|
130
|
+
// #170 — `spec` last: it describes the whole tree above it, so it reads
|
|
131
|
+
// naturally as the final "and here's the full machine-readable map" entry
|
|
132
|
+
// rather than being wedged between identity/credential/health utilities.
|
|
133
|
+
program.addCommand(spec);
|
|
129
134
|
// Management (#108) — human/CI surface, grouped below. `scraps` still
|
|
130
135
|
// holds every pre-#108 management command (create/update/delete/banner/
|
|
131
136
|
// watch/account.*/session.*/doctor/autofix/snapshot) exactly as before.
|
|
@@ -190,6 +195,64 @@ export function isHelpOrVersion(argv) {
|
|
|
190
195
|
export function isBareInvocation(argv) {
|
|
191
196
|
return argv.slice(2).length === 0;
|
|
192
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* True for `trawl spec` (with or without `--json`) — deliberately NOT folded
|
|
200
|
+
* into `isHelpOrVersion` above: that predicate's name and doc comment are
|
|
201
|
+
* about help/version queries specifically, and `spec` is a real, data-
|
|
202
|
+
* bearing command (see `isHelpOrVersion`'s own #170 test case), not a help
|
|
203
|
+
* query. This is its own narrow predicate for a DIFFERENT reason: `spec
|
|
204
|
+
* --json` is documented (docs/agent-quickstart.md) as the first call an AI
|
|
205
|
+
* agent makes against this CLI, so it must not mutate anything the caller
|
|
206
|
+
* did not ask it to — same requirement `--help`/`--version` already got
|
|
207
|
+
* from `isHelpOrVersion` (#73), extended here to cover `spec` too.
|
|
208
|
+
*
|
|
209
|
+
* Scope, stated honestly rather than aspirationally: this guard suppresses
|
|
210
|
+
* the skills auto-sync (an `rmSync(recursive)` + `cpSync` over the user's
|
|
211
|
+
* `~/.claude/skills`) and the update notifier (a config write plus a
|
|
212
|
+
* DETACHED CHILD that queries the npm registry — fatal in an
|
|
213
|
+
* egress-restricted sandbox). It does NOT suppress `initPostHog()`, which
|
|
214
|
+
* still creates the `conf` config file and mints a persistent
|
|
215
|
+
* `telemetryUserId` on first run, exactly as it does for every other
|
|
216
|
+
* command. That one is deliberate: `spec` is the signal that tells us
|
|
217
|
+
* whether agents are actually adopting the CLI, so it stays measured, and
|
|
218
|
+
* the caller's own controls (`TRAWL_TELEMETRY=0`, `DO_NOT_TRACK=1`) are the
|
|
219
|
+
* opt-out. That same first run also has `initPostHog()` write a one-time
|
|
220
|
+
* telemetry disclosure line (`ℹ Trawl CLI collects anonymous usage
|
|
221
|
+
* telemetry…`) to STDERR, ahead of the JSON payload — this guard does not
|
|
222
|
+
* suppress that either. `stdout` stays pure JSON either way, so this only
|
|
223
|
+
* bites a caller that merges the two streams (e.g. `2>&1`); whether a `spec`
|
|
224
|
+
* probe specifically should suppress the notice is a product call,
|
|
225
|
+
* deliberately not taken here. Do not upgrade this paragraph back to "no
|
|
226
|
+
* filesystem side effect" without also gating telemetry — a comment that
|
|
227
|
+
* overstates its own invariant is worse than no comment, because the next
|
|
228
|
+
* reader trusts it.
|
|
229
|
+
*
|
|
230
|
+
* Before this, `trawl spec --json` silently re-synced
|
|
231
|
+
* `~/.claude/skills` (and `./.claude/skills`) and printed `trawl: re-synced
|
|
232
|
+
* skill …` lines to stderr BEFORE the JSON payload — an agent's very first
|
|
233
|
+
* probe of the CLI mutated the user's filesystem and polluted the channel
|
|
234
|
+
* it was about to parse.
|
|
235
|
+
*
|
|
236
|
+
* Matches `spec` as the FIRST positional token, skipping any leading
|
|
237
|
+
* `-`-prefixed tokens first (a global flag ahead of the subcommand, e.g.
|
|
238
|
+
* `trawl --debug spec --json`) — the same argv-scan discipline `hasJsonFlag`
|
|
239
|
+
* below already applies. #170 review F4 — before this, a bare
|
|
240
|
+
* `argv.slice(2)[0] === 'spec'` check was defeated by ANY leading flag:
|
|
241
|
+
* `trawl --debug spec --json` read `--debug` as the first positional, missed
|
|
242
|
+
* the match entirely, and fell through to the normal startup path — running
|
|
243
|
+
* the filesystem-mutating skills auto-sync during what's documented
|
|
244
|
+
* (docs/agent-quickstart.md) as an agent's read-only first probe of the CLI.
|
|
245
|
+
* Still never a bare substring search: e.g. `trawl scraps create --title
|
|
246
|
+
* spec` (the literal string "spec" as a FLAG VALUE, not a leading flag) never
|
|
247
|
+
* matches, since `scraps` — the first non-`-`-prefixed token — isn't `spec`.
|
|
248
|
+
*/
|
|
249
|
+
export function isSpecQuery(argv) {
|
|
250
|
+
const args = argv.slice(2);
|
|
251
|
+
let i = 0;
|
|
252
|
+
while (i < args.length && args[i].startsWith('-'))
|
|
253
|
+
i++;
|
|
254
|
+
return args[i] === 'spec';
|
|
255
|
+
}
|
|
193
256
|
/** Best-effort scan for a `--json` flag in raw argv, used only when parsing
|
|
194
257
|
* itself failed before any command's own `.opts()` could be resolved (a
|
|
195
258
|
* commander usage error — unknown option/command, missing required arg). Same
|
|
@@ -290,7 +353,7 @@ export function bestEffortCommandName(argv, allowedNames) {
|
|
|
290
353
|
return 'unknown';
|
|
291
354
|
}
|
|
292
355
|
export async function runCli(argv = process.argv) {
|
|
293
|
-
if (!isHelpOrVersion(argv))
|
|
356
|
+
if (!isHelpOrVersion(argv) && !isSpecQuery(argv))
|
|
294
357
|
autoUpdateInstalledSkills();
|
|
295
358
|
// Pinch wave banner (#94) — bare `trawl` only, guarded so it never shows
|
|
296
359
|
// under NO_COLOR/non-TTY/piped output (pinchEnabled() covers all three).
|
|
@@ -392,7 +455,18 @@ export async function runCli(argv = process.argv) {
|
|
|
392
455
|
// of the outputError override above, which only reformats what
|
|
393
456
|
// gets WRITTEN to stderr) — strip it so the envelope's `message`
|
|
394
457
|
// stays clean for a machine parser.
|
|
395
|
-
|
|
458
|
+
//
|
|
459
|
+
// #170 review F3 — typed as ErrorEnvelope (not a bare object
|
|
460
|
+
// literal) so the compiler enforces `retryable` being non-optional
|
|
461
|
+
// here too. Mutation-proven: `tsc --noEmit` stayed at exit 0 with
|
|
462
|
+
// `...retryFieldsFor('usage')` removed entirely before this
|
|
463
|
+
// annotation existed.
|
|
464
|
+
const envelope = {
|
|
465
|
+
message: stripCommanderErrorPrefix(err.message),
|
|
466
|
+
kind: 'usage',
|
|
467
|
+
...retryFieldsFor('usage'),
|
|
468
|
+
};
|
|
469
|
+
console.log(JSON.stringify({ error: envelope }));
|
|
396
470
|
}
|
|
397
471
|
process.exitCode = 2;
|
|
398
472
|
// #88 item 6 — currentCommand is still undefined here whenever the
|
|
@@ -443,7 +517,14 @@ export async function runCli(argv = process.argv) {
|
|
|
443
517
|
// Passive "update available" notifier (#129) — never on a help/version
|
|
444
518
|
// query, and each half individually guarded so a notifier bug can never
|
|
445
519
|
// turn a successful command into a failure or change its exit code.
|
|
446
|
-
|
|
520
|
+
// #170 review F5 — never on a `spec` query either: `maybeNotifyUpdate`/
|
|
521
|
+
// `scheduleUpdateCheck` write to disk (`Library/Preferences/…/config.json`,
|
|
522
|
+
// `update-check.json`) and the latter spawns a DETACHED child that queries
|
|
523
|
+
// the npm registry — real filesystem + network side effects `isSpecQuery`'s
|
|
524
|
+
// own doc comment already promises `spec --json` never has. Before this,
|
|
525
|
+
// `spec`'s only protection was skipping the skills auto-sync above; this
|
|
526
|
+
// finally block ran unconditionally regardless.
|
|
527
|
+
if (!isHelpOrVersion(argv) && !isSpecQuery(argv)) {
|
|
447
528
|
try {
|
|
448
529
|
maybeNotifyUpdate();
|
|
449
530
|
}
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
export declare class ApiError extends Error {
|
|
2
2
|
status: number;
|
|
3
|
-
|
|
3
|
+
next?: string[] | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* `next` is an OPTIONAL per-instance override of the envelope's default
|
|
6
|
+
* `next` steps (errors.ts's frozen `RETRY_POLICY`) — set only at the two
|
|
7
|
+
* apiKey-mode 401 call sites below (via authNextSteps()), where the
|
|
8
|
+
* generic "trawl login --token <jwt>" default is inert until a live
|
|
9
|
+
* TRAWL_API_KEY/TRAWL_TOKEN is unset first (#169 review). Absent for every
|
|
10
|
+
* other error, so classifyError falls back to the frozen default exactly
|
|
11
|
+
* as before.
|
|
12
|
+
*/
|
|
13
|
+
constructor(status: number, message: string, next?: string[] | undefined);
|
|
4
14
|
}
|
|
5
15
|
/**
|
|
6
16
|
* A fetch-level failure — the request never got a response at all (DNS,
|
|
@@ -23,7 +33,9 @@ export declare class NetworkError extends Error {
|
|
|
23
33
|
* ApiError, absent here). (#88 item 4)
|
|
24
34
|
*/
|
|
25
35
|
export declare class AuthError extends Error {
|
|
26
|
-
|
|
36
|
+
next?: string[] | undefined;
|
|
37
|
+
/** See ApiError's `next` for what this overrides and why. */
|
|
38
|
+
constructor(message: string, next?: string[] | undefined);
|
|
27
39
|
}
|
|
28
40
|
/**
|
|
29
41
|
* The single "no token available" error — every call site in this file that
|
|
@@ -37,6 +49,28 @@ export declare class AuthError extends Error {
|
|
|
37
49
|
* the same thing everywhere it can be observed. (#86 findings 1/2, #88 item 4)
|
|
38
50
|
*/
|
|
39
51
|
export declare function notLoggedInError(): AuthError;
|
|
52
|
+
/**
|
|
53
|
+
* The "this route is JWT-only" error — thrown client-side, before any HTTP
|
|
54
|
+
* call, by a command that only works with a session JWT (today: `whoami`,
|
|
55
|
+
* see its own doc comment for why `GET /api/users/me` was descoped rather
|
|
56
|
+
* than relocated) when the resolved credential is a scoped API key instead.
|
|
57
|
+
* Distinct from notLoggedInError(): there IS a credential here, it's just
|
|
58
|
+
* the wrong shape for this one route. Left unhandled, that route would 401
|
|
59
|
+
* and surface the generic `notLoggedInError`-adjacent message ("Session
|
|
60
|
+
* expired or invalid. Run: trawl login") — wrong twice over under a key: the
|
|
61
|
+
* route never accepts keys at all (no session ever "expired"), and
|
|
62
|
+
* re-running `trawl login` genuinely IS the fix here, just not because
|
|
63
|
+
* anything expired. Classifies to the SAME exit 3 / kind:"auth" as every
|
|
64
|
+
* other auth failure (#169) — reusing the existing `auth` kind rather than
|
|
65
|
+
* minting a new one, since the taxonomy question is still "not
|
|
66
|
+
* authenticated the way this route needs," never a new category.
|
|
67
|
+
*
|
|
68
|
+
* Always called already knowing authMode is 'apiKey' (see whoami.ts) — the
|
|
69
|
+
* message and `next` both route through loginRemedyText()/authNextSteps()
|
|
70
|
+
* so this never emits the same inert "Run: trawl login" that finding 2
|
|
71
|
+
* corrects everywhere else. (#169 review round 2 — finding 2)
|
|
72
|
+
*/
|
|
73
|
+
export declare function apiKeyUnsupportedError(command: string): AuthError;
|
|
40
74
|
/**
|
|
41
75
|
* #91 P0 — some endpoints legitimately run 30–250s server-side: a scrap
|
|
42
76
|
* execute (worker-puppeteer navigation + antibot tier escalation + AI-fix
|
package/dist/lib/api.js
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
|
-
import { getApiUrl, getToken } from './config.js';
|
|
4
|
+
import { getApiUrl, getToken, getAuthMode, getLiveAuthEnvVar } from './config.js';
|
|
5
5
|
import { parseServerJson } from './json.js';
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
const pkg = JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf8'));
|
|
8
8
|
const USER_AGENT = `@trawlme/cli/${pkg.version}`;
|
|
9
9
|
export class ApiError extends Error {
|
|
10
10
|
status;
|
|
11
|
-
|
|
11
|
+
next;
|
|
12
|
+
/**
|
|
13
|
+
* `next` is an OPTIONAL per-instance override of the envelope's default
|
|
14
|
+
* `next` steps (errors.ts's frozen `RETRY_POLICY`) — set only at the two
|
|
15
|
+
* apiKey-mode 401 call sites below (via authNextSteps()), where the
|
|
16
|
+
* generic "trawl login --token <jwt>" default is inert until a live
|
|
17
|
+
* TRAWL_API_KEY/TRAWL_TOKEN is unset first (#169 review). Absent for every
|
|
18
|
+
* other error, so classifyError falls back to the frozen default exactly
|
|
19
|
+
* as before.
|
|
20
|
+
*/
|
|
21
|
+
constructor(status, message, next) {
|
|
12
22
|
super(message);
|
|
13
23
|
this.status = status;
|
|
24
|
+
this.next = next;
|
|
14
25
|
this.name = 'ApiError';
|
|
15
26
|
}
|
|
16
27
|
}
|
|
@@ -38,8 +49,11 @@ export class NetworkError extends Error {
|
|
|
38
49
|
* ApiError, absent here). (#88 item 4)
|
|
39
50
|
*/
|
|
40
51
|
export class AuthError extends Error {
|
|
41
|
-
|
|
52
|
+
next;
|
|
53
|
+
/** See ApiError's `next` for what this overrides and why. */
|
|
54
|
+
constructor(message, next) {
|
|
42
55
|
super(message);
|
|
56
|
+
this.next = next;
|
|
43
57
|
this.name = 'AuthError';
|
|
44
58
|
}
|
|
45
59
|
}
|
|
@@ -57,6 +71,144 @@ export class AuthError extends Error {
|
|
|
57
71
|
export function notLoggedInError() {
|
|
58
72
|
return new AuthError('Not logged in. Run: trawl login');
|
|
59
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* The actionable "how to recover" tail for an apiKey-mode auth failure —
|
|
76
|
+
* shared by sessionAuthMessage's apiKey branch and apiKeyUnsupportedError so
|
|
77
|
+
* neither one contradicts the other. `Run: trawl login` alone is INERT
|
|
78
|
+
* whenever TRAWL_API_KEY/TRAWL_TOKEN is live: getToken()'s own precedence
|
|
79
|
+
* (config.ts) keeps resolving the env credential over whatever `trawl login`
|
|
80
|
+
* just stored, so the login it just told the operator to run changes
|
|
81
|
+
* nothing on the very next request — reproduced end to end (`login --token
|
|
82
|
+
* <jwt>` reports success; the next call 401s again with the same rejected
|
|
83
|
+
* key). Leads with the unset step whenever there's a live var to unset;
|
|
84
|
+
* falls back to the plain original wording when there isn't (the stored
|
|
85
|
+
* config token case — `trawl login` alone already fixes that one). (#169
|
|
86
|
+
* review round 2 — finding 2)
|
|
87
|
+
*/
|
|
88
|
+
function loginRemedyText() {
|
|
89
|
+
const liveVar = getLiveAuthEnvVar();
|
|
90
|
+
return liveVar ? `unset ${liveVar}, then run: trawl login` : 'Run: trawl login';
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The machine-readable counterpart to loginRemedyText() — feeds `ApiError`/
|
|
94
|
+
* `AuthError`'s `next` override (see ApiError's doc comment) so an agent
|
|
95
|
+
* reading `--json` on stdout gets the SAME "unset first" step the prose
|
|
96
|
+
* message carries, never a `next` that quietly disagrees with the message
|
|
97
|
+
* next to it. Only ever called from a branch that already knows the auth
|
|
98
|
+
* mode is 'apiKey' — see both call sites. (#169 review round 2 — finding 2)
|
|
99
|
+
*/
|
|
100
|
+
function authNextSteps() {
|
|
101
|
+
const liveVar = getLiveAuthEnvVar();
|
|
102
|
+
const login = 'trawl login --token <jwt>';
|
|
103
|
+
return liveVar ? [`unset ${liveVar}`, login] : [login];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The "this route is JWT-only" error — thrown client-side, before any HTTP
|
|
107
|
+
* call, by a command that only works with a session JWT (today: `whoami`,
|
|
108
|
+
* see its own doc comment for why `GET /api/users/me` was descoped rather
|
|
109
|
+
* than relocated) when the resolved credential is a scoped API key instead.
|
|
110
|
+
* Distinct from notLoggedInError(): there IS a credential here, it's just
|
|
111
|
+
* the wrong shape for this one route. Left unhandled, that route would 401
|
|
112
|
+
* and surface the generic `notLoggedInError`-adjacent message ("Session
|
|
113
|
+
* expired or invalid. Run: trawl login") — wrong twice over under a key: the
|
|
114
|
+
* route never accepts keys at all (no session ever "expired"), and
|
|
115
|
+
* re-running `trawl login` genuinely IS the fix here, just not because
|
|
116
|
+
* anything expired. Classifies to the SAME exit 3 / kind:"auth" as every
|
|
117
|
+
* other auth failure (#169) — reusing the existing `auth` kind rather than
|
|
118
|
+
* minting a new one, since the taxonomy question is still "not
|
|
119
|
+
* authenticated the way this route needs," never a new category.
|
|
120
|
+
*
|
|
121
|
+
* Always called already knowing authMode is 'apiKey' (see whoami.ts) — the
|
|
122
|
+
* message and `next` both route through loginRemedyText()/authNextSteps()
|
|
123
|
+
* so this never emits the same inert "Run: trawl login" that finding 2
|
|
124
|
+
* corrects everywhere else. (#169 review round 2 — finding 2)
|
|
125
|
+
*/
|
|
126
|
+
export function apiKeyUnsupportedError(command) {
|
|
127
|
+
return new AuthError(`${command} requires a session (JWT) — this credential is a scoped API key, which this route does not accept. ${loginRemedyText()}`, authNextSteps());
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The ONE place that decides what a real server-issued 401 means, for
|
|
131
|
+
* whichever credential shape produced it (#169 review finding 1). Before this, a
|
|
132
|
+
* genuine 401 always got the same "Session expired or invalid" text — true
|
|
133
|
+
* for a JWT, but wrong twice over for a scoped API key: an API key doesn't
|
|
134
|
+
* have a "session" to expire, and re-running `trawl login` fixes nothing
|
|
135
|
+
* when the ROUTE itself is JWT-only (`scraps update`/`delete`, `scraps
|
|
136
|
+
* account *`/`scraps session *`, `scraps banner`, the SSE `scraps watch` —
|
|
137
|
+
* `whoami` already catches this client-side, see apiKeyUnsupportedError
|
|
138
|
+
* above, but those five/six never had an equivalent guard).
|
|
139
|
+
*
|
|
140
|
+
* #169 review round 2 — finding 1: the FIRST fix here asserted two things
|
|
141
|
+
* never established — that the route is JWT-only, AND that "the API key in
|
|
142
|
+
* use is valid." Both are false whenever the 401 is the KEY's own fault
|
|
143
|
+
* (revoked/rotated/malformed) rather than the route's — reproduced live
|
|
144
|
+
* against `GET /api/scraps` (dual-auth, works with a key) using a revoked
|
|
145
|
+
* key: the old text confidently certified the key as valid and blamed the
|
|
146
|
+
* route, which sends the operator away from the actual problem. trawl_node's
|
|
147
|
+
* authenticateApiKey.js (modules/developers/middlewares/authenticateApiKey.js)
|
|
148
|
+
* already tells the two cases apart in the response body — `responses.error`
|
|
149
|
+
* puts the real reason in `description` ("Invalid or expired API key" /
|
|
150
|
+
* "Invalid API key format" / "Missing or invalid Authorization header") for
|
|
151
|
+
* a REJECTED key, while a JWT-only route's `passport.authenticate('jwt')`
|
|
152
|
+
* (cookie-only extractor — a Bearer header never even reaches the verify
|
|
153
|
+
* step) sends passport's own bare `Unauthorized` body with nothing to
|
|
154
|
+
* disambiguate. `extractErrorMessage` already unwraps that envelope and
|
|
155
|
+
* prefers `description` over a `message` that only echoes the reason phrase
|
|
156
|
+
* — reused here instead of re-parsing, and its plain-text fallback (`return
|
|
157
|
+
* raw`) is exactly the bare `Unauthorized` case. So: a body that resolves to
|
|
158
|
+
* anything other than that bare word IS the server naming the real cause —
|
|
159
|
+
* trust it over any guess. Anything else (unrecoverable, or genuinely just
|
|
160
|
+
* "Unauthorized") gets an honest hedge between the two live possibilities,
|
|
161
|
+
* never a pick.
|
|
162
|
+
*
|
|
163
|
+
* Deliberately driven off the REAL response (called only where a 401 just
|
|
164
|
+
* came back from the server), not a client-side allow-list of "routes a key
|
|
165
|
+
* can reach" duplicated into every command handler — the server is the only
|
|
166
|
+
* side that actually knows which routes accept a key, and a second,
|
|
167
|
+
* hand-maintained list here would silently drift the moment trawl_node opens
|
|
168
|
+
* (or closes) a route to keys. That's also why the message can't name the
|
|
169
|
+
* specific CLI command the way apiKeyUnsupportedError does: this function
|
|
170
|
+
* only ever sees the HTTP response, never which `trawl` verb issued the
|
|
171
|
+
* request.
|
|
172
|
+
*
|
|
173
|
+
* `res` is optional and read defensively (`typeof res.text === 'function'`,
|
|
174
|
+
* `.catch(() => '')`) — the SSE call site (`stream()`) passes a real
|
|
175
|
+
* `Response` in production, but its own unit tests mock a bare `{ok,
|
|
176
|
+
* status}` with no `text()` at all; a missing/failed body read just falls
|
|
177
|
+
* through to the honest hedge rather than throwing a SECOND error while
|
|
178
|
+
* already handling the first.
|
|
179
|
+
*
|
|
180
|
+
* Still an ApiError(401), not an AuthError — a real HTTP round-trip
|
|
181
|
+
* happened, so the envelope's `status:401` field must stay honest about
|
|
182
|
+
* that (see AuthError's own doc comment on why a LOCAL failure never
|
|
183
|
+
* fabricates one). Same exit 3 / kind:"auth" either way — only the message
|
|
184
|
+
* text differs. (#169 review)
|
|
185
|
+
*/
|
|
186
|
+
async function sessionAuthMessage(authMode, res) {
|
|
187
|
+
if (authMode === 'jwt')
|
|
188
|
+
return 'Session expired or invalid. Run: trawl login';
|
|
189
|
+
const raw = res && typeof res.text === 'function' ? await res.text().catch(() => '') : '';
|
|
190
|
+
const cause = raw ? extractErrorMessage(raw, res?.statusText) : '';
|
|
191
|
+
if (cause && cause.trim().toLowerCase() !== 'unauthorized') {
|
|
192
|
+
return `${cause} (401). ${loginRemedyText()} to use a session instead.`;
|
|
193
|
+
}
|
|
194
|
+
return `The API key was rejected (401) — either this route requires a session (JWT), or the key is invalid or revoked. ${loginRemedyText()}.`;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* One shared header set for whichever credential `getToken()` resolved: a
|
|
198
|
+
* scoped API key (`trawl_*`) goes in `Authorization: Bearer`; a session JWT
|
|
199
|
+
* stays in the `TOKEN` cookie — trawl_node's passport JWT strategy is
|
|
200
|
+
* cookie-only on the REST surface, so a Bearer header there is silently
|
|
201
|
+
* ignored, which is why this is a SWITCH and not an addition. Never sends
|
|
202
|
+
* both — two credentials on one request is an ambiguity the server does not
|
|
203
|
+
* have to resolve in our favour. Takes the token the caller already
|
|
204
|
+
* resolved (never re-calls getToken() itself) so a request's headers and its
|
|
205
|
+
* own classification of that same token can't drift mid-flight. Used at all
|
|
206
|
+
* 5 call sites that attach a credential: request/upload/publicGet/getText/
|
|
207
|
+
* stream. (#169)
|
|
208
|
+
*/
|
|
209
|
+
function authHeaders(token) {
|
|
210
|
+
return getAuthMode(token) === 'apiKey' ? { Authorization: `Bearer ${token}` } : { Cookie: `TOKEN=${token}` };
|
|
211
|
+
}
|
|
60
212
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
61
213
|
/**
|
|
62
214
|
* #91 P0 — some endpoints legitimately run 30–250s server-side: a scrap
|
|
@@ -212,9 +364,15 @@ function extractUpgradeUrl(raw) {
|
|
|
212
364
|
}
|
|
213
365
|
return null;
|
|
214
366
|
}
|
|
215
|
-
async function throwIfError(res, isPublic = false) {
|
|
367
|
+
async function throwIfError(res, isPublic = false, authMode = 'jwt') {
|
|
216
368
|
if (res.status === 401 && !isPublic) {
|
|
217
|
-
|
|
369
|
+
// #169 review round 2 — finding 1: sessionAuthMessage now reads the body
|
|
370
|
+
// itself (the bug this whole branch used to have was discarding it
|
|
371
|
+
// without ever calling res.text()), so it's awaited here instead of
|
|
372
|
+
// called synchronously. `next` is only overridden in apiKey mode — see
|
|
373
|
+
// authNextSteps()'s own doc comment for why jwt mode keeps the frozen
|
|
374
|
+
// default (finding 2).
|
|
375
|
+
throw new ApiError(401, await sessionAuthMessage(authMode, res), authMode === 'apiKey' ? authNextSteps() : undefined);
|
|
218
376
|
}
|
|
219
377
|
if (!res.ok) {
|
|
220
378
|
const raw = await res.text();
|
|
@@ -249,10 +407,10 @@ async function request(path, options = {}, reqOpts = {}) {
|
|
|
249
407
|
'Content-Type': 'application/json',
|
|
250
408
|
'User-Agent': USER_AGENT,
|
|
251
409
|
...options.headers,
|
|
252
|
-
|
|
410
|
+
...authHeaders(token),
|
|
253
411
|
},
|
|
254
412
|
}, timeoutMs);
|
|
255
|
-
await throwIfError(res);
|
|
413
|
+
await throwIfError(res, false, getAuthMode(token));
|
|
256
414
|
const text = await res.text();
|
|
257
415
|
try {
|
|
258
416
|
if (!text)
|
|
@@ -281,10 +439,10 @@ async function upload(path, formData, reqOpts = {}) {
|
|
|
281
439
|
signal: AbortSignal.timeout(timeoutMs),
|
|
282
440
|
headers: {
|
|
283
441
|
'User-Agent': USER_AGENT,
|
|
284
|
-
|
|
442
|
+
...authHeaders(token),
|
|
285
443
|
},
|
|
286
444
|
}, timeoutMs);
|
|
287
|
-
await throwIfError(res);
|
|
445
|
+
await throwIfError(res, false, getAuthMode(token));
|
|
288
446
|
const text = await res.text();
|
|
289
447
|
try {
|
|
290
448
|
if (!text)
|
|
@@ -340,7 +498,7 @@ async function publicGet(path, reqOpts = {}) {
|
|
|
340
498
|
const res = await safeFetch(url, {
|
|
341
499
|
headers: {
|
|
342
500
|
'User-Agent': USER_AGENT,
|
|
343
|
-
...(token ?
|
|
501
|
+
...(token ? authHeaders(token) : {}),
|
|
344
502
|
},
|
|
345
503
|
signal: AbortSignal.timeout(timeoutMs),
|
|
346
504
|
}, timeoutMs);
|
|
@@ -369,11 +527,11 @@ async function getText(path, reqOpts = {}) {
|
|
|
369
527
|
const res = await safeFetch(url, {
|
|
370
528
|
headers: {
|
|
371
529
|
'User-Agent': USER_AGENT,
|
|
372
|
-
|
|
530
|
+
...authHeaders(token),
|
|
373
531
|
},
|
|
374
532
|
signal: AbortSignal.timeout(timeoutMs),
|
|
375
533
|
}, timeoutMs);
|
|
376
|
-
await throwIfError(res);
|
|
534
|
+
await throwIfError(res, false, getAuthMode(token));
|
|
377
535
|
return res.text();
|
|
378
536
|
}
|
|
379
537
|
export const api = {
|
|
@@ -405,9 +563,22 @@ export const api = {
|
|
|
405
563
|
headers: {
|
|
406
564
|
Accept: 'text/event-stream',
|
|
407
565
|
'User-Agent': USER_AGENT,
|
|
408
|
-
|
|
566
|
+
...authHeaders(token),
|
|
409
567
|
},
|
|
410
568
|
}, getTimeoutMs());
|
|
569
|
+
// `scraps watch` is one of the JWT-only routes (#169 review finding 1) —
|
|
570
|
+
// singled out here (not routed through throwIfError, since this call
|
|
571
|
+
// never sets `isPublic` and the SSE fetch has no timeout signal to
|
|
572
|
+
// thread through) so a real 401 gets the same honest, auth-mode-aware
|
|
573
|
+
// message every other JWT-only route does, instead of the opaque `SSE
|
|
574
|
+
// failed: 401`. sessionAuthMessage reads `res` defensively — a real SSE
|
|
575
|
+
// Response supports `.text()` in production; only this file's own mocks
|
|
576
|
+
// sometimes don't, and that falls through to the honest hedge rather
|
|
577
|
+
// than throwing a second error (see sessionAuthMessage's doc comment).
|
|
578
|
+
if (res.status === 401) {
|
|
579
|
+
const mode = getAuthMode(token);
|
|
580
|
+
throw new ApiError(401, await sessionAuthMessage(mode, res), mode === 'apiKey' ? authNextSteps() : undefined);
|
|
581
|
+
}
|
|
411
582
|
if (!res.ok || !res.body) {
|
|
412
583
|
throw new ApiError(res.status, `SSE failed: ${res.status}`);
|
|
413
584
|
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -24,14 +24,46 @@ declare const config: Conf<TrawlConfig>;
|
|
|
24
24
|
*/
|
|
25
25
|
export declare function getApiUrl(): string;
|
|
26
26
|
/**
|
|
27
|
-
* Resolve the effective session
|
|
28
|
-
* Precedence: TRAWL_TOKEN env > stored `trawl login`
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
27
|
+
* Resolve the effective credential — a scoped API key or a session JWT.
|
|
28
|
+
* Precedence: TRAWL_API_KEY env > TRAWL_TOKEN env > stored `trawl login`
|
|
29
|
+
* token. TRAWL_API_KEY resolves first so an agent harness that carries BOTH
|
|
30
|
+
* (e.g. a scoped key set globally alongside a leftover human TRAWL_TOKEN)
|
|
31
|
+
* gets the key unambiguously, never a silent fall-through to the
|
|
32
|
+
* higher-privilege JWT. Lets CI/agents authenticate headlessly
|
|
33
|
+
* (`TRAWL_API_KEY=trawl_xxx trawl list` or `TRAWL_TOKEN=<jwt> trawl list`)
|
|
34
|
+
* without ever touching the on-disk config — and without a stored token
|
|
35
|
+
* being silently sent to whatever TRAWL_API_URL points at instead (cross-env
|
|
36
|
+
* credential misuse). Every request/upload/publicGet/getText/stream call
|
|
37
|
+
* site in api.ts must read the token through this, never through
|
|
38
|
+
* `config.get('token')` directly. Mirrors getApiUrl(). (#68, #169)
|
|
35
39
|
*/
|
|
36
40
|
export declare function getToken(): string;
|
|
41
|
+
/**
|
|
42
|
+
* Discriminate which of the two credential shapes this CLI can send a
|
|
43
|
+
* `token` is: a scoped API key (`Authorization: Bearer`) or a session JWT
|
|
44
|
+
* (`Cookie: TOKEN=`). The `trawl_` prefix is the SERVER's own test —
|
|
45
|
+
* trawl_node's `authenticateApiKey.js` checks `rawKey.startsWith('trawl_')`
|
|
46
|
+
* — not a convention invented on this side; this function exists so the two
|
|
47
|
+
* sides can never quietly drift apart on what counts as a key. Defaults to
|
|
48
|
+
* the live `getToken()` result so most callers (e.g. `whoami`'s JWT-only
|
|
49
|
+
* guard) need no argument; `api.ts`'s `authHeaders()` instead passes the
|
|
50
|
+
* exact token it already resolved for THIS request, so a request's headers
|
|
51
|
+
* and its classification of that same token can never disagree. (#169)
|
|
52
|
+
*/
|
|
53
|
+
export declare function getAuthMode(token?: string): 'apiKey' | 'jwt';
|
|
54
|
+
/**
|
|
55
|
+
* Which of getToken()'s two env-var overrides actually won, if either —
|
|
56
|
+
* mirrors its TRAWL_API_KEY > TRAWL_TOKEN precedence exactly. Neither
|
|
57
|
+
* `trawl login` nor `trawl logout` can unset a caller's OWN environment, so
|
|
58
|
+
* whichever of these is set keeps overriding the stored config token
|
|
59
|
+
* regardless of what those commands just did (#169 review). login.ts's
|
|
60
|
+
* post-login/post-logout warnings and api.ts's apiKey-mode auth-failure
|
|
61
|
+
* messages/`next` steps all need the SAME answer to "what has to be unset
|
|
62
|
+
* before `trawl login` takes effect" — resolved once here rather than each
|
|
63
|
+
* call site re-deriving its own copy that could quietly drift from
|
|
64
|
+
* getToken()'s actual precedence. Returns null when the credential came from
|
|
65
|
+
* the stored config token instead — nothing to unset there, `trawl login`
|
|
66
|
+
* alone already fixes that case.
|
|
67
|
+
*/
|
|
68
|
+
export declare function getLiveAuthEnvVar(): 'TRAWL_API_KEY' | 'TRAWL_TOKEN' | null;
|
|
37
69
|
export default config;
|
package/dist/lib/config.js
CHANGED
|
@@ -31,17 +31,60 @@ export function getApiUrl() {
|
|
|
31
31
|
return override ? override : config.get('apiUrl');
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
|
-
* Resolve the effective session
|
|
35
|
-
* Precedence: TRAWL_TOKEN env > stored `trawl login`
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
34
|
+
* Resolve the effective credential — a scoped API key or a session JWT.
|
|
35
|
+
* Precedence: TRAWL_API_KEY env > TRAWL_TOKEN env > stored `trawl login`
|
|
36
|
+
* token. TRAWL_API_KEY resolves first so an agent harness that carries BOTH
|
|
37
|
+
* (e.g. a scoped key set globally alongside a leftover human TRAWL_TOKEN)
|
|
38
|
+
* gets the key unambiguously, never a silent fall-through to the
|
|
39
|
+
* higher-privilege JWT. Lets CI/agents authenticate headlessly
|
|
40
|
+
* (`TRAWL_API_KEY=trawl_xxx trawl list` or `TRAWL_TOKEN=<jwt> trawl list`)
|
|
41
|
+
* without ever touching the on-disk config — and without a stored token
|
|
42
|
+
* being silently sent to whatever TRAWL_API_URL points at instead (cross-env
|
|
43
|
+
* credential misuse). Every request/upload/publicGet/getText/stream call
|
|
44
|
+
* site in api.ts must read the token through this, never through
|
|
45
|
+
* `config.get('token')` directly. Mirrors getApiUrl(). (#68, #169)
|
|
42
46
|
*/
|
|
43
47
|
export function getToken() {
|
|
48
|
+
const apiKey = process.env['TRAWL_API_KEY']?.trim();
|
|
49
|
+
if (apiKey)
|
|
50
|
+
return apiKey;
|
|
44
51
|
const override = process.env['TRAWL_TOKEN']?.trim();
|
|
45
52
|
return override ? override : config.get('token');
|
|
46
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Discriminate which of the two credential shapes this CLI can send a
|
|
56
|
+
* `token` is: a scoped API key (`Authorization: Bearer`) or a session JWT
|
|
57
|
+
* (`Cookie: TOKEN=`). The `trawl_` prefix is the SERVER's own test —
|
|
58
|
+
* trawl_node's `authenticateApiKey.js` checks `rawKey.startsWith('trawl_')`
|
|
59
|
+
* — not a convention invented on this side; this function exists so the two
|
|
60
|
+
* sides can never quietly drift apart on what counts as a key. Defaults to
|
|
61
|
+
* the live `getToken()` result so most callers (e.g. `whoami`'s JWT-only
|
|
62
|
+
* guard) need no argument; `api.ts`'s `authHeaders()` instead passes the
|
|
63
|
+
* exact token it already resolved for THIS request, so a request's headers
|
|
64
|
+
* and its classification of that same token can never disagree. (#169)
|
|
65
|
+
*/
|
|
66
|
+
export function getAuthMode(token = getToken()) {
|
|
67
|
+
return token.startsWith('trawl_') ? 'apiKey' : 'jwt';
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Which of getToken()'s two env-var overrides actually won, if either —
|
|
71
|
+
* mirrors its TRAWL_API_KEY > TRAWL_TOKEN precedence exactly. Neither
|
|
72
|
+
* `trawl login` nor `trawl logout` can unset a caller's OWN environment, so
|
|
73
|
+
* whichever of these is set keeps overriding the stored config token
|
|
74
|
+
* regardless of what those commands just did (#169 review). login.ts's
|
|
75
|
+
* post-login/post-logout warnings and api.ts's apiKey-mode auth-failure
|
|
76
|
+
* messages/`next` steps all need the SAME answer to "what has to be unset
|
|
77
|
+
* before `trawl login` takes effect" — resolved once here rather than each
|
|
78
|
+
* call site re-deriving its own copy that could quietly drift from
|
|
79
|
+
* getToken()'s actual precedence. Returns null when the credential came from
|
|
80
|
+
* the stored config token instead — nothing to unset there, `trawl login`
|
|
81
|
+
* alone already fixes that case.
|
|
82
|
+
*/
|
|
83
|
+
export function getLiveAuthEnvVar() {
|
|
84
|
+
if (process.env['TRAWL_API_KEY']?.trim())
|
|
85
|
+
return 'TRAWL_API_KEY';
|
|
86
|
+
if (process.env['TRAWL_TOKEN']?.trim())
|
|
87
|
+
return 'TRAWL_TOKEN';
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
47
90
|
export default config;
|