@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/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
- console.log(JSON.stringify({ error: { message: stripCommanderErrorPrefix(err.message), kind: 'usage' } }));
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
- if (!isHelpOrVersion(argv)) {
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
- constructor(status: number, message: string);
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
- constructor(message: string);
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
- constructor(status, message) {
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
- constructor(message) {
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
- throw new ApiError(401, 'Session expired or invalid. Run: trawl login');
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
- Cookie: `TOKEN=${token}`,
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
- Cookie: `TOKEN=${token}`,
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 ? { Cookie: `TOKEN=${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
- Cookie: `TOKEN=${token}`,
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
- Cookie: `TOKEN=${token}`,
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
  }
@@ -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 token.
28
- * Precedence: TRAWL_TOKEN env > stored `trawl login` token.
29
- * Lets CI/agents authenticate headlessly (`TRAWL_TOKEN=<jwt> trawl scraps list`)
30
- * without ever touching the on-disk config and without a stored token being
31
- * silently sent to whatever TRAWL_API_URL points at instead (cross-env
32
- * credential misuse). Every request/upload/getText/stream call site in
33
- * api.ts must read the token through this, never through `config.get('token')`
34
- * directly. Mirrors getApiUrl(). (#68)
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;
@@ -31,17 +31,60 @@ export function getApiUrl() {
31
31
  return override ? override : config.get('apiUrl');
32
32
  }
33
33
  /**
34
- * Resolve the effective session token.
35
- * Precedence: TRAWL_TOKEN env > stored `trawl login` token.
36
- * Lets CI/agents authenticate headlessly (`TRAWL_TOKEN=<jwt> trawl scraps list`)
37
- * without ever touching the on-disk config and without a stored token being
38
- * silently sent to whatever TRAWL_API_URL points at instead (cross-env
39
- * credential misuse). Every request/upload/getText/stream call site in
40
- * api.ts must read the token through this, never through `config.get('token')`
41
- * directly. Mirrors getApiUrl(). (#68)
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;