@trawlme/cli 1.19.1 → 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.
@@ -5,7 +5,8 @@ import { api, LONG_RUN_TIMEOUT_MS } from '../lib/api.js';
5
5
  import { table, json } from '../lib/format.js';
6
6
  import { promptPassword } from '../lib/prompt.js';
7
7
  import { validateObjectId } from '../lib/validate.js';
8
- import { classifyError, reportError, UsageError } from '../lib/errors.js';
8
+ import { classifyError, reportError, UsageError, RefusalError } from '../lib/errors.js';
9
+ import { confirmDestructive, isInteractive } from '../lib/confirm.js';
9
10
  import { formatDoctor, formatAutofix, fetchRunAndFix, pickRun, pickFix } from './doctor.js';
10
11
  import { renderPinch, pinchEnabled } from '../lib/pinch.js';
11
12
  /**
@@ -66,12 +67,21 @@ function statusIcon(status) {
66
67
  return chalk.dim('—');
67
68
  }
68
69
  export const scraps = new Command('scraps').description('Manage scraps');
69
- // shared SSE streaming helper
70
- async function watchActivities(id) {
71
- console.log(chalk.dim('Streaming activities (Ctrl+C to stop)…\n'));
70
+ // shared SSE streaming helper. `asJson` (#107) emits one raw JSON object per
71
+ // line (NDJSON) on stdout instead of the human-formatted timestamped text —
72
+ // a streaming command still needs a pure-stdout machine mode, just line-
73
+ // delimited instead of a single blob (there's no single "final" payload to
74
+ // wait for).
75
+ async function watchActivities(id, asJson) {
76
+ if (!asJson)
77
+ console.log(chalk.dim('Streaming activities (Ctrl+C to stop)…\n'));
72
78
  for await (const event of api.stream(`/api/scraps/${id}/activities/stream`)) {
73
79
  try {
74
80
  const activity = JSON.parse(event);
81
+ if (asJson) {
82
+ console.log(JSON.stringify(activity));
83
+ continue;
84
+ }
75
85
  const time = new Date(activity.createdAt).toLocaleTimeString();
76
86
  console.log(`${chalk.dim(`[${time}]`)} ${activity.message}`);
77
87
  }
@@ -121,6 +131,14 @@ const POLL_INTERVAL_MS = 2000;
121
131
  // Mirrors LONG_RUN_TIMEOUT_MS (#91 item 1) — the server-side worst case this
122
132
  // polls for is the same one that timeout was sized for.
123
133
  const POLL_TIMEOUT_MS = LONG_RUN_TIMEOUT_MS;
134
+ // #107 review F1 — a genuinely unreachable API must not run the watch
135
+ // silently for the full 300s timeout with dead air; after this many
136
+ // CONSECUTIVE (not cumulative — any successful poll resets the counter)
137
+ // failed polls, treat it as a persistent error and surface it immediately
138
+ // instead of waiting out the clock. At the default 2s interval this gives up
139
+ // after ~10s of continuous failures — comfortably longer than any single
140
+ // transient blip, nowhere near the 300s ceiling.
141
+ const MAX_CONSECUTIVE_POLL_ERRORS = 5;
124
142
  /**
125
143
  * #91 P1 — replaces "await the run to completion, THEN open the activities
126
144
  * SSE stream" (which showed NOTHING: the activities SSE
@@ -182,10 +200,37 @@ const POLL_TIMEOUT_MS = LONG_RUN_TIMEOUT_MS;
182
200
  * this function has, so it resolves to the same honest timeout rather than
183
201
  * risk reporting a possibly-wrong outcome (never a lie, at worst a timeout
184
202
  * telling the caller to check `doctor`).
203
+ *
204
+ * #107 review F1 — before this fix, `run|trigger --json --watch` was
205
+ * outcome-blind: `quiet` suppressed ALL output (including "Run finished:
206
+ * failure" and the timeout notice), a transient poll error was caught and
207
+ * silently retried FOREVER within the deadline, and the process always
208
+ * exited 0 after the poll loop regardless of what the watched run actually
209
+ * did — dead air, then a clean exit code, even for a failed or timed-out
210
+ * run. An agent scripting this CLI had no way to tell success from failure
211
+ * from "we gave up". Fixed by:
212
+ * - emitting exactly ONE final NDJSON line on stdout under `--json` once
213
+ * the watch reaches ANY of its three exits (terminal status, timeout, or
214
+ * a persistent poll error) — `{runId,status}` (+`error` for a poll
215
+ * error) — while every intermediate progress line stays suppressed
216
+ * (unchanged from before);
217
+ * - setting `process.exitCode` non-zero on a genuine run failure, a
218
+ * timeout, or a persistent poll error, and `0` on a real success — in
219
+ * BOTH `--json` and human `--watch` modes (human mode used to exit 0
220
+ * unconditionally, the same bug, just silent instead of dishonest);
221
+ * - giving up after `MAX_CONSECUTIVE_POLL_ERRORS` consecutive failed reads
222
+ * instead of retrying the same dead endpoint for the full 300s.
185
223
  */
186
224
  export async function pollRunProgress(id, before, opts = {}) {
187
225
  const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
188
226
  const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
227
+ // #107 — `run --json --watch` / `trigger --json --watch` must keep stdout
228
+ // pure JSON: none of this function's intermediate progress text is safe to
229
+ // print once a caller asked for --json. `asJson` suppresses every
230
+ // intermediate console.log below (including the pinch celebration frame)
231
+ // while the polling/wait logic itself runs unchanged; the ONE exception is
232
+ // the single final outcome line emitted right before each return below.
233
+ const asJson = opts.json ?? false;
189
234
  let beforeId = before?.id;
190
235
  let beforeAlreadyInFlight = before?.alreadyInFlight ?? false;
191
236
  // #97 — only an EXPLICIT captured:false (capture's GET actually threw)
@@ -193,10 +238,13 @@ export async function pollRunProgress(id, before, opts = {}) {
193
238
  // capture entirely) or `captured` being true/absent both mean "trust
194
239
  // beforeId as given", preserving every existing call site's behavior.
195
240
  let baselineEstablished = before?.captured ?? true;
196
- console.log(chalk.dim('Live activity streaming has no signal for this run (async/cross-pod) — polling for progress instead…\n'));
241
+ if (!asJson) {
242
+ console.log(chalk.dim('Live activity streaming has no signal for this run (async/cross-pod) — polling for progress instead…\n'));
243
+ }
197
244
  const deadline = Date.now() + timeoutMs;
198
245
  const seen = new Set();
199
246
  let first = true;
247
+ let consecutivePollErrors = 0;
200
248
  while (Date.now() < deadline) {
201
249
  if (!first)
202
250
  await sleep(intervalMs);
@@ -204,9 +252,28 @@ export async function pollRunProgress(id, before, opts = {}) {
204
252
  let scrap;
205
253
  try {
206
254
  scrap = await api.get(`/api/scraps/${id}`);
255
+ consecutivePollErrors = 0;
207
256
  }
208
- catch {
209
- continue; // transient — keep polling rather than aborting the wait
257
+ catch (err) {
258
+ consecutivePollErrors++;
259
+ // #107 review F1 — a single failed read is still a TRANSIENT blip,
260
+ // safe to retry within the deadline (unchanged). Only once reads fail
261
+ // this many times IN A ROW is the API treated as genuinely
262
+ // unreachable — surface that honestly instead of quietly burning the
263
+ // full 300s timeout on a dead endpoint.
264
+ if (consecutivePollErrors >= MAX_CONSECUTIVE_POLL_ERRORS) {
265
+ const message = err instanceof Error ? err.message : String(err);
266
+ if (asJson) {
267
+ const outcome = { runId: beforeId, status: 'poll_error', error: message };
268
+ console.log(JSON.stringify(outcome));
269
+ }
270
+ else {
271
+ console.log(chalk.red(`✗ Polling failed repeatedly (${message}) — check status with: trawl scraps doctor ${id}`));
272
+ }
273
+ process.exitCode = 1;
274
+ return;
275
+ }
276
+ continue;
210
277
  }
211
278
  const last = scrap.history?.[0];
212
279
  if (!last?._id)
@@ -233,6 +300,8 @@ export async function pollRunProgress(id, before, opts = {}) {
233
300
  if (seen.has(key))
234
301
  continue;
235
302
  seen.add(key);
303
+ if (asJson)
304
+ continue;
236
305
  const time = new Date(a.createdAt).toLocaleTimeString();
237
306
  console.log(`${chalk.dim(`[${time}]`)} ${a.message}`);
238
307
  }
@@ -242,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
- console.log(chalk.dim(`Run finished: ${outcome}`));
246
- // Pinch celebrates a clean run finish (#94) mirrors doctor.ts's own
247
- // `status === true` success definition (regardless of statusDetail),
248
- // never for a failed/regression run. Neither `run --watch` nor
249
- // `trigger --watch` (the only two callers) has a --json flag, so this
250
- // is always safe to print. Guarded by pinchEnabled() (NO_COLOR/non-TTY).
251
- if (last.status === true && pinchEnabled()) {
252
- console.log(renderPinch('celebrating'));
314
+ // #107 review F1 — the exit code, in BOTH modes: a regression row's
315
+ // WRITE actually succeeded (see lastStatus()'s own #91 comment above,
316
+ // and `scraps data`'s isRegression branch) and an 'empty' row is a
317
+ // genuine zero-item success neither is a real failure. Only
318
+ // status:false with neither of those details (e.g. 'error', or no
319
+ // detail at all) is a genuine failed run.
320
+ const isGenuineFailure = last.status === false && outcome !== 'empty' && outcome !== 'regression';
321
+ if (asJson) {
322
+ const payload = { runId: last._id, status: outcome };
323
+ console.log(JSON.stringify(payload));
324
+ }
325
+ else {
326
+ console.log(chalk.dim(`Run finished: ${outcome}`));
327
+ // Pinch celebrates a clean run finish (#94) — mirrors doctor.ts's own
328
+ // `status === true` success definition (regardless of statusDetail),
329
+ // never for a failed/regression run. Guarded by pinchEnabled()
330
+ // (NO_COLOR/non-TTY) and never under --json (stdout must stay pure).
331
+ if (last.status === true && pinchEnabled()) {
332
+ console.log(renderPinch('celebrating'));
333
+ }
253
334
  }
335
+ process.exitCode = isGenuineFailure ? 1 : 0;
254
336
  return;
255
337
  }
256
338
  }
257
- console.log(chalk.yellow(`⚠ Timed out waiting for the run to finish check status with: trawl scraps doctor ${id}`));
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 Error(message), { json: wantsJson });
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
- await oraPromise(() => api.get(`/api/scraps/load/${id}`, { timeoutMs: LONG_RUN_TIMEOUT_MS }), {
628
- text: 'Launching scrap…',
629
- successText: 'Scrap launched',
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
- await pollRunProgress(id, beforeRun);
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
- 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
- }
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(() => api.delete(`/api/scraps/${id}`), { text: 'Deleting…', successText: 'Scrap deleted' });
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
- await oraPromise(() => api.upload(`/api/scraps/${id}/banner`, formData), {
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
- .action(async (id) => {
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
- 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
- });
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
- console.log(chalk.yellow('⚠ Passing --password on the command line is insecure and may be stored in shell history.'));
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 data = await oraPromise(() => api.put(`/api/scraps/${id}/account`, { username, password }), { text: 'Saving credentials…', successText: 'Credentials saved' });
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
- if (!opts.force) {
1050
- const { createInterface } = await import('readline');
1051
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1052
- let answer;
1053
- try {
1054
- answer = await new Promise((resolve) => {
1055
- rl.question(`Delete account credentials for scrap ${chalk.bold(id)}? ${chalk.dim('[y/N]')} `, (a) => resolve(a.trim().toLowerCase()));
1056
- });
1057
- }
1058
- finally {
1059
- rl.close();
1060
- }
1061
- if (answer !== 'y' && answer !== 'yes') {
1062
- console.log(chalk.dim('Aborted.'));
1063
- return;
1064
- }
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(() => api.delete(`/api/scraps/${id}/account`), {
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
- .action(async (id) => {
1235
+ .option('--json', 'Output as JSON')
1236
+ .action(async (id, opts) => {
1076
1237
  validateObjectId(id);
1077
- await oraPromise(() => api.delete(`/api/scraps/${id}/account/session`), {
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 data = await oraPromise(() => api.put(`/api/scraps/${id}/account/session`, { cookies }), { text: 'Uploading session cookies…', successText: `Session cookies saved for scrap ${chalk.bold(id)}` });
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
  });