@trawlme/cli 1.18.2 → 1.18.4

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 CHANGED
@@ -41,7 +41,7 @@ trawl token Print the stored session JWT (for M
41
41
  ### Scraps
42
42
 
43
43
  ```
44
- trawl scraps list [--json] [--status <success|failure|never>] [--limit <n>] [--page <n>]
44
+ trawl scraps list [--json] [--status <success|failure|never|running|regression>] [--limit <n>] [--page <n>]
45
45
  trawl scraps get <id> [--json]
46
46
  trawl scraps create -t <title> [-u <url>] [-r <request>] [-d <description>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--json]
47
47
  trawl scraps update <id> [-t <title>] [-u <url>] [-r <request>] [-d <description>] [--cron <expr>|--no-cron] [--alert <email>|--no-alert] [--autofix|--no-autofix] [-p <json>|--params-file <path>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--force-tier <tier0|tier1|tier2|tier3|tier4>] [--json]
@@ -53,18 +53,21 @@ trawl scraps history <id> [--json] [-n <limit>]
53
53
  trawl scraps run-info <hid> [--json]
54
54
  trawl scraps doctor <id> [--json] [--autofix]
55
55
  trawl scraps autofix <id> [--json]
56
- trawl scraps snapshot <id> [--error] [-o <file>]
56
+ trawl scraps snapshot <id> [--error] [-o <file>] [--json]
57
57
  trawl scraps banner <id> -f <file>
58
58
  trawl scraps delete <id> [--force] Alias: rm
59
59
  ```
60
60
 
61
- - `--tier` forces a proxy tier; `--force-tier` raises the proxy-tier ceiling past the auto-cap (history-gated: may be refused or cost more). `create --json`/`update --json` print the full scrap object (including the `_tierOverride` outcome) on stdout; a refused tier override exits 1 with a standard `--json` error envelope.
62
- - `scraps doctor` diagnoses the last run (error, failed selector, block status, page state, autofix outcome); `--autofix` includes the full autofix diff/dry-run/knowledge.
63
- - `scraps autofix` shows the last auto-fix attempt on its own (decision, diff, dry-run, knowledge).
64
- - `scraps snapshot --error` fetches the error-path snapshot instead of the normal one; `-o <file>` writes to a file instead of stdout.
61
+ - `--tier` forces a proxy tier; `--force-tier` raises the proxy-tier ceiling past the auto-cap (history-gated: may be refused or cost more). `create --json`/`update --json` print the full scrap object (including the `_tierOverride` outcome) on stdout; a refused tier override exits 1 with a standard `--json` error envelope. When a tier was requested but the server's response carries no `_tierOverride` at all (an older server that can't confirm what actually got applied), a stderr warning is printed either way, and under `--json` the emitted object also carries `"_tierUnconfirmed": true` — the machine-readable counterpart to that warning, since a `--json` caller has no reliable reason to read stderr.
62
+ - `scraps doctor` diagnoses the last run (error, failed selector, block status, page state, autofix outcome); `--autofix` includes the full autofix diff/dry-run/knowledge. A run that is still in flight (`status: null`, server-side) shows a `running` badge, never `failed`; a run whose item count regressed vs baseline (`statusDetail: "regression"`) shows its own amber `regression` badge, never `failed` either (`scraps list`/`get` show the same distinction — a `▼` icon, not the red `✗` a genuine failure gets).
63
+ - `scraps autofix` shows the last auto-fix attempt on its own (decision, diff, dry-run, knowledge). `--json` on a scrap that has **never run** returns `{"status":"no_runs"}` (exit 0) — distinct from `null`, which means a run exists but had no auto-fix attempt.
64
+ - `scraps snapshot --error` fetches the error-path snapshot instead of the normal one; `-o <file>` writes to a file instead of stdout. On a scrap that has **never run**: `--json` returns `{"status":"no_runs"}` (exit 0), matching `doctor`/`autofix`; `-o <file>` (without `--json`) exits 4 (`not_found`) instead of silently exiting 0 with nothing written — a script checking the exit code alone must be able to tell "no file was produced" from success. `--json` takes priority when both are passed.
65
65
  - `scraps history` lists past runs (newest first); `scraps run-info <hid>` shows details of a single run from that history.
66
- - `scraps data` returns the last persisted run payload (no execute quota); `--fresh` runs the scrap live instead (consumes execute quota); `--errors` shows the last run's error detail. `[]` on stdout means a genuine zero-item successful run — a scrap that has never run, whose last run failed, or whose payload aged out of retention returns a `--json` error envelope (exit 4/1/4 respectively) instead.
66
+ - `scraps data` returns the last persisted run payload (no execute quota); `--fresh` runs the scrap live instead (consumes execute quota); `--errors` shows the last run's error detail (`--json` on a never-run scrap returns `{"status":"no_runs"}`, exit 0, matching `doctor --json`). `[]` on stdout means a genuine zero-item successful run — a scrap that has never run, whose last run failed, or whose payload aged out of retention returns a `--json` error envelope (exit 4/1/4 respectively) instead. Two more honest states: a run still **in flight** (`status: null` server-side) returns a `kind:"in_progress"` error envelope (exit 1, "retry shortly" — never suggests `--fresh`, which would just 429 against the run already holding the lock); a run whose item count **regressed** vs baseline (`statusDetail: "regression"`) still returns the real, non-empty items on stdout (exit 0) plus a stderr warning pointing at `scraps doctor <id>` — the data itself is genuine even though the run is flagged.
67
67
  - `scraps get` (and anything reading through it, like `scraps data`'s default path) embeds only the newest 100 history rows on the returned scrap object — `scraps run-info` and `scraps doctor` fetch a single run directly and are unaffected by that cap.
68
+ - `scraps banner -f <file>` only accepts `png`/`jpg`/`jpeg`/`webp`; any other extension is a usage error (exit 2) instead of silently uploading the file under a fabricated `image/png` Content-Type.
69
+ - **Long-running scrap execute (`scraps run`, `data --fresh`, `trigger --wait`):** these hit the same server-side scrap-execute path, which can legitimately take 30–250s (proxy tier escalation, AI-fix retries) — the CLI arms a 300s timeout for exactly these three 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 for a long-running scrap.
70
+ - **`--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. `scraps run --watch` and `scraps 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; a run that never reaches a terminal status within 300s prints an honest timeout notice pointing at `scraps doctor <id>`. `scraps watch <id>` (the standalone command, no trigger) is unchanged — it still opens the live SSE stream directly.
68
71
 
69
72
  ### Scrap accounts
70
73
 
@@ -89,7 +92,7 @@ trawl skills uninstall [<skill>] [--local] Remove
89
92
  trawl skills update [<skill>] [--local] [--force] Reinstall (force sync with CLI version)
90
93
  ```
91
94
 
92
- Skills auto-update when you upgrade the CLI — no need to re-install manually — but it is not silent: it prints an honest `trawl: re-synced skill "<name>" (<scope>) <old> → <new>` line to stderr whenever it rewrites a skill dir, so a rewrite is never invisible. Opt out with `TRAWL_SKILLS_SYNC=0`.
95
+ Skills auto-update when you upgrade the CLI — no need to re-install manually — but it is not silent: it prints an honest `trawl: re-synced skill "<name>" (<scope>) <old> → <new>` line to stderr whenever it rewrites a skill dir, so a rewrite is never invisible. If a bundled skill is renamed or dropped between CLI versions, the old install is swept too — a `trawl: removed orphaned skill "<name>" (<scope>) — no longer bundled with this CLI version` line to stderr, so a stale skill teaching outdated CLI usage never lingers silently. Only marker-owned dirs (installed by trawl itself) are ever touched by either line. Opt out with `TRAWL_SKILLS_SYNC=0`.
93
96
 
94
97
  A pre-existing skill directory that trawl did not install itself (no `.version` marker) is never touched — `install`/`update` refuse to overwrite it and require `--force` to proceed. This applies to the CLI-upgrade auto-sync too (it silently skips marker-less dirs rather than refusing, since there is no interactive user to show a refusal to).
95
98
 
@@ -143,7 +146,7 @@ Every command exits with one of these codes — scripts and agents driving the C
143
146
  | Code | Meaning |
144
147
  |------|--------------------------------------------------------------------------|
145
148
  | `0` | Success |
146
- | `1` | Unknown/generic error (an unmapped failure — API errors other than 401/404, an unhandled bug) |
149
+ | `1` | Unknown/generic error (an unmapped failure — API errors other than 401/404, an unhandled bug — or a business-logic refusal like `scraps data`'s `run_failed`/`in_progress` states) |
147
150
  | `2` | Usage error (bad flag/value, invalid ID, missing required argument, unknown option/command) |
148
151
  | `3` | Auth error (not logged in, or the session token is expired/invalid — run `trawl login`) |
149
152
  | `4` | Not found (no such resource, or — for `scraps data` — no persisted payload to read) |
@@ -160,7 +163,7 @@ Under `--json`, a failing command emits a single error envelope on stdout — `{
160
163
  | `TRAWL_TELEMETRY` | Set to `0` to disable telemetry for the current session |
161
164
  | `DO_NOT_TRACK` | Set to `1` to disable telemetry (cross-vendor convention, https://consoledonottrack.com) — same effect as `TRAWL_TELEMETRY=0` |
162
165
  | `TRAWL_CONFIG_DIR` | Override where the config file (token, API URL, telemetry state) is stored — useful for hermetic CI runs or concurrent `trawl login`s that must not share one on-disk file |
163
- | `TRAWL_TIMEOUT` | Override the per-request fetch timeout in milliseconds (default `30000`) |
166
+ | `TRAWL_TIMEOUT` | Override the per-request fetch timeout in milliseconds (default `30000`; `300000` for `scraps run`/`data --fresh`/`trigger --wait` — this env var always wins over that longer default too) |
164
167
  | `TRAWL_SKILLS_SYNC` | Set to `0` to disable the startup skills auto-sync entirely |
165
168
 
166
169
  Session override (no `trawl login` mutation, ideal for CI/QA against another env):
@@ -83,13 +83,26 @@ export function pickFix(fix) {
83
83
  */
84
84
  export function formatDoctor(scrapTitle, run, fix = null, scrapId) {
85
85
  const lines = [];
86
- // Header + status badge
86
+ // Header + status badge. status:null means a run is IN FLIGHT (node
87
+ // persists {status:null, statusDetail:null, inFlight:true} the moment a
88
+ // run starts) — that must never render as "failed". (#88 item 1)
89
+ //
90
+ // #91 LOW — a regression row (status:false, statusDetail:'regression') was
91
+ // falling into the same red "failed" bucket as a genuine failure, then
92
+ // showing a contradictory "Regression: X vs Y" detail line right below it.
93
+ // A regression run's write actually SUCCEEDED (the item count just dropped
94
+ // vs baseline, detected by an async patch afterward — see #88 item 2 in
95
+ // scraps.ts) — it needs its own distinct badge, not "failed".
87
96
  const ok = run.status === true;
88
97
  const badge = ok
89
98
  ? chalk.green('● success')
90
- : run.statusDetail === 'empty'
91
- ? chalk.yellow('● empty')
92
- : chalk.red('● failed');
99
+ : run.status === null
100
+ ? chalk.cyan('● running')
101
+ : run.statusDetail === 'empty'
102
+ ? chalk.yellow('● empty')
103
+ : run.statusDetail === 'regression'
104
+ ? chalk.hex('#FFA500')('● regression')
105
+ : chalk.red('● failed');
93
106
  lines.push(`${chalk.bold(scrapTitle)} ${badge}${run.statusDetail ? ` (${run.statusDetail})` : ''}`);
94
107
  lines.push(chalk.dim(` Run ID: ${run._id}`));
95
108
  // Error message — an honest accept-wall string for known-walled scraps
@@ -1,2 +1,27 @@
1
1
  import { Command } from 'commander';
2
2
  export declare const scraps: Command;
3
+ /**
4
+ * #91 P1 — replaces "await the run to completion, THEN open the activities
5
+ * SSE stream" (which showed NOTHING: the activities SSE
6
+ * (GET /api/scraps/:id/activities/stream) is backed by an in-process
7
+ * EventEmitter with no backlog — trawl_node
8
+ * modules/activities/services/activities.service.js — so by the time a
9
+ * synchronous run has already finished there is nothing left to emit; and
10
+ * the async `trigger` default enqueues the run onto a durable job queue a
11
+ * SEPARATE cron-consumer pod drains, whose in-process emitter never reaches
12
+ * the API pod holding the SSE connection at all).
13
+ *
14
+ * Instead this polls two REST reads that are BOTH Mongo-backed (not
15
+ * in-process), so they work no matter which pod actually executed the run:
16
+ * - GET /api/scraps/:id/activities?history=<hid>&limit=20 — the SAME
17
+ * activities-list endpoint `doctor`'s fetchRunAndFix already calls
18
+ * (src/commands/doctor.ts) — prints each new activity line once the new
19
+ * run's history id is known.
20
+ * - GET /api/scraps/:id — history[0].status/statusDetail, the SAME
21
+ * terminal-status signal `lastStatus()` above already trusts (status:null
22
+ * === in flight, #88 item 1) to know when the run is done.
23
+ */
24
+ export declare function pollRunProgress(id: string, beforeHistoryId: string | undefined, opts?: {
25
+ intervalMs?: number;
26
+ timeoutMs?: number;
27
+ }): Promise<void>;
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { oraPromise } from 'ora';
4
- import { api } from '../lib/api.js';
4
+ import { api, LONG_RUN_TIMEOUT_MS } from '../lib/api.js';
5
5
  import { table, json } from '../lib/format.js';
6
6
  import { promptPassword } from '../lib/prompt.js';
7
7
  import { validateObjectId } from '../lib/validate.js';
@@ -20,8 +20,23 @@ function usageError(message, opts = {}) {
20
20
  }
21
21
  function lastStatus(scrap) {
22
22
  const last = scrap.history?.[0];
23
- if (!last || last.status === null || last.status === undefined)
23
+ if (!last)
24
24
  return 'never';
25
+ // status:null means a run is IN FLIGHT (node persists {status:null,
26
+ // statusDetail:null, inFlight:true} the moment a run starts) — that is NOT
27
+ // the same thing as "this scrap has never run" (no history row at all).
28
+ // (#88 item 1)
29
+ if (last.status === null)
30
+ return 'running';
31
+ if (last.status === undefined)
32
+ return 'never';
33
+ // #91 LOW — same regression-as-failure bucketing bug as doctor's badge
34
+ // (formatDoctor): a regression row's write actually succeeded (item count
35
+ // just dropped vs baseline, flagged by an async patch afterward — #88 item
36
+ // 2), so it must not collapse into the same 'failure' bucket a genuine
37
+ // failed run gets.
38
+ if (last.status === false && last.statusDetail === 'regression')
39
+ return 'regression';
25
40
  return last.status === true ? 'success' : 'failure';
26
41
  }
27
42
  function lastRun(scrap) {
@@ -41,6 +56,12 @@ function statusIcon(status) {
41
56
  return chalk.green('✓');
42
57
  if (status === 'failure')
43
58
  return chalk.red('✗');
59
+ if (status === 'running')
60
+ return chalk.cyan('↻');
61
+ // #91 LOW — distinct amber icon, never the red ✗ a genuine failure gets
62
+ // (mirrors doctor's "● regression" badge — see formatDoctor).
63
+ if (status === 'regression')
64
+ return chalk.hex('#FFA500')('▼');
44
65
  return chalk.dim('—');
45
66
  }
46
67
  export const scraps = new Command('scraps').description('Manage scraps');
@@ -61,39 +82,151 @@ async function watchActivities(id) {
61
82
  }
62
83
  }
63
84
  }
85
+ /**
86
+ * #91 P1 — snapshot the current top history entry BEFORE triggering a run,
87
+ * so pollRunProgress can later detect the NEW run once the server pushes it
88
+ * (node persists a fresh {status:null, inFlight:true} row the moment a run
89
+ * starts — see HistorysService.create in trawl_node — so a changed
90
+ * history[0]._id is an honest "the new run has begun" signal). Best-effort:
91
+ * a failed lookup just means the poll treats the very first history row it
92
+ * sees as new, which is still correct for a scrap that has never run.
93
+ */
94
+ async function captureBeforeHistoryId(id) {
95
+ try {
96
+ const scrap = await api.get(`/api/scraps/${id}`);
97
+ return scrap.history?.[0]?._id;
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ function sleep(ms) {
104
+ return new Promise((resolve) => setTimeout(resolve, ms));
105
+ }
106
+ const POLL_INTERVAL_MS = 2000;
107
+ // Mirrors LONG_RUN_TIMEOUT_MS (#91 item 1) — the server-side worst case this
108
+ // polls for is the same one that timeout was sized for.
109
+ const POLL_TIMEOUT_MS = LONG_RUN_TIMEOUT_MS;
110
+ /**
111
+ * #91 P1 — replaces "await the run to completion, THEN open the activities
112
+ * SSE stream" (which showed NOTHING: the activities SSE
113
+ * (GET /api/scraps/:id/activities/stream) is backed by an in-process
114
+ * EventEmitter with no backlog — trawl_node
115
+ * modules/activities/services/activities.service.js — so by the time a
116
+ * synchronous run has already finished there is nothing left to emit; and
117
+ * the async `trigger` default enqueues the run onto a durable job queue a
118
+ * SEPARATE cron-consumer pod drains, whose in-process emitter never reaches
119
+ * the API pod holding the SSE connection at all).
120
+ *
121
+ * Instead this polls two REST reads that are BOTH Mongo-backed (not
122
+ * in-process), so they work no matter which pod actually executed the run:
123
+ * - GET /api/scraps/:id/activities?history=<hid>&limit=20 — the SAME
124
+ * activities-list endpoint `doctor`'s fetchRunAndFix already calls
125
+ * (src/commands/doctor.ts) — prints each new activity line once the new
126
+ * run's history id is known.
127
+ * - GET /api/scraps/:id — history[0].status/statusDetail, the SAME
128
+ * terminal-status signal `lastStatus()` above already trusts (status:null
129
+ * === in flight, #88 item 1) to know when the run is done.
130
+ */
131
+ export async function pollRunProgress(id, beforeHistoryId, opts = {}) {
132
+ const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
133
+ const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
134
+ console.log(chalk.dim('Live activity streaming has no signal for this run (async/cross-pod) — polling for progress instead…\n'));
135
+ const deadline = Date.now() + timeoutMs;
136
+ const seen = new Set();
137
+ let first = true;
138
+ while (Date.now() < deadline) {
139
+ if (!first)
140
+ await sleep(intervalMs);
141
+ first = false;
142
+ let scrap;
143
+ try {
144
+ scrap = await api.get(`/api/scraps/${id}`);
145
+ }
146
+ catch {
147
+ continue; // transient — keep polling rather than aborting the wait
148
+ }
149
+ const last = scrap.history?.[0];
150
+ if (!last?._id || last._id === beforeHistoryId)
151
+ continue; // new run not recorded yet
152
+ try {
153
+ const activities = await api.get(`/api/scraps/${id}/activities?history=${last._id}&limit=20`);
154
+ // Server returns newest-first — print unseen ones oldest-first.
155
+ for (const a of [...(activities ?? [])].reverse()) {
156
+ const key = a._id ?? `${a.createdAt}:${a.message}`;
157
+ if (seen.has(key))
158
+ continue;
159
+ seen.add(key);
160
+ const time = new Date(a.createdAt).toLocaleTimeString();
161
+ console.log(`${chalk.dim(`[${time}]`)} ${a.message}`);
162
+ }
163
+ }
164
+ catch {
165
+ // best-effort — terminal detection below still works without activity lines
166
+ }
167
+ if (last.status !== null) {
168
+ const outcome = last.statusDetail ?? (last.status ? 'success' : 'failure');
169
+ console.log(chalk.dim(`Run finished: ${outcome}`));
170
+ return;
171
+ }
172
+ }
173
+ console.log(chalk.yellow(`⚠ Timed out waiting for the run to finish — check status with: trawl scraps doctor ${id}`));
174
+ }
64
175
  // list
65
176
  scraps
66
177
  .command('list')
67
178
  .alias('ls')
68
179
  .description('List all scraps')
69
180
  .option('--json', 'Output as JSON')
70
- .option('--status <status>', 'Filter by last run status (success|failure|never)')
71
- .option('--limit <n>', 'Show only the first N results', (v) => parseInt(v, 10))
72
- .option('--page <n>', 'Fetch a specific page only (50 per page, no auto-pagination)', (v) => parseInt(v, 10))
181
+ .option('--status <status>', 'Filter by last run status (success|failure|never|running|regression)')
182
+ // #88 item 8 no custom parser here (unlike the old `(v) => parseInt(v,
183
+ // 10)`): a bad value like "abc" used to silently become NaN, which then
184
+ // sailed straight through `Number.isInteger`-less checks and into
185
+ // `.slice(0, NaN)` (silently truncates to 0 rows) or `?page=NaN` (silently
186
+ // sent to the server) — never a usage error. Keeping the raw string here
187
+ // lets the validation below mirror `history`'s own --limit check exactly
188
+ // (~line 660) and report the actual bad input in the error message.
189
+ .option('--limit <n>', 'Show only the first N results')
190
+ .option('--page <n>', 'Fetch a specific page only (50 per page, no auto-pagination)')
73
191
  .action(async (opts, cmd) => {
74
192
  // Guard: --limit and --page are mutually exclusive
75
193
  if (opts.limit !== undefined && opts.page !== undefined) {
76
194
  usageError('--limit and --page are mutually exclusive. Use one or the other.', { json: opts.json });
77
195
  return;
78
196
  }
197
+ let limit;
198
+ if (opts.limit !== undefined) {
199
+ limit = Number(opts.limit);
200
+ if (!Number.isInteger(limit) || limit <= 0) {
201
+ usageError(`Invalid --limit "${opts.limit}" (expected a positive integer)`, { json: opts.json });
202
+ return;
203
+ }
204
+ }
205
+ let page;
206
+ if (opts.page !== undefined) {
207
+ page = Number(opts.page);
208
+ if (!Number.isInteger(page) || page <= 0) {
209
+ usageError(`Invalid --page "${opts.page}" (expected a positive integer)`, { json: opts.json });
210
+ return;
211
+ }
212
+ }
79
213
  let data;
80
214
  try {
81
215
  data = await oraPromise(async () => {
82
- if (opts.page !== undefined) {
216
+ if (page !== undefined) {
83
217
  // Single-page mode: explicit page requested, no loop
84
- const pageNum = opts.page;
85
- return api.get(`/api/scraps?perPage=50&page=${pageNum}`);
218
+ return api.get(`/api/scraps?perPage=50&page=${page}`);
86
219
  }
87
220
  // Fetch-all mode: paginate until a page returns < 200 items
88
221
  const perPage = 200;
89
222
  let result = [];
90
- let page = 1;
223
+ let pageNum = 1;
91
224
  while (true) {
92
- const batch = await api.get(`/api/scraps?perPage=${perPage}&page=${page}`);
225
+ const batch = await api.get(`/api/scraps?perPage=${perPage}&page=${pageNum}`);
93
226
  result = result.concat(batch);
94
227
  if (batch.length < perPage)
95
228
  break;
96
- page++;
229
+ pageNum++;
97
230
  }
98
231
  return result;
99
232
  }, 'Fetching scraps…');
@@ -126,7 +259,6 @@ scraps
126
259
  if (opts.status)
127
260
  data = data.filter((s) => lastStatus(s) === opts.status);
128
261
  const totalMatched = data.length;
129
- const limit = opts.limit;
130
262
  const rows = limit !== undefined ? data.slice(0, limit) : data;
131
263
  if (opts.json)
132
264
  return json(rows);
@@ -206,6 +338,23 @@ function warnIfUnconfirmedTier(data, tierWasRequested, id) {
206
338
  return;
207
339
  console.error(chalk.yellow(` ⚠ Server did not confirm the tier change (older server) — verify with: trawl scraps get ${id}`));
208
340
  }
341
+ /**
342
+ * #88 item 3 — the --json machine-readable counterpart to
343
+ * warnIfUnconfirmedTier's stderr warning above. A --json caller (an agent
344
+ * scripting this CLI) has no reliable reason to read stderr — that channel
345
+ * is advisory-only everywhere else in this CLI, and stdout must stay the
346
+ * sole payload. Without this, the ONLY signal that the server never
347
+ * confirmed the tier change was a string on stderr, invisible to any --json
348
+ * consumer parsing stdout alone. Adds `_tierUnconfirmed: true` to the
349
+ * emitted object under EXACTLY the same condition warnIfUnconfirmedTier
350
+ * warns on (tier requested, response carries no `_tierOverride`) — never
351
+ * fabricated, never present otherwise.
352
+ */
353
+ function withTierUnconfirmed(data, tierWasRequested) {
354
+ if (data._tierOverride || !tierWasRequested)
355
+ return data;
356
+ return { ...data, _tierUnconfirmed: true };
357
+ }
209
358
  /** #86 finding 5 — the standard error envelope for a refused tier override,
210
359
  * routed through the same reportError() central formatting path used
211
360
  * everywhere else (exit 1: a business-logic refusal, not a usage error). */
@@ -247,7 +396,7 @@ scraps
247
396
  process.exitCode = reportTierRefusal(data, true);
248
397
  return;
249
398
  }
250
- json(data);
399
+ json(withTierUnconfirmed(data, tierWasRequested));
251
400
  return;
252
401
  }
253
402
  console.log(chalk.dim(` Title: ${data.title}`));
@@ -360,7 +509,7 @@ scraps
360
509
  process.exitCode = reportTierRefusal(data, true);
361
510
  return;
362
511
  }
363
- json(data);
512
+ json(withTierUnconfirmed(data, tierWasRequested));
364
513
  return;
365
514
  }
366
515
  const shown = data;
@@ -382,15 +531,20 @@ scraps
382
531
  scraps
383
532
  .command('run <id>')
384
533
  .description('Run a scrap')
385
- .option('-w, --watch', 'Stream activities after launching')
534
+ .option('-w, --watch', 'Show progress after launching (polls — see `trawl scraps trigger --watch`, #91)')
386
535
  .action(async (id, opts) => {
387
536
  validateObjectId(id);
388
- await oraPromise(() => api.get(`/api/scraps/load/${id}`), {
537
+ // #91 P1 captured BEFORE launching so pollRunProgress can tell "the run
538
+ // that's about to finish" apart from whatever the last run happened to be.
539
+ const beforeHistoryId = opts.watch ? await captureBeforeHistoryId(id) : undefined;
540
+ // #91 P0 — GET /api/scraps/load/:id runs the scrap synchronously
541
+ // server-side (30-250s); the 30s default was aborting it mid-flight.
542
+ await oraPromise(() => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS }), {
389
543
  text: 'Launching scrap…',
390
544
  successText: 'Scrap launched',
391
545
  });
392
546
  if (opts.watch) {
393
- await watchActivities(id);
547
+ await pollRunProgress(id, beforeHistoryId);
394
548
  }
395
549
  });
396
550
  // #70 — render an items array either as a table summary or --json. Shared by
@@ -440,8 +594,12 @@ scraps
440
594
  if (opts.errors) {
441
595
  const result = await fetchRunAndFix(id);
442
596
  if (!result) {
597
+ // #88 item 7 — unified no-runs shape with `doctor --json`: a bare
598
+ // `null` was indistinguishable from any other absent-payload state
599
+ // (a scrap CAN legitimately have a null-ish result elsewhere); an
600
+ // explicit `{status:"no_runs"}` object is unambiguous everywhere.
443
601
  if (opts.json) {
444
- json(null);
602
+ json({ status: 'no_runs' });
445
603
  return;
446
604
  }
447
605
  console.log(chalk.dim('No runs yet.'));
@@ -466,7 +624,9 @@ scraps
466
624
  // burning execute quota and 429ing if a run is already in flight. A user
467
625
  // or agent "just reading data" must never trigger that by accident.
468
626
  if (opts.fresh) {
469
- const loaded = await oraPromise(() => api.get(`/api/scraps/load/${id}`), {
627
+ // #91 P0 same long-run endpoint as `scraps run` (30-250s server-side);
628
+ // the 30s default was aborting it mid-flight.
629
+ const loaded = await oraPromise(() => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS }), {
470
630
  text: 'Launching a fresh scrap run (consumes execute quota)…',
471
631
  successText: 'Fresh run complete',
472
632
  });
@@ -502,16 +662,33 @@ scraps
502
662
  reportDataState(`Scrap ${id} has never run. Run it first (trawl scraps run ${id}) or pass --fresh to launch one now.`, 4, 'not_found', opts.json);
503
663
  return;
504
664
  }
665
+ // #88 item 1 — status:null is an IN-FLIGHT run (node persists
666
+ // {status:null, statusDetail:null, inFlight:true} the moment a run
667
+ // starts, and only flips status/statusDetail once it finishes). That is
668
+ // neither "never run" nor "the last run failed" — a caller reading data
669
+ // mid-run needs an honest "wait" signal. Never suggest --fresh here: a
670
+ // run already holds the server-side distributed lock, so --fresh would
671
+ // just 429 against it.
672
+ if (last.status === null) {
673
+ reportDataState(`Run in progress for ${id} — retry shortly.`, 1, 'in_progress', opts.json);
674
+ return;
675
+ }
505
676
  // #86 review — node persists status=false for a GENUINE zero-item run
506
677
  // too (historys schema: status boolean|null + statusDetail
507
678
  // success/error/empty/regression; a zero-item run is status=false +
508
679
  // statusDetail='empty', and the embedded history rows from GET
509
680
  // /api/scraps/:id include statusDetail via the repository populate
510
681
  // select). An 'empty' run is the one case [] is FOR — only a real
511
- // failure (error/regression/unknown detail) gets the run_failed
512
- // envelope.
682
+ // failure (error/unknown detail) gets the run_failed envelope.
683
+ //
684
+ // #88 item 2 — statusDetail='regression' is ALSO status=false (an async
685
+ // patch flips it after item count dropped vs baseline), but the row's
686
+ // `data` still holds REAL, non-empty items — the write that persisted
687
+ // them succeeded before the regression was even detected. Treating it as
688
+ // run_failed would hide genuine data behind a false negative.
513
689
  const isEmptyRun = last.status === false && last.statusDetail === 'empty';
514
- if (last.status === false && !isEmptyRun) {
690
+ const isRegression = last.status === false && last.statusDetail === 'regression';
691
+ if (last.status === false && !isEmptyRun && !isRegression) {
515
692
  reportDataState(`Last run failed — see: trawl scraps data ${id} --errors`, 1, 'run_failed', opts.json);
516
693
  return;
517
694
  }
@@ -533,9 +710,20 @@ scraps
533
710
  renderScrapItems([], opts.json);
534
711
  return;
535
712
  }
536
- reportDataState(`No persisted data for the last successful run of ${id} — it aged out of retention. Pass --fresh to launch a new run.`, 4, 'not_found', opts.json);
713
+ // #88 item 2 a regression row whose payload aged out of retention has
714
+ // nothing left to show either; fall through to the SAME honest
715
+ // aged-out envelope a normal successful row would get (never fabricate
716
+ // items, never silently succeed).
717
+ reportDataState(`No persisted data for the last run of ${id} — it aged out of retention. Pass --fresh to launch a new run.`, 4, 'not_found', opts.json);
537
718
  return;
538
719
  }
720
+ // #88 item 2 — a regression row's items are REAL (the write succeeded
721
+ // before the async patch flagged the drop) — return them on stdout
722
+ // (exit 0, both modes) with an honest stderr warning pointing at the
723
+ // diagnostic command, instead of hiding genuine data behind run_failed.
724
+ if (isRegression) {
725
+ console.error(chalk.yellow(`⚠ Item count regressed vs baseline for the last run of ${id} — see: trawl scraps doctor ${id}`));
726
+ }
539
727
  renderScrapItems(items, opts.json);
540
728
  });
541
729
  // history — list past runs for a scrap
@@ -633,7 +821,6 @@ scraps
633
821
  usageError(`File not found: ${opts.file}`);
634
822
  return;
635
823
  }
636
- const fileBuffer = readFileSync(opts.file);
637
824
  const filename = basename(opts.file);
638
825
  const ext = filename.split('.').pop()?.toLowerCase() ?? '';
639
826
  const mimeMap = {
@@ -642,7 +829,15 @@ scraps
642
829
  jpeg: 'image/jpeg',
643
830
  webp: 'image/webp',
644
831
  };
645
- const mimeType = mimeMap[ext] ?? 'image/png';
832
+ // #91 P2 an unsupported/unknown extension silently became image/png
833
+ // (uploading the raw bytes of, say, a .gif or .pdf under an image/png
834
+ // Content-Type — a lie about the actual file's format). Refuse instead.
835
+ const mimeType = mimeMap[ext];
836
+ if (!mimeType) {
837
+ usageError(`Unsupported image type "${ext ? `.${ext}` : filename}" — use png, jpg, or webp.`);
838
+ return;
839
+ }
840
+ const fileBuffer = readFileSync(opts.file);
646
841
  const blob = new Blob([fileBuffer], { type: mimeType });
647
842
  const formData = new FormData();
648
843
  formData.append('banner', blob, filename);
@@ -663,20 +858,27 @@ scraps
663
858
  scraps
664
859
  .command('trigger <id>')
665
860
  .description('Launch a scrap as a background worker (returns immediately)')
666
- .option('-w, --watch', 'Stream activities after triggering')
861
+ .option('-w, --watch', 'Poll for progress after triggering (#91 — the default async run happens in a separate cron pod; activities SSE never reaches it)')
667
862
  .option('--wait', 'Run synchronously and wait for the result (legacy behaviour)')
668
863
  .action(async (id, opts) => {
669
864
  validateObjectId(id);
865
+ // #91 P1 — captured BEFORE triggering so pollRunProgress can tell "the
866
+ // run we just triggered" apart from whatever the last run happened to be.
867
+ const beforeHistoryId = opts.watch ? await captureBeforeHistoryId(id) : undefined;
670
868
  // #50 — default async: the backend (#1313) kicks off the run and returns a
671
869
  // 'queued' envelope immediately instead of holding the connection for the
672
870
  // whole run. --wait restores the old synchronous round-trip.
673
871
  const path = opts.wait ? `/api/scraps/worker/${id}` : `/api/scraps/worker/${id}?wait=false`;
674
- await oraPromise(() => api.post(path), {
872
+ // #91 P0 the synchronous --wait branch runs the scrap server-side
873
+ // (30-250s), same as `scraps run`; the 30s default was aborting it
874
+ // mid-flight. The async (default) POST returns almost immediately, so it
875
+ // keeps the 30s default.
876
+ await oraPromise(() => (opts.wait ? api.post(path, undefined, { timeoutMs: LONG_RUN_TIMEOUT_MS }) : api.post(path)), {
675
877
  text: opts.wait ? 'Running worker…' : 'Triggering worker…',
676
878
  successText: opts.wait ? 'Worker run complete' : 'Worker triggered',
677
879
  });
678
880
  if (opts.watch)
679
- await watchActivities(id);
881
+ await pollRunProgress(id, beforeHistoryId);
680
882
  });
681
883
  // account subcommand group
682
884
  const account = scraps
@@ -881,14 +1083,19 @@ scraps
881
1083
  validateObjectId(id);
882
1084
  const result = await fetchRunAndFix(id);
883
1085
  if (!result) {
1086
+ // #88 item 7 — unified no-runs shape with `doctor --json` / `data
1087
+ // --errors --json`: a never-run scrap is a distinct, nameable state,
1088
+ // not the same bare `null` a run-with-no-fix-attempt returns below.
884
1089
  if (opts.json) {
885
- json(null);
1090
+ json({ status: 'no_runs' });
886
1091
  return;
887
1092
  }
888
1093
  console.log(chalk.dim('No runs yet.'));
889
1094
  return;
890
1095
  }
891
1096
  if (!result.fix) {
1097
+ // A run DID happen, it just had no autofix attempt — genuinely "no
1098
+ // data", unlike the never-run case above.
892
1099
  if (opts.json) {
893
1100
  json(null);
894
1101
  return;
@@ -906,11 +1113,26 @@ scraps
906
1113
  .description('Download captured page HTML for the last run of a scrap')
907
1114
  .option('--error', 'Fetch the error-path snapshot (errorSnapshot.html)')
908
1115
  .option('-o, --out <file>', 'Write HTML to a file instead of stdout')
1116
+ .option('--json', 'On a never-run scrap, output {"status":"no_runs"} (exit 0) instead of prose')
909
1117
  .action(async (id, opts) => {
910
1118
  validateObjectId(id);
911
1119
  const scrap = await api.get(`/api/scraps/${id}`);
912
1120
  const hid = scrap.history?.[0]?._id;
913
1121
  if (!hid) {
1122
+ // #91 P2 — same no-runs contract as `doctor`/`autofix`/`data --errors`
1123
+ // (`{status:"no_runs"}`, exit 0) under --json.
1124
+ if (opts.json) {
1125
+ json({ status: 'no_runs' });
1126
+ return;
1127
+ }
1128
+ // `-o` explicitly asked for a file to be written. Silently exiting 0
1129
+ // with nothing written (and only a dim console line) is indistinguishable
1130
+ // from success to a script checking the exit code alone — give it the
1131
+ // SAME not_found envelope `scraps data`'s own never-run case uses.
1132
+ if (opts.out) {
1133
+ reportDataState(`Scrap ${id} has never run — nothing to write to ${opts.out}.`, 4, 'not_found', false);
1134
+ return;
1135
+ }
914
1136
  console.log(chalk.dim('No runs yet.'));
915
1137
  return;
916
1138
  }
@@ -31,7 +31,16 @@ skills
31
31
  for (const name of bundled) {
32
32
  const userInstalled = isSkillInstalled(name, 'user');
33
33
  const localInstalled = isSkillInstalled(name, 'local');
34
- const installedVersion = getInstalledVersion(name, 'user') ?? getInstalledVersion(name, 'local');
34
+ // #91 same EISDIR class as autoUpdateInstalledSkills/installSkill: an
35
+ // unreadable `.version` marker on one skill must not crash the whole
36
+ // listing before the other skills are shown.
37
+ let installedVersion;
38
+ try {
39
+ installedVersion = getInstalledVersion(name, 'user') ?? getInstalledVersion(name, 'local');
40
+ }
41
+ catch {
42
+ installedVersion = null;
43
+ }
35
44
  const stale = installedVersion && installedVersion !== version;
36
45
  const tag = userInstalled
37
46
  ? localInstalled
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { getToken } from '../lib/config.js';
4
- import { ApiError, notLoggedInError } from '../lib/api.js';
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
  export const token = new Command('token')
@@ -21,7 +21,10 @@ export const token = new Command('token')
21
21
  const exp = decodeExp(stored);
22
22
  const nowSeconds = Math.floor(Date.now() / 1000);
23
23
  if (exp !== null && exp < nowSeconds) {
24
- process.exitCode = reportError(new ApiError(401, 'Session token expired. Run: trawl login to refresh.'));
24
+ // Decoded entirely client-side (no HTTP call made) AuthError, not a
25
+ // fabricated ApiError(401): the server never actually said this. (#88
26
+ // item 4)
27
+ process.exitCode = reportError(new AuthError('Session token expired. Run: trawl login to refresh.'));
25
28
  return;
26
29
  }
27
30
  // Print the raw token first (so it can be piped / copied)
package/dist/index.d.ts CHANGED
@@ -6,6 +6,15 @@ import { Command } from 'commander';
6
6
  * --url) don't start with '-' and would otherwise survive an argv filter and
7
7
  * leak to PostHog (#67). Falls back to 'unknown' when no command resolved
8
8
  * (e.g. an error thrown before any action ran).
9
+ *
10
+ * #88 item 9 — walks the FULL parent chain, not just the immediate parent:
11
+ * the old one-level join (`${parent.name()} ${own.name()}`) resolved a
12
+ * 4-deep command like `scraps account session set` down to just "session
13
+ * set", silently dropping "scraps account". The root program node (the
14
+ * 'trawl' Command itself, which has no `.parent`) is excluded from the
15
+ * chain — matching the pre-existing convention that a direct child of the
16
+ * root (e.g. `scraps list`, `telemetry on`) is named relative to its
17
+ * immediate group, never prefixed with the program name.
9
18
  */
10
19
  export declare function resolveCommandName(actionCommand: Command | undefined): string;
11
20
  /**
@@ -33,6 +42,14 @@ export declare function isHelpOrVersion(argv: string[]): boolean;
33
42
  * "argv scan, never trust flag values" caveat as isHelpOrVersion: positional
34
43
  * values are never mistaken for `--json` since they don't equal the literal
35
44
  * string. (#86 finding 3)
45
+ *
46
+ * #88 item 5 — only scans tokens BEFORE the first bare `--`. Commander treats
47
+ * `--` as "end of options": everything after it is a positional operand, not
48
+ * a flag, even if the literal text is `--json`. `scraps list -- --json`
49
+ * passes `--json` as an (excess) positional argument, not the flag — an
50
+ * unscoped `argv.includes('--json')` would still match it and wrongly emit a
51
+ * JSON envelope for what is actually a plain usage error with no --json
52
+ * requested at all.
36
53
  */
37
54
  export declare function hasJsonFlag(argv: string[]): boolean;
38
55
  /**
@@ -50,4 +67,22 @@ export declare function hasJsonFlag(argv: string[]): boolean;
50
67
  * walks the whole tree and installs it everywhere. (#86 finding 3)
51
68
  */
52
69
  export declare function applyExitOverride(cmd: Command): void;
70
+ /**
71
+ * #88 item 6 — best-effort command-name recovery for a commander parse error
72
+ * that happened BEFORE any action ran, so `currentCommand` (the preAction
73
+ * hook's resolved command) is still undefined — e.g. an unknown option on an
74
+ * otherwise-valid subcommand, or excess arguments. Without this, EVERY such
75
+ * failure previously collapsed to resolveCommandName(undefined) === 'unknown'
76
+ * — and since 'unknown' was never itself allowlisted at the
77
+ * registerAllowedCommands call site (see runCli below), that capture was
78
+ * silently dropped by captureCommand's allowlist check: a dead branch that
79
+ * looked like it reported telemetry but never actually did.
80
+ *
81
+ * Matches ONLY a name already present in `allowedNames` (the real command
82
+ * tree, from collectCommandNames) — never invents one from raw argv text.
83
+ * Checks the two-token form first (`scraps boom`) since most usage errors
84
+ * happen on a nested leaf command; falls back to the single top-level token,
85
+ * then to 'unknown' (now itself allowlisted, so that capture fires too).
86
+ */
87
+ export declare function bestEffortCommandName(argv: string[], allowedNames: readonly string[]): string;
53
88
  export declare function runCli(argv?: string[]): Promise<void>;
package/dist/index.js CHANGED
@@ -19,13 +19,29 @@ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8
19
19
  * --url) don't start with '-' and would otherwise survive an argv filter and
20
20
  * leak to PostHog (#67). Falls back to 'unknown' when no command resolved
21
21
  * (e.g. an error thrown before any action ran).
22
+ *
23
+ * #88 item 9 — walks the FULL parent chain, not just the immediate parent:
24
+ * the old one-level join (`${parent.name()} ${own.name()}`) resolved a
25
+ * 4-deep command like `scraps account session set` down to just "session
26
+ * set", silently dropping "scraps account". The root program node (the
27
+ * 'trawl' Command itself, which has no `.parent`) is excluded from the
28
+ * chain — matching the pre-existing convention that a direct child of the
29
+ * root (e.g. `scraps list`, `telemetry on`) is named relative to its
30
+ * immediate group, never prefixed with the program name.
22
31
  */
23
32
  export function resolveCommandName(actionCommand) {
24
33
  if (!actionCommand)
25
34
  return 'unknown';
26
- return actionCommand.parent
27
- ? `${actionCommand.parent.name()} ${actionCommand.name()}`
28
- : actionCommand.name();
35
+ const chain = [];
36
+ let current = actionCommand;
37
+ while (current && current.parent) {
38
+ chain.unshift(current.name());
39
+ current = current.parent;
40
+ }
41
+ // `current` is now the root (no parent) — its name is excluded by design.
42
+ // If the chain came up empty, actionCommand itself IS the root (no parent
43
+ // at all) — fall back to its own name so the function never returns ''.
44
+ return chain.length > 0 ? chain.join(' ') : actionCommand.name();
29
45
  }
30
46
  /**
31
47
  * Walk the full command tree and produce every valid resolveCommandName()
@@ -86,9 +102,19 @@ export function isHelpOrVersion(argv) {
86
102
  * "argv scan, never trust flag values" caveat as isHelpOrVersion: positional
87
103
  * values are never mistaken for `--json` since they don't equal the literal
88
104
  * string. (#86 finding 3)
105
+ *
106
+ * #88 item 5 — only scans tokens BEFORE the first bare `--`. Commander treats
107
+ * `--` as "end of options": everything after it is a positional operand, not
108
+ * a flag, even if the literal text is `--json`. `scraps list -- --json`
109
+ * passes `--json` as an (excess) positional argument, not the flag — an
110
+ * unscoped `argv.includes('--json')` would still match it and wrongly emit a
111
+ * JSON envelope for what is actually a plain usage error with no --json
112
+ * requested at all.
89
113
  */
90
114
  export function hasJsonFlag(argv) {
91
- return argv.includes('--json');
115
+ const dashDashIdx = argv.indexOf('--');
116
+ const scanned = dashDashIdx === -1 ? argv : argv.slice(0, dashDashIdx);
117
+ return scanned.includes('--json');
92
118
  }
93
119
  /**
94
120
  * Commander's default (no exitOverride) calls `process.exit()` directly for
@@ -113,6 +139,35 @@ export function applyExitOverride(cmd) {
113
139
  * these already printed their own output (to stdout) via commander itself;
114
140
  * runCli's catch must treat them as a clean exit, not an error. (#86 finding 3) */
115
141
  const HELP_OR_VERSION_CODES = new Set(['commander.helpDisplayed', 'commander.help', 'commander.version']);
142
+ /**
143
+ * #88 item 6 — best-effort command-name recovery for a commander parse error
144
+ * that happened BEFORE any action ran, so `currentCommand` (the preAction
145
+ * hook's resolved command) is still undefined — e.g. an unknown option on an
146
+ * otherwise-valid subcommand, or excess arguments. Without this, EVERY such
147
+ * failure previously collapsed to resolveCommandName(undefined) === 'unknown'
148
+ * — and since 'unknown' was never itself allowlisted at the
149
+ * registerAllowedCommands call site (see runCli below), that capture was
150
+ * silently dropped by captureCommand's allowlist check: a dead branch that
151
+ * looked like it reported telemetry but never actually did.
152
+ *
153
+ * Matches ONLY a name already present in `allowedNames` (the real command
154
+ * tree, from collectCommandNames) — never invents one from raw argv text.
155
+ * Checks the two-token form first (`scraps boom`) since most usage errors
156
+ * happen on a nested leaf command; falls back to the single top-level token,
157
+ * then to 'unknown' (now itself allowlisted, so that capture fires too).
158
+ */
159
+ export function bestEffortCommandName(argv, allowedNames) {
160
+ const a2 = argv[2];
161
+ const a3 = argv[3];
162
+ if (a2 && a3) {
163
+ const combined = `${a2} ${a3}`;
164
+ if (allowedNames.includes(combined))
165
+ return combined;
166
+ }
167
+ if (a2 && allowedNames.includes(a2))
168
+ return a2;
169
+ return 'unknown';
170
+ }
116
171
  export async function runCli(argv = process.argv) {
117
172
  if (!isHelpOrVersion(argv))
118
173
  autoUpdateInstalledSkills();
@@ -122,7 +177,13 @@ export async function runCli(argv = process.argv) {
122
177
  // including subcommands added via addCommand() that don't otherwise
123
178
  // inherit it. (#86 finding 3)
124
179
  applyExitOverride(program);
125
- registerAllowedCommands(collectCommandNames(program));
180
+ const commandNames = collectCommandNames(program);
181
+ // #88 item 6 — 'unknown' is a legitimate resolveCommandName() output (the
182
+ // fallback for "no command resolved at all"), not free-form user input —
183
+ // it must be explicitly allowlisted here or every capture that falls back
184
+ // to it is silently dropped by captureCommand's allowlist check (a dead
185
+ // branch that looks like it reports telemetry but never does).
186
+ registerAllowedCommands([...commandNames, 'unknown']);
126
187
  // Track start times + the currently-resolved command per instance, so the
127
188
  // catch handler below can derive the exact same safe name the success path
128
189
  // uses — it must never re-derive anything from argv.
@@ -176,7 +237,15 @@ export async function runCli(argv = process.argv) {
176
237
  console.log(JSON.stringify({ error: { message: err.message, kind: 'usage' } }));
177
238
  }
178
239
  process.exitCode = 2;
179
- void captureCommand(resolveCommandName(currentCommand), { exit_code: 2, error: 'CommanderError' });
240
+ // #88 item 6 — currentCommand is still undefined here whenever the
241
+ // parse error happened before preAction fired (the common case for a
242
+ // usage error — commander validates flags/arity before invoking the
243
+ // action). Try a best-effort match against the real command tree
244
+ // instead of collapsing straight to 'unknown'.
245
+ const commandName = currentCommand
246
+ ? resolveCommandName(currentCommand)
247
+ : bestEffortCommandName(argv, commandNames);
248
+ void captureCommand(commandName, { exit_code: 2, error: 'CommanderError' });
180
249
  }
181
250
  }
182
251
  else {
package/dist/lib/api.d.ts CHANGED
@@ -11,25 +11,58 @@ export declare class ApiError extends Error {
11
11
  export declare class NetworkError extends Error {
12
12
  constructor(message: string);
13
13
  }
14
+ /**
15
+ * A LOCAL auth failure — no token available, or a locally-decoded token
16
+ * that's provably expired, discovered entirely client-side before any HTTP
17
+ * call was ever made. Distinguished from ApiError(401) (a real server-issued
18
+ * 401 response) so the --json envelope never claims `status:401` for
19
+ * something the server never said — that would be a fabricated fact,
20
+ * indistinguishable from an actual server round-trip to a machine consumer.
21
+ * Both classify to the same exit code (3) / kind "auth" in classifyError
22
+ * (errors.ts); only the envelope's `status` field differs (present for
23
+ * ApiError, absent here). (#88 item 4)
24
+ */
25
+ export declare class AuthError extends Error {
26
+ constructor(message: string);
27
+ }
14
28
  /**
15
29
  * The single "no token available" error — every call site in this file that
16
30
  * needs a token (request/upload/getText/stream) used to throw its own copy
17
31
  * of `new Error('Not logged in. Run: trawl login')`, which fell through
18
32
  * classifyError's generic branch (exit 1, kind:"unknown") — indistinguishable
19
- * from an arbitrary bug. Auth-classifying it as an ApiError(401) puts it on
20
- * the exact same exit-3 / kind:"auth" path a real 401 response already takes.
33
+ * from an arbitrary bug. Auth-classifying it puts it on the exact same
34
+ * exit-3 / kind:"auth" path a real 401 response already takes — but as an
35
+ * AuthError (no HTTP call happened here), never a fabricated ApiError(401).
21
36
  * `trawl token` (src/commands/token.ts) reuses this too, so "no token" means
22
- * the same thing everywhere it can be observed. (#86 findings 1/2)
37
+ * the same thing everywhere it can be observed. (#86 findings 1/2, #88 item 4)
23
38
  */
24
- export declare function notLoggedInError(): ApiError;
39
+ export declare function notLoggedInError(): AuthError;
40
+ /**
41
+ * #91 P0 — some endpoints legitimately run 30–250s server-side: a scrap
42
+ * execute (worker-puppeteer navigation + antibot tier escalation + AI-fix
43
+ * dry-run retries). The generic 30s default was aborting those mid-flight
44
+ * and surfacing a fabricated `NetworkError timed out` (exit 5) for a request
45
+ * that was always going to succeed given enough time. Passed as the per-call
46
+ * `{timeoutMs}` override at exactly the 3 call sites that hit those
47
+ * endpoints (src/commands/scraps.ts): `scraps run` / `data --fresh`
48
+ * (GET /api/scraps/load/:id) and `scraps trigger --wait`
49
+ * (POST /api/scraps/worker/:id, synchronous branch only — the default async
50
+ * `?wait=false` POST returns almost immediately and keeps the 30s default).
51
+ * 300s leaves margin over the ~250s worst case without being unboundedly long.
52
+ */
53
+ export declare const LONG_RUN_TIMEOUT_MS = 300000;
54
+ export interface RequestOptions {
55
+ /** Per-call timeout override in ms (e.g. LONG_RUN_TIMEOUT_MS). Ignored — env wins — when TRAWL_TIMEOUT is set; see getTimeoutMs. */
56
+ timeoutMs?: number;
57
+ }
25
58
  export declare const api: {
26
- get: <T>(path: string) => Promise<T>;
27
- getText: (path: string) => Promise<string>;
28
- post: <T>(path: string, body?: unknown) => Promise<T>;
29
- put: <T>(path: string, body?: unknown) => Promise<T>;
30
- delete: <T>(path: string) => Promise<T>;
31
- upload: <T>(path: string, formData: FormData) => Promise<T>;
32
- publicPost: <T>(path: string, body?: unknown, baseUrlOverride?: string) => Promise<{
59
+ get: <T>(path: string, opts?: RequestOptions) => Promise<T>;
60
+ getText: (path: string, opts?: RequestOptions) => Promise<string>;
61
+ post: <T>(path: string, body?: unknown, opts?: RequestOptions) => Promise<T>;
62
+ put: <T>(path: string, body?: unknown, opts?: RequestOptions) => Promise<T>;
63
+ delete: <T>(path: string, opts?: RequestOptions) => Promise<T>;
64
+ upload: <T>(path: string, formData: FormData, opts?: RequestOptions) => Promise<T>;
65
+ publicPost: <T>(path: string, body?: unknown, baseUrlOverride?: string, opts?: RequestOptions) => Promise<{
33
66
  data: T;
34
67
  headers: Headers;
35
68
  }>;
package/dist/lib/api.js CHANGED
@@ -25,42 +25,94 @@ export class NetworkError extends Error {
25
25
  this.name = 'NetworkError';
26
26
  }
27
27
  }
28
+ /**
29
+ * A LOCAL auth failure — no token available, or a locally-decoded token
30
+ * that's provably expired, discovered entirely client-side before any HTTP
31
+ * call was ever made. Distinguished from ApiError(401) (a real server-issued
32
+ * 401 response) so the --json envelope never claims `status:401` for
33
+ * something the server never said — that would be a fabricated fact,
34
+ * indistinguishable from an actual server round-trip to a machine consumer.
35
+ * Both classify to the same exit code (3) / kind "auth" in classifyError
36
+ * (errors.ts); only the envelope's `status` field differs (present for
37
+ * ApiError, absent here). (#88 item 4)
38
+ */
39
+ export class AuthError extends Error {
40
+ constructor(message) {
41
+ super(message);
42
+ this.name = 'AuthError';
43
+ }
44
+ }
28
45
  /**
29
46
  * The single "no token available" error — every call site in this file that
30
47
  * needs a token (request/upload/getText/stream) used to throw its own copy
31
48
  * of `new Error('Not logged in. Run: trawl login')`, which fell through
32
49
  * classifyError's generic branch (exit 1, kind:"unknown") — indistinguishable
33
- * from an arbitrary bug. Auth-classifying it as an ApiError(401) puts it on
34
- * the exact same exit-3 / kind:"auth" path a real 401 response already takes.
50
+ * from an arbitrary bug. Auth-classifying it puts it on the exact same
51
+ * exit-3 / kind:"auth" path a real 401 response already takes — but as an
52
+ * AuthError (no HTTP call happened here), never a fabricated ApiError(401).
35
53
  * `trawl token` (src/commands/token.ts) reuses this too, so "no token" means
36
- * the same thing everywhere it can be observed. (#86 findings 1/2)
54
+ * the same thing everywhere it can be observed. (#86 findings 1/2, #88 item 4)
37
55
  */
38
56
  export function notLoggedInError() {
39
- return new ApiError(401, 'Not logged in. Run: trawl login');
57
+ return new AuthError('Not logged in. Run: trawl login');
40
58
  }
41
59
  const DEFAULT_TIMEOUT_MS = 30_000;
42
- /** Effective fetch timeout — TRAWL_TIMEOUT env override (ms), default 30s. (#71) */
43
- function getTimeoutMs() {
60
+ /**
61
+ * #91 P0 — some endpoints legitimately run 30–250s server-side: a scrap
62
+ * execute (worker-puppeteer navigation + antibot tier escalation + AI-fix
63
+ * dry-run retries). The generic 30s default was aborting those mid-flight
64
+ * and surfacing a fabricated `NetworkError timed out` (exit 5) for a request
65
+ * that was always going to succeed given enough time. Passed as the per-call
66
+ * `{timeoutMs}` override at exactly the 3 call sites that hit those
67
+ * endpoints (src/commands/scraps.ts): `scraps run` / `data --fresh`
68
+ * (GET /api/scraps/load/:id) and `scraps trigger --wait`
69
+ * (POST /api/scraps/worker/:id, synchronous branch only — the default async
70
+ * `?wait=false` POST returns almost immediately and keeps the 30s default).
71
+ * 300s leaves margin over the ~250s worst case without being unboundedly long.
72
+ */
73
+ export const LONG_RUN_TIMEOUT_MS = 300_000;
74
+ /**
75
+ * Effective fetch timeout for one request.
76
+ *
77
+ * Precedence (#91): TRAWL_TIMEOUT (env) ALWAYS wins when set to a valid
78
+ * positive number — an operator/CI override must be able to force a shorter
79
+ * or longer ceiling globally (e.g. a slow CI network, or a deliberately tight
80
+ * smoke-test budget) without editing call sites. Only when the env var is
81
+ * unset/invalid does the caller-supplied per-call `overrideMs` apply (e.g.
82
+ * LONG_RUN_TIMEOUT_MS for the 3 long-run call sites); otherwise
83
+ * DEFAULT_TIMEOUT_MS. This preserves #71's original "env always overrides"
84
+ * contract while layering the new per-call default beneath it, never above.
85
+ */
86
+ function getTimeoutMs(overrideMs) {
44
87
  const raw = process.env['TRAWL_TIMEOUT']?.trim();
45
- if (!raw)
46
- return DEFAULT_TIMEOUT_MS;
47
- const n = Number(raw);
48
- return Number.isFinite(n) && n > 0 ? n : DEFAULT_TIMEOUT_MS;
88
+ if (raw) {
89
+ const n = Number(raw);
90
+ if (Number.isFinite(n) && n > 0)
91
+ return n;
92
+ }
93
+ return overrideMs !== undefined && overrideMs > 0 ? overrideMs : DEFAULT_TIMEOUT_MS;
49
94
  }
50
95
  /**
51
96
  * Wrap a `fetch()` call so connection-level failures (ECONNREFUSED, DNS,
52
97
  * timeout, …) surface as a NetworkError carrying the effective URL + the
53
98
  * unwrapped `err.cause` detail, instead of a bare "fetch failed" with no
54
99
  * actionable information. (#71 findings 4/58)
100
+ *
101
+ * `effectiveTimeoutMs` is the ACTUAL timeout that was armed for this specific
102
+ * call (already resolved via getTimeoutMs by the caller) — used only to
103
+ * report an honest number in the timeout message; #91 — before this it
104
+ * always re-derived a fresh `getTimeoutMs()` with no override, so a request
105
+ * armed with LONG_RUN_TIMEOUT_MS that timed out would have lied and reported
106
+ * "timed out after 30000ms".
55
107
  */
56
- async function safeFetch(url, options) {
108
+ async function safeFetch(url, options, effectiveTimeoutMs) {
57
109
  try {
58
110
  return await fetch(url, options);
59
111
  }
60
112
  catch (err) {
61
113
  const e = err;
62
114
  if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
63
- throw new NetworkError(`Request to ${url} timed out after ${getTimeoutMs()}ms (override with TRAWL_TIMEOUT env var, ms)`);
115
+ throw new NetworkError(`Request to ${url} timed out after ${effectiveTimeoutMs}ms (override with TRAWL_TIMEOUT env var, ms)`);
64
116
  }
65
117
  const cause = e?.cause;
66
118
  const causeDetail = cause?.code ? ` (${cause.code})` : cause?.message ? ` (${cause.message})` : '';
@@ -176,21 +228,22 @@ async function throwIfError(res, isPublic = false) {
176
228
  throw new ApiError(res.status, `${res.status} ${res.statusText}: ${full}`);
177
229
  }
178
230
  }
179
- async function request(path, options = {}) {
231
+ async function request(path, options = {}, reqOpts = {}) {
180
232
  const token = getToken();
181
233
  if (!token)
182
234
  throw notLoggedInError();
183
235
  const url = `${getApiUrl()}${path}`;
236
+ const timeoutMs = getTimeoutMs(reqOpts.timeoutMs);
184
237
  const res = await safeFetch(url, {
185
238
  ...options,
186
- signal: AbortSignal.timeout(getTimeoutMs()),
239
+ signal: AbortSignal.timeout(timeoutMs),
187
240
  headers: {
188
241
  'Content-Type': 'application/json',
189
242
  'User-Agent': USER_AGENT,
190
243
  ...options.headers,
191
244
  Cookie: `TOKEN=${token}`,
192
245
  },
193
- });
246
+ }, timeoutMs);
194
247
  await throwIfError(res);
195
248
  const text = await res.text();
196
249
  try {
@@ -207,21 +260,22 @@ async function request(path, options = {}) {
207
260
  throw new Error('Invalid JSON in server response');
208
261
  }
209
262
  }
210
- async function upload(path, formData) {
263
+ async function upload(path, formData, reqOpts = {}) {
211
264
  const token = getToken();
212
265
  if (!token)
213
266
  throw notLoggedInError();
214
267
  const url = `${getApiUrl()}${path}`;
268
+ const timeoutMs = getTimeoutMs(reqOpts.timeoutMs);
215
269
  // Do NOT set Content-Type — fetch sets it automatically with the correct multipart boundary
216
270
  const res = await safeFetch(url, {
217
271
  method: 'POST',
218
272
  body: formData,
219
- signal: AbortSignal.timeout(getTimeoutMs()),
273
+ signal: AbortSignal.timeout(timeoutMs),
220
274
  headers: {
221
275
  'User-Agent': USER_AGENT,
222
276
  Cookie: `TOKEN=${token}`,
223
277
  },
224
- });
278
+ }, timeoutMs);
225
279
  await throwIfError(res);
226
280
  const text = await res.text();
227
281
  try {
@@ -238,14 +292,15 @@ async function upload(path, formData) {
238
292
  throw new Error('Invalid JSON in server response');
239
293
  }
240
294
  }
241
- async function publicPost(path, body, baseUrlOverride) {
295
+ async function publicPost(path, body, baseUrlOverride, reqOpts = {}) {
242
296
  const url = `${baseUrlOverride ?? getApiUrl()}${path}`;
297
+ const timeoutMs = getTimeoutMs(reqOpts.timeoutMs);
243
298
  const res = await safeFetch(url, {
244
299
  method: 'POST',
245
300
  headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
246
301
  body: body ? JSON.stringify(body) : undefined,
247
- signal: AbortSignal.timeout(getTimeoutMs()),
248
- });
302
+ signal: AbortSignal.timeout(timeoutMs),
303
+ }, timeoutMs);
249
304
  await throwIfError(res, true);
250
305
  const text = await res.text();
251
306
  try {
@@ -256,35 +311,36 @@ async function publicPost(path, body, baseUrlOverride) {
256
311
  throw new Error('Invalid JSON in server response');
257
312
  }
258
313
  }
259
- async function getText(path) {
314
+ async function getText(path, reqOpts = {}) {
260
315
  const token = getToken();
261
316
  if (!token)
262
317
  throw notLoggedInError();
263
318
  const url = `${getApiUrl()}${path}`;
319
+ const timeoutMs = getTimeoutMs(reqOpts.timeoutMs);
264
320
  const res = await safeFetch(url, {
265
321
  headers: {
266
322
  'User-Agent': USER_AGENT,
267
323
  Cookie: `TOKEN=${token}`,
268
324
  },
269
- signal: AbortSignal.timeout(getTimeoutMs()),
270
- });
325
+ signal: AbortSignal.timeout(timeoutMs),
326
+ }, timeoutMs);
271
327
  await throwIfError(res);
272
328
  return res.text();
273
329
  }
274
330
  export const api = {
275
- get: (path) => request(path),
276
- getText: (path) => getText(path),
277
- post: (path, body) => request(path, {
331
+ get: (path, opts) => request(path, {}, opts),
332
+ getText: (path, opts) => getText(path, opts),
333
+ post: (path, body, opts) => request(path, {
278
334
  method: 'POST',
279
335
  body: body ? JSON.stringify(body) : undefined,
280
- }),
281
- put: (path, body) => request(path, {
336
+ }, opts),
337
+ put: (path, body, opts) => request(path, {
282
338
  method: 'PUT',
283
339
  body: body ? JSON.stringify(body) : undefined,
284
- }),
285
- delete: (path) => request(path, { method: 'DELETE' }),
286
- upload: (path, formData) => upload(path, formData),
287
- publicPost: (path, body, baseUrlOverride) => publicPost(path, body, baseUrlOverride),
340
+ }, opts),
341
+ delete: (path, opts) => request(path, { method: 'DELETE' }, opts),
342
+ upload: (path, formData, opts) => upload(path, formData, opts),
343
+ publicPost: (path, body, baseUrlOverride, opts) => publicPost(path, body, baseUrlOverride, opts),
288
344
  stream: async function* (path) {
289
345
  const token = getToken();
290
346
  if (!token)
@@ -293,13 +349,15 @@ export const api = {
293
349
  // No AbortSignal.timeout here — a long-running `watch`/`--watch` stream is
294
350
  // expected to sit open indefinitely; only connection-level failures
295
351
  // (never a timeout) should surface via safeFetch's cause-unwrapping. (#71)
352
+ // effectiveTimeoutMs passed to safeFetch here is only ever used to format
353
+ // a timeout message that can't actually fire (no signal attached).
296
354
  const res = await safeFetch(url, {
297
355
  headers: {
298
356
  Accept: 'text/event-stream',
299
357
  'User-Agent': USER_AGENT,
300
358
  Cookie: `TOKEN=${token}`,
301
359
  },
302
- });
360
+ }, getTimeoutMs());
303
361
  if (!res.ok || !res.body) {
304
362
  throw new ApiError(res.status, `SSE failed: ${res.status}`);
305
363
  }
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { ApiError, NetworkError } from './api.js';
2
+ import { ApiError, AuthError, NetworkError } from './api.js';
3
3
  /**
4
4
  * Thrown for CLI usage / input-validation failures (bad flag value, malformed
5
5
  * JSON, invalid ObjectId, missing required prompt input, …). Distinguished
@@ -22,6 +22,14 @@ export class UsageError extends Error {
22
22
  */
23
23
  export function classifyError(err) {
24
24
  const message = err instanceof Error ? err.message : String(err);
25
+ // #88 item 4 — a LOCAL auth failure (no token, or a locally-decoded expired
26
+ // token) never made an HTTP call, so its envelope must never carry
27
+ // `status:401` — that would claim a server response that never happened.
28
+ // Same exit code / kind as a real server 401 (ApiError below); only the
29
+ // envelope shape differs.
30
+ if (err instanceof AuthError) {
31
+ return { exitCode: 3, envelope: { message, kind: 'auth' } };
32
+ }
25
33
  if (err instanceof ApiError) {
26
34
  if (err.status === 401)
27
35
  return { exitCode: 3, envelope: { message, status: 401, kind: 'auth' } };
@@ -43,7 +43,20 @@ export function installSkill(name, scope, opts = {}) {
43
43
  }
44
44
  const dest = join(getSkillsBase(scope), name);
45
45
  if (existsSync(dest)) {
46
- const owned = getInstalledVersion(name, scope) !== null;
46
+ // #91 an unreadable `.version` (e.g. EISDIR from a directory instead of
47
+ // a file, a corrupted/interrupted install) is NOT proof of ownership
48
+ // either — treat it exactly like a missing marker (unowned) instead of
49
+ // letting the raw fs error crash the whole batch (`skills install`/
50
+ // `update` with no skill argument loops over every bundled skill; one
51
+ // malformed dest must not block installing the others).
52
+ let installedVersion;
53
+ try {
54
+ installedVersion = getInstalledVersion(name, scope);
55
+ }
56
+ catch {
57
+ installedVersion = null;
58
+ }
59
+ const owned = installedVersion !== null;
47
60
  if (!owned && !opts.force) {
48
61
  throw new Error(`Refusing to overwrite "${dest}" — it was not installed by trawl (no .version marker). ` +
49
62
  `Pass --force to overwrite it anyway.`);
@@ -102,8 +115,19 @@ export function removeOrphanedSkills(scope) {
102
115
  }
103
116
  if (bundled.has(name))
104
117
  continue;
105
- // No `.version` marker → not ours → never delete it.
106
- if (getInstalledVersion(name, scope) === null)
118
+ // No `.version` marker → not ours → never delete it. Read is wrapped:
119
+ // a weird `.version` (e.g. a directory instead of a file → EISDIR on
120
+ // readFileSync) must skip only THIS entry, not blow up the whole sweep —
121
+ // a single malformed install must never leave every other entry
122
+ // unswept. (#88 item 12)
123
+ let installedVersion;
124
+ try {
125
+ installedVersion = getInstalledVersion(name, scope);
126
+ }
127
+ catch {
128
+ continue;
129
+ }
130
+ if (installedVersion === null)
107
131
  continue;
108
132
  rmSync(dir, { recursive: true, force: true });
109
133
  process.stderr.write(`trawl: removed orphaned skill "${name}" (${scope}) — no longer bundled with this CLI version\n`);
@@ -136,7 +160,21 @@ export function autoUpdateInstalledSkills() {
136
160
  for (const scope of ['user', 'local']) {
137
161
  if (!isSkillInstalled(name, scope))
138
162
  continue;
139
- const installed = getInstalledVersion(name, scope);
163
+ // #91 mirrors removeOrphanedSkills' guard below: a weird `.version`
164
+ // marker (e.g. a directory instead of a file, from a corrupted /
165
+ // interrupted install) throws EISDIR on readFileSync. Without this
166
+ // per-entry guard, that throw was caught by this function's OUTER
167
+ // try/catch (below) — which aborts the ENTIRE function, so every
168
+ // remaining bundled skill silently stopped syncing AND the orphan
169
+ // sweep (removeOrphanedSkills, called after this loop) never ran
170
+ // either. Must skip only THIS entry, never abort the whole sweep.
171
+ let installed;
172
+ try {
173
+ installed = getInstalledVersion(name, scope);
174
+ }
175
+ catch {
176
+ continue;
177
+ }
140
178
  // No `.version` marker → not ours → never delete it.
141
179
  if (installed === null)
142
180
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.18.2",
3
+ "version": "1.18.4",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "url": "https://github.com/comes-io/trawl_cli/issues"
41
41
  },
42
42
  "dependencies": {
43
- "@trawlme/skills": "1.3.1",
43
+ "@trawlme/skills": "1.3.3",
44
44
  "chalk": "^5.6.2",
45
45
  "commander": "^14.0.3",
46
46
  "conf": "^15.1.0",