@trawlme/cli 1.20.0 → 1.21.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 +40 -26
- package/dist/commands/login.js +35 -11
- package/dist/commands/scraps.d.ts +32 -0
- package/dist/commands/scraps.js +256 -79
- package/dist/commands/skills.js +52 -6
- package/dist/commands/telemetry.js +23 -3
- package/dist/commands/token.js +9 -3
- 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/package.json +1 -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,19 +311,42 @@ 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
|
-
|
|
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));
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
console.log(chalk.yellow(`⚠ Timed out waiting for the run to finish — check status with: trawl scraps doctor ${id}`));
|
|
348
|
+
}
|
|
349
|
+
process.exitCode = 1;
|
|
258
350
|
}
|
|
259
351
|
// list
|
|
260
352
|
scraps
|
|
@@ -441,11 +533,17 @@ function withTierUnconfirmed(data, tierWasRequested) {
|
|
|
441
533
|
}
|
|
442
534
|
/** #86 finding 5 — the standard error envelope for a refused tier override,
|
|
443
535
|
* routed through the same reportError() central formatting path used
|
|
444
|
-
* everywhere else (exit 1: a business-logic refusal, not a usage error).
|
|
536
|
+
* everywhere else (exit 1: a business-logic refusal, not a usage error).
|
|
537
|
+
*
|
|
538
|
+
* #107 review F3 — uses `RefusalError` (kind:"refused"), not a bare `Error`
|
|
539
|
+
* (which fell through classifyError's default `kind:"unknown"` bucket,
|
|
540
|
+
* indistinguishable from a generic crash even though the README sells `kind`
|
|
541
|
+
* as the machine discriminant an agent branches on).
|
|
542
|
+
*/
|
|
445
543
|
function reportTierRefusal(data, wantsJson) {
|
|
446
544
|
const ov = data._tierOverride;
|
|
447
545
|
const message = `Tier ceiling override refused: ${ov?.reason ?? 'unknown'} (requested ${ov?.requestedMaxTier ?? '—'}; kept the registry cap)`;
|
|
448
|
-
return reportError(new
|
|
546
|
+
return reportError(new RefusalError(message), { json: wantsJson });
|
|
449
547
|
}
|
|
450
548
|
// create
|
|
451
549
|
scraps
|
|
@@ -616,6 +714,7 @@ scraps
|
|
|
616
714
|
.command('run <id>')
|
|
617
715
|
.description('Run a scrap')
|
|
618
716
|
.option('-w, --watch', 'Show progress after launching (polls — see `trawl scraps trigger --watch`, #91)')
|
|
717
|
+
.option('--json', 'Output the raw launch payload as JSON')
|
|
619
718
|
.action(async (id, opts) => {
|
|
620
719
|
validateObjectId(id);
|
|
621
720
|
// #91 P1 / #93 item 1 — captured BEFORE launching so pollRunProgress can
|
|
@@ -624,12 +723,22 @@ scraps
|
|
|
624
723
|
const beforeRun = opts.watch ? await captureBeforeRunState(id) : undefined;
|
|
625
724
|
// #91 P0 — GET /api/scraps/load/:id runs the scrap synchronously
|
|
626
725
|
// server-side (30-250s); the 30s default was aborting it mid-flight.
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
726
|
+
const call = () => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS });
|
|
727
|
+
// #107 — under --json the stdout path stays pure: no spinner channel at
|
|
728
|
+
// all, mirroring `trawl fetch`'s own --json handling.
|
|
729
|
+
const data = opts.json
|
|
730
|
+
? await call()
|
|
731
|
+
: await oraPromise(call, { text: 'Launching scrap…', successText: 'Scrap launched' });
|
|
732
|
+
if (opts.json)
|
|
733
|
+
json(data);
|
|
631
734
|
if (opts.watch) {
|
|
632
|
-
|
|
735
|
+
// #107 — under --json, pollRunProgress suppresses its own intermediate
|
|
736
|
+
// console.log calls and instead emits exactly ONE final NDJSON outcome
|
|
737
|
+
// line (+ sets process.exitCode honestly) once the watch reaches a
|
|
738
|
+
// terminal status, a timeout, or a persistent poll error (review F1) —
|
|
739
|
+
// `run --json --watch` never again exits 0 after dead air regardless
|
|
740
|
+
// of what the watched run actually did.
|
|
741
|
+
await pollRunProgress(id, beforeRun, { json: opts.json });
|
|
633
742
|
}
|
|
634
743
|
});
|
|
635
744
|
// #70 — render an items array either as a table summary or --json. Shared by
|
|
@@ -893,38 +1002,50 @@ scraps
|
|
|
893
1002
|
.alias('rm')
|
|
894
1003
|
.description('Delete a scrap')
|
|
895
1004
|
.option('-f, --force', 'Skip confirmation prompt')
|
|
1005
|
+
.option('--json', 'Output as JSON')
|
|
896
1006
|
.action(async (id, opts) => {
|
|
897
1007
|
validateObjectId(id);
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1008
|
+
// #107 — never blocks on a y/N under --json or a non-TTY invocation
|
|
1009
|
+
// (agent/CI subprocess); -f/--force always pre-confirms.
|
|
1010
|
+
//
|
|
1011
|
+
// #107 review F2 — `message` (the plain-id form) feeds the refusal
|
|
1012
|
+
// UsageError, which can land verbatim in the `--json` error envelope;
|
|
1013
|
+
// `chalk.bold(id)` is passed ONLY as `promptMessage`, shown solely on
|
|
1014
|
+
// the interactive TTY `[y/N]` prompt. Before this split, the styled
|
|
1015
|
+
// string was the ONLY message confirmDestructive had, so a non-TTY/
|
|
1016
|
+
// --json refusal on `scraps delete X --json` emitted raw ANSI escape
|
|
1017
|
+
// bytes inside the JSON string.
|
|
1018
|
+
const { proceed, blocked } = await confirmDestructive(`Delete scrap ${id}?`, {
|
|
1019
|
+
force: opts.force,
|
|
1020
|
+
json: opts.json,
|
|
1021
|
+
promptMessage: `Delete scrap ${chalk.bold(id)}?`,
|
|
1022
|
+
});
|
|
1023
|
+
if (blocked)
|
|
1024
|
+
return;
|
|
1025
|
+
if (!proceed) {
|
|
1026
|
+
console.log(chalk.dim('Aborted.'));
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const call = () => api.delete(`/api/scraps/${id}`);
|
|
1030
|
+
if (opts.json) {
|
|
1031
|
+
await call();
|
|
1032
|
+
json({ deleted: true, id });
|
|
1033
|
+
return;
|
|
914
1034
|
}
|
|
915
|
-
await oraPromise(
|
|
1035
|
+
await oraPromise(call, { text: 'Deleting…', successText: 'Scrap deleted' });
|
|
916
1036
|
});
|
|
917
1037
|
// banner
|
|
918
1038
|
scraps
|
|
919
1039
|
.command('banner <id>')
|
|
920
1040
|
.description('Upload a banner image for a scrap')
|
|
921
1041
|
.requiredOption('-f, --file <path>', 'Path to image file (jpg, png, webp)')
|
|
1042
|
+
.option('--json', 'Output as JSON')
|
|
922
1043
|
.action(async (id, opts) => {
|
|
923
1044
|
validateObjectId(id);
|
|
924
1045
|
const { readFileSync, existsSync } = await import('fs');
|
|
925
1046
|
const { basename } = await import('path');
|
|
926
1047
|
if (!existsSync(opts.file)) {
|
|
927
|
-
usageError(`File not found: ${opts.file}
|
|
1048
|
+
usageError(`File not found: ${opts.file}`, { json: opts.json });
|
|
928
1049
|
return;
|
|
929
1050
|
}
|
|
930
1051
|
const filename = basename(opts.file);
|
|
@@ -940,14 +1061,20 @@ scraps
|
|
|
940
1061
|
// Content-Type — a lie about the actual file's format). Refuse instead.
|
|
941
1062
|
const mimeType = mimeMap[ext];
|
|
942
1063
|
if (!mimeType) {
|
|
943
|
-
usageError(`Unsupported image type "${ext ? `.${ext}` : filename}" — use png, jpg, or webp
|
|
1064
|
+
usageError(`Unsupported image type "${ext ? `.${ext}` : filename}" — use png, jpg, or webp.`, { json: opts.json });
|
|
944
1065
|
return;
|
|
945
1066
|
}
|
|
946
1067
|
const fileBuffer = readFileSync(opts.file);
|
|
947
1068
|
const blob = new Blob([fileBuffer], { type: mimeType });
|
|
948
1069
|
const formData = new FormData();
|
|
949
1070
|
formData.append('banner', blob, filename);
|
|
950
|
-
|
|
1071
|
+
const call = () => api.upload(`/api/scraps/${id}/banner`, formData);
|
|
1072
|
+
if (opts.json) {
|
|
1073
|
+
const data = await call();
|
|
1074
|
+
json(data);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
await oraPromise(call, {
|
|
951
1078
|
text: 'Uploading banner…',
|
|
952
1079
|
successText: `Banner uploaded for scrap ${chalk.bold(id)}`,
|
|
953
1080
|
});
|
|
@@ -956,9 +1083,10 @@ scraps
|
|
|
956
1083
|
scraps
|
|
957
1084
|
.command('watch <id>')
|
|
958
1085
|
.description('Stream scrap activities in real-time')
|
|
959
|
-
.
|
|
1086
|
+
.option('--json', 'Output each activity as a JSON line (NDJSON) instead of formatted text')
|
|
1087
|
+
.action(async (id, opts) => {
|
|
960
1088
|
validateObjectId(id);
|
|
961
|
-
await watchActivities(id);
|
|
1089
|
+
await watchActivities(id, opts.json);
|
|
962
1090
|
});
|
|
963
1091
|
// trigger
|
|
964
1092
|
scraps
|
|
@@ -966,6 +1094,7 @@ scraps
|
|
|
966
1094
|
.description('Launch a scrap as a background worker (returns immediately)')
|
|
967
1095
|
.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
1096
|
.option('--wait', 'Run synchronously and wait for the result (legacy behaviour)')
|
|
1097
|
+
.option('--json', 'Output the raw trigger payload as JSON')
|
|
969
1098
|
.action(async (id, opts) => {
|
|
970
1099
|
validateObjectId(id);
|
|
971
1100
|
// #91 P1 / #93 item 1 — captured BEFORE triggering so pollRunProgress can
|
|
@@ -983,12 +1112,21 @@ scraps
|
|
|
983
1112
|
// (30-250s), same as `scraps run`; the 30s default was aborting it
|
|
984
1113
|
// mid-flight. The async (default) POST returns almost immediately, so it
|
|
985
1114
|
// keeps the 30s default.
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1115
|
+
const call = () => (opts.wait ? api.post(path, undefined, { timeoutMs: LONG_RUN_TIMEOUT_MS }) : api.post(path));
|
|
1116
|
+
// #107 — under --json the stdout path stays pure: no spinner channel.
|
|
1117
|
+
const data = opts.json
|
|
1118
|
+
? await call()
|
|
1119
|
+
: await oraPromise(call, {
|
|
1120
|
+
text: opts.wait ? 'Running worker…' : 'Triggering worker…',
|
|
1121
|
+
successText: opts.wait ? 'Worker run complete' : 'Worker triggered',
|
|
1122
|
+
});
|
|
1123
|
+
if (opts.json)
|
|
1124
|
+
json(data);
|
|
1125
|
+
// #107 — see the matching comment on `run`'s --watch call above (review
|
|
1126
|
+
// F1): honest final NDJSON line + exit code under --json, human mode
|
|
1127
|
+
// gets the same honest exit code too.
|
|
990
1128
|
if (opts.watch)
|
|
991
|
-
await pollRunProgress(id, beforeRun);
|
|
1129
|
+
await pollRunProgress(id, beforeRun, { json: opts.json });
|
|
992
1130
|
});
|
|
993
1131
|
// account subcommand group
|
|
994
1132
|
const account = scraps
|
|
@@ -1013,28 +1151,49 @@ account
|
|
|
1013
1151
|
.description('Set credentials for a scrap account')
|
|
1014
1152
|
.option('-u, --username <username>', 'Account username')
|
|
1015
1153
|
.option('-p, --password <password>', 'Account password (insecure: prefer interactive prompt)')
|
|
1154
|
+
.option('--json', 'Output as JSON')
|
|
1016
1155
|
.action(async (id, opts) => {
|
|
1017
1156
|
validateObjectId(id);
|
|
1018
1157
|
let username = opts.username || '';
|
|
1019
1158
|
let password = opts.password || '';
|
|
1020
1159
|
if (opts.password) {
|
|
1021
|
-
|
|
1160
|
+
// #107 — this advisory is stderr-only: stdout must stay pure under
|
|
1161
|
+
// --json, and every other advisory in this CLI already follows that
|
|
1162
|
+
// rule (warnIfUnconfirmedTier, token.ts's expiry hints, …).
|
|
1163
|
+
console.error(chalk.yellow('⚠ Passing --password on the command line is insecure and may be stored in shell history.'));
|
|
1022
1164
|
}
|
|
1165
|
+
// #107 — never blocks on a readline prompt under --json or a non-TTY
|
|
1166
|
+
// invocation (agent/CI subprocess); pass -u/-p instead.
|
|
1167
|
+
const interactive = isInteractive({ json: opts.json });
|
|
1023
1168
|
if (!username) {
|
|
1169
|
+
if (!interactive) {
|
|
1170
|
+
usageError('Username is required — pass -u/--username (refusing to block on a prompt, non-interactive).', { json: opts.json });
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1024
1173
|
username = await promptLine('Username: ');
|
|
1025
1174
|
if (!username) {
|
|
1026
|
-
usageError('Username is required.');
|
|
1175
|
+
usageError('Username is required.', { json: opts.json });
|
|
1027
1176
|
return;
|
|
1028
1177
|
}
|
|
1029
1178
|
}
|
|
1030
1179
|
if (!password) {
|
|
1180
|
+
if (!interactive) {
|
|
1181
|
+
usageError('Password is required — pass -p/--password (refusing to block on a prompt, non-interactive).', { json: opts.json });
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1031
1184
|
password = await promptPassword('Password: ');
|
|
1032
1185
|
if (!password) {
|
|
1033
|
-
usageError('Password is required.');
|
|
1186
|
+
usageError('Password is required.', { json: opts.json });
|
|
1034
1187
|
return;
|
|
1035
1188
|
}
|
|
1036
1189
|
}
|
|
1037
|
-
const
|
|
1190
|
+
const call = () => api.put(`/api/scraps/${id}/account`, { username, password });
|
|
1191
|
+
if (opts.json) {
|
|
1192
|
+
const data = await call();
|
|
1193
|
+
json(data);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
const data = await oraPromise(call, { text: 'Saving credentials…', successText: 'Credentials saved' });
|
|
1038
1197
|
const acc = data.account;
|
|
1039
1198
|
console.log(chalk.dim(' Credentials: ') + (acc.hasCredentials ? chalk.green('✓ configured') : chalk.dim('not set')));
|
|
1040
1199
|
console.log(chalk.dim(' Session: ') + (acc.hasSession ? chalk.green('active') : chalk.dim('none')));
|
|
@@ -1044,26 +1203,27 @@ account
|
|
|
1044
1203
|
.command('delete <id>')
|
|
1045
1204
|
.description('Delete account credentials for a scrap')
|
|
1046
1205
|
.option('-f, --force', 'Skip confirmation prompt')
|
|
1206
|
+
.option('--json', 'Output as JSON')
|
|
1047
1207
|
.action(async (id, opts) => {
|
|
1048
1208
|
validateObjectId(id);
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1209
|
+
// #107 — never blocks on a y/N under --json or a non-TTY invocation.
|
|
1210
|
+
// #107 review F2 — plain-id `message` for the refusal/JSON envelope,
|
|
1211
|
+
// styled `promptMessage` for the interactive TTY prompt only (see the
|
|
1212
|
+
// matching comment on `scraps delete` above).
|
|
1213
|
+
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)}?` });
|
|
1214
|
+
if (blocked)
|
|
1215
|
+
return;
|
|
1216
|
+
if (!proceed) {
|
|
1217
|
+
console.log(chalk.dim('Aborted.'));
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
const call = () => api.delete(`/api/scraps/${id}/account`);
|
|
1221
|
+
if (opts.json) {
|
|
1222
|
+
await call();
|
|
1223
|
+
json({ deleted: true, id });
|
|
1224
|
+
return;
|
|
1065
1225
|
}
|
|
1066
|
-
await oraPromise(
|
|
1226
|
+
await oraPromise(call, {
|
|
1067
1227
|
text: 'Deleting credentials…',
|
|
1068
1228
|
successText: 'Account credentials deleted',
|
|
1069
1229
|
});
|
|
@@ -1072,9 +1232,16 @@ account
|
|
|
1072
1232
|
account
|
|
1073
1233
|
.command('clear-session <id>')
|
|
1074
1234
|
.description('Clear the saved session for a scrap account')
|
|
1075
|
-
.
|
|
1235
|
+
.option('--json', 'Output as JSON')
|
|
1236
|
+
.action(async (id, opts) => {
|
|
1076
1237
|
validateObjectId(id);
|
|
1077
|
-
|
|
1238
|
+
const call = () => api.delete(`/api/scraps/${id}/account/session`);
|
|
1239
|
+
if (opts.json) {
|
|
1240
|
+
await call();
|
|
1241
|
+
json({ cleared: true, id });
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
await oraPromise(call, {
|
|
1078
1245
|
text: 'Clearing session…',
|
|
1079
1246
|
successText: 'Session cleared',
|
|
1080
1247
|
});
|
|
@@ -1088,11 +1255,12 @@ accountSession
|
|
|
1088
1255
|
.command('set <id>')
|
|
1089
1256
|
.description('Upload browser session cookies for a scrap (Puppeteer cookie JSON array)')
|
|
1090
1257
|
.requiredOption('-c, --cookies <file>', 'Path to a Puppeteer cookie JSON array file')
|
|
1258
|
+
.option('--json', 'Output as JSON')
|
|
1091
1259
|
.action(async (id, opts) => {
|
|
1092
1260
|
validateObjectId(id);
|
|
1093
1261
|
const { existsSync, readFileSync } = await import('fs');
|
|
1094
1262
|
if (!existsSync(opts.cookies)) {
|
|
1095
|
-
usageError(`File not found: ${opts.cookies}
|
|
1263
|
+
usageError(`File not found: ${opts.cookies}`, { json: opts.json });
|
|
1096
1264
|
return;
|
|
1097
1265
|
}
|
|
1098
1266
|
let cookies;
|
|
@@ -1101,22 +1269,31 @@ accountSession
|
|
|
1101
1269
|
cookies = JSON.parse(raw);
|
|
1102
1270
|
}
|
|
1103
1271
|
catch (e) {
|
|
1104
|
-
usageError(`Failed to parse cookies file: ${e.message}
|
|
1272
|
+
usageError(`Failed to parse cookies file: ${e.message}`, { json: opts.json });
|
|
1105
1273
|
return;
|
|
1106
1274
|
}
|
|
1107
1275
|
if (!Array.isArray(cookies)) {
|
|
1108
|
-
usageError('Cookies file must contain a JSON array');
|
|
1276
|
+
usageError('Cookies file must contain a JSON array', { json: opts.json });
|
|
1109
1277
|
return;
|
|
1110
1278
|
}
|
|
1111
1279
|
if (cookies.length === 0) {
|
|
1112
|
-
usageError('Cookies array must not be empty');
|
|
1280
|
+
usageError('Cookies array must not be empty', { json: opts.json });
|
|
1113
1281
|
return;
|
|
1114
1282
|
}
|
|
1115
1283
|
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)');
|
|
1284
|
+
usageError('Each cookie must have a name (string) and value (string)', { json: opts.json });
|
|
1117
1285
|
return;
|
|
1118
1286
|
}
|
|
1119
|
-
const
|
|
1287
|
+
const call = () => api.put(`/api/scraps/${id}/account/session`, { cookies });
|
|
1288
|
+
if (opts.json) {
|
|
1289
|
+
const data = await call();
|
|
1290
|
+
json(data);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const data = await oraPromise(call, {
|
|
1294
|
+
text: 'Uploading session cookies…',
|
|
1295
|
+
successText: `Session cookies saved for scrap ${chalk.bold(id)}`,
|
|
1296
|
+
});
|
|
1120
1297
|
const acc = data.account;
|
|
1121
1298
|
console.log(chalk.dim(' Session: ') + (acc.hasSession ? chalk.green('✓ active') : chalk.dim('none')));
|
|
1122
1299
|
});
|