@trawlme/cli 3.7.2 → 3.7.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 CHANGED
@@ -79,7 +79,7 @@ trawl ping [--json] Health/version handshake against
79
79
  ### Scrap management
80
80
 
81
81
  ```
82
- trawl scraps create -t <title> [-u <url>] [-r <request>] [-d <description>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--json]
82
+ trawl scraps create -t <title> -u <url> [-r <request>] [-d <description>] [--tier <tier0|tier1|tier2|tier3|tier4>] [--json]
83
83
  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]
84
84
  trawl scraps watch <id> [--json]
85
85
  trawl scraps doctor <id> [--json] [--autofix]
@@ -89,6 +89,7 @@ trawl scraps banner <id> -f <file> [--json]
89
89
  trawl scraps delete <id> [--force] [--json] Alias: rm
90
90
  ```
91
91
 
92
+ - `-u/--url` is **the site the scrap targets**, not necessarily the exact URL fetched at run time — a scrap whose real target is computed inside its `-r/--request` script (a templated search query, an interpolated item id, e.g. `https://www.ebay.com/sch/i.html?_nkw=${query}`) should still supply a base/site URL. Every domain-level policy (proxy tier ceiling, provider routing, per-domain memoize) keys off this field, so a blank value silently disables all of it. `create` requires it — validated client-side (must parse as an `http`/`https` URL) so a missing or malformed value fails fast with a readable usage error (exit 2) instead of a raw server 400/422; `update` keeps it optional (omitting it is still a no-op, matching the API) but a value that **is** passed is validated the same way.
92
93
  - `--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 (`kind:"refused"` — distinct from `"unknown"`, so a script can branch on "the server said no"). 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.
93
94
  - `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.
94
95
  - `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.
@@ -1,11 +1,11 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { spin } from '../lib/spinner.js';
3
+ import { spin, confirmNonTTY } from '../lib/spinner.js';
4
4
  import { api, LONG_RUN_TIMEOUT_MS } from '../lib/api.js';
5
5
  import { table, json, formatDate } from '../lib/format.js';
6
6
  import { parseServerJson } from '../lib/json.js';
7
7
  import { promptPassword } from '../lib/prompt.js';
8
- import { validateObjectId } from '../lib/validate.js';
8
+ import { validateObjectId, requireUrl } from '../lib/validate.js';
9
9
  import { classifyError, reportError, UsageError, RefusalError } from '../lib/errors.js';
10
10
  import { confirmDestructive, isInteractive } from '../lib/confirm.js';
11
11
  import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix } from './doctor.js';
@@ -22,6 +22,31 @@ import { maybeShowReferralTip } from '../lib/tips.js';
22
22
  function usageError(message, opts = {}) {
23
23
  process.exitCode = reportError(new UsageError(message), { json: opts.json });
24
24
  }
25
+ /**
26
+ * #163 — client-side URL validation shared by `create`/`update`, mirroring
27
+ * the server's now-required-and-parseable `url` field (trawl_node#1823,
28
+ * PR #1828: `z.string().trim().default('')` -> `z.string().trim().url()`).
29
+ * Gives a readable message BEFORE the request goes out instead of a raw
30
+ * 400/422 from the server. Reuses the exact same `requireUrl` helper
31
+ * `trawl create`/`trawl login --url` already validate against (lib/validate.js),
32
+ * just routed through this file's own return-style `usageError()` convention
33
+ * (never throw — every other validation in this file, tier/limit/page/params/
34
+ * status, follows the same pattern) instead of the throw-style those two
35
+ * commands use. Returns `undefined` (never throws) on a bad value — the
36
+ * caller must check for that and `return` without calling the API.
37
+ */
38
+ function tryValidateUrl(value, opts) {
39
+ try {
40
+ return requireUrl(value, '--url');
41
+ }
42
+ catch (err) {
43
+ if (err instanceof UsageError) {
44
+ usageError(err.message, { json: opts.json });
45
+ return undefined;
46
+ }
47
+ throw err;
48
+ }
49
+ }
25
50
  function lastStatus(scrap) {
26
51
  const last = scrap.history?.[0];
27
52
  if (!last)
@@ -565,7 +590,7 @@ scraps
565
590
  .command('create')
566
591
  .description('Create a new scrap')
567
592
  .requiredOption('-t, --title <title>', 'Scrap title')
568
- .option('-u, --url <url>', 'Target URL')
593
+ .option('-u, --url <url>', 'Target URL — the site the scrap targets, not necessarily the exact URL fetched at run time (a request script can compute that, e.g. a templated search query). Required.')
569
594
  .option('-r, --request <request>', 'Request/query')
570
595
  .option('-d, --description <text>', 'Scrap description')
571
596
  .option('--tier <tier>', `Force proxy tier (${VALID_TIERS.join('|')})`)
@@ -575,9 +600,16 @@ scraps
575
600
  usageError(`Invalid --tier "${opts.tier}" (allowed: ${VALID_TIERS.join(', ')})`, { json: opts.json });
576
601
  return;
577
602
  }
603
+ // #163 — the API now requires a parseable `url` on create (trawl_node
604
+ // #1823/#1828); validate client-side BEFORE the request goes out so a
605
+ // missing/unparseable value surfaces as a readable usage error instead
606
+ // of a raw server 400/422.
607
+ const url = tryValidateUrl(opts.url, opts);
608
+ if (url === undefined)
609
+ return;
578
610
  const data = await spin(() => api.post('/api/scraps', {
579
611
  title: opts.title,
580
- ...(opts.url && { url: opts.url }),
612
+ url,
581
613
  request: opts.request || '',
582
614
  ...(opts.description !== undefined && { description: opts.description }),
583
615
  ...(opts.tier !== undefined && { proxyTier: opts.tier }),
@@ -606,7 +638,7 @@ scraps
606
638
  .command('update <id>')
607
639
  .description('Update an existing scrap')
608
640
  .option('-t, --title <title>', 'New title')
609
- .option('-u, --url <url>', 'New target URL')
641
+ .option('-u, --url <url>', 'New target URL — the site the scrap targets. Optional: omitting it leaves the current value unchanged (a no-op, matching the API); when passed it must still be a parseable URL.')
610
642
  .option('-r, --request <request>', 'New request')
611
643
  .option('-d, --description <text>', 'New description')
612
644
  .option('--cron <expression>', 'Cron expression (empty string to disable)')
@@ -633,8 +665,15 @@ scraps
633
665
  const body = {};
634
666
  if (opts.title !== undefined)
635
667
  body.title = opts.title;
636
- if (opts.url !== undefined)
637
- body.url = opts.url;
668
+ // #163 — omitting --url stays a no-op (the API keeps that allowed on
669
+ // update), but a value that IS supplied must still be a parseable URL —
670
+ // validate it client-side before the request goes out, same as create.
671
+ if (opts.url !== undefined) {
672
+ const url = tryValidateUrl(opts.url, opts);
673
+ if (url === undefined)
674
+ return;
675
+ body.url = url;
676
+ }
638
677
  if (opts.request !== undefined)
639
678
  body.request = opts.request;
640
679
  if (opts.description !== undefined)
@@ -747,6 +786,24 @@ export function attachRunCommand(parent, attachOpts = {}) {
747
786
  : await spin(call, { text: 'Launching scrap…', successText: 'Scrap launched' });
748
787
  if (opts.json)
749
788
  json(data);
789
+ else if (!opts.watch) {
790
+ /* #166 — see confirmNonTTY. Two deliberate choices here:
791
+ *
792
+ * "run complete", not "launched": `run` is the SYNCHRONOUS verb —
793
+ * GET /api/scraps/load/:id executes the scrap server-side (30-250s)
794
+ * and returns the finished result, so by the time this prints the run
795
+ * is over. The spinner's successText says "Scrap launched", but that
796
+ * is stderr chrome for a human watching it happen; THIS line is the
797
+ * machine surface, where a script reading "launched" would poll for a
798
+ * completion that already happened. `trigger` already draws the same
799
+ * distinction ("Worker triggered" async vs "Worker run complete" under
800
+ * --wait) — this matches that vocabulary.
801
+ *
802
+ * `--watch` is excluded because pollRunProgress below does its own
803
+ * reporting; without that guard a watched run would print this line
804
+ * and then narrate the same run. */
805
+ confirmNonTTY(`Scrap ${id} run complete`);
806
+ }
750
807
  if (opts.watch) {
751
808
  // #107 — under --json, pollRunProgress suppresses its own intermediate
752
809
  // console.log calls and instead emits exactly ONE final NDJSON outcome
@@ -1079,6 +1136,7 @@ scraps
1079
1136
  return;
1080
1137
  }
1081
1138
  await spin(call, { text: 'Deleting…', successText: 'Scrap deleted' });
1139
+ confirmNonTTY(`Scrap ${id} deleted`); // #166
1082
1140
  });
1083
1141
  // banner
1084
1142
  scraps
@@ -1124,6 +1182,8 @@ scraps
1124
1182
  text: 'Uploading banner…',
1125
1183
  successText: `Banner uploaded for scrap ${chalk.bold(id)}`,
1126
1184
  });
1185
+ // #166 — plain, unstyled: chalk.bold would emit escape codes into a pipe.
1186
+ confirmNonTTY(`Banner uploaded for scrap ${id}`);
1127
1187
  });
1128
1188
  // watch (stream activities)
1129
1189
  scraps
@@ -1167,8 +1227,17 @@ export function attachTriggerCommand(parent, attachOpts = {}) {
1167
1227
  text: opts.wait ? 'Running worker…' : 'Triggering worker…',
1168
1228
  successText: opts.wait ? 'Worker run complete' : 'Worker triggered',
1169
1229
  });
1170
- if (opts.json)
1230
+ if (opts.json) {
1171
1231
  json(data);
1232
+ }
1233
+ else {
1234
+ /* #160, folded onto the shared helper by #166 — the rationale and the
1235
+ * known residual now live once, on `confirmNonTTY` itself, instead of
1236
+ * in the one call site that happened to be fixed first. Wording is
1237
+ * unchanged on purpose: the trawl-internal QA runbook asserts on the
1238
+ * exact "Worker triggered" / "Worker run complete" substrings. */
1239
+ confirmNonTTY(opts.wait ? `Worker run complete for scrap ${id}` : `Worker triggered for scrap ${id}`);
1240
+ }
1172
1241
  // #107 — see the matching comment on `run`'s --watch call above (review
1173
1242
  // F1): honest final NDJSON line + exit code under --json, human mode
1174
1243
  // gets the same honest exit code too.
@@ -1276,6 +1345,7 @@ account
1276
1345
  text: 'Deleting credentials…',
1277
1346
  successText: 'Account credentials deleted',
1278
1347
  });
1348
+ confirmNonTTY(`Account credentials deleted for scrap ${id}`); // #166
1279
1349
  });
1280
1350
  // account clear-session
1281
1351
  account
@@ -1294,6 +1364,7 @@ account
1294
1364
  text: 'Clearing session…',
1295
1365
  successText: 'Session cleared',
1296
1366
  });
1367
+ confirmNonTTY(`Session cleared for scrap ${id}`); // #166
1297
1368
  });
1298
1369
  // account session subcommand group
1299
1370
  const accountSession = account
@@ -15,4 +15,35 @@ type SpinOptions<T> = string | {
15
15
  * to stderr, stdout stays clean either way).
16
16
  */
17
17
  export declare function spin<T>(action: Action<T>, options?: SpinOptions<T>): Promise<T>;
18
+ /**
19
+ * The counterpart to `spin()`'s silence — print a plain-text confirmation on
20
+ * stdout when, and only when, stdout is not a TTY.
21
+ *
22
+ * #160 then #166. `spin()` above deliberately emits nothing when stderr is not
23
+ * a TTY, so for every command whose only non-`--json` feedback was its
24
+ * `successText`, a piped or redirected invocation wrote **zero bytes to both
25
+ * streams while exiting 0**. Our ICP is agent builders, and agents pipe stdout:
26
+ * a verb that writes nothing on success is unusable from a script, because the
27
+ * caller cannot tell success from a no-op. Two of the affected commands were
28
+ * deletes, where that ambiguity is at its worst.
29
+ *
30
+ * Lives here, next to the silence it compensates for, because #160 fixed
31
+ * `trigger` by inlining this rule and #166 then found five siblings carrying
32
+ * the identical defect — five more inlined copies is how the next one gets
33
+ * missed. Callers pass the message; the gate lives in one place.
34
+ *
35
+ * Gated on **stdout**, not stderr: stdout is the stream a script actually reads
36
+ * (`$(trawl …)`, `> out.txt`, `| jq`), independent of whatever `spin()` decides
37
+ * about stderr. So an interactive session, where the ora spinner already
38
+ * confirmed on stderr, never gets a duplicate line here.
39
+ *
40
+ * Never call this on a `--json` path: under `--json`, stdout must stay exactly
41
+ * one parseable document.
42
+ *
43
+ * Known residual, unchanged from #160: stdout attached to a real terminal while
44
+ * stderr is separately redirected still prints nothing on either stream. That is
45
+ * not the reported or common shape (full redirection, or stdout-only capture),
46
+ * and widening the gate would put a duplicate line in front of interactive users.
47
+ */
48
+ export declare function confirmNonTTY(message: string): void;
18
49
  export {};
@@ -15,3 +15,37 @@ export function spin(action, options) {
15
15
  // oraPromise's own overloads accept (action, string) and (action, options).
16
16
  return oraPromise(action, options);
17
17
  }
18
+ /**
19
+ * The counterpart to `spin()`'s silence — print a plain-text confirmation on
20
+ * stdout when, and only when, stdout is not a TTY.
21
+ *
22
+ * #160 then #166. `spin()` above deliberately emits nothing when stderr is not
23
+ * a TTY, so for every command whose only non-`--json` feedback was its
24
+ * `successText`, a piped or redirected invocation wrote **zero bytes to both
25
+ * streams while exiting 0**. Our ICP is agent builders, and agents pipe stdout:
26
+ * a verb that writes nothing on success is unusable from a script, because the
27
+ * caller cannot tell success from a no-op. Two of the affected commands were
28
+ * deletes, where that ambiguity is at its worst.
29
+ *
30
+ * Lives here, next to the silence it compensates for, because #160 fixed
31
+ * `trigger` by inlining this rule and #166 then found five siblings carrying
32
+ * the identical defect — five more inlined copies is how the next one gets
33
+ * missed. Callers pass the message; the gate lives in one place.
34
+ *
35
+ * Gated on **stdout**, not stderr: stdout is the stream a script actually reads
36
+ * (`$(trawl …)`, `> out.txt`, `| jq`), independent of whatever `spin()` decides
37
+ * about stderr. So an interactive session, where the ora spinner already
38
+ * confirmed on stderr, never gets a duplicate line here.
39
+ *
40
+ * Never call this on a `--json` path: under `--json`, stdout must stay exactly
41
+ * one parseable document.
42
+ *
43
+ * Known residual, unchanged from #160: stdout attached to a real terminal while
44
+ * stderr is separately redirected still prints nothing on either stream. That is
45
+ * not the reported or common shape (full redirection, or stdout-only capture),
46
+ * and widening the gate would put a duplicate line in front of interactive users.
47
+ */
48
+ export function confirmNonTTY(message) {
49
+ if (!process.stdout.isTTY)
50
+ console.log(message);
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trawlme/cli",
3
- "version": "3.7.2",
3
+ "version": "3.7.5",
4
4
  "description": "Trawl CLI — manage scraps from the terminal",
5
5
  "type": "module",
6
6
  "bin": {