@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.
- package/README.md +74 -51
- package/dist/commands/login.js +35 -11
- package/dist/commands/scraps.d.ts +61 -0
- package/dist/commands/scraps.js +635 -437
- package/dist/commands/skills.js +52 -6
- package/dist/commands/telemetry.js +23 -3
- package/dist/commands/token.js +9 -3
- package/dist/index.d.ts +8 -0
- package/dist/index.js +45 -7
- package/dist/lib/confirm.d.ts +70 -0
- package/dist/lib/confirm.js +79 -0
- package/dist/lib/errors.d.ts +13 -0
- package/dist/lib/errors.js +19 -0
- package/docs/agent-quickstart.md +103 -0
- package/package.json +2 -1
package/dist/commands/scraps.js
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
//
|
|
247
|
-
// `
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
632
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
json
|
|
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
|
-
|
|
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
|
-
// --
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
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
|
-
// #
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
808
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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('
|
|
838
|
-
.
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1049
|
+
await call();
|
|
1050
|
+
json({ deleted: true, id });
|
|
886
1051
|
return;
|
|
887
1052
|
}
|
|
888
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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(
|
|
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
|
-
.
|
|
1256
|
+
.option('--json', 'Output as JSON')
|
|
1257
|
+
.action(async (id, opts) => {
|
|
1076
1258
|
validateObjectId(id);
|
|
1077
|
-
|
|
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(
|
|
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
|
});
|