@trawlme/cli 1.18.3 → 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
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`.
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
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.
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
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
 
@@ -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):
@@ -86,6 +86,13 @@ export function formatDoctor(scrapTitle, run, fix = null, scrapId) {
86
86
  // Header + status badge. status:null means a run is IN FLIGHT (node
87
87
  // persists {status:null, statusDetail:null, inFlight:true} the moment a
88
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".
89
96
  const ok = run.status === true;
90
97
  const badge = ok
91
98
  ? chalk.green('● success')
@@ -93,7 +100,9 @@ export function formatDoctor(scrapTitle, run, fix = null, scrapId) {
93
100
  ? chalk.cyan('● running')
94
101
  : run.statusDetail === 'empty'
95
102
  ? chalk.yellow('● empty')
96
- : chalk.red('● failed');
103
+ : run.statusDetail === 'regression'
104
+ ? chalk.hex('#FFA500')('● regression')
105
+ : chalk.red('● failed');
97
106
  lines.push(`${chalk.bold(scrapTitle)} ${badge}${run.statusDetail ? ` (${run.statusDetail})` : ''}`);
98
107
  lines.push(chalk.dim(` Run ID: ${run._id}`));
99
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';
@@ -30,6 +30,13 @@ function lastStatus(scrap) {
30
30
  return 'running';
31
31
  if (last.status === undefined)
32
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';
33
40
  return last.status === true ? 'success' : 'failure';
34
41
  }
35
42
  function lastRun(scrap) {
@@ -51,6 +58,10 @@ function statusIcon(status) {
51
58
  return chalk.red('✗');
52
59
  if (status === 'running')
53
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')('▼');
54
65
  return chalk.dim('—');
55
66
  }
56
67
  export const scraps = new Command('scraps').description('Manage scraps');
@@ -71,13 +82,103 @@ async function watchActivities(id) {
71
82
  }
72
83
  }
73
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
+ }
74
175
  // list
75
176
  scraps
76
177
  .command('list')
77
178
  .alias('ls')
78
179
  .description('List all scraps')
79
180
  .option('--json', 'Output as JSON')
80
- .option('--status <status>', 'Filter by last run status (success|failure|never|running)')
181
+ .option('--status <status>', 'Filter by last run status (success|failure|never|running|regression)')
81
182
  // #88 item 8 — no custom parser here (unlike the old `(v) => parseInt(v,
82
183
  // 10)`): a bad value like "abc" used to silently become NaN, which then
83
184
  // sailed straight through `Number.isInteger`-less checks and into
@@ -430,15 +531,20 @@ scraps
430
531
  scraps
431
532
  .command('run <id>')
432
533
  .description('Run a scrap')
433
- .option('-w, --watch', 'Stream activities after launching')
534
+ .option('-w, --watch', 'Show progress after launching (polls — see `trawl scraps trigger --watch`, #91)')
434
535
  .action(async (id, opts) => {
435
536
  validateObjectId(id);
436
- 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 }), {
437
543
  text: 'Launching scrap…',
438
544
  successText: 'Scrap launched',
439
545
  });
440
546
  if (opts.watch) {
441
- await watchActivities(id);
547
+ await pollRunProgress(id, beforeHistoryId);
442
548
  }
443
549
  });
444
550
  // #70 — render an items array either as a table summary or --json. Shared by
@@ -518,7 +624,9 @@ scraps
518
624
  // burning execute quota and 429ing if a run is already in flight. A user
519
625
  // or agent "just reading data" must never trigger that by accident.
520
626
  if (opts.fresh) {
521
- 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 }), {
522
630
  text: 'Launching a fresh scrap run (consumes execute quota)…',
523
631
  successText: 'Fresh run complete',
524
632
  });
@@ -713,7 +821,6 @@ scraps
713
821
  usageError(`File not found: ${opts.file}`);
714
822
  return;
715
823
  }
716
- const fileBuffer = readFileSync(opts.file);
717
824
  const filename = basename(opts.file);
718
825
  const ext = filename.split('.').pop()?.toLowerCase() ?? '';
719
826
  const mimeMap = {
@@ -722,7 +829,15 @@ scraps
722
829
  jpeg: 'image/jpeg',
723
830
  webp: 'image/webp',
724
831
  };
725
- 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);
726
841
  const blob = new Blob([fileBuffer], { type: mimeType });
727
842
  const formData = new FormData();
728
843
  formData.append('banner', blob, filename);
@@ -743,20 +858,27 @@ scraps
743
858
  scraps
744
859
  .command('trigger <id>')
745
860
  .description('Launch a scrap as a background worker (returns immediately)')
746
- .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)')
747
862
  .option('--wait', 'Run synchronously and wait for the result (legacy behaviour)')
748
863
  .action(async (id, opts) => {
749
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;
750
868
  // #50 — default async: the backend (#1313) kicks off the run and returns a
751
869
  // 'queued' envelope immediately instead of holding the connection for the
752
870
  // whole run. --wait restores the old synchronous round-trip.
753
871
  const path = opts.wait ? `/api/scraps/worker/${id}` : `/api/scraps/worker/${id}?wait=false`;
754
- 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)), {
755
877
  text: opts.wait ? 'Running worker…' : 'Triggering worker…',
756
878
  successText: opts.wait ? 'Worker run complete' : 'Worker triggered',
757
879
  });
758
880
  if (opts.watch)
759
- await watchActivities(id);
881
+ await pollRunProgress(id, beforeHistoryId);
760
882
  });
761
883
  // account subcommand group
762
884
  const account = scraps
@@ -991,11 +1113,26 @@ scraps
991
1113
  .description('Download captured page HTML for the last run of a scrap')
992
1114
  .option('--error', 'Fetch the error-path snapshot (errorSnapshot.html)')
993
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')
994
1117
  .action(async (id, opts) => {
995
1118
  validateObjectId(id);
996
1119
  const scrap = await api.get(`/api/scraps/${id}`);
997
1120
  const hid = scrap.history?.[0]?._id;
998
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
+ }
999
1136
  console.log(chalk.dim('No runs yet.'));
1000
1137
  return;
1001
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
package/dist/lib/api.d.ts CHANGED
@@ -37,14 +37,32 @@ export declare class AuthError extends Error {
37
37
  * the same thing everywhere it can be observed. (#86 findings 1/2, #88 item 4)
38
38
  */
39
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
+ }
40
58
  export declare const api: {
41
- get: <T>(path: string) => Promise<T>;
42
- getText: (path: string) => Promise<string>;
43
- post: <T>(path: string, body?: unknown) => Promise<T>;
44
- put: <T>(path: string, body?: unknown) => Promise<T>;
45
- delete: <T>(path: string) => Promise<T>;
46
- upload: <T>(path: string, formData: FormData) => Promise<T>;
47
- 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<{
48
66
  data: T;
49
67
  headers: Headers;
50
68
  }>;
package/dist/lib/api.js CHANGED
@@ -57,28 +57,62 @@ export function notLoggedInError() {
57
57
  return new AuthError('Not logged in. Run: trawl login');
58
58
  }
59
59
  const DEFAULT_TIMEOUT_MS = 30_000;
60
- /** Effective fetch timeout — TRAWL_TIMEOUT env override (ms), default 30s. (#71) */
61
- 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) {
62
87
  const raw = process.env['TRAWL_TIMEOUT']?.trim();
63
- if (!raw)
64
- return DEFAULT_TIMEOUT_MS;
65
- const n = Number(raw);
66
- 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;
67
94
  }
68
95
  /**
69
96
  * Wrap a `fetch()` call so connection-level failures (ECONNREFUSED, DNS,
70
97
  * timeout, …) surface as a NetworkError carrying the effective URL + the
71
98
  * unwrapped `err.cause` detail, instead of a bare "fetch failed" with no
72
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".
73
107
  */
74
- async function safeFetch(url, options) {
108
+ async function safeFetch(url, options, effectiveTimeoutMs) {
75
109
  try {
76
110
  return await fetch(url, options);
77
111
  }
78
112
  catch (err) {
79
113
  const e = err;
80
114
  if (e?.name === 'TimeoutError' || e?.name === 'AbortError') {
81
- 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)`);
82
116
  }
83
117
  const cause = e?.cause;
84
118
  const causeDetail = cause?.code ? ` (${cause.code})` : cause?.message ? ` (${cause.message})` : '';
@@ -194,21 +228,22 @@ async function throwIfError(res, isPublic = false) {
194
228
  throw new ApiError(res.status, `${res.status} ${res.statusText}: ${full}`);
195
229
  }
196
230
  }
197
- async function request(path, options = {}) {
231
+ async function request(path, options = {}, reqOpts = {}) {
198
232
  const token = getToken();
199
233
  if (!token)
200
234
  throw notLoggedInError();
201
235
  const url = `${getApiUrl()}${path}`;
236
+ const timeoutMs = getTimeoutMs(reqOpts.timeoutMs);
202
237
  const res = await safeFetch(url, {
203
238
  ...options,
204
- signal: AbortSignal.timeout(getTimeoutMs()),
239
+ signal: AbortSignal.timeout(timeoutMs),
205
240
  headers: {
206
241
  'Content-Type': 'application/json',
207
242
  'User-Agent': USER_AGENT,
208
243
  ...options.headers,
209
244
  Cookie: `TOKEN=${token}`,
210
245
  },
211
- });
246
+ }, timeoutMs);
212
247
  await throwIfError(res);
213
248
  const text = await res.text();
214
249
  try {
@@ -225,21 +260,22 @@ async function request(path, options = {}) {
225
260
  throw new Error('Invalid JSON in server response');
226
261
  }
227
262
  }
228
- async function upload(path, formData) {
263
+ async function upload(path, formData, reqOpts = {}) {
229
264
  const token = getToken();
230
265
  if (!token)
231
266
  throw notLoggedInError();
232
267
  const url = `${getApiUrl()}${path}`;
268
+ const timeoutMs = getTimeoutMs(reqOpts.timeoutMs);
233
269
  // Do NOT set Content-Type — fetch sets it automatically with the correct multipart boundary
234
270
  const res = await safeFetch(url, {
235
271
  method: 'POST',
236
272
  body: formData,
237
- signal: AbortSignal.timeout(getTimeoutMs()),
273
+ signal: AbortSignal.timeout(timeoutMs),
238
274
  headers: {
239
275
  'User-Agent': USER_AGENT,
240
276
  Cookie: `TOKEN=${token}`,
241
277
  },
242
- });
278
+ }, timeoutMs);
243
279
  await throwIfError(res);
244
280
  const text = await res.text();
245
281
  try {
@@ -256,14 +292,15 @@ async function upload(path, formData) {
256
292
  throw new Error('Invalid JSON in server response');
257
293
  }
258
294
  }
259
- async function publicPost(path, body, baseUrlOverride) {
295
+ async function publicPost(path, body, baseUrlOverride, reqOpts = {}) {
260
296
  const url = `${baseUrlOverride ?? getApiUrl()}${path}`;
297
+ const timeoutMs = getTimeoutMs(reqOpts.timeoutMs);
261
298
  const res = await safeFetch(url, {
262
299
  method: 'POST',
263
300
  headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
264
301
  body: body ? JSON.stringify(body) : undefined,
265
- signal: AbortSignal.timeout(getTimeoutMs()),
266
- });
302
+ signal: AbortSignal.timeout(timeoutMs),
303
+ }, timeoutMs);
267
304
  await throwIfError(res, true);
268
305
  const text = await res.text();
269
306
  try {
@@ -274,35 +311,36 @@ async function publicPost(path, body, baseUrlOverride) {
274
311
  throw new Error('Invalid JSON in server response');
275
312
  }
276
313
  }
277
- async function getText(path) {
314
+ async function getText(path, reqOpts = {}) {
278
315
  const token = getToken();
279
316
  if (!token)
280
317
  throw notLoggedInError();
281
318
  const url = `${getApiUrl()}${path}`;
319
+ const timeoutMs = getTimeoutMs(reqOpts.timeoutMs);
282
320
  const res = await safeFetch(url, {
283
321
  headers: {
284
322
  'User-Agent': USER_AGENT,
285
323
  Cookie: `TOKEN=${token}`,
286
324
  },
287
- signal: AbortSignal.timeout(getTimeoutMs()),
288
- });
325
+ signal: AbortSignal.timeout(timeoutMs),
326
+ }, timeoutMs);
289
327
  await throwIfError(res);
290
328
  return res.text();
291
329
  }
292
330
  export const api = {
293
- get: (path) => request(path),
294
- getText: (path) => getText(path),
295
- 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, {
296
334
  method: 'POST',
297
335
  body: body ? JSON.stringify(body) : undefined,
298
- }),
299
- put: (path, body) => request(path, {
336
+ }, opts),
337
+ put: (path, body, opts) => request(path, {
300
338
  method: 'PUT',
301
339
  body: body ? JSON.stringify(body) : undefined,
302
- }),
303
- delete: (path) => request(path, { method: 'DELETE' }),
304
- upload: (path, formData) => upload(path, formData),
305
- 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),
306
344
  stream: async function* (path) {
307
345
  const token = getToken();
308
346
  if (!token)
@@ -311,13 +349,15 @@ export const api = {
311
349
  // No AbortSignal.timeout here — a long-running `watch`/`--watch` stream is
312
350
  // expected to sit open indefinitely; only connection-level failures
313
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).
314
354
  const res = await safeFetch(url, {
315
355
  headers: {
316
356
  Accept: 'text/event-stream',
317
357
  'User-Agent': USER_AGENT,
318
358
  Cookie: `TOKEN=${token}`,
319
359
  },
320
- });
360
+ }, getTimeoutMs());
321
361
  if (!res.ok || !res.body) {
322
362
  throw new ApiError(res.status, `SSE failed: ${res.status}`);
323
363
  }
@@ -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.`);
@@ -147,7 +160,21 @@ export function autoUpdateInstalledSkills() {
147
160
  for (const scope of ['user', 'local']) {
148
161
  if (!isSkillInstalled(name, scope))
149
162
  continue;
150
- 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
+ }
151
178
  // No `.version` marker → not ours → never delete it.
152
179
  if (installed === null)
153
180
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "1.18.3",
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.2",
43
+ "@trawlme/skills": "1.3.3",
44
44
  "chalk": "^5.6.2",
45
45
  "commander": "^14.0.3",
46
46
  "conf": "^15.1.0",