@trawlme/cli 1.18.3 → 1.18.5
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 +8 -5
- package/dist/commands/doctor.js +10 -1
- package/dist/commands/scraps.d.ts +44 -0
- package/dist/commands/scraps.js +201 -11
- package/dist/commands/skills.js +28 -1
- package/dist/lib/api.d.ts +25 -7
- package/dist/lib/api.js +70 -30
- package/dist/lib/skills.js +29 -2
- package/package.json +2 -2
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):
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
:
|
|
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,46 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
export declare const scraps: Command;
|
|
3
|
+
/** The top-of-history snapshot pollRunProgress needs to identify which run
|
|
4
|
+
* it's watching — see captureBeforeRunState. */
|
|
5
|
+
interface BeforeRunState {
|
|
6
|
+
id?: string;
|
|
7
|
+
alreadyInFlight: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* #91 P1 — replaces "await the run to completion, THEN open the activities
|
|
11
|
+
* SSE stream" (which showed NOTHING: the activities SSE
|
|
12
|
+
* (GET /api/scraps/:id/activities/stream) is backed by an in-process
|
|
13
|
+
* EventEmitter with no backlog — trawl_node
|
|
14
|
+
* modules/activities/services/activities.service.js — so by the time a
|
|
15
|
+
* synchronous run has already finished there is nothing left to emit; and
|
|
16
|
+
* the async `trigger` default enqueues the run onto a durable job queue a
|
|
17
|
+
* SEPARATE cron-consumer pod drains, whose in-process emitter never reaches
|
|
18
|
+
* the API pod holding the SSE connection at all).
|
|
19
|
+
*
|
|
20
|
+
* Instead this polls two REST reads that are BOTH Mongo-backed (not
|
|
21
|
+
* in-process), so they work no matter which pod actually executed the run:
|
|
22
|
+
* - GET /api/scraps/:id/activities?history=<hid>&limit=20 — the SAME
|
|
23
|
+
* activities-list endpoint `doctor`'s fetchRunAndFix already calls
|
|
24
|
+
* (src/commands/doctor.ts) — prints each new activity line once the new
|
|
25
|
+
* run's history id is known.
|
|
26
|
+
* - GET /api/scraps/:id — history[0].status/statusDetail, the SAME
|
|
27
|
+
* terminal-status signal `lastStatus()` above already trusts (status:null
|
|
28
|
+
* === in flight, #88 item 1) to know when the run is done.
|
|
29
|
+
*
|
|
30
|
+
* #93 item 1 — dedup-race fix. "Is this the run we're watching?" used to be
|
|
31
|
+
* a single check: `history[0]._id !== beforeHistoryId`. That's wrong when
|
|
32
|
+
* `before.alreadyInFlight` is true (a `trigger` call deduped onto a worker
|
|
33
|
+
* job that was ALREADY pending/running at capture time): the top row IS the
|
|
34
|
+
* run we're watching, but its `_id` never changes, so the old guard never
|
|
35
|
+
* released and the poll ran the full timeout to a false "Timed out". The run
|
|
36
|
+
* we're watching is now EITHER a brand-new id (fresh trigger, the common
|
|
37
|
+
* case) OR the same id that was already in-flight (status:null) at capture
|
|
38
|
+
* (the dedup case) — a same-id row that was already TERMINAL at capture is
|
|
39
|
+
* neither, and must not be latched onto as "done" (it's just the previous
|
|
40
|
+
* run, still sitting there until a genuinely new run supersedes it).
|
|
41
|
+
*/
|
|
42
|
+
export declare function pollRunProgress(id: string, before: BeforeRunState | undefined, opts?: {
|
|
43
|
+
intervalMs?: number;
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
}): Promise<void>;
|
|
46
|
+
export {};
|
package/dist/commands/scraps.js
CHANGED
|
@@ -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,130 @@ async function watchActivities(id) {
|
|
|
71
82
|
}
|
|
72
83
|
}
|
|
73
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* #91 P1 / #93 item 1 — snapshot the current top history entry BEFORE
|
|
87
|
+
* triggering a run, so pollRunProgress can later tell "the run we just
|
|
88
|
+
* launched" apart from whatever the last run happened to be. Node persists a
|
|
89
|
+
* fresh {status:null, inFlight:true} row the moment a run starts (see
|
|
90
|
+
* HistorysService.create in trawl_node), so a changed history[0]._id is
|
|
91
|
+
* normally an honest "the new run has begun" signal — EXCEPT `trigger`
|
|
92
|
+
* (method:'worker') dedups onto an already-pending/running worker job
|
|
93
|
+
* (ScrapJobsService, LIVE_STATUSES) instead of creating a new row: the top
|
|
94
|
+
* row's `_id` never changes even though this launch IS that run. Recording
|
|
95
|
+
* `alreadyInFlight` (status===null at capture time) lets pollRunProgress
|
|
96
|
+
* recognize that dedup case too, instead of waiting forever for an `_id`
|
|
97
|
+
* that will never arrive (#93 item 1).
|
|
98
|
+
*
|
|
99
|
+
* Best-effort: a failed lookup falls back to `{alreadyInFlight:false}`,
|
|
100
|
+
* which is still correct for a scrap that has never run (id undefined).
|
|
101
|
+
*/
|
|
102
|
+
async function captureBeforeRunState(id) {
|
|
103
|
+
try {
|
|
104
|
+
const scrap = await api.get(`/api/scraps/${id}`);
|
|
105
|
+
const top = scrap.history?.[0];
|
|
106
|
+
return { id: top?._id, alreadyInFlight: top?.status === null };
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return { id: undefined, alreadyInFlight: false };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function sleep(ms) {
|
|
113
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
114
|
+
}
|
|
115
|
+
const POLL_INTERVAL_MS = 2000;
|
|
116
|
+
// Mirrors LONG_RUN_TIMEOUT_MS (#91 item 1) — the server-side worst case this
|
|
117
|
+
// polls for is the same one that timeout was sized for.
|
|
118
|
+
const POLL_TIMEOUT_MS = LONG_RUN_TIMEOUT_MS;
|
|
119
|
+
/**
|
|
120
|
+
* #91 P1 — replaces "await the run to completion, THEN open the activities
|
|
121
|
+
* SSE stream" (which showed NOTHING: the activities SSE
|
|
122
|
+
* (GET /api/scraps/:id/activities/stream) is backed by an in-process
|
|
123
|
+
* EventEmitter with no backlog — trawl_node
|
|
124
|
+
* modules/activities/services/activities.service.js — so by the time a
|
|
125
|
+
* synchronous run has already finished there is nothing left to emit; and
|
|
126
|
+
* the async `trigger` default enqueues the run onto a durable job queue a
|
|
127
|
+
* SEPARATE cron-consumer pod drains, whose in-process emitter never reaches
|
|
128
|
+
* the API pod holding the SSE connection at all).
|
|
129
|
+
*
|
|
130
|
+
* Instead this polls two REST reads that are BOTH Mongo-backed (not
|
|
131
|
+
* in-process), so they work no matter which pod actually executed the run:
|
|
132
|
+
* - GET /api/scraps/:id/activities?history=<hid>&limit=20 — the SAME
|
|
133
|
+
* activities-list endpoint `doctor`'s fetchRunAndFix already calls
|
|
134
|
+
* (src/commands/doctor.ts) — prints each new activity line once the new
|
|
135
|
+
* run's history id is known.
|
|
136
|
+
* - GET /api/scraps/:id — history[0].status/statusDetail, the SAME
|
|
137
|
+
* terminal-status signal `lastStatus()` above already trusts (status:null
|
|
138
|
+
* === in flight, #88 item 1) to know when the run is done.
|
|
139
|
+
*
|
|
140
|
+
* #93 item 1 — dedup-race fix. "Is this the run we're watching?" used to be
|
|
141
|
+
* a single check: `history[0]._id !== beforeHistoryId`. That's wrong when
|
|
142
|
+
* `before.alreadyInFlight` is true (a `trigger` call deduped onto a worker
|
|
143
|
+
* job that was ALREADY pending/running at capture time): the top row IS the
|
|
144
|
+
* run we're watching, but its `_id` never changes, so the old guard never
|
|
145
|
+
* released and the poll ran the full timeout to a false "Timed out". The run
|
|
146
|
+
* we're watching is now EITHER a brand-new id (fresh trigger, the common
|
|
147
|
+
* case) OR the same id that was already in-flight (status:null) at capture
|
|
148
|
+
* (the dedup case) — a same-id row that was already TERMINAL at capture is
|
|
149
|
+
* neither, and must not be latched onto as "done" (it's just the previous
|
|
150
|
+
* run, still sitting there until a genuinely new run supersedes it).
|
|
151
|
+
*/
|
|
152
|
+
export async function pollRunProgress(id, before, opts = {}) {
|
|
153
|
+
const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
|
|
154
|
+
const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
|
|
155
|
+
const beforeId = before?.id;
|
|
156
|
+
const beforeAlreadyInFlight = before?.alreadyInFlight ?? false;
|
|
157
|
+
console.log(chalk.dim('Live activity streaming has no signal for this run (async/cross-pod) — polling for progress instead…\n'));
|
|
158
|
+
const deadline = Date.now() + timeoutMs;
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
let first = true;
|
|
161
|
+
while (Date.now() < deadline) {
|
|
162
|
+
if (!first)
|
|
163
|
+
await sleep(intervalMs);
|
|
164
|
+
first = false;
|
|
165
|
+
let scrap;
|
|
166
|
+
try {
|
|
167
|
+
scrap = await api.get(`/api/scraps/${id}`);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
continue; // transient — keep polling rather than aborting the wait
|
|
171
|
+
}
|
|
172
|
+
const last = scrap.history?.[0];
|
|
173
|
+
if (!last?._id)
|
|
174
|
+
continue; // no history row recorded yet
|
|
175
|
+
const isNewRun = last._id !== beforeId;
|
|
176
|
+
const isDedupOntoInFlight = last._id === beforeId && beforeAlreadyInFlight;
|
|
177
|
+
if (!isNewRun && !isDedupOntoInFlight)
|
|
178
|
+
continue; // still the stale previous run
|
|
179
|
+
try {
|
|
180
|
+
const activities = await api.get(`/api/scraps/${id}/activities?history=${last._id}&limit=20`);
|
|
181
|
+
// Server returns newest-first — print unseen ones oldest-first.
|
|
182
|
+
for (const a of [...(activities ?? [])].reverse()) {
|
|
183
|
+
const key = a._id ?? `${a.createdAt}:${a.message}`;
|
|
184
|
+
if (seen.has(key))
|
|
185
|
+
continue;
|
|
186
|
+
seen.add(key);
|
|
187
|
+
const time = new Date(a.createdAt).toLocaleTimeString();
|
|
188
|
+
console.log(`${chalk.dim(`[${time}]`)} ${a.message}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// best-effort — terminal detection below still works without activity lines
|
|
193
|
+
}
|
|
194
|
+
if (last.status !== null) {
|
|
195
|
+
const outcome = last.statusDetail ?? (last.status ? 'success' : 'failure');
|
|
196
|
+
console.log(chalk.dim(`Run finished: ${outcome}`));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
console.log(chalk.yellow(`⚠ Timed out waiting for the run to finish — check status with: trawl scraps doctor ${id}`));
|
|
201
|
+
}
|
|
74
202
|
// list
|
|
75
203
|
scraps
|
|
76
204
|
.command('list')
|
|
77
205
|
.alias('ls')
|
|
78
206
|
.description('List all scraps')
|
|
79
207
|
.option('--json', 'Output as JSON')
|
|
80
|
-
.option('--status <status>', 'Filter by last run status (success|failure|never|running)')
|
|
208
|
+
.option('--status <status>', 'Filter by last run status (success|failure|never|running|regression)')
|
|
81
209
|
// #88 item 8 — no custom parser here (unlike the old `(v) => parseInt(v,
|
|
82
210
|
// 10)`): a bad value like "abc" used to silently become NaN, which then
|
|
83
211
|
// sailed straight through `Number.isInteger`-less checks and into
|
|
@@ -430,15 +558,21 @@ scraps
|
|
|
430
558
|
scraps
|
|
431
559
|
.command('run <id>')
|
|
432
560
|
.description('Run a scrap')
|
|
433
|
-
.option('-w, --watch', '
|
|
561
|
+
.option('-w, --watch', 'Show progress after launching (polls — see `trawl scraps trigger --watch`, #91)')
|
|
434
562
|
.action(async (id, opts) => {
|
|
435
563
|
validateObjectId(id);
|
|
436
|
-
|
|
564
|
+
// #91 P1 / #93 item 1 — captured BEFORE launching so pollRunProgress can
|
|
565
|
+
// tell "the run that's about to finish" apart from whatever the last run
|
|
566
|
+
// happened to be (including a dedup onto an already-in-flight run).
|
|
567
|
+
const beforeRun = opts.watch ? await captureBeforeRunState(id) : undefined;
|
|
568
|
+
// #91 P0 — GET /api/scraps/load/:id runs the scrap synchronously
|
|
569
|
+
// server-side (30-250s); the 30s default was aborting it mid-flight.
|
|
570
|
+
await oraPromise(() => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS }), {
|
|
437
571
|
text: 'Launching scrap…',
|
|
438
572
|
successText: 'Scrap launched',
|
|
439
573
|
});
|
|
440
574
|
if (opts.watch) {
|
|
441
|
-
await
|
|
575
|
+
await pollRunProgress(id, beforeRun);
|
|
442
576
|
}
|
|
443
577
|
});
|
|
444
578
|
// #70 — render an items array either as a table summary or --json. Shared by
|
|
@@ -518,7 +652,9 @@ scraps
|
|
|
518
652
|
// burning execute quota and 429ing if a run is already in flight. A user
|
|
519
653
|
// or agent "just reading data" must never trigger that by accident.
|
|
520
654
|
if (opts.fresh) {
|
|
521
|
-
|
|
655
|
+
// #91 P0 — same long-run endpoint as `scraps run` (30-250s server-side);
|
|
656
|
+
// the 30s default was aborting it mid-flight.
|
|
657
|
+
const loaded = await oraPromise(() => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS }), {
|
|
522
658
|
text: 'Launching a fresh scrap run (consumes execute quota)…',
|
|
523
659
|
successText: 'Fresh run complete',
|
|
524
660
|
});
|
|
@@ -533,6 +669,27 @@ scraps
|
|
|
533
669
|
console.log(chalk.dim('No data yet. Run the scrap first.'));
|
|
534
670
|
return;
|
|
535
671
|
}
|
|
672
|
+
// #93 item 2 — --fresh used to render items without ever checking for a
|
|
673
|
+
// regression, unlike the persisted `data` path below (#88 item 2). The
|
|
674
|
+
// load() response's OWN embedded `scrap.history[0]` can't be trusted for
|
|
675
|
+
// this (see the ScrapLoadResult comment above): it may be the previous
|
|
676
|
+
// run's row, and even the right row never carries the regression flip.
|
|
677
|
+
// `--fresh` runs synchronously to completion server-side though — by the
|
|
678
|
+
// time this call returns, node has already awaited the regression patch
|
|
679
|
+
// — so a fresh GET /api/scraps/:id (the SAME read the persisted path
|
|
680
|
+
// below already trusts) reliably observes the finalized DB state.
|
|
681
|
+
// Best-effort: never fail --fresh's real output over this side check,
|
|
682
|
+
// and never gate on it — the items returned ARE this run's real,
|
|
683
|
+
// synchronously-computed data regardless of what this check finds.
|
|
684
|
+
try {
|
|
685
|
+
const fresh = await api.get(`/api/scraps/${id}`);
|
|
686
|
+
if (fresh.history?.[0]?.statusDetail === 'regression') {
|
|
687
|
+
console.error(chalk.yellow(`⚠ Item count regressed vs baseline for the last run of ${id} — see: trawl scraps doctor ${id}`));
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
// best-effort — the fresh run's items are still valid without this check
|
|
692
|
+
}
|
|
536
693
|
renderScrapItems(items, opts.json);
|
|
537
694
|
return;
|
|
538
695
|
}
|
|
@@ -713,7 +870,6 @@ scraps
|
|
|
713
870
|
usageError(`File not found: ${opts.file}`);
|
|
714
871
|
return;
|
|
715
872
|
}
|
|
716
|
-
const fileBuffer = readFileSync(opts.file);
|
|
717
873
|
const filename = basename(opts.file);
|
|
718
874
|
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
|
719
875
|
const mimeMap = {
|
|
@@ -722,7 +878,15 @@ scraps
|
|
|
722
878
|
jpeg: 'image/jpeg',
|
|
723
879
|
webp: 'image/webp',
|
|
724
880
|
};
|
|
725
|
-
|
|
881
|
+
// #91 P2 — an unsupported/unknown extension silently became image/png
|
|
882
|
+
// (uploading the raw bytes of, say, a .gif or .pdf under an image/png
|
|
883
|
+
// Content-Type — a lie about the actual file's format). Refuse instead.
|
|
884
|
+
const mimeType = mimeMap[ext];
|
|
885
|
+
if (!mimeType) {
|
|
886
|
+
usageError(`Unsupported image type "${ext ? `.${ext}` : filename}" — use png, jpg, or webp.`);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const fileBuffer = readFileSync(opts.file);
|
|
726
890
|
const blob = new Blob([fileBuffer], { type: mimeType });
|
|
727
891
|
const formData = new FormData();
|
|
728
892
|
formData.append('banner', blob, filename);
|
|
@@ -743,20 +907,31 @@ scraps
|
|
|
743
907
|
scraps
|
|
744
908
|
.command('trigger <id>')
|
|
745
909
|
.description('Launch a scrap as a background worker (returns immediately)')
|
|
746
|
-
.option('-w, --watch', '
|
|
910
|
+
.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
911
|
.option('--wait', 'Run synchronously and wait for the result (legacy behaviour)')
|
|
748
912
|
.action(async (id, opts) => {
|
|
749
913
|
validateObjectId(id);
|
|
914
|
+
// #91 P1 / #93 item 1 — captured BEFORE triggering so pollRunProgress can
|
|
915
|
+
// tell "the run we just triggered" apart from whatever the last run
|
|
916
|
+
// happened to be. This is the dedup-prone path: `trigger`'s method:'worker'
|
|
917
|
+
// collapses onto an already-pending/running worker job for the same scrap
|
|
918
|
+
// (ScrapJobsService, LIVE_STATUSES) instead of creating a new history row —
|
|
919
|
+
// captureBeforeRunState records that so pollRunProgress can still track it.
|
|
920
|
+
const beforeRun = opts.watch ? await captureBeforeRunState(id) : undefined;
|
|
750
921
|
// #50 — default async: the backend (#1313) kicks off the run and returns a
|
|
751
922
|
// 'queued' envelope immediately instead of holding the connection for the
|
|
752
923
|
// whole run. --wait restores the old synchronous round-trip.
|
|
753
924
|
const path = opts.wait ? `/api/scraps/worker/${id}` : `/api/scraps/worker/${id}?wait=false`;
|
|
754
|
-
|
|
925
|
+
// #91 P0 — the synchronous --wait branch runs the scrap server-side
|
|
926
|
+
// (30-250s), same as `scraps run`; the 30s default was aborting it
|
|
927
|
+
// mid-flight. The async (default) POST returns almost immediately, so it
|
|
928
|
+
// keeps the 30s default.
|
|
929
|
+
await oraPromise(() => (opts.wait ? api.post(path, undefined, { timeoutMs: LONG_RUN_TIMEOUT_MS }) : api.post(path)), {
|
|
755
930
|
text: opts.wait ? 'Running worker…' : 'Triggering worker…',
|
|
756
931
|
successText: opts.wait ? 'Worker run complete' : 'Worker triggered',
|
|
757
932
|
});
|
|
758
933
|
if (opts.watch)
|
|
759
|
-
await
|
|
934
|
+
await pollRunProgress(id, beforeRun);
|
|
760
935
|
});
|
|
761
936
|
// account subcommand group
|
|
762
937
|
const account = scraps
|
|
@@ -991,11 +1166,26 @@ scraps
|
|
|
991
1166
|
.description('Download captured page HTML for the last run of a scrap')
|
|
992
1167
|
.option('--error', 'Fetch the error-path snapshot (errorSnapshot.html)')
|
|
993
1168
|
.option('-o, --out <file>', 'Write HTML to a file instead of stdout')
|
|
1169
|
+
.option('--json', 'On a never-run scrap, output {"status":"no_runs"} (exit 0) instead of prose')
|
|
994
1170
|
.action(async (id, opts) => {
|
|
995
1171
|
validateObjectId(id);
|
|
996
1172
|
const scrap = await api.get(`/api/scraps/${id}`);
|
|
997
1173
|
const hid = scrap.history?.[0]?._id;
|
|
998
1174
|
if (!hid) {
|
|
1175
|
+
// #91 P2 — same no-runs contract as `doctor`/`autofix`/`data --errors`
|
|
1176
|
+
// (`{status:"no_runs"}`, exit 0) under --json.
|
|
1177
|
+
if (opts.json) {
|
|
1178
|
+
json({ status: 'no_runs' });
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
// `-o` explicitly asked for a file to be written. Silently exiting 0
|
|
1182
|
+
// with nothing written (and only a dim console line) is indistinguishable
|
|
1183
|
+
// from success to a script checking the exit code alone — give it the
|
|
1184
|
+
// SAME not_found envelope `scraps data`'s own never-run case uses.
|
|
1185
|
+
if (opts.out) {
|
|
1186
|
+
reportDataState(`Scrap ${id} has never run — nothing to write to ${opts.out}.`, 4, 'not_found', false);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
999
1189
|
console.log(chalk.dim('No runs yet.'));
|
|
1000
1190
|
return;
|
|
1001
1191
|
}
|
package/dist/commands/skills.js
CHANGED
|
@@ -31,7 +31,34 @@ skills
|
|
|
31
31
|
for (const name of bundled) {
|
|
32
32
|
const userInstalled = isSkillInstalled(name, 'user');
|
|
33
33
|
const localInstalled = isSkillInstalled(name, 'local');
|
|
34
|
-
|
|
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
|
+
//
|
|
38
|
+
// #93 item 3 — the two scope reads must be guarded INDEPENDENTLY. A
|
|
39
|
+
// single try/catch wrapped around `getInstalledVersion(name, 'user') ??
|
|
40
|
+
// getInstalledVersion(name, 'local')` still throws the whole expression
|
|
41
|
+
// the moment the 'user' read throws (e.g. a corrupt/EISDIR `.version`
|
|
42
|
+
// marker) — `??` never gets a chance to evaluate the 'local' fallback,
|
|
43
|
+
// so a healthy local install gets masked as "not installed" too. Every
|
|
44
|
+
// other marker read in this codebase (installSkill, removeOrphanedSkills,
|
|
45
|
+
// autoUpdateInstalledSkills — all in lib/skills.ts) already guards each
|
|
46
|
+
// scope on its own; this was the one chained exception.
|
|
47
|
+
let installedVersion;
|
|
48
|
+
try {
|
|
49
|
+
installedVersion = getInstalledVersion(name, 'user');
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
installedVersion = null;
|
|
53
|
+
}
|
|
54
|
+
if (installedVersion === null) {
|
|
55
|
+
try {
|
|
56
|
+
installedVersion = getInstalledVersion(name, 'local');
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
installedVersion = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
35
62
|
const stale = installedVersion && installedVersion !== version;
|
|
36
63
|
const tag = userInstalled
|
|
37
64
|
? 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
|
-
/**
|
|
61
|
-
|
|
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 (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 ${
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
}
|
package/dist/lib/skills.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
"version": "1.18.5",
|
|
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.
|
|
43
|
+
"@trawlme/skills": "1.3.3",
|
|
44
44
|
"chalk": "^5.6.2",
|
|
45
45
|
"commander": "^14.0.3",
|
|
46
46
|
"conf": "^15.1.0",
|