@vigilbase/cli 0.2.2 → 0.2.4

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.
Files changed (77) hide show
  1. package/CLOUDFLARE.md +2 -2
  2. package/COMMANDS.md +128 -42
  3. package/LOCAL.md +99 -38
  4. package/ONE.md +1 -1
  5. package/README.md +217 -66
  6. package/SECURITY.md +1 -1
  7. package/dist/auth/acl.d.ts +6 -0
  8. package/dist/auth/acl.js +45 -0
  9. package/dist/auth/device-auth.d.ts +3 -0
  10. package/dist/auth/device-auth.js +13 -3
  11. package/dist/auth/provider.js +4 -4
  12. package/dist/auth/refresh.d.ts +5 -4
  13. package/dist/auth/refresh.js +82 -41
  14. package/dist/auth/store.d.ts +25 -0
  15. package/dist/auth/store.js +91 -11
  16. package/dist/bin.js +13 -1
  17. package/dist/cli.js +205 -81
  18. package/dist/commands/account.js +11 -7
  19. package/dist/commands/auth.d.ts +6 -0
  20. package/dist/commands/auth.js +75 -24
  21. package/dist/commands/billing.js +2 -2
  22. package/dist/commands/cloudflare.d.ts +1 -1
  23. package/dist/commands/cloudflare.js +31 -17
  24. package/dist/commands/helpers.js +3 -3
  25. package/dist/commands/index.d.ts +1 -0
  26. package/dist/commands/index.js +2 -1
  27. package/dist/commands/legacy-validation.d.ts +20 -0
  28. package/dist/commands/legacy-validation.js +77 -3
  29. package/dist/commands/local.js +186 -65
  30. package/dist/commands/one.js +16 -13
  31. package/dist/commands/public-tools.js +66 -35
  32. package/dist/commands/runs.js +5 -5
  33. package/dist/commands/security.d.ts +1 -1
  34. package/dist/commands/security.js +41 -37
  35. package/dist/commands/watchdog.js +5 -5
  36. package/dist/lib/banner.d.ts +5 -11
  37. package/dist/lib/banner.js +11 -31
  38. package/dist/lib/client.d.ts +9 -0
  39. package/dist/lib/client.js +31 -2
  40. package/dist/lib/cloudflare-client.d.ts +2 -2
  41. package/dist/lib/config.d.ts +16 -0
  42. package/dist/lib/config.js +77 -19
  43. package/dist/lib/format.d.ts +5 -1
  44. package/dist/lib/format.js +110 -35
  45. package/dist/lib/help.js +57 -6
  46. package/dist/lib/network.d.ts +12 -3
  47. package/dist/lib/network.js +73 -9
  48. package/dist/lib/one-client.d.ts +2 -0
  49. package/dist/lib/one-client.js +86 -12
  50. package/dist/lib/open-url.js +2 -1
  51. package/dist/lib/platform.d.ts +2 -0
  52. package/dist/lib/platform.js +5 -0
  53. package/dist/lib/progress.d.ts +18 -3
  54. package/dist/lib/progress.js +72 -10
  55. package/dist/lib/system-trust.d.ts +2 -0
  56. package/dist/lib/system-trust.js +6 -0
  57. package/dist/lib/tui.d.ts +1 -1
  58. package/dist/lib/tui.js +5 -7
  59. package/dist/local/cloudflare-checkup/cf/child-env.js +2 -4
  60. package/dist/local/cloudflare-checkup/cf/install.d.ts +4 -2
  61. package/dist/local/cloudflare-checkup/cf/install.js +55 -24
  62. package/dist/local/cloudflare-checkup/cf/locate.d.ts +2 -0
  63. package/dist/local/cloudflare-checkup/cf/locate.js +10 -1
  64. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +18 -3
  65. package/dist/local/cloudflare-checkup/cf/run-cf.js +75 -17
  66. package/dist/local/cloudflare-checkup/contract.d.ts +16 -9
  67. package/dist/local/cloudflare-checkup/contract.js +80 -14
  68. package/dist/local/cloudflare-checkup/errors.d.ts +34 -1
  69. package/dist/local/cloudflare-checkup/errors.js +36 -5
  70. package/dist/local/cloudflare-checkup/output.d.ts +6 -0
  71. package/dist/local/cloudflare-checkup/output.js +9 -0
  72. package/dist/local/cloudflare-checkup/snapshot-file.d.ts +6 -2
  73. package/dist/local/cloudflare-checkup/snapshot-file.js +25 -11
  74. package/dist/local/cloudflare-checkup/submit-client.js +7 -1
  75. package/dist/types.d.ts +6 -1
  76. package/esm-only.cjs +3 -0
  77. package/package.json +7 -4
@@ -1,7 +1,18 @@
1
1
  import { stripVTControlCharacters } from 'node:util';
2
- /** Untrusted API data must never become terminal control sequences or bidi overrides. */
2
+ import { LOCAL_ERROR_CODES } from '../local/cloudflare-checkup/errors.js';
3
+ /** A scan that ended without a result: com's copy (apps/com/lib/tools/execute-internal-scan.ts). */
4
+ export const SCAN_FAILED_MESSAGE = "The scan couldn't be completed. Try again later.";
5
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+ /**
7
+ * Terminal controls, bidi overrides, and invisible characters an agent reads but a person can't
8
+ * see: zero-width characters, word joiners, the BOM, line separators and tag characters.
9
+ */
10
+ const TERMINAL_UNSAFE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]/gu;
11
+ /** The same, less the C0 controls JSON.stringify already escapes. */
12
+ const JSON_UNSAFE = /[\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u2064\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]/gu;
13
+ /** Untrusted API data must never become terminal control sequences, bidi overrides or invisible text. */
3
14
  export function sanitizeTerminal(value) {
4
- return stripVTControlCharacters(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '');
15
+ return stripVTControlCharacters(value).replace(TERMINAL_UNSAFE, '');
5
16
  }
6
17
  /** One sanitised line, cut to `maxLength` characters with an ellipsis. */
7
18
  export function singleLine(value, maxLength = 300) {
@@ -22,7 +33,7 @@ export function createWriter(stdout = process.stdout, stderr = process.stderr) {
22
33
  }
23
34
  export function printJson(writer, value) {
24
35
  // Keep JSON lossless while preventing literal terminal/bidi controls in rendered output.
25
- writer.stdout(JSON.stringify(value, null, 2).replace(/[\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`));
36
+ writer.stdout(JSON.stringify(value, null, 2).replace(JSON_UNSAFE, (char) => [...Array(char.length).keys()].map((index) => `\\u${char.charCodeAt(index).toString(16).padStart(4, '0')}`).join('')));
26
37
  }
27
38
  function asRecord(value) {
28
39
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -153,6 +164,9 @@ function collectFindings(result) {
153
164
  if (!header)
154
165
  continue;
155
166
  const status = typeof header.status === 'string' ? header.status : '';
167
+ // An absent header the scanner graded good (X-XSS-Protection) is not a finding.
168
+ if (status === 'good')
169
+ continue;
156
170
  if (status !== 'missing' && status !== 'misconfigured' && header.present !== false) {
157
171
  continue;
158
172
  }
@@ -188,30 +202,49 @@ function collectFindings(result) {
188
202
  return findings;
189
203
  }
190
204
  const MAX_FINDING_LINES = 12;
205
+ /** The shareable report link, only when it is a plain https://vigilbase.com URL. */
206
+ function reportUrl(value) {
207
+ if (typeof value !== 'string')
208
+ return null;
209
+ let url;
210
+ try {
211
+ url = new URL(value);
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ if (url.protocol !== 'https:' || url.hostname !== 'vigilbase.com' || url.port || url.username || url.password)
217
+ return null;
218
+ return url.href;
219
+ }
191
220
  /** Compact human lines for a scan result (no UI/skill links, no summary dump). */
192
221
  function summarizeResultCompact(result) {
193
222
  if (!result)
194
223
  return [];
224
+ // Server text never starts a line of its own: each message line is indented like the rest.
195
225
  const lines = [];
196
226
  const message = typeof result.message === 'string' ? result.message : null;
197
227
  if (message)
198
- lines.push(message);
228
+ lines.push(...sanitizeTerminal(message).split('\n').map((line) => line.trim()).filter(Boolean));
199
229
  const counts = summarizeCounts(result);
200
230
  if (counts)
201
231
  lines.push(counts);
202
232
  const findings = collectFindings(result);
203
233
  for (const finding of findings.slice(0, MAX_FINDING_LINES)) {
204
- const sev = finding.severity ? ` ${finding.severity}` : '';
234
+ const sev = finding.severity ? ` ${singleLine(finding.severity, 20)}` : '';
205
235
  if (finding.recommendation) {
206
- lines.push(`✗${sev} ${finding.title} — ${finding.recommendation}`);
236
+ lines.push(`✗${sev} ${singleLine(finding.title)} — ${singleLine(finding.recommendation)}`);
207
237
  }
208
238
  else {
209
- lines.push(`✗${sev} ${finding.title}`);
239
+ lines.push(`✗${sev} ${singleLine(finding.title)}`);
210
240
  }
211
241
  }
212
242
  if (findings.length > MAX_FINDING_LINES) {
213
243
  lines.push(`… +${findings.length - MAX_FINDING_LINES} more (use --json)`);
214
244
  }
245
+ const report = reportUrl(result.shareableUrl);
246
+ if (report)
247
+ lines.push(`Report: ${report}`);
215
248
  return lines;
216
249
  }
217
250
  export function formatToolsList(catalog, mode, writer, options) {
@@ -219,11 +252,12 @@ export function formatToolsList(catalog, mode, writer, options) {
219
252
  printJson(writer, catalog);
220
253
  return;
221
254
  }
222
- const publicNames = catalog.tools.map((tool) => tool.toolName).join(' ');
255
+ // Catalog names are the server's: a newline in one must not start a line of its own.
256
+ const publicNames = catalog.tools.map((tool) => singleLine(tool.toolName, 100)).join(' ');
223
257
  writer.stdout(`public ${publicNames || '(none)'}`);
224
258
  const authRequired = options?.authRequiredTools ?? options?.sensitiveTools ?? [];
225
259
  if (authRequired.length) {
226
- writer.stdout(`auth-req ${authRequired.join(' ')}`);
260
+ writer.stdout(`auth-req ${authRequired.map((name) => singleLine(name, 100)).join(' ')}`);
227
261
  }
228
262
  if (typeof options?.isAuthenticated === 'boolean') {
229
263
  writer.stdout(options.isAuthenticated ? 'auth signed in' : 'auth signed out');
@@ -231,49 +265,79 @@ export function formatToolsList(catalog, mode, writer, options) {
231
265
  }
232
266
  export function formatToolDescription(tool, mode, writer, skillMarkdown) {
233
267
  if (mode === 'json') {
234
- printJson(writer, { ...tool, skillMarkdown: skillMarkdown ?? null });
268
+ printJson(writer, { success: true, ...tool, skillMarkdown: skillMarkdown ?? null });
235
269
  return;
236
270
  }
237
- writer.stdout(`${tool.toolName} ${tool.name}`);
238
- const blurb = tool.description.length > 100
239
- ? `${tool.description.slice(0, 97).trimEnd()}…`
240
- : tool.description;
241
- writer.stdout(blurb);
242
- writer.stdout(`vigilbase run ${tool.toolName} <target>`);
271
+ // Catalog text is the server's: each field stays on its own line.
272
+ writer.stdout(`${singleLine(tool.toolName, 100)} ${singleLine(tool.name)}`);
273
+ writer.stdout(singleLine(tool.description, 2000));
274
+ if (tool.targetDescription)
275
+ writer.stdout(`Target: ${singleLine(tool.targetDescription)}`);
276
+ writer.stdout(`vigilbase run ${singleLine(tool.toolName, 100)} <target>`);
243
277
  if (skillMarkdown) {
244
278
  writer.stdout();
245
279
  writer.stdout(skillMarkdown.trimEnd());
246
280
  }
247
281
  }
282
+ /**
283
+ * Why a scan failed, or null when it has a result. A scan the scanner could not grade has
284
+ * `status: "error"` with a message and no grade: One and vigilbase.com both send it as a 422,
285
+ * which the clients turn into a `scan_unassessed` result.
286
+ */
287
+ function scanFailure(result) {
288
+ if (result.success && result.result?.status !== 'error')
289
+ return null;
290
+ const message = result.result?.status === 'error'
291
+ ? result.result.message
292
+ : result.success ? undefined : typeof result.error === 'string' ? result.error : result.error?.message;
293
+ // Vigilbase's scanner wrote it, but it is bounded like any server reason.
294
+ return (typeof message === 'string' && singleLine(message)) || SCAN_FAILED_MESSAGE;
295
+ }
296
+ /** Whether a scan produced a result; the exit code follows it. */
297
+ export function isSuccessfulScan(result) {
298
+ return scanFailure(result) === null;
299
+ }
248
300
  export function formatExecuteResult(result, mode, writer) {
301
+ const failure = scanFailure(result);
249
302
  if (mode === 'json') {
250
- printJson(writer, result);
303
+ // A scan without a result fails in JSON as in the exit code.
304
+ // A failed scan has no grade, whatever placeholder the scanner used.
305
+ printJson(writer, failure === null ? result : { ...result, success: false, error: failure, grade: null, gradeScore: null });
251
306
  return;
252
307
  }
253
- const tool = result.toolName ?? 'tool';
254
- const target = result.target ?? '';
255
- const grade = result.grade != null
256
- ? `${result.grade}${result.gradeScore != null ? ` ${result.gradeScore}` : ''}`
308
+ const tool = singleLine(result.toolName ?? 'tool', 100);
309
+ const target = singleLine(result.target ?? '', 2100);
310
+ const gradeLetter = result.grade != null ? singleLine(String(result.grade), 10) : '';
311
+ const grade = failure === null && result.grade != null
312
+ ? `${gradeLetter}${result.gradeScore != null ? ` ${singleLine(String(result.gradeScore), 10)}` : ''}`
257
313
  : 'n/a';
258
- const head = result.success
259
- ? `✓ ${tool} · ${target} · ${grade}`
260
- : `✗ ${tool} · ${target} · ${grade}`;
261
- if (result.success)
262
- writer.stdout(head);
263
- else
264
- writer.stderr(head);
265
- const error = typeof result.error === 'string' ? result.error : result.error?.message;
266
- if (error) {
267
- writer.stderr(` ${error}`);
314
+ if (failure !== null) {
315
+ writer.stderr(`✗ ${tool} · ${target} · ${grade}`);
316
+ writer.stderr(` ${failure}`);
317
+ return;
268
318
  }
319
+ // A graded scan exits 0 whatever the grade; only a secure one without an F gets the ✓.
320
+ const isClean = result.result?.status === 'secure' && gradeLetter.toUpperCase() !== 'F';
321
+ writer.stdout(`${isClean ? '✓' : '•'} ${tool} · ${target} · ${grade}`);
269
322
  for (const line of summarizeResultCompact(result.result)) {
270
323
  writer.stdout(` ${line}`);
271
324
  }
272
325
  }
326
+ /** Issues of a ZodError, also one from another copy of zod. */
327
+ function zodErrorIssues(error) {
328
+ if (!(error instanceof Error) || error.name !== 'ZodError' || !('issues' in error) || !Array.isArray(error.issues))
329
+ return null;
330
+ return error.issues;
331
+ }
273
332
  export function formatError(error, mode, writer) {
274
333
  let message = error instanceof Error ? error.message : String(error);
275
334
  let reason = '';
276
- // Server bodies can contain credentials, customer data, or arbitrary error text.
335
+ const zodIssues = zodErrorIssues(error);
336
+ // A ZodError's message is its serialised issue list; show the issue messages instead.
337
+ if (zodIssues)
338
+ message = zodIssues.map((issue) => `${issue.path?.length ? `${issue.path.map(String).join('.')}: ` : ''}${singleLine(String(issue.message))}`).join(' ') || 'Invalid argument.';
339
+ // Server bodies can contain credentials, customer data, or arbitrary error text, so an error that
340
+ // carries one shows only text the CLI wrote, or the reason the public client accepted.
277
341
  if (error && typeof error === 'object' && 'body' in error && error.body != null && 'status' in error && typeof error.status === 'number') {
278
342
  if ('code' in error && error.code === 'FLAREHAWK_VIEW_AS_REQUIRED') {
279
343
  message = 'Select a customer with --customer-id <customer UUID> for security commands.';
@@ -281,13 +345,16 @@ export function formatError(error, mode, writer) {
281
345
  else {
282
346
  // Only the reason the public client accepted from a known problem+json error is shown.
283
347
  reason = 'reason' in error && typeof error.reason === 'string' ? singleLine(error.reason) : '';
284
- message = reason || `Request failed (HTTP ${error.status}).`;
348
+ const isCliAuthored = 'isCliAuthored' in error && error.isCliAuthored === true;
349
+ message = reason || (isCliAuthored ? message : `Request failed (HTTP ${error.status}).`);
285
350
  }
286
351
  }
287
352
  const payload = {
288
353
  success: false,
289
354
  error: message,
290
355
  };
356
+ if (zodIssues)
357
+ payload.code = 'INVALID_ARGUMENT';
291
358
  if (error && typeof error === 'object') {
292
359
  if ('status' in error && typeof error.status === 'number' &&
293
360
  Number.isInteger(error.status) && error.status >= 0 && error.status <= 599) {
@@ -310,14 +377,22 @@ export function formatError(error, mode, writer) {
310
377
  if (hint)
311
378
  payload.hint = hint;
312
379
  }
380
+ // The One tool run a failed scan recorded, for `vigilbase runs get`.
381
+ if ('toolRunId' in error && typeof error.toolRunId === 'string' && UUID.test(error.toolRunId)) {
382
+ payload.toolRunId = error.toolRunId;
383
+ }
313
384
  }
314
385
  if (mode === 'json') {
315
386
  printJson(writer, payload);
316
387
  return;
317
388
  }
318
- writer.stderr(`Error: ${message}${reason && typeof payload.code === 'string' ? ` (${payload.code})` : ''}`);
389
+ // Local checkup codes show as in the terminal UI; invalid-argument messages already name the flag.
390
+ const isLocalCode = LOCAL_ERROR_CODES.includes(payload.code);
391
+ writer.stderr(`Error: ${message}${(reason || isLocalCode) && typeof payload.code === 'string' ? ` (${payload.code})` : ''}`);
319
392
  if (typeof payload.retryAfterSec === 'number') {
320
- writer.stderr(`Retry after ${payload.retryAfterSec}s`);
393
+ // Quotas reset after up to an hour: minutes read better than thousands of seconds.
394
+ const seconds = payload.retryAfterSec;
395
+ writer.stderr(seconds < 120 ? `Retry after ${seconds}s` : `Retry after about ${Math.round(seconds / 60)} minutes`);
321
396
  }
322
397
  if (typeof payload.hint === 'string') {
323
398
  writer.stderr(`Hint: ${payload.hint}`);
package/dist/lib/help.js CHANGED
@@ -19,6 +19,52 @@ export function isCatalogTool(name) {
19
19
  }
20
20
  /** Column where descriptions start in the main help: two-space indent, term, at least two spaces. */
21
21
  const DESCRIPTION_COLUMN = 31;
22
+ /** Help lines fit an 80-column terminal. */
23
+ const HELP_WIDTH = 80;
24
+ /**
25
+ * Where a wrapped line continues: under the description of a row, under the text of a usage
26
+ * line, at the indent of a note, or at the margin for prose.
27
+ */
28
+ function continuationIndent(line) {
29
+ const described = /^ {2}\S.*? {2,}(?=\S)/.exec(line);
30
+ if (described)
31
+ return described[0].length;
32
+ if (line.startsWith('Usage: '))
33
+ return 'Usage: '.length;
34
+ const indent = /^ */.exec(line)[0].length;
35
+ return indent === 0 ? 0 : Math.max(indent, 4);
36
+ }
37
+ /** Where to cut a line at most `width` long: before an option group, else at a space, else after a |. */
38
+ function breakPoint(line, from, width) {
39
+ const head = line.slice(0, width + 1);
40
+ const bracket = head.lastIndexOf(' [');
41
+ if (bracket > from + (width - from) / 2)
42
+ return { cut: bracket, skip: 1 };
43
+ const space = head.lastIndexOf(' ');
44
+ if (space > from)
45
+ return { cut: space, skip: 1 };
46
+ const bar = line.slice(0, width).lastIndexOf('|');
47
+ return bar > from ? { cut: bar + 1, skip: 0 } : null;
48
+ }
49
+ /** Wraps help lines longer than HELP_WIDTH, keeping descriptions aligned. */
50
+ function wrapHelp(text) {
51
+ return text.split('\n').flatMap((line) => {
52
+ const lines = [];
53
+ const pad = ' '.repeat(continuationIndent(line));
54
+ let rest = line;
55
+ // Past the first cut, only cut after the indent, so every line is shorter than the last.
56
+ let from = /^ */.exec(line)[0].length;
57
+ while (rest.length > HELP_WIDTH) {
58
+ const at = breakPoint(rest, from, HELP_WIDTH);
59
+ if (!at)
60
+ break;
61
+ lines.push(rest.slice(0, at.cut).trimEnd());
62
+ rest = `${pad}${rest.slice(at.cut + at.skip).trimStart()}`;
63
+ from = pad.length;
64
+ }
65
+ return [...lines, rest];
66
+ }).join('\n');
67
+ }
22
68
  /** Two-column help rows whose descriptions all start at DESCRIPTION_COLUMN. */
23
69
  function helpRows(rows) {
24
70
  return rows
@@ -35,7 +81,7 @@ const GLOBAL_OPTIONS = helpRows([
35
81
  ['--auth-url <origin>', 'Identity issuer (default: auth.vigilbase.com)'],
36
82
  ['--client-id <id>', 'Device authorization client ID'],
37
83
  ]);
38
- export const HELP_TEXT = `Usage: vigilbase <command>
84
+ export const HELP_TEXT = wrapHelp(`Usage: vigilbase <command>
39
85
 
40
86
  Public tools (no login)
41
87
  list List available tools
@@ -63,7 +109,7 @@ Account (login required)
63
109
  cf <command> Cloudflare accounts, zones, DNS, WAF, analytics
64
110
  security <command> Incidents, logs, assets, detections, intel, reports
65
111
  watchdog [list|scans] Watchdog targets / scans
66
- customers <command> Customer records, billing, reports, investigations
112
+ customers <command> Customers, billing, reports, investigations
67
113
  orgs <command> Organizations, members, permission roles
68
114
  monitoring <command> Health, probes, metrics, customer targets
69
115
  support tickets Support ticket history
@@ -81,7 +127,7 @@ Examples
81
127
  vigilbase runs list --json
82
128
  vigilbase cf zones
83
129
  vigilbase security incidents --severity high
84
- `;
130
+ `);
85
131
  /** How to run a catalog tool: `help <tool>` and `<tool> --help`, since tools run as `run <tool> <target>`. */
86
132
  function helpForTool(tool) {
87
133
  if (isPublicTool(tool)) {
@@ -100,6 +146,10 @@ Details: vigilbase describe ${tool}`;
100
146
  }
101
147
  export const HELP_USAGE = 'Usage: vigilbase help [<command> | <tool>]';
102
148
  export function helpForCommand(command) {
149
+ const help = commandHelp(command);
150
+ return help === null ? null : wrapHelp(help);
151
+ }
152
+ function commandHelp(command) {
103
153
  switch (command) {
104
154
  case 'help':
105
155
  return `${HELP_USAGE}
@@ -127,13 +177,14 @@ Run vigilbase help <tool> for a tool's usage.`;
127
177
 
128
178
  Checks the public tools API, your session (Identity's userinfo endpoint when
129
179
  signed in through Identity, otherwise One's session endpoint) and any saved
130
- sign-in independently. Login is optional. Exits with status 1 if any check fails.
131
- Use --base-url, --one-url, --auth-url and --timeout to check other environments.`;
180
+ sign-in independently. Login is optional. Exits with status 1 if any check
181
+ fails. Use --base-url, --one-url, --auth-url and --timeout to check other
182
+ environments.`;
132
183
  case 'account':
133
184
  return `Usage: vigilbase account [services|org|endpoints|sessions|invites|billing] [--json]
134
185
 
135
186
  Requires \`vigilbase login\`.
136
- account invites create --email user@example.com [--role member] --confirm`;
187
+ account invites create <email>|--email <email> [--role <role>] --confirm`;
137
188
  case 'runhistory':
138
189
  case 'runs':
139
190
  return `Usage: vigilbase runs [list|get <UUID>] [--json]
@@ -1,10 +1,14 @@
1
1
  import type { CliConfig } from './config.js';
2
2
  export declare class NetworkError extends Error {
3
3
  readonly code: string;
4
+ readonly hint?: string | undefined;
4
5
  readonly status = 0;
5
- constructor(message: string, code: string, cause?: unknown);
6
+ /** `hint` is printed after the error; doctor, which is the connectivity check, leaves it out. */
7
+ constructor(message: string, code: string, cause?: unknown, hint?: string | undefined);
6
8
  }
7
9
  export declare function networkError(error: unknown, url: string, timeoutMs: number, timedOut: boolean): NetworkError;
10
+ /** The option that sets each service's origin, which a caller names for the redirect message. */
11
+ export type OriginOption = '--base-url' | '--one-url' | '--auth-url';
8
12
  export declare const MAX_RESPONSE_BYTES: number;
9
13
  /** Bound decoded bytes even when Content-Length is absent, compressed, or dishonest. */
10
14
  export declare function readBoundedBody(response: Response, signal: AbortSignal, maxBytes?: number): Promise<string>;
@@ -18,8 +22,13 @@ export declare function anySignal(signals: AbortSignal[]): {
18
22
  signal: AbortSignal;
19
23
  dispose: () => void;
20
24
  };
21
- /** Keep the timeout active until the complete response body has been read. */
22
- export declare function requestText(config: CliConfig, url: string, init?: RequestInit): Promise<{
25
+ /**
26
+ * Keep the timeout active until the complete response body has been read. `option` names the option that sets the
27
+ * service's origin, for the message when it redirects.
28
+ */
29
+ export declare function requestText(config: CliConfig, url: string, { option, ...init }?: RequestInit & {
30
+ option?: OriginOption;
31
+ }): Promise<{
23
32
  response: Response;
24
33
  text: string;
25
34
  }>;
@@ -1,9 +1,13 @@
1
+ import { isSystemTrustConfigured } from './system-trust.js';
1
2
  export class NetworkError extends Error {
2
3
  code;
4
+ hint;
3
5
  status = 0;
4
- constructor(message, code, cause) {
6
+ /** `hint` is printed after the error; doctor, which is the connectivity check, leaves it out. */
7
+ constructor(message, code, cause, hint) {
5
8
  super(message, { cause });
6
9
  this.code = code;
10
+ this.hint = hint;
7
11
  this.name = 'NetworkError';
8
12
  }
9
13
  }
@@ -18,21 +22,70 @@ function errorCodes(error, seen = new Set()) {
18
22
  ...(Array.isArray(item.errors) ? item.errors.flatMap((e) => errorCodes(e, seen)) : []),
19
23
  ];
20
24
  }
25
+ /** Messages along the cause chain: undici reports a blocked port or a refused redirect only by message. */
26
+ function causeMessages(error, seen = new Set()) {
27
+ if (!error || typeof error !== 'object' || seen.has(error))
28
+ return [];
29
+ seen.add(error);
30
+ const item = error;
31
+ return [...(typeof item.message === 'string' ? [item.message] : []), ...causeMessages(item.cause, seen)];
32
+ }
21
33
  export function networkError(error, url, timeoutMs, timedOut) {
22
34
  // Never echo query strings, URL credentials, request bodies, or raw causes.
23
- const endpoint = new URL(url).origin;
35
+ const { origin: endpoint, port } = new URL(url);
24
36
  const codes = errorCodes(error);
25
37
  const code = codes[0] ?? 'NETWORK_ERROR';
38
+ const messages = causeMessages(error);
26
39
  if (timedOut || codes.some((c) => /TIMEOUT|ETIMEDOUT/.test(c))) {
27
40
  return new NetworkError(`Request to ${endpoint} timed out after ${timeoutMs}ms. Check your connection or increase --timeout.`, 'TIMEOUT', error);
28
41
  }
42
+ if (messages.includes('bad port')) {
43
+ return new NetworkError(`Node's fetch refuses port ${port} of ${endpoint} (a port the Fetch standard blocks). Use another port.`, 'BLOCKED_PORT', error);
44
+ }
45
+ if (messages.includes('unexpected redirect')) {
46
+ return new NetworkError(`${endpoint} answered with a redirect, which the CLI does not follow.`, 'UNEXPECTED_REDIRECT', error);
47
+ }
29
48
  if (codes.some((c) => /CERT|SELF_SIGNED|UNABLE_TO_VERIFY|UNABLE_TO_GET_ISSUER/.test(c))) {
30
- return new NetworkError(`TLS certificate verification failed for ${endpoint} (${code}). Use Node 22.19+ or Node 24.5+ for automatic system certificate trust. If your organization uses a private CA, configure NODE_EXTRA_CA_CERTS with its trusted PEM certificate.`, code, error);
49
+ // Without system trust (older Node), the OS certificate store isn't used at all.
50
+ const upgrade = isSystemTrustConfigured() ? '' : ' Use Node 22.19+ or Node 24.5+ for automatic system certificate trust.';
51
+ return new NetworkError(`TLS certificate verification failed for ${endpoint} (${code}).${upgrade} If your organization uses a private CA, configure NODE_EXTRA_CA_CERTS with its trusted PEM certificate.`, code, error);
31
52
  }
32
53
  if (codes.some((c) => c === 'ENOTFOUND' || c === 'EAI_AGAIN')) {
33
54
  return new NetworkError(`Cannot resolve ${endpoint} (${code}). Check DNS, your VPN, and the configured API URL.`, code, error);
34
55
  }
35
- return new NetworkError(`Cannot connect to ${endpoint} (${code}). Check your connection, VPN, proxy settings, and API URL. Run \`vigilbase doctor\` for connectivity checks.`, code, error);
56
+ return new NetworkError(`Cannot connect to ${endpoint} (${code}). Check your connection, VPN, proxy settings, and API URL.`, code, error, 'Run `vigilbase doctor` for connectivity checks.');
57
+ }
58
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
59
+ /** The option that sets this origin, when the caller didn't name it. */
60
+ function optionFor(config, origin) {
61
+ if (origin === config.publicBaseUrl)
62
+ return '--base-url';
63
+ if (origin === config.oneBaseUrl)
64
+ return '--one-url';
65
+ if (origin === config.identityBaseUrl)
66
+ return '--auth-url';
67
+ return undefined;
68
+ }
69
+ /** A redirect the CLI refuses, naming only the target's origin: its path and query may carry tokens. */
70
+ function redirectError(config, url, response, named) {
71
+ const { origin } = new URL(url);
72
+ let target;
73
+ try {
74
+ const location = new URL(response.headers.get('location') ?? '', url);
75
+ if ((location.protocol === 'https:' || location.protocol === 'http:') && location.origin !== origin)
76
+ target = location.origin;
77
+ }
78
+ catch {
79
+ // An unparsable Location: say only that it redirected.
80
+ }
81
+ // Public, One and Identity may share an origin, so the caller's service decides the option.
82
+ const option = named ?? optionFor(config, origin);
83
+ const message = !target
84
+ ? `${origin} answered with a redirect (HTTP ${response.status}), which the CLI does not follow.`
85
+ : option
86
+ ? `${origin} redirected (HTTP ${response.status}) to ${target}. Set ${option} to that origin.`
87
+ : `${origin} redirected (HTTP ${response.status}) to ${target}, which the CLI does not follow.`;
88
+ return new NetworkError(message, 'UNEXPECTED_REDIRECT');
36
89
  }
37
90
  export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
38
91
  /** Bound decoded bytes even when Content-Length is absent, compressed, or dishonest. */
@@ -97,22 +150,33 @@ export function anySignal(signals) {
97
150
  signal.addEventListener('abort', onAbort, { once: true });
98
151
  return { signal: controller.signal, dispose };
99
152
  }
100
- /** Keep the timeout active until the complete response body has been read. */
101
- export async function requestText(config, url, init = {}) {
153
+ /**
154
+ * Keep the timeout active until the complete response body has been read. `option` names the option that sets the
155
+ * service's origin, for the message when it redirects.
156
+ */
157
+ export async function requestText(config, url, { option, ...init } = {}) {
102
158
  const controller = new AbortController();
103
159
  const timer = setTimeout(() => controller.abort(), config.timeoutMs);
104
160
  const { signal, dispose } = init.signal
105
161
  ? anySignal([init.signal, controller.signal])
106
162
  : { signal: controller.signal, dispose: () => undefined };
107
163
  try {
108
- // Never forward token-bearing POST bodies (or credentials) through redirects.
109
- // 'manual' is honoured because it surfaces the 3xx without following it.
110
- const response = await fetch(url, { ...init, signal, redirect: init.redirect === 'manual' ? 'manual' : 'error' });
164
+ // Never follow redirects: they could carry token-bearing POST bodies (or credentials) elsewhere.
165
+ // 'manual' surfaces the 3xx, which is refused here unless the caller asked for it.
166
+ const response = await fetch(url, { ...init, signal, redirect: 'manual' });
167
+ if (init.redirect !== 'manual' && REDIRECT_STATUSES.has(response.status) && response.headers.has('location')) {
168
+ await response.body?.cancel().catch(() => undefined);
169
+ throw redirectError(config, url, response, option);
170
+ }
111
171
  return { response, text: await readBoundedBody(response, signal) };
112
172
  }
113
173
  catch (error) {
114
174
  if (error instanceof NetworkError)
115
175
  throw error;
176
+ // Fetch refuses a header value (a token with a newline) before sending anything. Its message holds the value.
177
+ if (error instanceof TypeError && error.cause === undefined && /header/i.test(error.message)) {
178
+ throw Object.assign(new Error('A request header contains characters HTTP does not allow, so nothing was sent.'), { code: 'INVALID_ARGUMENT' });
179
+ }
116
180
  if (init.signal?.aborted) {
117
181
  throw new NetworkError('Request cancelled.', 'CANCELLED', error);
118
182
  }
@@ -7,6 +7,8 @@ import type { CliConfig } from './config.js';
7
7
  export type OneToolExecuteResponse = {
8
8
  success: boolean;
9
9
  error?: string;
10
+ /** `scan_unassessed`: the scanner ran but could not assess the target. */
11
+ code?: string;
10
12
  data?: {
11
13
  toolRunId?: string;
12
14
  status?: string;