@trawlme/cli 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,8 @@ 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';
8
- import { classifyError, reportError, UsageError } from '../lib/errors.js';
8
+ import { classifyError, reportError, UsageError, RefusalError } from '../lib/errors.js';
9
+ import { confirmDestructive, isInteractive } from '../lib/confirm.js';
9
10
  import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix } from './doctor.js';
10
11
  import { renderPinch, pinchEnabled } from '../lib/pinch.js';
11
12
  /**
@@ -66,12 +67,21 @@ function statusIcon(status) {
66
67
  return chalk.dim('—');
67
68
  }
68
69
  export const scraps = new Command('scraps').description('Manage scraps');
69
- // shared SSE streaming helper
70
- async function watchActivities(id) {
71
- console.log(chalk.dim('Streaming activities (Ctrl+C to stop)…\n'));
70
+ // shared SSE streaming helper. `asJson` (#107) emits one raw JSON object per
71
+ // line (NDJSON) on stdout instead of the human-formatted timestamped text —
72
+ // a streaming command still needs a pure-stdout machine mode, just line-
73
+ // delimited instead of a single blob (there's no single "final" payload to
74
+ // wait for).
75
+ async function watchActivities(id, asJson) {
76
+ if (!asJson)
77
+ console.log(chalk.dim('Streaming activities (Ctrl+C to stop)…\n'));
72
78
  for await (const event of api.stream(`/api/scraps/${id}/activities/stream`)) {
73
79
  try {
74
80
  const activity = JSON.parse(event);
81
+ if (asJson) {
82
+ console.log(JSON.stringify(activity));
83
+ continue;
84
+ }
75
85
  const time = new Date(activity.createdAt).toLocaleTimeString();
76
86
  console.log(`${chalk.dim(`[${time}]`)} ${activity.message}`);
77
87
  }
@@ -121,6 +131,14 @@ const POLL_INTERVAL_MS = 2000;
121
131
  // Mirrors LONG_RUN_TIMEOUT_MS (#91 item 1) — the server-side worst case this
122
132
  // polls for is the same one that timeout was sized for.
123
133
  const POLL_TIMEOUT_MS = LONG_RUN_TIMEOUT_MS;
134
+ // #107 review F1 — a genuinely unreachable API must not run the watch
135
+ // silently for the full 300s timeout with dead air; after this many
136
+ // CONSECUTIVE (not cumulative — any successful poll resets the counter)
137
+ // failed polls, treat it as a persistent error and surface it immediately
138
+ // instead of waiting out the clock. At the default 2s interval this gives up
139
+ // after ~10s of continuous failures — comfortably longer than any single
140
+ // transient blip, nowhere near the 300s ceiling.
141
+ const MAX_CONSECUTIVE_POLL_ERRORS = 5;
124
142
  /**
125
143
  * #91 P1 — replaces "await the run to completion, THEN open the activities
126
144
  * SSE stream" (which showed NOTHING: the activities SSE
@@ -182,10 +200,37 @@ const POLL_TIMEOUT_MS = LONG_RUN_TIMEOUT_MS;
182
200
  * this function has, so it resolves to the same honest timeout rather than
183
201
  * risk reporting a possibly-wrong outcome (never a lie, at worst a timeout
184
202
  * telling the caller to check `doctor`).
203
+ *
204
+ * #107 review F1 — before this fix, `run|trigger --json --watch` was
205
+ * outcome-blind: `quiet` suppressed ALL output (including "Run finished:
206
+ * failure" and the timeout notice), a transient poll error was caught and
207
+ * silently retried FOREVER within the deadline, and the process always
208
+ * exited 0 after the poll loop regardless of what the watched run actually
209
+ * did — dead air, then a clean exit code, even for a failed or timed-out
210
+ * run. An agent scripting this CLI had no way to tell success from failure
211
+ * from "we gave up". Fixed by:
212
+ * - emitting exactly ONE final NDJSON line on stdout under `--json` once
213
+ * the watch reaches ANY of its three exits (terminal status, timeout, or
214
+ * a persistent poll error) — `{runId,status}` (+`error` for a poll
215
+ * error) — while every intermediate progress line stays suppressed
216
+ * (unchanged from before);
217
+ * - setting `process.exitCode` non-zero on a genuine run failure, a
218
+ * timeout, or a persistent poll error, and `0` on a real success — in
219
+ * BOTH `--json` and human `--watch` modes (human mode used to exit 0
220
+ * unconditionally, the same bug, just silent instead of dishonest);
221
+ * - giving up after `MAX_CONSECUTIVE_POLL_ERRORS` consecutive failed reads
222
+ * instead of retrying the same dead endpoint for the full 300s.
185
223
  */
186
224
  export async function pollRunProgress(id, before, opts = {}) {
187
225
  const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
188
226
  const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
227
+ // #107 — `run --json --watch` / `trigger --json --watch` must keep stdout
228
+ // pure JSON: none of this function's intermediate progress text is safe to
229
+ // print once a caller asked for --json. `asJson` suppresses every
230
+ // intermediate console.log below (including the pinch celebration frame)
231
+ // while the polling/wait logic itself runs unchanged; the ONE exception is
232
+ // the single final outcome line emitted right before each return below.
233
+ const asJson = opts.json ?? false;
189
234
  let beforeId = before?.id;
190
235
  let beforeAlreadyInFlight = before?.alreadyInFlight ?? false;
191
236
  // #97 — only an EXPLICIT captured:false (capture's GET actually threw)
@@ -193,10 +238,13 @@ export async function pollRunProgress(id, before, opts = {}) {
193
238
  // capture entirely) or `captured` being true/absent both mean "trust
194
239
  // beforeId as given", preserving every existing call site's behavior.
195
240
  let baselineEstablished = before?.captured ?? true;
196
- console.log(chalk.dim('Live activity streaming has no signal for this run (async/cross-pod) — polling for progress instead…\n'));
241
+ if (!asJson) {
242
+ console.log(chalk.dim('Live activity streaming has no signal for this run (async/cross-pod) — polling for progress instead…\n'));
243
+ }
197
244
  const deadline = Date.now() + timeoutMs;
198
245
  const seen = new Set();
199
246
  let first = true;
247
+ let consecutivePollErrors = 0;
200
248
  while (Date.now() < deadline) {
201
249
  if (!first)
202
250
  await sleep(intervalMs);
@@ -204,9 +252,28 @@ export async function pollRunProgress(id, before, opts = {}) {
204
252
  let scrap;
205
253
  try {
206
254
  scrap = await api.get(`/api/scraps/${id}`);
255
+ consecutivePollErrors = 0;
207
256
  }
208
- catch {
209
- continue; // transient — keep polling rather than aborting the wait
257
+ catch (err) {
258
+ consecutivePollErrors++;
259
+ // #107 review F1 — a single failed read is still a TRANSIENT blip,
260
+ // safe to retry within the deadline (unchanged). Only once reads fail
261
+ // this many times IN A ROW is the API treated as genuinely
262
+ // unreachable — surface that honestly instead of quietly burning the
263
+ // full 300s timeout on a dead endpoint.
264
+ if (consecutivePollErrors >= MAX_CONSECUTIVE_POLL_ERRORS) {
265
+ const message = err instanceof Error ? err.message : String(err);
266
+ if (asJson) {
267
+ const outcome = { runId: beforeId, status: 'poll_error', error: message };
268
+ console.log(JSON.stringify(outcome));
269
+ }
270
+ else {
271
+ console.log(chalk.red(`✗ Polling failed repeatedly (${message}) — check status with: trawl scraps doctor ${id}`));
272
+ }
273
+ process.exitCode = 1;
274
+ return;
275
+ }
276
+ continue;
210
277
  }
211
278
  const last = scrap.history?.[0];
212
279
  if (!last?._id)
@@ -233,6 +300,8 @@ export async function pollRunProgress(id, before, opts = {}) {
233
300
  if (seen.has(key))
234
301
  continue;
235
302
  seen.add(key);
303
+ if (asJson)
304
+ continue;
236
305
  const time = new Date(a.createdAt).toLocaleTimeString();
237
306
  console.log(`${chalk.dim(`[${time}]`)} ${a.message}`);
238
307
  }
@@ -242,141 +311,170 @@ export async function pollRunProgress(id, before, opts = {}) {
242
311
  }
243
312
  if (last.status !== null) {
244
313
  const outcome = last.statusDetail ?? (last.status ? 'success' : 'failure');
245
- console.log(chalk.dim(`Run finished: ${outcome}`));
246
- // Pinch celebrates a clean run finish (#94) mirrors doctor.ts's own
247
- // `status === true` success definition (regardless of statusDetail),
248
- // never for a failed/regression run. Neither `run --watch` nor
249
- // `trigger --watch` (the only two callers) has a --json flag, so this
250
- // is always safe to print. Guarded by pinchEnabled() (NO_COLOR/non-TTY).
251
- if (last.status === true && pinchEnabled()) {
252
- console.log(renderPinch('celebrating'));
314
+ // #107 review F1 — the exit code, in BOTH modes: a regression row's
315
+ // WRITE actually succeeded (see lastStatus()'s own #91 comment above,
316
+ // and `scraps data`'s isRegression branch) and an 'empty' row is a
317
+ // genuine zero-item success neither is a real failure. Only
318
+ // status:false with neither of those details (e.g. 'error', or no
319
+ // detail at all) is a genuine failed run.
320
+ const isGenuineFailure = last.status === false && outcome !== 'empty' && outcome !== 'regression';
321
+ if (asJson) {
322
+ const payload = { runId: last._id, status: outcome };
323
+ console.log(JSON.stringify(payload));
324
+ }
325
+ else {
326
+ console.log(chalk.dim(`Run finished: ${outcome}`));
327
+ // Pinch celebrates a clean run finish (#94) — mirrors doctor.ts's own
328
+ // `status === true` success definition (regardless of statusDetail),
329
+ // never for a failed/regression run. Guarded by pinchEnabled()
330
+ // (NO_COLOR/non-TTY) and never under --json (stdout must stay pure).
331
+ if (last.status === true && pinchEnabled()) {
332
+ console.log(renderPinch('celebrating'));
333
+ }
253
334
  }
335
+ process.exitCode = isGenuineFailure ? 1 : 0;
254
336
  return;
255
337
  }
256
338
  }
257
- console.log(chalk.yellow(`⚠ Timed out waiting for the run to finish check status with: trawl scraps doctor ${id}`));
258
- }
259
- // list
260
- scraps
261
- .command('list')
262
- .alias('ls')
263
- .description('List all scraps')
264
- .option('--json', 'Output as JSON')
265
- .option('--status <status>', 'Filter by last run status (success|failure|never|running|regression)')
266
- // #88 item 8 — no custom parser here (unlike the old `(v) => parseInt(v,
267
- // 10)`): a bad value like "abc" used to silently become NaN, which then
268
- // sailed straight through `Number.isInteger`-less checks and into
269
- // `.slice(0, NaN)` (silently truncates to 0 rows) or `?page=NaN` (silently
270
- // sent to the server) — never a usage error. Keeping the raw string here
271
- // lets the validation below mirror `history`'s own --limit check exactly
272
- // (~line 660) and report the actual bad input in the error message.
273
- .option('--limit <n>', 'Show only the first N results')
274
- .option('--page <n>', 'Fetch a specific page only (50 per page, no auto-pagination)')
275
- .action(async (opts, cmd) => {
276
- // Guard: --limit and --page are mutually exclusive
277
- if (opts.limit !== undefined && opts.page !== undefined) {
278
- usageError('--limit and --page are mutually exclusive. Use one or the other.', { json: opts.json });
279
- return;
339
+ // #107 review F1 timeout: honest non-zero exit in both modes, plus the
340
+ // machine-readable final line under --json (never silently exit 0 after a
341
+ // watch that never actually confirmed what happened).
342
+ if (asJson) {
343
+ const outcome = { runId: beforeId, status: 'timeout' };
344
+ console.log(JSON.stringify(outcome));
280
345
  }
281
- let limit;
282
- if (opts.limit !== undefined) {
283
- limit = Number(opts.limit);
284
- if (!Number.isInteger(limit) || limit <= 0) {
285
- usageError(`Invalid --limit "${opts.limit}" (expected a positive integer)`, { json: opts.json });
286
- return;
287
- }
346
+ else {
347
+ console.log(chalk.yellow(`⚠ Timed out waiting for the run to finish — check status with: trawl scraps doctor ${id}`));
288
348
  }
289
- let page;
290
- if (opts.page !== undefined) {
291
- page = Number(opts.page);
292
- if (!Number.isInteger(page) || page <= 0) {
293
- usageError(`Invalid --page "${opts.page}" (expected a positive integer)`, { json: opts.json });
349
+ process.exitCode = 1;
350
+ }
351
+ // list — promoted to a top-level verb (#108, see the AttachOptions comment above)
352
+ export function attachListCommand(parent, attachOpts = {}) {
353
+ return parent
354
+ .command('list', attachOpts)
355
+ .alias('ls')
356
+ .description('List all scraps')
357
+ .option('--json', 'Output as JSON')
358
+ .option('--status <status>', 'Filter by last run status (success|failure|never|running|regression)')
359
+ // #88 item 8 — no custom parser here (unlike the old `(v) => parseInt(v,
360
+ // 10)`): a bad value like "abc" used to silently become NaN, which then
361
+ // sailed straight through `Number.isInteger`-less checks and into
362
+ // `.slice(0, NaN)` (silently truncates to 0 rows) or `?page=NaN` (silently
363
+ // sent to the server) — never a usage error. Keeping the raw string here
364
+ // lets the validation below mirror `history`'s own --limit check exactly
365
+ // (~line 660) and report the actual bad input in the error message.
366
+ .option('--limit <n>', 'Show only the first N results')
367
+ .option('--page <n>', 'Fetch a specific page only (50 per page, no auto-pagination)')
368
+ .action(async (opts, cmd) => {
369
+ // Guard: --limit and --page are mutually exclusive
370
+ if (opts.limit !== undefined && opts.page !== undefined) {
371
+ usageError('--limit and --page are mutually exclusive. Use one or the other.', { json: opts.json });
294
372
  return;
295
373
  }
296
- }
297
- let data;
298
- try {
299
- data = await oraPromise(async () => {
300
- if (page !== undefined) {
301
- // Single-page mode: explicit page requested, no loop
302
- return api.get(`/api/scraps?perPage=50&page=${page}`);
374
+ let limit;
375
+ if (opts.limit !== undefined) {
376
+ limit = Number(opts.limit);
377
+ if (!Number.isInteger(limit) || limit <= 0) {
378
+ usageError(`Invalid --limit "${opts.limit}" (expected a positive integer)`, { json: opts.json });
379
+ return;
303
380
  }
304
- // Fetch-all mode: paginate until a page returns < 200 items
305
- const perPage = 200;
306
- let result = [];
307
- let pageNum = 1;
308
- while (true) {
309
- const batch = await api.get(`/api/scraps?perPage=${perPage}&page=${pageNum}`);
310
- result = result.concat(batch);
311
- if (batch.length < perPage)
312
- break;
313
- pageNum++;
381
+ }
382
+ let page;
383
+ if (opts.page !== undefined) {
384
+ page = Number(opts.page);
385
+ if (!Number.isInteger(page) || page <= 0) {
386
+ usageError(`Invalid --page "${opts.page}" (expected a positive integer)`, { json: opts.json });
387
+ return;
314
388
  }
315
- return result;
316
- }, 'Fetching scraps…');
317
- }
318
- catch (err) {
319
- // Spinner already failed by oraPromise — report with the scrap-specific
320
- // prefix kept, but route through the shared classifier so exit code +
321
- // --json envelope stay consistent with every other command. (#71)
322
- //
323
- // This bespoke catch (kept for the "Failed to fetch scraps:" prefix,
324
- // which the shared reportError() can't add) used to silently swallow
325
- // --debug: unlike the central index.ts catch, it never printed the raw
326
- // stack trace. optsWithGlobals() reads --debug off the ROOT command
327
- // (this leaf has no --debug of its own) so it can honor the flag
328
- // locally instead. (#86 finding 9)
329
- const isDebug = Boolean(cmd.optsWithGlobals().debug || process.env['DEBUG']);
330
- const { exitCode, envelope } = classifyError(err);
331
- const message = `Failed to fetch scraps: ${envelope.message}`;
332
- if (isDebug)
333
- console.error(err);
334
- if (opts.json) {
335
- console.log(JSON.stringify({ error: { ...envelope, message } }));
336
389
  }
337
- else if (!isDebug) {
338
- console.error(chalk.red(`✗ ${message}`));
390
+ let data;
391
+ try {
392
+ data = await oraPromise(async () => {
393
+ if (page !== undefined) {
394
+ // Single-page mode: explicit page requested, no loop
395
+ return api.get(`/api/scraps?perPage=50&page=${page}`);
396
+ }
397
+ // Fetch-all mode: paginate until a page returns < 200 items
398
+ const perPage = 200;
399
+ let result = [];
400
+ let pageNum = 1;
401
+ while (true) {
402
+ const batch = await api.get(`/api/scraps?perPage=${perPage}&page=${pageNum}`);
403
+ result = result.concat(batch);
404
+ if (batch.length < perPage)
405
+ break;
406
+ pageNum++;
407
+ }
408
+ return result;
409
+ }, 'Fetching scraps…');
339
410
  }
340
- process.exitCode = exitCode;
341
- return;
342
- }
343
- if (opts.status)
344
- data = data.filter((s) => lastStatus(s) === opts.status);
345
- const totalMatched = data.length;
346
- const rows = limit !== undefined ? data.slice(0, limit) : data;
347
- if (opts.json)
348
- return json(rows);
349
- const tableRows = rows.map((s) => ({
350
- id: s._id,
351
- title: s.title || '(untitled)',
352
- cron: s.cron || '—',
353
- status: statusIcon(lastStatus(s)),
354
- 'last run': lastRun(s),
355
- updated: new Date(s.updatedAt).toLocaleDateString(),
356
- }));
357
- table(tableRows, ['id', 'title', 'cron', 'status', 'last run', 'updated']);
358
- // Print footer when --limit truncates
359
- if (limit !== undefined && rows.length < totalMatched) {
360
- console.log(chalk.dim(`Showing ${rows.length} of ${totalMatched} — omit --limit to see all`));
361
- }
362
- });
363
- // get
364
- scraps
365
- .command('get <id>')
366
- .description('Get scrap details')
367
- .option('--json', 'Output as JSON')
368
- .action(async (id, opts) => {
369
- validateObjectId(id);
370
- const data = await api.get(`/api/scraps/${id}`);
371
- if (opts.json)
372
- return json(data);
373
- console.log(chalk.bold(data.title || '(untitled)'));
374
- console.log(chalk.dim(` ID: `) + data._id);
375
- console.log(chalk.dim(` Cron: `) + (data.cron || '—'));
376
- console.log(chalk.dim(` Status: `) + statusIcon(lastStatus(data)));
377
- console.log(chalk.dim(` Last run: `) + lastRun(data));
378
- console.log(chalk.dim(` Updated: `) + new Date(data.updatedAt).toLocaleString());
379
- });
411
+ catch (err) {
412
+ // Spinner already failed by oraPromise — report with the scrap-specific
413
+ // prefix kept, but route through the shared classifier so exit code +
414
+ // --json envelope stay consistent with every other command. (#71)
415
+ //
416
+ // This bespoke catch (kept for the "Failed to fetch scraps:" prefix,
417
+ // which the shared reportError() can't add) used to silently swallow
418
+ // --debug: unlike the central index.ts catch, it never printed the raw
419
+ // stack trace. optsWithGlobals() reads --debug off the ROOT command
420
+ // (this leaf has no --debug of its own) so it can honor the flag
421
+ // locally instead. (#86 finding 9)
422
+ const isDebug = Boolean(cmd.optsWithGlobals().debug || process.env['DEBUG']);
423
+ const { exitCode, envelope } = classifyError(err);
424
+ const message = `Failed to fetch scraps: ${envelope.message}`;
425
+ if (isDebug)
426
+ console.error(err);
427
+ if (opts.json) {
428
+ console.log(JSON.stringify({ error: { ...envelope, message } }));
429
+ }
430
+ else if (!isDebug) {
431
+ console.error(chalk.red(`✗ ${message}`));
432
+ }
433
+ process.exitCode = exitCode;
434
+ return;
435
+ }
436
+ if (opts.status)
437
+ data = data.filter((s) => lastStatus(s) === opts.status);
438
+ const totalMatched = data.length;
439
+ const rows = limit !== undefined ? data.slice(0, limit) : data;
440
+ if (opts.json)
441
+ return json(rows);
442
+ const tableRows = rows.map((s) => ({
443
+ id: s._id,
444
+ title: s.title || '(untitled)',
445
+ cron: s.cron || '—',
446
+ status: statusIcon(lastStatus(s)),
447
+ 'last run': lastRun(s),
448
+ updated: new Date(s.updatedAt).toLocaleDateString(),
449
+ }));
450
+ table(tableRows, ['id', 'title', 'cron', 'status', 'last run', 'updated']);
451
+ // Print footer when --limit truncates
452
+ if (limit !== undefined && rows.length < totalMatched) {
453
+ console.log(chalk.dim(`Showing ${rows.length} of ${totalMatched} — omit --limit to see all`));
454
+ }
455
+ });
456
+ }
457
+ attachListCommand(scraps, { hidden: true });
458
+ // get — promoted to a top-level verb (#108)
459
+ export function attachGetCommand(parent, attachOpts = {}) {
460
+ return parent
461
+ .command('get <id>', attachOpts)
462
+ .description('Get scrap details')
463
+ .option('--json', 'Output as JSON')
464
+ .action(async (id, opts) => {
465
+ validateObjectId(id);
466
+ const data = await api.get(`/api/scraps/${id}`);
467
+ if (opts.json)
468
+ return json(data);
469
+ console.log(chalk.bold(data.title || '(untitled)'));
470
+ console.log(chalk.dim(` ID: `) + data._id);
471
+ console.log(chalk.dim(` Cron: `) + (data.cron || '—'));
472
+ console.log(chalk.dim(` Status: `) + statusIcon(lastStatus(data)));
473
+ console.log(chalk.dim(` Last run: `) + lastRun(data));
474
+ console.log(chalk.dim(` Updated: `) + new Date(data.updatedAt).toLocaleString());
475
+ });
476
+ }
477
+ attachGetCommand(scraps, { hidden: true });
380
478
  const VALID_TIERS = ['tier0', 'tier1', 'tier2', 'tier3', 'tier4'];
381
479
  /**
382
480
  * #86 findings 4/5 — shared honest-tier renderer for `create --tier` and
@@ -420,7 +518,7 @@ function renderTierOverrideHuman(data) {
420
518
  function warnIfUnconfirmedTier(data, tierWasRequested, id) {
421
519
  if (data._tierOverride || !tierWasRequested)
422
520
  return;
423
- console.error(chalk.yellow(` ⚠ Server did not confirm the tier change (older server) — verify with: trawl scraps get ${id}`));
521
+ console.error(chalk.yellow(` ⚠ Server did not confirm the tier change (older server) — verify with: trawl get ${id}`));
424
522
  }
425
523
  /**
426
524
  * #88 item 3 — the --json machine-readable counterpart to
@@ -441,11 +539,17 @@ function withTierUnconfirmed(data, tierWasRequested) {
441
539
  }
442
540
  /** #86 finding 5 — the standard error envelope for a refused tier override,
443
541
  * routed through the same reportError() central formatting path used
444
- * everywhere else (exit 1: a business-logic refusal, not a usage error). */
542
+ * everywhere else (exit 1: a business-logic refusal, not a usage error).
543
+ *
544
+ * #107 review F3 — uses `RefusalError` (kind:"refused"), not a bare `Error`
545
+ * (which fell through classifyError's default `kind:"unknown"` bucket,
546
+ * indistinguishable from a generic crash even though the README sells `kind`
547
+ * as the machine discriminant an agent branches on).
548
+ */
445
549
  function reportTierRefusal(data, wantsJson) {
446
550
  const ov = data._tierOverride;
447
551
  const message = `Tier ceiling override refused: ${ov?.reason ?? 'unknown'} (requested ${ov?.requestedMaxTier ?? '—'}; kept the registry cap)`;
448
- return reportError(new Error(message), { json: wantsJson });
552
+ return reportError(new RefusalError(message), { json: wantsJson });
449
553
  }
450
554
  // create
451
555
  scraps
@@ -611,27 +715,41 @@ scraps
611
715
  if (refused)
612
716
  process.exitCode = 1;
613
717
  });
614
- // run
615
- scraps
616
- .command('run <id>')
617
- .description('Run a scrap')
618
- .option('-w, --watch', 'Show progress after launching (polls — see `trawl scraps trigger --watch`, #91)')
619
- .action(async (id, opts) => {
620
- validateObjectId(id);
621
- // #91 P1 / #93 item 1 — captured BEFORE launching so pollRunProgress can
622
- // tell "the run that's about to finish" apart from whatever the last run
623
- // happened to be (including a dedup onto an already-in-flight run).
624
- const beforeRun = opts.watch ? await captureBeforeRunState(id) : undefined;
625
- // #91 P0 GET /api/scraps/load/:id runs the scrap synchronously
626
- // server-side (30-250s); the 30s default was aborting it mid-flight.
627
- await oraPromise(() => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS }), {
628
- text: 'Launching scrap…',
629
- successText: 'Scrap launched',
718
+ // run — promoted to a top-level verb (#108)
719
+ export function attachRunCommand(parent, attachOpts = {}) {
720
+ return parent
721
+ .command('run <id>', attachOpts)
722
+ .description('Run a scrap')
723
+ .option('-w, --watch', 'Show progress after launching (polls — see `trawl trigger --watch`, #91)')
724
+ .option('--json', 'Output the raw launch payload as JSON')
725
+ .action(async (id, opts) => {
726
+ validateObjectId(id);
727
+ // #91 P1 / #93 item 1 captured BEFORE launching so pollRunProgress can
728
+ // tell "the run that's about to finish" apart from whatever the last run
729
+ // happened to be (including a dedup onto an already-in-flight run).
730
+ const beforeRun = opts.watch ? await captureBeforeRunState(id) : undefined;
731
+ // #91 P0 — GET /api/scraps/load/:id runs the scrap synchronously
732
+ // server-side (30-250s); the 30s default was aborting it mid-flight.
733
+ const call = () => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS });
734
+ // #107 — under --json the stdout path stays pure: no spinner channel at
735
+ // all, mirroring `trawl fetch`'s own --json handling.
736
+ const data = opts.json
737
+ ? await call()
738
+ : await oraPromise(call, { text: 'Launching scrap…', successText: 'Scrap launched' });
739
+ if (opts.json)
740
+ json(data);
741
+ if (opts.watch) {
742
+ // #107 — under --json, pollRunProgress suppresses its own intermediate
743
+ // console.log calls and instead emits exactly ONE final NDJSON outcome
744
+ // line (+ sets process.exitCode honestly) once the watch reaches a
745
+ // terminal status, a timeout, or a persistent poll error (review F1) —
746
+ // `run --json --watch` never again exits 0 after dead air regardless
747
+ // of what the watched run actually did.
748
+ await pollRunProgress(id, beforeRun, { json: opts.json });
749
+ }
630
750
  });
631
- if (opts.watch) {
632
- await pollRunProgress(id, beforeRun);
633
- }
634
- });
751
+ }
752
+ attachRunCommand(scraps, { hidden: true });
635
753
  // #70 — render an items array either as a table summary or --json. Shared by
636
754
  // both the default (persisted read) and --fresh (live execute) paths of `data`.
637
755
  function renderScrapItems(items, asJson) {
@@ -665,266 +783,287 @@ function reportDataState(message, exitCode, kind, wantsJson) {
665
783
  }
666
784
  process.exitCode = exitCode;
667
785
  }
668
- // data
669
- scraps
670
- .command('data <id>')
671
- .description('Get scrap data (last persisted run — read-only, no quota). Use --fresh to launch a new run instead.')
672
- .option('--json', 'Output as JSON')
673
- .option('--errors', 'Show failure diagnostics when the last run failed')
674
- .option('--fresh', 'Launch a fresh run instead of reading the last persisted payload (consumes execute quota, same as `scraps run`)')
675
- .action(async (id, opts) => {
676
- validateObjectId(id);
677
- // --errors: fetch full run + fix detail via fetchRunAndFix (DRY with doctor).
678
- // Read-only: GET /api/scraps/:id + GET /api/historys/:hid, no quota, no run lock.
679
- if (opts.errors) {
680
- const result = await fetchRunAndFix(id);
681
- if (!result) {
682
- // #88 item 7 — unified no-runs shape with `doctor --json`: a bare
683
- // `null` was indistinguishable from any other absent-payload state
684
- // (a scrap CAN legitimately have a null-ish result elsewhere); an
685
- // explicit `{status:"no_runs"}` object is unambiguous everywhere.
686
- if (opts.json) {
687
- json({ status: 'no_runs' });
786
+ // data — promoted to a top-level verb (#108)
787
+ export function attachDataCommand(parent, attachOpts = {}) {
788
+ return parent
789
+ .command('data <id>', attachOpts)
790
+ .description('Get scrap data (last persisted run — read-only, no quota). Use --fresh to launch a new run instead.')
791
+ .option('--json', 'Output as JSON')
792
+ .option('--errors', 'Show failure diagnostics when the last run failed')
793
+ .option('--fresh', 'Launch a fresh run instead of reading the last persisted payload (consumes execute quota, same as `run`)')
794
+ .action(async (id, opts) => {
795
+ validateObjectId(id);
796
+ // --errors: fetch full run + fix detail via fetchRunAndFix (DRY with doctor).
797
+ // Read-only: GET /api/scraps/:id + GET /api/historys/:hid, no quota, no run lock.
798
+ if (opts.errors) {
799
+ const result = await fetchRunAndFix(id);
800
+ if (!result) {
801
+ // #88 item 7 unified no-runs shape with `doctor --json`: a bare
802
+ // `null` was indistinguishable from any other absent-payload state
803
+ // (a scrap CAN legitimately have a null-ish result elsewhere); an
804
+ // explicit `{status:"no_runs"}` object is unambiguous everywhere.
805
+ if (opts.json) {
806
+ json({ status: 'no_runs' });
807
+ return;
808
+ }
809
+ console.log(chalk.dim('No runs yet.'));
688
810
  return;
689
811
  }
690
- console.log(chalk.dim('No runs yet.'));
812
+ // --json is honored for BOTH outcomes (success or failure) — an agent
813
+ // parsing `data --errors --json` must always get the flat run object,
814
+ // never prose gated behind a status check. (#71 finding 13)
815
+ if (opts.json)
816
+ return json(pickRun(result.run));
817
+ if (result.run.status === true) {
818
+ console.log(chalk.green('✓ Last run succeeded. No errors to show.'));
819
+ return;
820
+ }
821
+ console.log(formatDoctor(result.scrap.title, result.run, result.fix, id));
691
822
  return;
692
823
  }
693
- // --json is honored for BOTH outcomes (success or failure) an agent
694
- // parsing `data --errors --json` must always get the flat run object,
695
- // never prose gated behind a status check. (#71 finding 13)
696
- if (opts.json)
697
- return json(pickRun(result.run));
698
- if (result.run.status === true) {
699
- console.log(chalk.green('✓ Last run succeeded. No errors to show.'));
824
+ // #70 — --fresh is the explicit opt-in for a LIVE run. This is what the
825
+ // default path used to do silently: GET /api/scraps/load/:id executes a
826
+ // fresh scrap run server-side (requireQuota('scraps','execute') + a
827
+ // distributed run lock — trawl_node modules/scraps/routes/scraps.routes.js),
828
+ // burning execute quota and 429ing if a run is already in flight. A user
829
+ // or agent "just reading data" must never trigger that by accident.
830
+ if (opts.fresh) {
831
+ // #91 P0 — same long-run endpoint as `run` (30-250s server-side);
832
+ // the 30s default was aborting it mid-flight.
833
+ const loaded = await oraPromise(() => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS }), {
834
+ text: 'Launching a fresh scrap run (consumes execute quota)…',
835
+ successText: 'Fresh run complete',
836
+ });
837
+ const items = loaded?.result?.data;
838
+ if (!Array.isArray(items)) {
839
+ // --json always returns an array from `data` — [] is the honest
840
+ // "no items" signal instead of prose breaking JSON parsing. (#71)
841
+ if (opts.json) {
842
+ json([]);
843
+ return;
844
+ }
845
+ console.log(chalk.dim('No data yet. Run the scrap first.'));
846
+ return;
847
+ }
848
+ // #93 item 2 — --fresh used to render items without ever checking for a
849
+ // regression, unlike the persisted `data` path below (#88 item 2). The
850
+ // load() response's OWN embedded `scrap.history[0]` can't be trusted for
851
+ // this (see the ScrapLoadResult comment above): it may be the previous
852
+ // run's row, and even the right row never carries the regression flip.
853
+ // `--fresh` runs synchronously to completion server-side though — by the
854
+ // time this call returns, node has already awaited the regression patch
855
+ // — so a fresh GET /api/scraps/:id (the SAME read the persisted path
856
+ // below already trusts) reliably observes the finalized DB state.
857
+ // Best-effort: never fail --fresh's real output over this side check,
858
+ // and never gate on it — the items returned ARE this run's real,
859
+ // synchronously-computed data regardless of what this check finds.
860
+ try {
861
+ const fresh = await api.get(`/api/scraps/${id}`);
862
+ if (fresh.history?.[0]?.statusDetail === 'regression') {
863
+ console.error(chalk.yellow(`⚠ Item count regressed vs baseline for the last run of ${id} — see: trawl scraps doctor ${id}`));
864
+ }
865
+ }
866
+ catch {
867
+ // best-effort — the fresh run's items are still valid without this check
868
+ }
869
+ renderScrapItems(items, opts.json);
700
870
  return;
701
871
  }
702
- console.log(formatDoctor(result.scrap.title, result.run, result.fix, id));
703
- return;
704
- }
705
- // #70 — --fresh is the explicit opt-in for a LIVE run. This is what the
706
- // default path used to do silently: GET /api/scraps/load/:id executes a
707
- // fresh scrap run server-side (requireQuota('scraps','execute') + a
708
- // distributed run lock trawl_node modules/scraps/routes/scraps.routes.js),
709
- // burning execute quota and 429ing if a run is already in flight. A user
710
- // or agent "just reading data" must never trigger that by accident.
711
- if (opts.fresh) {
712
- // #91 P0 same long-run endpoint as `scraps run` (30-250s server-side);
713
- // the 30s default was aborting it mid-flight.
714
- const loaded = await oraPromise(() => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS }), {
715
- text: 'Launching a fresh scrap run (consumes execute quota)…',
716
- successText: 'Fresh run complete',
717
- });
718
- const items = loaded?.result?.data;
872
+ // Default: read the last PERSISTED run payload — no quota, no run lock.
873
+ // History.data (trawl_node modules/historys/services/historys.service.js
874
+ // `update()`) is a JSON-stringified clone of the worker result, so it
875
+ // carries the same `.data` items array the live load endpoint returns
876
+ // it's just pulled from the most recent history row instead of a fresh
877
+ // run. Retention keeps this only for the newest row per (scrap, status)
878
+ // bucket (config.trawl.keepData, default 1); older rows null it out.
879
+ //
880
+ // #86 finding 6 [] is reserved for a GENUINE zero-item successful run.
881
+ // Every other outcome below is an honest error envelope instead: never
882
+ // run (not_found/4), last run failed (1), or the payload aged out of
883
+ // retention (not_found/4) all used to collapse into the same silent [].
884
+ const scrap = await api.get(`/api/scraps/${id}`);
885
+ const last = scrap.history?.[0];
886
+ if (!last?._id) {
887
+ reportDataState(`Scrap ${id} has never run. Run it first (trawl run ${id}) or pass --fresh to launch one now.`, 4, 'not_found', opts.json);
888
+ return;
889
+ }
890
+ // #88 item 1 — status:null is an IN-FLIGHT run (node persists
891
+ // {status:null, statusDetail:null, inFlight:true} the moment a run
892
+ // starts, and only flips status/statusDetail once it finishes). That is
893
+ // neither "never run" nor "the last run failed" — a caller reading data
894
+ // mid-run needs an honest "wait" signal. Never suggest --fresh here: a
895
+ // run already holds the server-side distributed lock, so --fresh would
896
+ // just 429 against it.
897
+ if (last.status === null) {
898
+ reportDataState(`Run in progress for ${id} — retry shortly.`, 1, 'in_progress', opts.json);
899
+ return;
900
+ }
901
+ // #86 review — node persists status=false for a GENUINE zero-item run
902
+ // too (historys schema: status boolean|null + statusDetail
903
+ // success/error/empty/regression; a zero-item run is status=false +
904
+ // statusDetail='empty', and the embedded history rows from GET
905
+ // /api/scraps/:id include statusDetail via the repository populate
906
+ // select). An 'empty' run is the one case [] is FOR — only a real
907
+ // failure (error/unknown detail) gets the run_failed envelope.
908
+ //
909
+ // #88 item 2 — statusDetail='regression' is ALSO status=false (an async
910
+ // patch flips it after item count dropped vs baseline), but the row's
911
+ // `data` still holds REAL, non-empty items — the write that persisted
912
+ // them succeeded before the regression was even detected. Treating it as
913
+ // run_failed would hide genuine data behind a false negative.
914
+ const isEmptyRun = last.status === false && last.statusDetail === 'empty';
915
+ const isRegression = last.status === false && last.statusDetail === 'regression';
916
+ if (last.status === false && !isEmptyRun && !isRegression) {
917
+ reportDataState(`Last run failed — see: trawl data ${id} --errors`, 1, 'run_failed', opts.json);
918
+ return;
919
+ }
920
+ const detail = await api.get(`/api/historys/${last._id}`);
921
+ let items;
922
+ if (typeof detail?.data === 'string' && detail.data) {
923
+ try {
924
+ items = JSON.parse(detail.data)?.data;
925
+ }
926
+ catch {
927
+ items = undefined;
928
+ }
929
+ }
719
930
  if (!Array.isArray(items)) {
720
- // --json always returns an array from `data` — [] is the honest
721
- // "no items" signal instead of prose breaking JSON parsing. (#71)
722
- if (opts.json) {
723
- json([]);
931
+ if (isEmptyRun) {
932
+ // A genuine zero-item run whose payload is '[]' or absent — both are
933
+ // the SAME honest answer: no items, exit 0. Never the retention
934
+ // message (nothing aged out; there was nothing to persist).
935
+ renderScrapItems([], opts.json);
724
936
  return;
725
937
  }
726
- console.log(chalk.dim('No data yet. Run the scrap first.'));
938
+ // #88 item 2 a regression row whose payload aged out of retention has
939
+ // nothing left to show either; fall through to the SAME honest
940
+ // aged-out envelope a normal successful row would get (never fabricate
941
+ // items, never silently succeed).
942
+ reportDataState(`No persisted data for the last run of ${id} — it aged out of retention. Pass --fresh to launch a new run.`, 4, 'not_found', opts.json);
727
943
  return;
728
944
  }
729
- // #93 item 2 — --fresh used to render items without ever checking for a
730
- // regression, unlike the persisted `data` path below (#88 item 2). The
731
- // load() response's OWN embedded `scrap.history[0]` can't be trusted for
732
- // this (see the ScrapLoadResult comment above): it may be the previous
733
- // run's row, and even the right row never carries the regression flip.
734
- // `--fresh` runs synchronously to completion server-side thoughby the
735
- // time this call returns, node has already awaited the regression patch
736
- // — so a fresh GET /api/scraps/:id (the SAME read the persisted path
737
- // below already trusts) reliably observes the finalized DB state.
738
- // Best-effort: never fail --fresh's real output over this side check,
739
- // and never gate on it — the items returned ARE this run's real,
740
- // synchronously-computed data regardless of what this check finds.
741
- try {
742
- const fresh = await api.get(`/api/scraps/${id}`);
743
- if (fresh.history?.[0]?.statusDetail === 'regression') {
744
- console.error(chalk.yellow(`⚠ Item count regressed vs baseline for the last run of ${id} — see: trawl scraps doctor ${id}`));
745
- }
746
- }
747
- catch {
748
- // best-effort — the fresh run's items are still valid without this check
945
+ // #88 item 2 — a regression row's items are REAL (the write succeeded
946
+ // before the async patch flagged the drop) return them on stdout
947
+ // (exit 0, both modes) with an honest stderr warning pointing at the
948
+ // diagnostic command, instead of hiding genuine data behind run_failed.
949
+ if (isRegression) {
950
+ console.error(chalk.yellow(`⚠ Item count regressed vs baseline for the last run of ${id} see: trawl scraps doctor ${id}`));
749
951
  }
750
952
  renderScrapItems(items, opts.json);
751
- return;
752
- }
753
- // Default: read the last PERSISTED run payload — no quota, no run lock.
754
- // History.data (trawl_node modules/historys/services/historys.service.js
755
- // `update()`) is a JSON-stringified clone of the worker result, so it
756
- // carries the same `.data` items array the live load endpoint returns —
757
- // it's just pulled from the most recent history row instead of a fresh
758
- // run. Retention keeps this only for the newest row per (scrap, status)
759
- // bucket (config.trawl.keepData, default 1); older rows null it out.
760
- //
761
- // #86 finding 6 — [] is reserved for a GENUINE zero-item successful run.
762
- // Every other outcome below is an honest error envelope instead: never
763
- // run (not_found/4), last run failed (1), or the payload aged out of
764
- // retention (not_found/4) all used to collapse into the same silent [].
765
- const scrap = await api.get(`/api/scraps/${id}`);
766
- const last = scrap.history?.[0];
767
- if (!last?._id) {
768
- reportDataState(`Scrap ${id} has never run. Run it first (trawl scraps run ${id}) or pass --fresh to launch one now.`, 4, 'not_found', opts.json);
769
- return;
770
- }
771
- // #88 item 1 — status:null is an IN-FLIGHT run (node persists
772
- // {status:null, statusDetail:null, inFlight:true} the moment a run
773
- // starts, and only flips status/statusDetail once it finishes). That is
774
- // neither "never run" nor "the last run failed" — a caller reading data
775
- // mid-run needs an honest "wait" signal. Never suggest --fresh here: a
776
- // run already holds the server-side distributed lock, so --fresh would
777
- // just 429 against it.
778
- if (last.status === null) {
779
- reportDataState(`Run in progress for ${id} — retry shortly.`, 1, 'in_progress', opts.json);
780
- return;
781
- }
782
- // #86 review — node persists status=false for a GENUINE zero-item run
783
- // too (historys schema: status boolean|null + statusDetail
784
- // success/error/empty/regression; a zero-item run is status=false +
785
- // statusDetail='empty', and the embedded history rows from GET
786
- // /api/scraps/:id include statusDetail via the repository populate
787
- // select). An 'empty' run is the one case [] is FOR — only a real
788
- // failure (error/unknown detail) gets the run_failed envelope.
789
- //
790
- // #88 item 2 — statusDetail='regression' is ALSO status=false (an async
791
- // patch flips it after item count dropped vs baseline), but the row's
792
- // `data` still holds REAL, non-empty items — the write that persisted
793
- // them succeeded before the regression was even detected. Treating it as
794
- // run_failed would hide genuine data behind a false negative.
795
- const isEmptyRun = last.status === false && last.statusDetail === 'empty';
796
- const isRegression = last.status === false && last.statusDetail === 'regression';
797
- if (last.status === false && !isEmptyRun && !isRegression) {
798
- reportDataState(`Last run failed — see: trawl scraps data ${id} --errors`, 1, 'run_failed', opts.json);
799
- return;
800
- }
801
- const detail = await api.get(`/api/historys/${last._id}`);
802
- let items;
803
- if (typeof detail?.data === 'string' && detail.data) {
804
- try {
805
- items = JSON.parse(detail.data)?.data;
953
+ });
954
+ }
955
+ attachDataCommand(scraps, { hidden: true });
956
+ // history — list past runs for a scrap — promoted to a top-level verb (#108)
957
+ export function attachHistoryCommand(parent, attachOpts = {}) {
958
+ return parent
959
+ .command('history <id>', attachOpts)
960
+ .description('List past runs for a scrap (newest first)')
961
+ .option('--json', 'Output as JSON')
962
+ .option('-n, --limit <n>', 'Max runs to show', '20')
963
+ .action(async (id, opts) => {
964
+ validateObjectId(id);
965
+ const limit = Number(opts.limit);
966
+ if (!Number.isInteger(limit) || limit < 1) {
967
+ usageError(`Invalid --limit "${opts.limit}" (expected a positive integer)`, { json: opts.json });
968
+ return;
806
969
  }
807
- catch {
808
- items = undefined;
970
+ const scrap = await api.get(`/api/scraps/${id}`);
971
+ const runs = (scrap.history ?? []).slice(0, limit).map((h) => ({
972
+ hid: h._id,
973
+ status: h.statusDetail ?? null,
974
+ time: h.time ?? null,
975
+ tier: h.proxyTier ?? null,
976
+ failureKind: h.failureKind ?? null,
977
+ blockType: h.blockType ?? null,
978
+ createdAt: h.createdAt ?? null,
979
+ }));
980
+ if (opts.json) {
981
+ json(runs);
982
+ return;
809
983
  }
810
- }
811
- if (!Array.isArray(items)) {
812
- if (isEmptyRun) {
813
- // A genuine zero-item run whose payload is '[]' or absent — both are
814
- // the SAME honest answer: no items, exit 0. Never the retention
815
- // message (nothing aged out; there was nothing to persist).
816
- renderScrapItems([], opts.json);
984
+ table(runs, ['hid', 'status', 'time', 'tier', 'failureKind', 'blockType', 'createdAt']);
985
+ });
986
+ }
987
+ attachHistoryCommand(scraps, { hidden: true });
988
+ // run-info single-run detail by history id promoted to a top-level verb (#108)
989
+ export function attachRunInfoCommand(parent, attachOpts = {}) {
990
+ return parent
991
+ .command('run-info <hid>', attachOpts)
992
+ .description('Show details of a single run (status, tier, failureKind, error)')
993
+ .option('--json', 'Output as JSON')
994
+ .action(async (hid, opts) => {
995
+ validateObjectId(hid);
996
+ const h = await api.get(`/api/historys/${hid}`);
997
+ const info = {
998
+ hid,
999
+ status: h.statusDetail ?? null,
1000
+ time: h.time ?? null,
1001
+ tier: h.proxyTier ?? null,
1002
+ failureKind: h.failureKind ?? null,
1003
+ blockType: h.blockType ?? null,
1004
+ errorMessage: h.errorSnapshot?.errorMessage ?? null,
1005
+ selector: h.errorSnapshot?.selector ?? null,
1006
+ emptyContext: h.errorSnapshot?.emptyContext ?? null,
1007
+ createdAt: h.createdAt ?? null,
1008
+ };
1009
+ if (opts.json) {
1010
+ json(info);
817
1011
  return;
818
1012
  }
819
- // #88 item 2 a regression row whose payload aged out of retention has
820
- // nothing left to show either; fall through to the SAME honest
821
- // aged-out envelope a normal successful row would get (never fabricate
822
- // items, never silently succeed).
823
- reportDataState(`No persisted data for the last run of ${id} — it aged out of retention. Pass --fresh to launch a new run.`, 4, 'not_found', opts.json);
824
- return;
825
- }
826
- // #88 item 2 — a regression row's items are REAL (the write succeeded
827
- // before the async patch flagged the drop) — return them on stdout
828
- // (exit 0, both modes) with an honest stderr warning pointing at the
829
- // diagnostic command, instead of hiding genuine data behind run_failed.
830
- if (isRegression) {
831
- console.error(chalk.yellow(`⚠ Item count regressed vs baseline for the last run of ${id} — see: trawl scraps doctor ${id}`));
832
- }
833
- renderScrapItems(items, opts.json);
834
- });
835
- // history — list past runs for a scrap
1013
+ table([info], ['hid', 'status', 'time', 'tier', 'failureKind', 'blockType', 'errorMessage', 'selector', 'emptyContext', 'createdAt']);
1014
+ });
1015
+ }
1016
+ attachRunInfoCommand(scraps, { hidden: true });
1017
+ // delete
836
1018
  scraps
837
- .command('history <id>')
838
- .description('List past runs for a scrap (newest first)')
1019
+ .command('delete <id>')
1020
+ .alias('rm')
1021
+ .description('Delete a scrap')
1022
+ .option('-f, --force', 'Skip confirmation prompt')
839
1023
  .option('--json', 'Output as JSON')
840
- .option('-n, --limit <n>', 'Max runs to show', '20')
841
1024
  .action(async (id, opts) => {
842
1025
  validateObjectId(id);
843
- const limit = Number(opts.limit);
844
- if (!Number.isInteger(limit) || limit < 1) {
845
- usageError(`Invalid --limit "${opts.limit}" (expected a positive integer)`, { json: opts.json });
1026
+ // #107 never blocks on a y/N under --json or a non-TTY invocation
1027
+ // (agent/CI subprocess); -f/--force always pre-confirms.
1028
+ //
1029
+ // #107 review F2 — `message` (the plain-id form) feeds the refusal
1030
+ // UsageError, which can land verbatim in the `--json` error envelope;
1031
+ // `chalk.bold(id)` is passed ONLY as `promptMessage`, shown solely on
1032
+ // the interactive TTY `[y/N]` prompt. Before this split, the styled
1033
+ // string was the ONLY message confirmDestructive had, so a non-TTY/
1034
+ // --json refusal on `scraps delete X --json` emitted raw ANSI escape
1035
+ // bytes inside the JSON string.
1036
+ const { proceed, blocked } = await confirmDestructive(`Delete scrap ${id}?`, {
1037
+ force: opts.force,
1038
+ json: opts.json,
1039
+ promptMessage: `Delete scrap ${chalk.bold(id)}?`,
1040
+ });
1041
+ if (blocked)
846
1042
  return;
847
- }
848
- const scrap = await api.get(`/api/scraps/${id}`);
849
- const runs = (scrap.history ?? []).slice(0, limit).map((h) => ({
850
- hid: h._id,
851
- status: h.statusDetail ?? null,
852
- time: h.time ?? null,
853
- tier: h.proxyTier ?? null,
854
- failureKind: h.failureKind ?? null,
855
- blockType: h.blockType ?? null,
856
- createdAt: h.createdAt ?? null,
857
- }));
858
- if (opts.json) {
859
- json(runs);
1043
+ if (!proceed) {
1044
+ console.log(chalk.dim('Aborted.'));
860
1045
  return;
861
1046
  }
862
- table(runs, ['hid', 'status', 'time', 'tier', 'failureKind', 'blockType', 'createdAt']);
863
- });
864
- // run-info — single-run detail by history id
865
- scraps
866
- .command('run-info <hid>')
867
- .description('Show details of a single run (status, tier, failureKind, error)')
868
- .option('--json', 'Output as JSON')
869
- .action(async (hid, opts) => {
870
- validateObjectId(hid);
871
- const h = await api.get(`/api/historys/${hid}`);
872
- const info = {
873
- hid,
874
- status: h.statusDetail ?? null,
875
- time: h.time ?? null,
876
- tier: h.proxyTier ?? null,
877
- failureKind: h.failureKind ?? null,
878
- blockType: h.blockType ?? null,
879
- errorMessage: h.errorSnapshot?.errorMessage ?? null,
880
- selector: h.errorSnapshot?.selector ?? null,
881
- emptyContext: h.errorSnapshot?.emptyContext ?? null,
882
- createdAt: h.createdAt ?? null,
883
- };
1047
+ const call = () => api.delete(`/api/scraps/${id}`);
884
1048
  if (opts.json) {
885
- json(info);
1049
+ await call();
1050
+ json({ deleted: true, id });
886
1051
  return;
887
1052
  }
888
- table([info], ['hid', 'status', 'time', 'tier', 'failureKind', 'blockType', 'errorMessage', 'selector', 'emptyContext', 'createdAt']);
889
- });
890
- // delete
891
- scraps
892
- .command('delete <id>')
893
- .alias('rm')
894
- .description('Delete a scrap')
895
- .option('-f, --force', 'Skip confirmation prompt')
896
- .action(async (id, opts) => {
897
- validateObjectId(id);
898
- if (!opts.force) {
899
- const { createInterface } = await import('readline');
900
- const rl = createInterface({ input: process.stdin, output: process.stdout });
901
- let answer;
902
- try {
903
- answer = await new Promise((resolve) => {
904
- rl.question(`Delete scrap ${chalk.bold(id)}? ${chalk.dim('[y/N]')} `, (a) => resolve(a.trim().toLowerCase()));
905
- });
906
- }
907
- finally {
908
- rl.close();
909
- }
910
- if (answer !== 'y' && answer !== 'yes') {
911
- console.log(chalk.dim('Aborted.'));
912
- return;
913
- }
914
- }
915
- await oraPromise(() => api.delete(`/api/scraps/${id}`), { text: 'Deleting…', successText: 'Scrap deleted' });
1053
+ await oraPromise(call, { text: 'Deleting…', successText: 'Scrap deleted' });
916
1054
  });
917
1055
  // banner
918
1056
  scraps
919
1057
  .command('banner <id>')
920
1058
  .description('Upload a banner image for a scrap')
921
1059
  .requiredOption('-f, --file <path>', 'Path to image file (jpg, png, webp)')
1060
+ .option('--json', 'Output as JSON')
922
1061
  .action(async (id, opts) => {
923
1062
  validateObjectId(id);
924
1063
  const { readFileSync, existsSync } = await import('fs');
925
1064
  const { basename } = await import('path');
926
1065
  if (!existsSync(opts.file)) {
927
- usageError(`File not found: ${opts.file}`);
1066
+ usageError(`File not found: ${opts.file}`, { json: opts.json });
928
1067
  return;
929
1068
  }
930
1069
  const filename = basename(opts.file);
@@ -940,14 +1079,20 @@ scraps
940
1079
  // Content-Type — a lie about the actual file's format). Refuse instead.
941
1080
  const mimeType = mimeMap[ext];
942
1081
  if (!mimeType) {
943
- usageError(`Unsupported image type "${ext ? `.${ext}` : filename}" — use png, jpg, or webp.`);
1082
+ usageError(`Unsupported image type "${ext ? `.${ext}` : filename}" — use png, jpg, or webp.`, { json: opts.json });
944
1083
  return;
945
1084
  }
946
1085
  const fileBuffer = readFileSync(opts.file);
947
1086
  const blob = new Blob([fileBuffer], { type: mimeType });
948
1087
  const formData = new FormData();
949
1088
  formData.append('banner', blob, filename);
950
- await oraPromise(() => api.upload(`/api/scraps/${id}/banner`, formData), {
1089
+ const call = () => api.upload(`/api/scraps/${id}/banner`, formData);
1090
+ if (opts.json) {
1091
+ const data = await call();
1092
+ json(data);
1093
+ return;
1094
+ }
1095
+ await oraPromise(call, {
951
1096
  text: 'Uploading banner…',
952
1097
  successText: `Banner uploaded for scrap ${chalk.bold(id)}`,
953
1098
  });
@@ -956,40 +1101,54 @@ scraps
956
1101
  scraps
957
1102
  .command('watch <id>')
958
1103
  .description('Stream scrap activities in real-time')
959
- .action(async (id) => {
960
- validateObjectId(id);
961
- await watchActivities(id);
962
- });
963
- // trigger
964
- scraps
965
- .command('trigger <id>')
966
- .description('Launch a scrap as a background worker (returns immediately)')
967
- .option('-w, --watch', 'Poll for progress after triggering (#91 — the default async run happens in a separate cron pod; activities SSE never reaches it)')
968
- .option('--wait', 'Run synchronously and wait for the result (legacy behaviour)')
1104
+ .option('--json', 'Output each activity as a JSON line (NDJSON) instead of formatted text')
969
1105
  .action(async (id, opts) => {
970
1106
  validateObjectId(id);
971
- // #91 P1 / #93 item 1 — captured BEFORE triggering so pollRunProgress can
972
- // tell "the run we just triggered" apart from whatever the last run
973
- // happened to be. This is the dedup-prone path: `trigger`'s method:'worker'
974
- // collapses onto an already-pending/running worker job for the same scrap
975
- // (ScrapJobsService, LIVE_STATUSES) instead of creating a new history row —
976
- // captureBeforeRunState records that so pollRunProgress can still track it.
977
- const beforeRun = opts.watch ? await captureBeforeRunState(id) : undefined;
978
- // #50 — default async: the backend (#1313) kicks off the run and returns a
979
- // 'queued' envelope immediately instead of holding the connection for the
980
- // whole run. --wait restores the old synchronous round-trip.
981
- const path = opts.wait ? `/api/scraps/worker/${id}` : `/api/scraps/worker/${id}?wait=false`;
982
- // #91 P0 — the synchronous --wait branch runs the scrap server-side
983
- // (30-250s), same as `scraps run`; the 30s default was aborting it
984
- // mid-flight. The async (default) POST returns almost immediately, so it
985
- // keeps the 30s default.
986
- await oraPromise(() => (opts.wait ? api.post(path, undefined, { timeoutMs: LONG_RUN_TIMEOUT_MS }) : api.post(path)), {
987
- text: opts.wait ? 'Running worker…' : 'Triggering worker…',
988
- successText: opts.wait ? 'Worker run complete' : 'Worker triggered',
989
- });
990
- if (opts.watch)
991
- await pollRunProgress(id, beforeRun);
1107
+ await watchActivities(id, opts.json);
992
1108
  });
1109
+ // trigger — promoted to a top-level verb (#108)
1110
+ export function attachTriggerCommand(parent, attachOpts = {}) {
1111
+ return parent
1112
+ .command('trigger <id>', attachOpts)
1113
+ .description('Launch a scrap as a background worker (returns immediately)')
1114
+ .option('-w, --watch', 'Poll for progress after triggering (#91 — the default async run happens in a separate cron pod; activities SSE never reaches it)')
1115
+ .option('--wait', 'Run synchronously and wait for the result (legacy behaviour)')
1116
+ .option('--json', 'Output the raw trigger payload as JSON')
1117
+ .action(async (id, opts) => {
1118
+ validateObjectId(id);
1119
+ // #91 P1 / #93 item 1 — captured BEFORE triggering so pollRunProgress can
1120
+ // tell "the run we just triggered" apart from whatever the last run
1121
+ // happened to be. This is the dedup-prone path: `trigger`'s method:'worker'
1122
+ // collapses onto an already-pending/running worker job for the same scrap
1123
+ // (ScrapJobsService, LIVE_STATUSES) instead of creating a new history row —
1124
+ // captureBeforeRunState records that so pollRunProgress can still track it.
1125
+ const beforeRun = opts.watch ? await captureBeforeRunState(id) : undefined;
1126
+ // #50 — default async: the backend (#1313) kicks off the run and returns a
1127
+ // 'queued' envelope immediately instead of holding the connection for the
1128
+ // whole run. --wait restores the old synchronous round-trip.
1129
+ const path = opts.wait ? `/api/scraps/worker/${id}` : `/api/scraps/worker/${id}?wait=false`;
1130
+ // #91 P0 — the synchronous --wait branch runs the scrap server-side
1131
+ // (30-250s), same as `run`; the 30s default was aborting it
1132
+ // mid-flight. The async (default) POST returns almost immediately, so it
1133
+ // keeps the 30s default.
1134
+ const call = () => (opts.wait ? api.post(path, undefined, { timeoutMs: LONG_RUN_TIMEOUT_MS }) : api.post(path));
1135
+ // #107 — under --json the stdout path stays pure: no spinner channel.
1136
+ const data = opts.json
1137
+ ? await call()
1138
+ : await oraPromise(call, {
1139
+ text: opts.wait ? 'Running worker…' : 'Triggering worker…',
1140
+ successText: opts.wait ? 'Worker run complete' : 'Worker triggered',
1141
+ });
1142
+ if (opts.json)
1143
+ json(data);
1144
+ // #107 — see the matching comment on `run`'s --watch call above (review
1145
+ // F1): honest final NDJSON line + exit code under --json, human mode
1146
+ // gets the same honest exit code too.
1147
+ if (opts.watch)
1148
+ await pollRunProgress(id, beforeRun, { json: opts.json });
1149
+ });
1150
+ }
1151
+ attachTriggerCommand(scraps, { hidden: true });
993
1152
  // account subcommand group
994
1153
  const account = scraps
995
1154
  .command('account')
@@ -1013,28 +1172,49 @@ account
1013
1172
  .description('Set credentials for a scrap account')
1014
1173
  .option('-u, --username <username>', 'Account username')
1015
1174
  .option('-p, --password <password>', 'Account password (insecure: prefer interactive prompt)')
1175
+ .option('--json', 'Output as JSON')
1016
1176
  .action(async (id, opts) => {
1017
1177
  validateObjectId(id);
1018
1178
  let username = opts.username || '';
1019
1179
  let password = opts.password || '';
1020
1180
  if (opts.password) {
1021
- console.log(chalk.yellow('⚠ Passing --password on the command line is insecure and may be stored in shell history.'));
1181
+ // #107 this advisory is stderr-only: stdout must stay pure under
1182
+ // --json, and every other advisory in this CLI already follows that
1183
+ // rule (warnIfUnconfirmedTier, token.ts's expiry hints, …).
1184
+ console.error(chalk.yellow('⚠ Passing --password on the command line is insecure and may be stored in shell history.'));
1022
1185
  }
1186
+ // #107 — never blocks on a readline prompt under --json or a non-TTY
1187
+ // invocation (agent/CI subprocess); pass -u/-p instead.
1188
+ const interactive = isInteractive({ json: opts.json });
1023
1189
  if (!username) {
1190
+ if (!interactive) {
1191
+ usageError('Username is required — pass -u/--username (refusing to block on a prompt, non-interactive).', { json: opts.json });
1192
+ return;
1193
+ }
1024
1194
  username = await promptLine('Username: ');
1025
1195
  if (!username) {
1026
- usageError('Username is required.');
1196
+ usageError('Username is required.', { json: opts.json });
1027
1197
  return;
1028
1198
  }
1029
1199
  }
1030
1200
  if (!password) {
1201
+ if (!interactive) {
1202
+ usageError('Password is required — pass -p/--password (refusing to block on a prompt, non-interactive).', { json: opts.json });
1203
+ return;
1204
+ }
1031
1205
  password = await promptPassword('Password: ');
1032
1206
  if (!password) {
1033
- usageError('Password is required.');
1207
+ usageError('Password is required.', { json: opts.json });
1034
1208
  return;
1035
1209
  }
1036
1210
  }
1037
- const data = await oraPromise(() => api.put(`/api/scraps/${id}/account`, { username, password }), { text: 'Saving credentials…', successText: 'Credentials saved' });
1211
+ const call = () => api.put(`/api/scraps/${id}/account`, { username, password });
1212
+ if (opts.json) {
1213
+ const data = await call();
1214
+ json(data);
1215
+ return;
1216
+ }
1217
+ const data = await oraPromise(call, { text: 'Saving credentials…', successText: 'Credentials saved' });
1038
1218
  const acc = data.account;
1039
1219
  console.log(chalk.dim(' Credentials: ') + (acc.hasCredentials ? chalk.green('✓ configured') : chalk.dim('not set')));
1040
1220
  console.log(chalk.dim(' Session: ') + (acc.hasSession ? chalk.green('active') : chalk.dim('none')));
@@ -1044,26 +1224,27 @@ account
1044
1224
  .command('delete <id>')
1045
1225
  .description('Delete account credentials for a scrap')
1046
1226
  .option('-f, --force', 'Skip confirmation prompt')
1227
+ .option('--json', 'Output as JSON')
1047
1228
  .action(async (id, opts) => {
1048
1229
  validateObjectId(id);
1049
- if (!opts.force) {
1050
- const { createInterface } = await import('readline');
1051
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1052
- let answer;
1053
- try {
1054
- answer = await new Promise((resolve) => {
1055
- rl.question(`Delete account credentials for scrap ${chalk.bold(id)}? ${chalk.dim('[y/N]')} `, (a) => resolve(a.trim().toLowerCase()));
1056
- });
1057
- }
1058
- finally {
1059
- rl.close();
1060
- }
1061
- if (answer !== 'y' && answer !== 'yes') {
1062
- console.log(chalk.dim('Aborted.'));
1063
- return;
1064
- }
1230
+ // #107 — never blocks on a y/N under --json or a non-TTY invocation.
1231
+ // #107 review F2 plain-id `message` for the refusal/JSON envelope,
1232
+ // styled `promptMessage` for the interactive TTY prompt only (see the
1233
+ // matching comment on `scraps delete` above).
1234
+ const { proceed, blocked } = await confirmDestructive(`Delete account credentials for scrap ${id}?`, { force: opts.force, json: opts.json, promptMessage: `Delete account credentials for scrap ${chalk.bold(id)}?` });
1235
+ if (blocked)
1236
+ return;
1237
+ if (!proceed) {
1238
+ console.log(chalk.dim('Aborted.'));
1239
+ return;
1240
+ }
1241
+ const call = () => api.delete(`/api/scraps/${id}/account`);
1242
+ if (opts.json) {
1243
+ await call();
1244
+ json({ deleted: true, id });
1245
+ return;
1065
1246
  }
1066
- await oraPromise(() => api.delete(`/api/scraps/${id}/account`), {
1247
+ await oraPromise(call, {
1067
1248
  text: 'Deleting credentials…',
1068
1249
  successText: 'Account credentials deleted',
1069
1250
  });
@@ -1072,9 +1253,16 @@ account
1072
1253
  account
1073
1254
  .command('clear-session <id>')
1074
1255
  .description('Clear the saved session for a scrap account')
1075
- .action(async (id) => {
1256
+ .option('--json', 'Output as JSON')
1257
+ .action(async (id, opts) => {
1076
1258
  validateObjectId(id);
1077
- await oraPromise(() => api.delete(`/api/scraps/${id}/account/session`), {
1259
+ const call = () => api.delete(`/api/scraps/${id}/account/session`);
1260
+ if (opts.json) {
1261
+ await call();
1262
+ json({ cleared: true, id });
1263
+ return;
1264
+ }
1265
+ await oraPromise(call, {
1078
1266
  text: 'Clearing session…',
1079
1267
  successText: 'Session cleared',
1080
1268
  });
@@ -1088,11 +1276,12 @@ accountSession
1088
1276
  .command('set <id>')
1089
1277
  .description('Upload browser session cookies for a scrap (Puppeteer cookie JSON array)')
1090
1278
  .requiredOption('-c, --cookies <file>', 'Path to a Puppeteer cookie JSON array file')
1279
+ .option('--json', 'Output as JSON')
1091
1280
  .action(async (id, opts) => {
1092
1281
  validateObjectId(id);
1093
1282
  const { existsSync, readFileSync } = await import('fs');
1094
1283
  if (!existsSync(opts.cookies)) {
1095
- usageError(`File not found: ${opts.cookies}`);
1284
+ usageError(`File not found: ${opts.cookies}`, { json: opts.json });
1096
1285
  return;
1097
1286
  }
1098
1287
  let cookies;
@@ -1101,22 +1290,31 @@ accountSession
1101
1290
  cookies = JSON.parse(raw);
1102
1291
  }
1103
1292
  catch (e) {
1104
- usageError(`Failed to parse cookies file: ${e.message}`);
1293
+ usageError(`Failed to parse cookies file: ${e.message}`, { json: opts.json });
1105
1294
  return;
1106
1295
  }
1107
1296
  if (!Array.isArray(cookies)) {
1108
- usageError('Cookies file must contain a JSON array');
1297
+ usageError('Cookies file must contain a JSON array', { json: opts.json });
1109
1298
  return;
1110
1299
  }
1111
1300
  if (cookies.length === 0) {
1112
- usageError('Cookies array must not be empty');
1301
+ usageError('Cookies array must not be empty', { json: opts.json });
1113
1302
  return;
1114
1303
  }
1115
1304
  if (!cookies.every((c) => c && typeof c.name === 'string' && typeof c.value === 'string')) {
1116
- usageError('Each cookie must have a name (string) and value (string)');
1305
+ usageError('Each cookie must have a name (string) and value (string)', { json: opts.json });
1306
+ return;
1307
+ }
1308
+ const call = () => api.put(`/api/scraps/${id}/account/session`, { cookies });
1309
+ if (opts.json) {
1310
+ const data = await call();
1311
+ json(data);
1117
1312
  return;
1118
1313
  }
1119
- const data = await oraPromise(() => api.put(`/api/scraps/${id}/account/session`, { cookies }), { text: 'Uploading session cookies…', successText: `Session cookies saved for scrap ${chalk.bold(id)}` });
1314
+ const data = await oraPromise(call, {
1315
+ text: 'Uploading session cookies…',
1316
+ successText: `Session cookies saved for scrap ${chalk.bold(id)}`,
1317
+ });
1120
1318
  const acc = data.account;
1121
1319
  console.log(chalk.dim(' Session: ') + (acc.hasSession ? chalk.green('✓ active') : chalk.dim('none')));
1122
1320
  });