@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/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
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { spin } from '../lib/spinner.js';
|
|
3
|
+
import { spin, confirmNonTTY } from '../lib/spinner.js';
|
|
4
4
|
import { api, LONG_RUN_TIMEOUT_MS } from '../lib/api.js';
|
|
5
5
|
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';
|
|
@@ -590,7 +590,17 @@ scraps
|
|
|
590
590
|
.command('create')
|
|
591
591
|
.description('Create a new scrap')
|
|
592
592
|
.requiredOption('-t, --title <title>', 'Scrap title')
|
|
593
|
-
|
|
593
|
+
// #170 review F6 — promoted from a plain `.option()` to `.requiredOption()`:
|
|
594
|
+
// this is unconditionally required (no positional alternative exists on
|
|
595
|
+
// THIS command, unlike the top-level `trawl create <url>`, which accepts
|
|
596
|
+
// `[url]` OR `--url` — deliberately left untouched, that either/or needs a
|
|
597
|
+
// human product decision, tracked separately). Before this, the flag's
|
|
598
|
+
// description carried the only "Required." signal, in English prose — the
|
|
599
|
+
// exact thing docs/agent-quickstart.md tells an agent not to parse.
|
|
600
|
+
// Commander itself now enforces it, so `trawl spec --json`'s published
|
|
601
|
+
// `mandatory:true` is honest instead of a second, driftable copy of this
|
|
602
|
+
// same fact.
|
|
603
|
+
.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
604
|
.option('-r, --request <request>', 'Request/query')
|
|
595
605
|
.option('-d, --description <text>', 'Scrap description')
|
|
596
606
|
.option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
|
|
@@ -786,6 +796,24 @@ export function attachRunCommand(parent, attachOpts = {}) {
|
|
|
786
796
|
: await spin(call, { text: 'Launching scrap…', successText: 'Scrap launched' });
|
|
787
797
|
if (opts.json)
|
|
788
798
|
json(data);
|
|
799
|
+
else if (!opts.watch) {
|
|
800
|
+
/* #166 — see confirmNonTTY. Two deliberate choices here:
|
|
801
|
+
*
|
|
802
|
+
* "run complete", not "launched": `run` is the SYNCHRONOUS verb —
|
|
803
|
+
* GET /api/scraps/load/:id executes the scrap server-side (30-250s)
|
|
804
|
+
* and returns the finished result, so by the time this prints the run
|
|
805
|
+
* is over. The spinner's successText says "Scrap launched", but that
|
|
806
|
+
* is stderr chrome for a human watching it happen; THIS line is the
|
|
807
|
+
* machine surface, where a script reading "launched" would poll for a
|
|
808
|
+
* completion that already happened. `trigger` already draws the same
|
|
809
|
+
* distinction ("Worker triggered" async vs "Worker run complete" under
|
|
810
|
+
* --wait) — this matches that vocabulary.
|
|
811
|
+
*
|
|
812
|
+
* `--watch` is excluded because pollRunProgress below does its own
|
|
813
|
+
* reporting; without that guard a watched run would print this line
|
|
814
|
+
* and then narrate the same run. */
|
|
815
|
+
confirmNonTTY(`Scrap ${id} run complete`);
|
|
816
|
+
}
|
|
789
817
|
if (opts.watch) {
|
|
790
818
|
// #107 — under --json, pollRunProgress suppresses its own intermediate
|
|
791
819
|
// console.log calls and instead emits exactly ONE final NDJSON outcome
|
|
@@ -838,10 +866,27 @@ function renderScrapItems(items, asJson) {
|
|
|
838
866
|
* all "usage" (2) — never-run / aged-out-of-retention are not_found (4), a
|
|
839
867
|
* failed last run is a business-logic failure (1).
|
|
840
868
|
*/
|
|
841
|
-
|
|
869
|
+
/**
|
|
870
|
+
* #170 — `retryable`/`next` follow the SAME frozen `RETRY_POLICY` map
|
|
871
|
+
* `classifyError` reads (errors.ts), keyed by `kind` — this path never
|
|
872
|
+
* builds its own copy. `retryOverride` is for the one case here where the
|
|
873
|
+
* kind alone is genuinely wrong: `in_progress`'s caller passes
|
|
874
|
+
* `{retryable:true, next:['trawl data <id>']}` since retrying `data` (not
|
|
875
|
+
* `--fresh`, which would 429 against the lock already held) IS worth doing
|
|
876
|
+
* once the run finishes. Every other kind here (`not_found`, `run_failed`)
|
|
877
|
+
* isn't in `RETRY_POLICY` at all — `retryFieldsFor` is total, so those fall
|
|
878
|
+
* back to its conservative "not retryable, nothing to suggest" default.
|
|
879
|
+
*/
|
|
880
|
+
function reportDataState(message, exitCode, kind, wantsJson, retryOverride) {
|
|
842
881
|
console.error(chalk.red(`✗ ${message}`));
|
|
843
882
|
if (wantsJson) {
|
|
844
|
-
|
|
883
|
+
const { retryable, next } = retryOverride ?? retryFieldsFor(kind);
|
|
884
|
+
// #170 review F3 — typed as ErrorEnvelope so the compiler enforces
|
|
885
|
+
// `retryable` here too (this was one of two emitters a mutation test
|
|
886
|
+
// proved `tsc --noEmit` never actually guarded before this annotation —
|
|
887
|
+
// the untyped literal let `retryable` be omitted silently).
|
|
888
|
+
const envelope = { message, kind, retryable, ...(next ? { next } : {}) };
|
|
889
|
+
console.log(JSON.stringify({ error: envelope }));
|
|
845
890
|
}
|
|
846
891
|
process.exitCode = exitCode;
|
|
847
892
|
}
|
|
@@ -957,7 +1002,10 @@ export function attachDataCommand(parent, attachOpts = {}) {
|
|
|
957
1002
|
// run already holds the server-side distributed lock, so --fresh would
|
|
958
1003
|
// just 429 against it.
|
|
959
1004
|
if (last.status === null) {
|
|
960
|
-
|
|
1005
|
+
// #170 — retrying `trawl data <id>` (unchanged) once the run
|
|
1006
|
+
// finishes IS worth it; `--fresh` never is (it would 429 against the
|
|
1007
|
+
// lock this very run already holds) — never suggested here.
|
|
1008
|
+
reportDataState(`Run in progress for ${id} — retry shortly.`, 1, 'in_progress', opts.json, { retryable: true, next: [`trawl data ${id}`] });
|
|
961
1009
|
return;
|
|
962
1010
|
}
|
|
963
1011
|
// #86 review — node persists status=false for a GENUINE zero-item run
|
|
@@ -1118,6 +1166,7 @@ scraps
|
|
|
1118
1166
|
return;
|
|
1119
1167
|
}
|
|
1120
1168
|
await spin(call, { text: 'Deleting…', successText: 'Scrap deleted' });
|
|
1169
|
+
confirmNonTTY(`Scrap ${id} deleted`); // #166
|
|
1121
1170
|
});
|
|
1122
1171
|
// banner
|
|
1123
1172
|
scraps
|
|
@@ -1163,6 +1212,8 @@ scraps
|
|
|
1163
1212
|
text: 'Uploading banner…',
|
|
1164
1213
|
successText: `Banner uploaded for scrap ${chalk.bold(id)}`,
|
|
1165
1214
|
});
|
|
1215
|
+
// #166 — plain, unstyled: chalk.bold would emit escape codes into a pipe.
|
|
1216
|
+
confirmNonTTY(`Banner uploaded for scrap ${id}`);
|
|
1166
1217
|
});
|
|
1167
1218
|
// watch (stream activities)
|
|
1168
1219
|
scraps
|
|
@@ -1209,24 +1260,13 @@ export function attachTriggerCommand(parent, attachOpts = {}) {
|
|
|
1209
1260
|
if (opts.json) {
|
|
1210
1261
|
json(data);
|
|
1211
1262
|
}
|
|
1212
|
-
else
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
// whatever spin() decides about stderr — so an interactive TTY
|
|
1220
|
-
// session, where the ora spinner already confirmed this on stderr,
|
|
1221
|
-
// never sees a duplicate line here. Keeps the exact "Worker
|
|
1222
|
-
// triggered"/"Worker run complete" substrings the trawl-internal QA
|
|
1223
|
-
// runbook asserts on, plus the scrap id so a script has something to
|
|
1224
|
-
// parse. Known residual: stdout attached to a real terminal while
|
|
1225
|
-
// stderr is separately redirected (rare) still prints nothing on
|
|
1226
|
-
// either stream, same as pre-#160 — not the reported/common shape
|
|
1227
|
-
// (full redirection or stdout-only capture), left alone to keep this
|
|
1228
|
-
// fix narrowly scoped to the stream it actually touches.
|
|
1229
|
-
console.log(opts.wait ? `Worker run complete for scrap ${id}` : `Worker triggered for scrap ${id}`);
|
|
1263
|
+
else {
|
|
1264
|
+
/* #160, folded onto the shared helper by #166 — the rationale and the
|
|
1265
|
+
* known residual now live once, on `confirmNonTTY` itself, instead of
|
|
1266
|
+
* in the one call site that happened to be fixed first. Wording is
|
|
1267
|
+
* unchanged on purpose: the trawl-internal QA runbook asserts on the
|
|
1268
|
+
* exact "Worker triggered" / "Worker run complete" substrings. */
|
|
1269
|
+
confirmNonTTY(opts.wait ? `Worker run complete for scrap ${id}` : `Worker triggered for scrap ${id}`);
|
|
1230
1270
|
}
|
|
1231
1271
|
// #107 — see the matching comment on `run`'s --watch call above (review
|
|
1232
1272
|
// F1): honest final NDJSON line + exit code under --json, human mode
|
|
@@ -1335,6 +1375,7 @@ account
|
|
|
1335
1375
|
text: 'Deleting credentials…',
|
|
1336
1376
|
successText: 'Account credentials deleted',
|
|
1337
1377
|
});
|
|
1378
|
+
confirmNonTTY(`Account credentials deleted for scrap ${id}`); // #166
|
|
1338
1379
|
});
|
|
1339
1380
|
// account clear-session
|
|
1340
1381
|
account
|
|
@@ -1353,6 +1394,7 @@ account
|
|
|
1353
1394
|
text: 'Clearing session…',
|
|
1354
1395
|
successText: 'Session cleared',
|
|
1355
1396
|
});
|
|
1397
|
+
confirmNonTTY(`Session cleared for scrap ${id}`); // #166
|
|
1356
1398
|
});
|
|
1357
1399
|
// account session subcommand group
|
|
1358
1400
|
const accountSession = account
|
|
@@ -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);
|