@trawlme/cli 3.7.5 → 3.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -6
- package/dist/commands/login.js +19 -1
- package/dist/commands/scraps.js +90 -7
- 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/docs/agent-quickstart.md +88 -11
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -29,11 +29,14 @@ trawl upgrade --check # only report if an update is available (exit 1 if so)
|
|
|
29
29
|
|
|
30
30
|
## Authentication
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Four methods:
|
|
33
33
|
|
|
34
34
|
1. **Interactive** — `trawl login` (prompts for email and password)
|
|
35
35
|
2. **Env var** — `TRAWL_TOKEN=<jwt> trawl list` (CI/CD, bypasses prompt)
|
|
36
36
|
3. **Token flag** — `trawl login --token <jwt>` (CI/CD, direct JWT)
|
|
37
|
+
4. **API key** — `TRAWL_API_KEY=trawl_xxx trawl list` (scoped, revocable one-by-one — the recommended credential for an agent driving this CLI; see [docs/agent-quickstart.md](docs/agent-quickstart.md))
|
|
38
|
+
|
|
39
|
+
A `TRAWL_API_KEY` is a `trawl_*`-prefixed credential (create/revoke one in the Trawl dashboard) sent as `Authorization: Bearer` instead of the session `Cookie: TOKEN=` a JWT uses — `trawl` picks the right one automatically based on the credential's own shape, never a flag. `TRAWL_API_KEY` wins over `TRAWL_TOKEN` when both happen to be set. It is scoped server-side — in practice to a set of scraps, since the dashboard's key-create form sets only the scrap allow-list; narrowing which *actions* a key may perform needs an explicit `scopes` array at creation over the API, and a key without one has every action granted. It works on most of the Core tier — `create`/`list`/`get`/`data`/`history`/`run-info`/`run`/`trigger`/`ping`, including the `--watch` **polling flag** on `run`/`trigger` — plus, outside Core, `scraps account status`/`scraps doctor`/`scraps autofix` (all three only ever read routes trawl_node opened to keys). It does **not** work on `whoami`, `scraps update`/`delete`, `scraps account set`/`delete`/`clear-session`/`session set`, `scraps banner`, `scraps snapshot` (the scrap lookup it starts from is dual-auth, but the `html-snapshot` route it downloads from isn't), or the standalone SSE **command** `scraps watch` (do not conflate the two: `--watch` is a flag on `run`/`trigger` and works under a key; `scraps watch` is a separate command and is JWT-only) — those stay JWT-only and fail with a `kind:"auth"` envelope (exit `3`) pointing at `trawl login` under a key. This list mirrors trawl_node's route wiring as of this writing, not a frozen guarantee — for anything not named here, trust the real `--json` envelope over this paragraph. Never send both a key and a JWT on the same request — the CLI only ever attaches one.
|
|
37
40
|
|
|
38
41
|
Custom API URL: `trawl login --url https://self-hosted.example.com`
|
|
39
42
|
|
|
@@ -56,15 +59,16 @@ trawl data <id> [--json] [--fresh] [--errors] Get scrap data (last persisted ru
|
|
|
56
59
|
trawl history <id> [--json] [-n <limit>] List past runs for a scrap (newest first)
|
|
57
60
|
trawl run-info <hid> [--json] Show details of a single run
|
|
58
61
|
trawl whoami [--json] Show the authenticated user's identity
|
|
59
|
-
trawl token [--json] Print the stored session JWT (for MCP Bearer auth)
|
|
62
|
+
trawl token [--json] Print the stored credential — API key or session JWT (for MCP Bearer auth)
|
|
60
63
|
trawl ping [--json] Health/version handshake against the Trawl API
|
|
64
|
+
trawl spec [--json] Print a versioned, machine-readable description of the command tree
|
|
61
65
|
```
|
|
62
66
|
|
|
63
67
|
(`trawl create` AI-generates and persists a new scrap from a URL + a goal — distinct from `trawl scraps create`, which is raw manual scrap entry.)
|
|
64
68
|
|
|
65
69
|
> **No breaking change:** every verb above is also still reachable under its pre-reorg path, `trawl scraps <verb>` (e.g. `trawl scraps list`, `trawl scraps run <id>`) — kept as a hidden alias so scripts written before the surface reorg keep working. `trawl --help` only shows the top-level form above; `trawl scraps --help` only shows the remaining scrap-management commands below.
|
|
66
70
|
|
|
67
|
-
`create`/`whoami`/`ping` are fully non-interactive — all three read auth only from `TRAWL_TOKEN`/the stored login token, never prompt. `trawl create` runs the AI wizard server-side (`POST /api/ai/wizard`): generate scrap code from `--prompt` via LLM, persist the scrap, trigger its FIRST run, and auto-fix on failure (default on — `--no-autofix` disables it, sending `autoFix:false`). On a successful first run (human mode) it prints a small **data sample** (item count + first-item fields + one truncated value) as proof of value — best-effort, silent if the sample can't be fetched — and points `Next step` at `trawl data <id>` (the data), with `trawl get <id>` as the secondary detail view. `--json` skips the sample fetch and prints the raw wizard payload verbatim. `success` is an honest outcome of that first run, not "did the HTTP call succeed" — a failed first run is still a 200 response (the scrap was still created; auto-fix, when enabled, retries in the background), and the CLI exits 1 in that case (both human and `--json` modes) even though `--json` always prints the raw payload verbatim. The call legitimately takes 30–250s+ server-side (AI generation + a real run), same long-run timeout as `run`/`data --fresh`/`trigger --wait` below. `trawl whoami`/`trawl ping` mirror the MCP `trawl_whoami`/`trawl_health_ping` tools as closely as the REST surface allows (`GET /api/users/me` / `GET /api/health`) — `ping`'s `--json` payload is admin-enriched (version/uptime/db) and just `{"status":"ok"}` for anyone else.
|
|
71
|
+
`create`/`whoami`/`ping` are fully non-interactive — all three read auth only from `TRAWL_API_KEY`/`TRAWL_TOKEN`/the stored login token, never prompt (`whoami` is JWT-only — see [Authentication](#authentication) — so it still refuses honestly under a key instead of prompting). `trawl create` runs the AI wizard server-side (`POST /api/ai/wizard`): generate scrap code from `--prompt` via LLM, persist the scrap, trigger its FIRST run, and auto-fix on failure (default on — `--no-autofix` disables it, sending `autoFix:false`). On a successful first run (human mode) it prints a small **data sample** (item count + first-item fields + one truncated value) as proof of value — best-effort, silent if the sample can't be fetched — and points `Next step` at `trawl data <id>` (the data), with `trawl get <id>` as the secondary detail view. `--json` skips the sample fetch and prints the raw wizard payload verbatim. `success` is an honest outcome of that first run, not "did the HTTP call succeed" — a failed first run is still a 200 response (the scrap was still created; auto-fix, when enabled, retries in the background), and the CLI exits 1 in that case (both human and `--json` modes) even though `--json` always prints the raw payload verbatim. The call legitimately takes 30–250s+ server-side (AI generation + a real run), same long-run timeout as `run`/`data --fresh`/`trigger --wait` below. `trawl whoami`/`trawl ping` mirror the MCP `trawl_whoami`/`trawl_health_ping` tools as closely as the REST surface allows (`GET /api/users/me` / `GET /api/health`) — `ping`'s `--json` payload is admin-enriched (version/uptime/db) and just `{"status":"ok"}` for anyone else.
|
|
68
72
|
|
|
69
73
|
> **`create` is NOT idempotent, and every wizard-created scrap runs on a DAILY cron by default.** A client-side timeout (exit `5`, a `NetworkError`) does not mean the wizard failed server-side — scrap creation + the first run keep going after the CLI gives up waiting, so the scrap may already exist. Run `trawl list` and look for a matching URL/title **before** retrying — a blind retry creates a DUPLICATE scrap and burns AI-generation quota a second time for the same goal. Separately, the scrap the wizard creates is scheduled to re-run every day at 07:00 UTC (`cron: "0 7 * * *"`, hardcoded server-side, unrelated to `--no-autofix`) — each of those recurring runs consumes execute quota like any other run. Review the generated scrap, then change or disable the schedule with `trawl scraps update <id> --cron <expr>` (or `--no-cron` to disable it). Because the call can legitimately run 250s+, also confirm `TRAWL_TIMEOUT` isn't set to something tighter than `create` needs — the env var always wins over `create`'s own 300s default (see [Environment variables](#environment-variables)), so a value set for another purpose (e.g. a tight CI smoke-test budget) silently clamps `create` too; unset it or raise it before running `create`.
|
|
70
74
|
|
|
@@ -74,6 +78,7 @@ trawl ping [--json] Health/version handshake against
|
|
|
74
78
|
- `get` (and anything reading through it, like `data`'s default path) embeds only the newest 100 history rows on the returned scrap object — `run-info` and `scraps doctor` fetch a single run directly and are unaffected by that cap. `list`/`get` show a distinct amber `▼` "regression" badge, never the red `✗` a genuine failure gets (matches `scraps doctor`'s own badge).
|
|
75
79
|
- **Long-running calls (`create`, `run`, `data --fresh`, `trigger --wait`):** these hit server-side paths that can legitimately take 30–250s+ (AI generation + scrap creation + a first run for `create`; proxy tier escalation + AI-fix retries for the other three) — the CLI arms a 300s timeout for exactly these four call sites instead of the generic 30s default. `TRAWL_TIMEOUT` (see below) still overrides ALL requests, including these — set it if you need a tighter or looser ceiling than 300s, but note a global override that tight also clamps `create`.
|
|
76
80
|
- **`--watch` is poll-based, not a live stream:** the activities SSE endpoint has no backlog and, for the default async `trigger` (no `--wait`), runs in a separate cron-consumer pod whose events never reach the API pod holding the SSE connection — a naive "await the run, then open SSE" shows nothing. `run --watch` and `trigger --watch` instead poll `GET /api/scraps/:id` (terminal status) and the activities REST list until the run finishes, printing each new activity line as it appears. The watched run's outcome drives the exit code too, in BOTH human and `--json` mode: a genuinely failed terminal run, a poll timeout, or a persistently unreachable API all exit non-zero — a clean successful run is the only exit `0`. A run that never reaches a terminal status within 300s prints an honest timeout notice pointing at `scraps doctor <id>` (human mode) — see the `--json` shape below. `scraps watch <id>` (the standalone command, no trigger) is unchanged — it still opens the live SSE stream directly.
|
|
81
|
+
- `spec --json` prints the CLI's own command tree — `{specVersion, cliVersion, commands[], exitCodes, errorKinds, kindExitCodes}` — DERIVED at runtime by walking the live commander tree (never a hand-maintained file, which would silently drift from reality). Each entry in `commands[]` carries its full path (e.g. `"scraps account session set"`), description, `hidden` (the legacy `scraps <verb>` aliases above), `leaf` (false for a pure namespace/group node like `scraps`/`skills`/`telemetry` — invoking one directly is a guaranteed-failing tool, not a real command), `aliases`, `arguments`, and `options`; `exitCodes`/`errorKinds`/`kindExitCodes` are read from the exact same source `classifyError` uses (see [Exit codes](#exit-codes)) — never a second copy. `kindExitCodes` is the inverse of `exitCodes`: `kind -> exitCode`, since exit code `1` alone is a shared bucket (`api`/`refused`/`unknown`/`in_progress`/`run_failed`/`upgrade_failed`) that `exitCodes`' flat label can't disambiguate. Without `--json`, `spec` prints one short human line (version + visible command count) pointing at `--json`.
|
|
77
82
|
- `run --json`/`trigger --json` bypass the spinner and print the raw launch/trigger payload on stdout; combined with `--watch`, every intermediate progress line stays suppressed (stdout stays pure JSON) and, once the watch reaches its outcome, exactly ONE final NDJSON line is emitted: `{"runId","status"}` (the honest terminal status — `success`/`error`/`empty`/`regression`/…), `{"runId","status":"timeout"}` on a poll timeout, or `{"runId","status":"poll_error","error"}` if the API stays unreachable for several consecutive polls — `process.exitCode` is non-zero for all three except a genuine success. `scraps watch --json` emits one raw JSON object per activity line (NDJSON) instead of the formatted `[time] message` text — there's no single final payload to wait for on a live stream.
|
|
78
83
|
|
|
79
84
|
### Scrap management
|
|
@@ -135,7 +140,9 @@ trawl login [--url <url>] [--token <jwt>] [--email <email>] [--password <pass>]
|
|
|
135
140
|
trawl logout [--json]
|
|
136
141
|
```
|
|
137
142
|
|
|
138
|
-
`login --json` prints `{"ok":true,"apiUrl","config",email?}` on success — never the raw token (that's `trawl token`'s job, in the [Core commands](#core-commands-agent--human) section above — non-interactive/agent-consumable, unlike `login`/`logout`). `token --json` prints `{"token","exp","expiresAt"}`
|
|
143
|
+
`login --json` prints `{"ok":true,"apiUrl","config",email?}` on success — never the raw token (that's `trawl token`'s job, in the [Core commands](#core-commands-agent--human) section above — non-interactive/agent-consumable, unlike `login`/`logout`). `token --json` prints `{"token","exp","expiresAt","mode"}` — `mode` is `"apiKey"` or `"jwt"`, so a machine caller can tell which credential it just got without re-deriving the `trawl_*` prefix itself; `exp`/`expiresAt` are `null` for an API key (it has no expiry to report). Human mode prints the credential on stdout plus a stderr advisory: an expiry countdown for a JWT, or a one-line note for an API key that it does not expire on its own. See [Non-interactive rule](#non-interactive-rule) below: a missing `--email`/`--password` refuses immediately (usage error, exit 2) instead of prompting when `--json` is set or stdin/stdout isn't a real TTY.
|
|
144
|
+
|
|
145
|
+
If `TRAWL_API_KEY` or `TRAWL_TOKEN` is set in the environment, both `login` and `logout` print a stderr warning naming that variable — it outranks the config-stored token (see [Authentication](#authentication)) for every subsequent command regardless of what `login`/`logout` just did. On `logout` specifically this means access is **not actually revoked** while that variable stays set.
|
|
139
146
|
|
|
140
147
|
### Telemetry
|
|
141
148
|
|
|
@@ -204,16 +211,19 @@ Every command exits with one of these codes — scripts and agents driving the C
|
|
|
204
211
|
| `0` | Success |
|
|
205
212
|
| `1` | Unknown/generic error (an unmapped failure — API errors other than 401/404, an unhandled bug — or a business-logic refusal like `data`'s `run_failed`/`in_progress` states, or `trawl create`'s honest `success:false` first-run outcome). **Known overload:** `create`'s domain-level failure and an arbitrary unmapped bug both land on `1` — a script needs to read the `--json` payload's own `success` field (for `create`) to tell them apart; this is intentional (the REST contract's outcome states are a separate axis from the CLI's transport-error taxonomy) and documented here rather than "resolved" by inventing a new code that would only apply to one command. |
|
|
206
213
|
| `2` | Usage error (bad flag/value, invalid ID, missing required argument, unknown option/command — including the [non-interactive rule](#non-interactive-rule) refusing to prompt) |
|
|
207
|
-
| `3` | Auth error (not logged in,
|
|
214
|
+
| `3` | Auth error (not logged in, the session token is expired/invalid, or a JWT-only command was run under an API key — see [Authentication](#authentication)) — run `trawl login` |
|
|
208
215
|
| `4` | Not found (no such resource, or — for `data` — no persisted payload to read) |
|
|
209
216
|
| `5` | Network error (the API host is unreachable, DNS/connection/TLS failure, or the request timed out) |
|
|
210
217
|
|
|
211
|
-
Under `--json`, a failing command emits a single error envelope on stdout — `{"error":{"message","status?","kind"}}` — instead of prose; the human-readable line always goes to stderr, never stdout. `kind`
|
|
218
|
+
Under `--json`, a failing command emits a single error envelope on stdout — `{"error":{"message","status?","kind","retryable","next?"}}` — instead of prose; the human-readable line always goes to stderr, never stdout. `kind` **stays the primary discriminant** — the short string a script should switch on (`"usage"`/`"auth"`/`"not_found"`/`"network"`/`"api"`/`"refused"`/`"unknown"`/`"in_progress"`/`"run_failed"`/`"upgrade_failed"`; `"refused"` is a business-logic refusal the server explicitly reported back, e.g. `scraps create|update`'s tier-ceiling override rejection — distinct from `"unknown"`, which stays reserved for an unmapped bug); `status` is present only when a real HTTP response carried one (never fabricated for a local failure like an expired-locally JWT or a refused confirmation prompt).
|
|
219
|
+
|
|
220
|
+
`retryable` and `next` are additive — a retry loop branches on `retryable`, not on parsing `message` (e.g. the pre-existing "retry shortly" text is English prose, not a machine contract). `retryable` answers one question: is retrying the SAME command, unchanged, worth it? A network failure is (`retryable:true`); a bad flag, an expired token, or a 404 isn't — retrying without changing anything just repeats the same failure. `next`, when present, lists commands worth running next, most useful first (e.g. `["trawl login --token <jwt>"]` on an auth failure — the one form of `login` that never blocks on an interactive prompt) — it is empty/absent whenever there is nothing honest to suggest, never filled in just to look helpful. Two cases where `kind` alone doesn't tell the whole retry story: a `429` response (`kind:"api"`) is retryable even though `"api"` isn't by default; `data`'s "run in progress" refusal is retryable with `next:["trawl data <id>"]` — it never suggests `--fresh`, which would just 429 against the run already holding the lock.
|
|
212
221
|
|
|
213
222
|
## Environment variables
|
|
214
223
|
|
|
215
224
|
| Variable | Description |
|
|
216
225
|
|-----------------------|--------------------------------------------------------------------|
|
|
226
|
+
| `TRAWL_API_KEY` | Scoped `trawl_*` API key — takes precedence over `TRAWL_TOKEN`; recommended for an agent driving the CLI (see [Authentication](#authentication)) |
|
|
217
227
|
| `TRAWL_TOKEN` | JWT token — bypasses login prompt, useful for CI/CD |
|
|
218
228
|
| `TRAWL_API_URL` | Override the API base URL for the session (takes precedence over `trawl login --url`) |
|
|
219
229
|
| `TRAWL_TELEMETRY` | Set to `0` to disable telemetry for the current session |
|
package/dist/commands/login.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import config, { getApiUrl } from '../lib/config.js';
|
|
3
|
+
import config, { getApiUrl, getLiveAuthEnvVar } from '../lib/config.js';
|
|
4
4
|
import { api } from '../lib/api.js';
|
|
5
5
|
import { requireFreshJwt, requireUrl } from '../lib/validate.js';
|
|
6
6
|
import { promptPassword } from '../lib/prompt.js';
|
|
@@ -32,6 +32,14 @@ async function promptEmail() {
|
|
|
32
32
|
* structured result on stdout instead of the chalk lines — never the raw
|
|
33
33
|
* token itself (that's what `trawl token` is for). */
|
|
34
34
|
function reportLoginSuccess(opts, email) {
|
|
35
|
+
// #169 review finding 3 — stderr, both under --json and plain output: this
|
|
36
|
+
// is a warning about what happens AFTER login succeeds, not part of either
|
|
37
|
+
// payload shape, so it must never be silently dropped just because --json
|
|
38
|
+
// is set.
|
|
39
|
+
const overrideVar = getLiveAuthEnvVar();
|
|
40
|
+
if (overrideVar) {
|
|
41
|
+
console.error(chalk.yellow(`⚠ ${overrideVar} is set in your environment — it overrides the token just stored here for every subsequent command until you unset it.`));
|
|
42
|
+
}
|
|
35
43
|
if (opts.json) {
|
|
36
44
|
json({ ok: true, apiUrl: getApiUrl(), config: config.path, ...(email && { email }) });
|
|
37
45
|
return;
|
|
@@ -110,6 +118,16 @@ export const logout = new Command('logout')
|
|
|
110
118
|
.option('--json', 'Output as JSON')
|
|
111
119
|
.action((opts) => {
|
|
112
120
|
config.set('token', '');
|
|
121
|
+
// #169 review finding 3 — this is the sharpest version of the gap: an
|
|
122
|
+
// operator runs `logout` SPECIFICALLY to kill access, and if
|
|
123
|
+
// TRAWL_API_KEY/TRAWL_TOKEN is set, access is NOT killed — every
|
|
124
|
+
// subsequent command keeps authenticating with the env credential this
|
|
125
|
+
// command cannot touch. The wording below must not read as a variant of
|
|
126
|
+
// "logged out"; it says plainly that access is still live.
|
|
127
|
+
const overrideVar = getLiveAuthEnvVar();
|
|
128
|
+
if (overrideVar) {
|
|
129
|
+
console.error(chalk.yellow(`⚠ ${overrideVar} is still set in your environment — access is NOT revoked. Every subsequent command will keep authenticating with it until you unset it (or revoke the key in the dashboard).`));
|
|
130
|
+
}
|
|
113
131
|
if (opts.json) {
|
|
114
132
|
json({ ok: true });
|
|
115
133
|
return;
|
package/dist/commands/scraps.js
CHANGED
|
@@ -6,7 +6,7 @@ import { table, json, formatDate } from '../lib/format.js';
|
|
|
6
6
|
import { parseServerJson } from '../lib/json.js';
|
|
7
7
|
import { promptPassword } from '../lib/prompt.js';
|
|
8
8
|
import { validateObjectId, requireUrl } from '../lib/validate.js';
|
|
9
|
-
import { classifyError, reportError, UsageError, RefusalError } from '../lib/errors.js';
|
|
9
|
+
import { classifyError, reportError, retryFieldsFor, UsageError, RefusalError } from '../lib/errors.js';
|
|
10
10
|
import { confirmDestructive, isInteractive } from '../lib/confirm.js';
|
|
11
11
|
import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix } from './doctor.js';
|
|
12
12
|
import { renderPinch, pinchEnabled } from '../lib/pinch.js';
|
|
@@ -47,6 +47,58 @@ function tryValidateUrl(value, opts) {
|
|
|
47
47
|
throw err;
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* One-line rendering of an emptyContext blob for the run-info table. Two
|
|
52
|
+
* buckets are actionable and must stay distinct: selectors that matched zero
|
|
53
|
+
* nodes (the page changed) and selectors the browser rejected outright
|
|
54
|
+
* (`-1`, a bug in the scrap script). Folding the second into "all matched"
|
|
55
|
+
* would report a broken selector as healthy. `--json` still emits the full
|
|
56
|
+
* object, `page.topClasses` / `topAnchorPaths` included.
|
|
57
|
+
*/
|
|
58
|
+
function summarizeEmptyContext(ctx) {
|
|
59
|
+
if (!ctx || typeof ctx !== 'object')
|
|
60
|
+
return null;
|
|
61
|
+
const parts = [];
|
|
62
|
+
const selectors = ctx.selectors && typeof ctx.selectors === 'object' ? ctx.selectors : {};
|
|
63
|
+
const names = Object.keys(selectors);
|
|
64
|
+
// Selectors may themselves contain a comma (`div, span`), so quote each one
|
|
65
|
+
// — a raw join renders one grouping selector as if it were two.
|
|
66
|
+
const list = (ns) => `${ns.slice(0, 3).map((s) => `\`${s}\``).join(', ')}${ns.length > 3 ? ` (+${ns.length - 3})` : ''}`;
|
|
67
|
+
// Four disjoint, exhaustive buckets — every selector lands in exactly one, so
|
|
68
|
+
// no count is ever dropped and none is summarised as something it is not.
|
|
69
|
+
// `selectors` comes from a Mongoose Mixed field, so a count may be `null` or
|
|
70
|
+
// a string: that is `unreadable`, which is neither healthy nor zero-match and
|
|
71
|
+
// must be named as its own thing next to the others, not collapsed into them.
|
|
72
|
+
const isCount = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
73
|
+
const zero = names.filter((s) => selectors[s] === 0);
|
|
74
|
+
const invalid = names.filter((s) => selectors[s] === -1);
|
|
75
|
+
const matched = names.filter((s) => isCount(selectors[s]) && selectors[s] > 0);
|
|
76
|
+
const unreadable = names.filter((s) => !zero.includes(s) && !invalid.includes(s) && !matched.includes(s));
|
|
77
|
+
const mixed = zero.length > 0 || invalid.length > 0 || unreadable.length > 0;
|
|
78
|
+
if (zero.length)
|
|
79
|
+
parts.push(`0-match: ${list(zero)}`);
|
|
80
|
+
if (invalid.length)
|
|
81
|
+
parts.push(`invalid: ${list(invalid)}`);
|
|
82
|
+
if (unreadable.length)
|
|
83
|
+
parts.push(`unreadable: ${list(unreadable)}`);
|
|
84
|
+
if (matched.length) {
|
|
85
|
+
// Named even in a mixed set: "3 of the selectors still work" is the
|
|
86
|
+
// difference between a page that moved and a page that vanished.
|
|
87
|
+
parts.push(mixed ? `${matched.length} matched` : `${matched.length} selector(s), all matched`);
|
|
88
|
+
}
|
|
89
|
+
// Same Mixed-field caution on the title: coerce before calling a string
|
|
90
|
+
// method (a numeric title would otherwise crash `run-info` on the very run
|
|
91
|
+
// it exists to explain), and test against null/undefined rather than
|
|
92
|
+
// truthiness so a falsy-but-real title like `0` still shows. Inner double
|
|
93
|
+
// quotes would close the wrapper early and corrupt the line.
|
|
94
|
+
const title = ctx.page?.title;
|
|
95
|
+
if (title !== undefined && title !== null && String(title) !== '') {
|
|
96
|
+
parts.push(`title="${String(title).replace(/"/g, "'")}"`);
|
|
97
|
+
}
|
|
98
|
+
if (typeof ctx.page?.totalAnchors === 'number')
|
|
99
|
+
parts.push(`${ctx.page.totalAnchors} anchors`);
|
|
100
|
+
return parts.length ? parts.join(' | ') : null;
|
|
101
|
+
}
|
|
50
102
|
function lastStatus(scrap) {
|
|
51
103
|
const last = scrap.history?.[0];
|
|
52
104
|
if (!last)
|
|
@@ -590,7 +642,17 @@ scraps
|
|
|
590
642
|
.command('create')
|
|
591
643
|
.description('Create a new scrap')
|
|
592
644
|
.requiredOption('-t, --title <title>', 'Scrap title')
|
|
593
|
-
|
|
645
|
+
// #170 review F6 — promoted from a plain `.option()` to `.requiredOption()`:
|
|
646
|
+
// this is unconditionally required (no positional alternative exists on
|
|
647
|
+
// THIS command, unlike the top-level `trawl create <url>`, which accepts
|
|
648
|
+
// `[url]` OR `--url` — deliberately left untouched, that either/or needs a
|
|
649
|
+
// human product decision, tracked separately). Before this, the flag's
|
|
650
|
+
// description carried the only "Required." signal, in English prose — the
|
|
651
|
+
// exact thing docs/agent-quickstart.md tells an agent not to parse.
|
|
652
|
+
// Commander itself now enforces it, so `trawl spec --json`'s published
|
|
653
|
+
// `mandatory:true` is honest instead of a second, driftable copy of this
|
|
654
|
+
// same fact.
|
|
655
|
+
.requiredOption('-u, --url <url>', 'Target URL — the site the scrap targets, not necessarily the exact URL fetched at run time (a request script can compute that, e.g. a templated search query).')
|
|
594
656
|
.option('-r, --request <request>', 'Request/query')
|
|
595
657
|
.option('-d, --description <text>', 'Scrap description')
|
|
596
658
|
.option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
|
|
@@ -856,10 +918,27 @@ function renderScrapItems(items, asJson) {
|
|
|
856
918
|
* all "usage" (2) — never-run / aged-out-of-retention are not_found (4), a
|
|
857
919
|
* failed last run is a business-logic failure (1).
|
|
858
920
|
*/
|
|
859
|
-
|
|
921
|
+
/**
|
|
922
|
+
* #170 — `retryable`/`next` follow the SAME frozen `RETRY_POLICY` map
|
|
923
|
+
* `classifyError` reads (errors.ts), keyed by `kind` — this path never
|
|
924
|
+
* builds its own copy. `retryOverride` is for the one case here where the
|
|
925
|
+
* kind alone is genuinely wrong: `in_progress`'s caller passes
|
|
926
|
+
* `{retryable:true, next:['trawl data <id>']}` since retrying `data` (not
|
|
927
|
+
* `--fresh`, which would 429 against the lock already held) IS worth doing
|
|
928
|
+
* once the run finishes. Every other kind here (`not_found`, `run_failed`)
|
|
929
|
+
* isn't in `RETRY_POLICY` at all — `retryFieldsFor` is total, so those fall
|
|
930
|
+
* back to its conservative "not retryable, nothing to suggest" default.
|
|
931
|
+
*/
|
|
932
|
+
function reportDataState(message, exitCode, kind, wantsJson, retryOverride) {
|
|
860
933
|
console.error(chalk.red(`✗ ${message}`));
|
|
861
934
|
if (wantsJson) {
|
|
862
|
-
|
|
935
|
+
const { retryable, next } = retryOverride ?? retryFieldsFor(kind);
|
|
936
|
+
// #170 review F3 — typed as ErrorEnvelope so the compiler enforces
|
|
937
|
+
// `retryable` here too (this was one of two emitters a mutation test
|
|
938
|
+
// proved `tsc --noEmit` never actually guarded before this annotation —
|
|
939
|
+
// the untyped literal let `retryable` be omitted silently).
|
|
940
|
+
const envelope = { message, kind, retryable, ...(next ? { next } : {}) };
|
|
941
|
+
console.log(JSON.stringify({ error: envelope }));
|
|
863
942
|
}
|
|
864
943
|
process.exitCode = exitCode;
|
|
865
944
|
}
|
|
@@ -975,7 +1054,10 @@ export function attachDataCommand(parent, attachOpts = {}) {
|
|
|
975
1054
|
// run already holds the server-side distributed lock, so --fresh would
|
|
976
1055
|
// just 429 against it.
|
|
977
1056
|
if (last.status === null) {
|
|
978
|
-
|
|
1057
|
+
// #170 — retrying `trawl data <id>` (unchanged) once the run
|
|
1058
|
+
// finishes IS worth it; `--fresh` never is (it would 429 against the
|
|
1059
|
+
// lock this very run already holds) — never suggested here.
|
|
1060
|
+
reportDataState(`Run in progress for ${id} — retry shortly.`, 1, 'in_progress', opts.json, { retryable: true, next: [`trawl data ${id}`] });
|
|
979
1061
|
return;
|
|
980
1062
|
}
|
|
981
1063
|
// #86 review — node persists status=false for a GENUINE zero-item run
|
|
@@ -1088,14 +1170,15 @@ export function attachRunInfoCommand(parent, attachOpts = {}) {
|
|
|
1088
1170
|
blockType: h.blockType ?? null,
|
|
1089
1171
|
errorMessage: h.errorSnapshot?.errorMessage ?? null,
|
|
1090
1172
|
selector: h.errorSnapshot?.selector ?? null,
|
|
1091
|
-
emptyContext: h.
|
|
1173
|
+
emptyContext: h.emptyContext ?? null,
|
|
1092
1174
|
createdAt: h.createdAt ?? null,
|
|
1093
1175
|
};
|
|
1094
1176
|
if (opts.json) {
|
|
1095
1177
|
json(info);
|
|
1096
1178
|
return;
|
|
1097
1179
|
}
|
|
1098
|
-
|
|
1180
|
+
// The blob is an object; the table needs one line. --json keeps it whole.
|
|
1181
|
+
table([{ ...info, emptyContext: summarizeEmptyContext(info.emptyContext) }], ['hid', 'status', 'time', 'tier', 'failureKind', 'blockType', 'errorMessage', 'selector', 'emptyContext', 'createdAt']);
|
|
1099
1182
|
});
|
|
1100
1183
|
}
|
|
1101
1184
|
attachRunInfoCommand(scraps, { hidden: true });
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
/**
|
|
3
|
+
* `trawl spec --json` (#170) — a versioned, machine-readable description of
|
|
4
|
+
* the command tree, so an agent can learn the CLI's surface without parsing
|
|
5
|
+
* ~250 lines of README prose. Everything below is DERIVED at runtime from
|
|
6
|
+
* the live commander `Command` tree (`buildSpec`) — never a hand-maintained
|
|
7
|
+
* file, which would be a second source of truth that silently drifts. See
|
|
8
|
+
* `tests/contracts/` for the README-vs-spec cross-check that exists
|
|
9
|
+
* specifically to catch that drift.
|
|
10
|
+
*/
|
|
11
|
+
export interface CliSpecArgument {
|
|
12
|
+
name: string;
|
|
13
|
+
required: boolean;
|
|
14
|
+
variadic: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface CliSpecOption {
|
|
17
|
+
long: string;
|
|
18
|
+
short?: string;
|
|
19
|
+
description: string;
|
|
20
|
+
/**
|
|
21
|
+
* commander's `Option#required`/`Option#mandatory` are a false-friend pair
|
|
22
|
+
* that an earlier draft of this file collapsed into one `required` key —
|
|
23
|
+
* DON'T "simplify" it back. `Option#required` means "if this option is
|
|
24
|
+
* used, a value must follow" (true for any `<value>` flag, e.g. `--limit
|
|
25
|
+
* <n>`, whether or not the option itself is optional to pass). `mandatory`
|
|
26
|
+
* means "the user MUST pass this option at all" (only true for
|
|
27
|
+
* `.requiredOption()`). Naming the value-arity field `required` (as this
|
|
28
|
+
* did before) reads as "you must specify this option" — wrong for ~30 of
|
|
29
|
+
* the ~34 options that tripped it. See commander's option.js:15/19.
|
|
30
|
+
*/
|
|
31
|
+
mandatory: boolean;
|
|
32
|
+
valueRequired: boolean;
|
|
33
|
+
negatable: boolean;
|
|
34
|
+
default?: unknown;
|
|
35
|
+
}
|
|
36
|
+
export interface CliSpecCommand {
|
|
37
|
+
/** Full path from the program root, e.g. "scraps account session set". */
|
|
38
|
+
name: string;
|
|
39
|
+
description: string;
|
|
40
|
+
hidden: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* True when this node has no subcommands of its own — i.e. it is an
|
|
43
|
+
* executable leaf (`.action()` actually runs something), not a pure
|
|
44
|
+
* namespace/group node like `scraps`/`skills`/`telemetry`/`scraps account`.
|
|
45
|
+
* An agent that emits one tool per spec entry needs this to skip group
|
|
46
|
+
* nodes: invoking one directly (e.g. `trawl scraps`) exits non-zero with
|
|
47
|
+
* usage text on stderr and ZERO bytes on stdout — a guaranteed-failing
|
|
48
|
+
* tool, not a --json envelope. Derived the same way
|
|
49
|
+
* `tests/contracts/readme-commands.test.ts` independently re-derives it
|
|
50
|
+
* (its own `leafNames()` walk existed ONLY because this field didn't) —
|
|
51
|
+
* that test now consumes THIS field instead of re-walking the tree itself.
|
|
52
|
+
*/
|
|
53
|
+
leaf: boolean;
|
|
54
|
+
aliases: string[];
|
|
55
|
+
arguments: CliSpecArgument[];
|
|
56
|
+
options: CliSpecOption[];
|
|
57
|
+
}
|
|
58
|
+
export interface CliSpec {
|
|
59
|
+
specVersion: 1;
|
|
60
|
+
cliVersion: string;
|
|
61
|
+
commands: CliSpecCommand[];
|
|
62
|
+
/** "0".."5" → a short kind label. Same constant `classifyError` reads
|
|
63
|
+
* (errors.ts's EXIT_CODE_LABELS) — never a second copy. Code `1` is a
|
|
64
|
+
* SHARED bucket (several `kind`s land there, see `kindExitCodes` below
|
|
65
|
+
* for the honest reverse mapping) — this label names the generic/unmapped
|
|
66
|
+
* case, not an exhaustive claim that `1` means only that. */
|
|
67
|
+
exitCodes: Record<string, string>;
|
|
68
|
+
/** Every `kind` a --json error envelope can carry — the classifyError
|
|
69
|
+
* kinds PLUS the hand-built ones (`in_progress`/`run_failed`/
|
|
70
|
+
* `upgrade_failed`). Same constant (errors.ts's ENVELOPE_KINDS) — never a
|
|
71
|
+
* second copy, and never `ERROR_KINDS` (that one omits the hand-built
|
|
72
|
+
* kinds by design — see its doc comment). */
|
|
73
|
+
errorKinds: string[];
|
|
74
|
+
/**
|
|
75
|
+
* #170 review F7 — the inverse of `exitCodes`: `kind -> exitCode`, one
|
|
76
|
+
* entry per `errorKinds` member. `exitCodes` alone can't tell an agent
|
|
77
|
+
* which of the several kinds sharing code `1` (`api`/`refused`/`unknown`/
|
|
78
|
+
* `in_progress`/`run_failed`/`upgrade_failed`) it actually got — this
|
|
79
|
+
* field answers that directly instead of requiring the reverse lookup to
|
|
80
|
+
* be reconstructed by hand. Same source constant as `errorKinds`
|
|
81
|
+
* (errors.ts's KIND_EXIT_CODES) — never a second copy.
|
|
82
|
+
*/
|
|
83
|
+
kindExitCodes: Record<string, number>;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build the full spec from a live, already-constructed program (e.g.
|
|
87
|
+
* `createProgram()`'s return value). Includes every node in the tree —
|
|
88
|
+
* hidden legacy aliases (`scraps list`, …) included, flagged via `hidden`,
|
|
89
|
+
* so a consumer that wants only the advertised surface can filter on it.
|
|
90
|
+
*/
|
|
91
|
+
export declare function buildSpec(program: Command): CliSpec;
|
|
92
|
+
export declare const spec: Command;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Command, Help } from 'commander';
|
|
2
|
+
import { json } from '../lib/format.js';
|
|
3
|
+
import { EXIT_CODE_LABELS, ENVELOPE_KINDS, KIND_EXIT_CODES } from '../lib/errors.js';
|
|
4
|
+
function buildOption(option) {
|
|
5
|
+
const entry = {
|
|
6
|
+
long: option.long ?? '',
|
|
7
|
+
description: option.description,
|
|
8
|
+
mandatory: option.mandatory,
|
|
9
|
+
valueRequired: option.required,
|
|
10
|
+
negatable: option.negate,
|
|
11
|
+
};
|
|
12
|
+
if (option.short)
|
|
13
|
+
entry.short = option.short;
|
|
14
|
+
if (option.defaultValue !== undefined)
|
|
15
|
+
entry.default = option.defaultValue;
|
|
16
|
+
return entry;
|
|
17
|
+
}
|
|
18
|
+
function buildCommandEntry(cmd, name, hidden) {
|
|
19
|
+
return {
|
|
20
|
+
name,
|
|
21
|
+
description: cmd.description(),
|
|
22
|
+
hidden,
|
|
23
|
+
leaf: cmd.commands.length === 0,
|
|
24
|
+
aliases: [...cmd.aliases()],
|
|
25
|
+
arguments: cmd.registeredArguments.map((arg) => ({
|
|
26
|
+
name: arg.name(),
|
|
27
|
+
required: arg.required,
|
|
28
|
+
variadic: arg.variadic,
|
|
29
|
+
})),
|
|
30
|
+
options: cmd.options.map(buildOption),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Walk `cmd.commands` recursively, collecting one entry per node under its
|
|
35
|
+
* FULL path name. `hidden` is read via commander's own
|
|
36
|
+
* `Help#visibleCommands()` — the same idiom this codebase already uses
|
|
37
|
+
* (scraps.test.ts's #108 describe block) — rather than reaching for the
|
|
38
|
+
* private, untyped `_hidden` field directly.
|
|
39
|
+
*/
|
|
40
|
+
function walk(cmd, prefix, out) {
|
|
41
|
+
const visible = new Set(new Help().visibleCommands(cmd));
|
|
42
|
+
for (const sub of cmd.commands) {
|
|
43
|
+
const name = prefix ? `${prefix} ${sub.name()}` : sub.name();
|
|
44
|
+
out.push(buildCommandEntry(sub, name, !visible.has(sub)));
|
|
45
|
+
walk(sub, name, out);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Build the full spec from a live, already-constructed program (e.g.
|
|
50
|
+
* `createProgram()`'s return value). Includes every node in the tree —
|
|
51
|
+
* hidden legacy aliases (`scraps list`, …) included, flagged via `hidden`,
|
|
52
|
+
* so a consumer that wants only the advertised surface can filter on it.
|
|
53
|
+
*/
|
|
54
|
+
export function buildSpec(program) {
|
|
55
|
+
const commands = [];
|
|
56
|
+
walk(program, '', commands);
|
|
57
|
+
return {
|
|
58
|
+
specVersion: 1,
|
|
59
|
+
cliVersion: program.version() ?? 'unknown',
|
|
60
|
+
commands,
|
|
61
|
+
exitCodes: { ...EXIT_CODE_LABELS },
|
|
62
|
+
errorKinds: [...ENVELOPE_KINDS],
|
|
63
|
+
kindExitCodes: { ...KIND_EXIT_CODES },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export const spec = new Command('spec')
|
|
67
|
+
.description('Print a versioned, machine-readable description of the command tree')
|
|
68
|
+
.option('--json', 'Output as JSON')
|
|
69
|
+
.action((opts) => {
|
|
70
|
+
// `spec` is registered as a direct child of the program root
|
|
71
|
+
// (index.ts's createProgram), so `.parent` IS that root by the time this
|
|
72
|
+
// action ever runs — commander sets it in `addCommand()`. Falling back
|
|
73
|
+
// to `spec` itself only matters for an isolated unit invocation (e.g. a
|
|
74
|
+
// test driving `spec.parseAsync()` directly, unattached to a program).
|
|
75
|
+
const root = spec.parent ?? spec;
|
|
76
|
+
const cliSpec = buildSpec(root);
|
|
77
|
+
if (opts.json) {
|
|
78
|
+
json(cliSpec);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const visibleCount = cliSpec.commands.filter((c) => !c.hidden).length;
|
|
82
|
+
console.log(`trawl ${cliSpec.cliVersion} — ${visibleCount} commands. Use --json for the full machine-readable spec.`);
|
|
83
|
+
});
|
package/dist/commands/token.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { getToken } from '../lib/config.js';
|
|
3
|
+
import { getToken, getAuthMode } from '../lib/config.js';
|
|
4
4
|
import { AuthError, notLoggedInError } from '../lib/api.js';
|
|
5
5
|
import { reportError } from '../lib/errors.js';
|
|
6
6
|
import { decodeExp } from '../lib/jwt.js';
|
|
7
7
|
import { json } from '../lib/format.js';
|
|
8
8
|
export const token = new Command('token')
|
|
9
|
-
.description('Print the stored session JWT (for MCP Bearer auth)')
|
|
10
|
-
.option('--json', 'Output as JSON ({token, exp, expiresAt}) instead of the raw
|
|
9
|
+
.description('Print the stored credential — a scoped API key or a session JWT (for MCP Bearer auth)')
|
|
10
|
+
.option('--json', 'Output as JSON ({token, exp, expiresAt, mode}) instead of the raw credential')
|
|
11
11
|
.action((opts) => {
|
|
12
|
-
// getToken() resolves
|
|
13
|
-
// token (see config.ts
|
|
14
|
-
// the CLI instead of reading the config store directly. (#86
|
|
12
|
+
// getToken() resolves TRAWL_API_KEY env first, then TRAWL_TOKEN env, then
|
|
13
|
+
// the stored config token (see config.ts) — matching every other token
|
|
14
|
+
// consumer in the CLI instead of reading the config store directly. (#86
|
|
15
|
+
// finding 1, #169)
|
|
15
16
|
const stored = getToken();
|
|
16
17
|
if (!stored) {
|
|
17
18
|
// Auth-classified (ApiError 401 → exit 3, kind:"auth"), not a generic
|
|
@@ -20,6 +21,27 @@ export const token = new Command('token')
|
|
|
20
21
|
process.exitCode = reportError(notLoggedInError(), { json: opts.json });
|
|
21
22
|
return;
|
|
22
23
|
}
|
|
24
|
+
// #169 review finding 2 — a scoped API key is not a JWT: it has no `exp`
|
|
25
|
+
// claim to decode (decodeExp() would return null for one anyway, since
|
|
26
|
+
// it isn't dot-segmented), and unlike a session JWT it does NOT expire
|
|
27
|
+
// on its own — it stays live until revoked in the dashboard. Branching
|
|
28
|
+
// on getAuthMode() explicitly (rather than silently relying on
|
|
29
|
+
// decodeExp()'s null-for-non-JWT behaviour) means the distinct advisory
|
|
30
|
+
// below is a deliberate case, not an accident of what decodeExp() happens
|
|
31
|
+
// to return — and it replaces the old "(could not decode expiry — verify
|
|
32
|
+
// the token manually)" fallback, which was written for a malformed JWT
|
|
33
|
+
// and, read against a key, wrongly implied something was wrong with it.
|
|
34
|
+
if (getAuthMode(stored) === 'apiKey') {
|
|
35
|
+
if (opts.json) {
|
|
36
|
+
json({ token: stored, exp: null, expiresAt: null, mode: 'apiKey' });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Print the raw key first (so it can be piped / copied) — same
|
|
40
|
+
// stdout-only-the-credential contract `$(trawl token)` relies on.
|
|
41
|
+
console.log(stored);
|
|
42
|
+
console.error(chalk.dim(' This is a scoped API key (trawl_*) — it does not expire on its own. Revoke it in the dashboard to end its access.'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
23
45
|
const exp = decodeExp(stored);
|
|
24
46
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
25
47
|
if (exp !== null && exp < nowSeconds) {
|
|
@@ -30,7 +52,12 @@ export const token = new Command('token')
|
|
|
30
52
|
return;
|
|
31
53
|
}
|
|
32
54
|
if (opts.json) {
|
|
33
|
-
json({
|
|
55
|
+
json({
|
|
56
|
+
token: stored,
|
|
57
|
+
exp,
|
|
58
|
+
expiresAt: exp !== null ? new Date(exp * 1000).toISOString() : null,
|
|
59
|
+
mode: 'jwt',
|
|
60
|
+
});
|
|
34
61
|
return;
|
|
35
62
|
}
|
|
36
63
|
// Print the raw token first (so it can be piped / copied)
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -3,6 +3,7 @@ import chalk from 'chalk';
|
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { json } from '../lib/format.js';
|
|
6
|
+
import { retryFieldsFor } from '../lib/errors.js';
|
|
6
7
|
import { PKG_NAME, currentVersion, fetchLatestVersion } from '../lib/version.js';
|
|
7
8
|
const execFileP = promisify(execFile);
|
|
8
9
|
export const upgrade = new Command('upgrade')
|
|
@@ -20,11 +21,16 @@ export const upgrade = new Command('upgrade')
|
|
|
20
21
|
return;
|
|
21
22
|
}
|
|
22
23
|
if (opts.check) {
|
|
24
|
+
// #170 review F10 — set BEFORE the --json early return. Before this,
|
|
25
|
+
// `trawl upgrade --check` exited 1 but `trawl upgrade --check --json`
|
|
26
|
+
// (identical state) exited 0 — the exit code was set below the --json
|
|
27
|
+
// `return`, so a --json caller never saw it. README documents this gate
|
|
28
|
+
// as "exit 1 if so" with no --json carve-out; a CI step gating on the
|
|
29
|
+
// exit code alone silently never fired under --json.
|
|
30
|
+
process.exitCode = 1;
|
|
23
31
|
if (opts.json)
|
|
24
32
|
return json({ package: PKG_NAME, current, latest, upToDate: false, upgraded: false });
|
|
25
33
|
console.log(`${chalk.yellow('↑')} Update available: ${chalk.bold(current)} → ${chalk.bold(latest)}. Run ${chalk.cyan('trawl upgrade')} to install.`);
|
|
26
|
-
// Honest exit code so a script can gate on "is an update available".
|
|
27
|
-
process.exitCode = 1;
|
|
28
34
|
return;
|
|
29
35
|
}
|
|
30
36
|
// Install the latest globally. This shells out to the same npm the user
|
|
@@ -43,7 +49,25 @@ export const upgrade = new Command('upgrade')
|
|
|
43
49
|
? 'permission denied on the global npm prefix — retry with sudo, or use a Node version manager.'
|
|
44
50
|
: (e.stderr?.trim() || e.message);
|
|
45
51
|
if (opts.json) {
|
|
46
|
-
|
|
52
|
+
// `upgrade_failed` is intentionally NOT in errors.ts's RETRY_POLICY
|
|
53
|
+
// (that map is the exhaustive set of kinds classifyError can
|
|
54
|
+
// produce — ERROR_KINDS derives from its keys) — retryFieldsFor is
|
|
55
|
+
// total, so an unmapped kind still gets the honest conservative
|
|
56
|
+
// default (`retryable:false`) instead of silently omitting the
|
|
57
|
+
// field. It IS registered in ENVELOPE_KINDS, the superset
|
|
58
|
+
// `spec --json`'s errorKinds actually publishes — see that
|
|
59
|
+
// constant's doc comment.
|
|
60
|
+
//
|
|
61
|
+
// #170 review F3 — typed as ErrorEnvelope so the compiler enforces
|
|
62
|
+
// `retryable` here too (this was one of the two emitters a
|
|
63
|
+
// mutation-test proved `tsc --noEmit` never actually guarded before
|
|
64
|
+
// this annotation).
|
|
65
|
+
const envelope = {
|
|
66
|
+
message: `upgrade failed: ${hint}`,
|
|
67
|
+
kind: 'upgrade_failed',
|
|
68
|
+
...retryFieldsFor('upgrade_failed'),
|
|
69
|
+
};
|
|
70
|
+
console.log(JSON.stringify({ error: envelope }));
|
|
47
71
|
}
|
|
48
72
|
else {
|
|
49
73
|
console.error(chalk.red(`✗ Upgrade failed: ${hint}`));
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { api } from '../lib/api.js';
|
|
3
|
+
import { api, apiKeyUnsupportedError } from '../lib/api.js';
|
|
4
|
+
import { getAuthMode } from '../lib/config.js';
|
|
4
5
|
import { json } from '../lib/format.js';
|
|
5
6
|
export const whoami = new Command('whoami')
|
|
6
7
|
.description("Show the authenticated user's identity")
|
|
7
8
|
.option('--json', 'Output as JSON')
|
|
8
9
|
.action(async (opts) => {
|
|
10
|
+
// #169 — JWT-only route (see WhoamiResponse's own doc comment above for
|
|
11
|
+
// why it stays that way). Under a scoped API key, a real request here
|
|
12
|
+
// would 401 and surface the generic "Session expired or invalid" text —
|
|
13
|
+
// wrong twice over: this route never accepts keys at all, and no
|
|
14
|
+
// session ever "expired". Detect it client-side, before any HTTP call,
|
|
15
|
+
// and say so plainly instead.
|
|
16
|
+
if (getAuthMode() === 'apiKey') {
|
|
17
|
+
throw apiKeyUnsupportedError('trawl whoami');
|
|
18
|
+
}
|
|
9
19
|
const data = await api.get('/api/users/me');
|
|
10
20
|
if (opts.json) {
|
|
11
21
|
json(data);
|