@vigilbase/cli 0.2.3 → 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 (73) hide show
  1. package/CLOUDFLARE.md +2 -2
  2. package/COMMANDS.md +111 -44
  3. package/LOCAL.md +99 -38
  4. package/ONE.md +1 -1
  5. package/README.md +215 -67
  6. package/dist/auth/acl.d.ts +6 -0
  7. package/dist/auth/acl.js +45 -0
  8. package/dist/auth/device-auth.js +1 -0
  9. package/dist/auth/provider.js +4 -4
  10. package/dist/auth/refresh.d.ts +5 -4
  11. package/dist/auth/refresh.js +82 -41
  12. package/dist/auth/store.d.ts +25 -0
  13. package/dist/auth/store.js +91 -11
  14. package/dist/bin.js +13 -1
  15. package/dist/cli.js +205 -81
  16. package/dist/commands/account.js +11 -7
  17. package/dist/commands/auth.d.ts +6 -0
  18. package/dist/commands/auth.js +75 -24
  19. package/dist/commands/billing.js +2 -2
  20. package/dist/commands/cloudflare.d.ts +1 -1
  21. package/dist/commands/cloudflare.js +31 -17
  22. package/dist/commands/helpers.js +3 -3
  23. package/dist/commands/index.d.ts +1 -0
  24. package/dist/commands/index.js +2 -1
  25. package/dist/commands/legacy-validation.d.ts +20 -0
  26. package/dist/commands/legacy-validation.js +77 -3
  27. package/dist/commands/local.js +186 -65
  28. package/dist/commands/one.js +16 -13
  29. package/dist/commands/public-tools.js +52 -27
  30. package/dist/commands/runs.js +5 -5
  31. package/dist/commands/security.d.ts +1 -1
  32. package/dist/commands/security.js +41 -37
  33. package/dist/commands/watchdog.js +5 -5
  34. package/dist/lib/banner.d.ts +5 -11
  35. package/dist/lib/banner.js +11 -31
  36. package/dist/lib/client.js +19 -1
  37. package/dist/lib/cloudflare-client.d.ts +2 -2
  38. package/dist/lib/config.d.ts +16 -0
  39. package/dist/lib/config.js +77 -19
  40. package/dist/lib/format.d.ts +1 -1
  41. package/dist/lib/format.js +75 -24
  42. package/dist/lib/help.js +57 -6
  43. package/dist/lib/network.d.ts +12 -3
  44. package/dist/lib/network.js +73 -9
  45. package/dist/lib/one-client.js +9 -2
  46. package/dist/lib/open-url.js +2 -1
  47. package/dist/lib/platform.d.ts +2 -0
  48. package/dist/lib/platform.js +5 -0
  49. package/dist/lib/progress.d.ts +18 -3
  50. package/dist/lib/progress.js +72 -10
  51. package/dist/lib/system-trust.d.ts +2 -0
  52. package/dist/lib/system-trust.js +6 -0
  53. package/dist/lib/tui.d.ts +1 -1
  54. package/dist/lib/tui.js +5 -7
  55. package/dist/local/cloudflare-checkup/cf/child-env.js +2 -4
  56. package/dist/local/cloudflare-checkup/cf/install.d.ts +4 -2
  57. package/dist/local/cloudflare-checkup/cf/install.js +55 -24
  58. package/dist/local/cloudflare-checkup/cf/locate.d.ts +2 -0
  59. package/dist/local/cloudflare-checkup/cf/locate.js +10 -1
  60. package/dist/local/cloudflare-checkup/cf/run-cf.d.ts +18 -3
  61. package/dist/local/cloudflare-checkup/cf/run-cf.js +75 -17
  62. package/dist/local/cloudflare-checkup/contract.d.ts +16 -9
  63. package/dist/local/cloudflare-checkup/contract.js +80 -14
  64. package/dist/local/cloudflare-checkup/errors.d.ts +34 -1
  65. package/dist/local/cloudflare-checkup/errors.js +36 -5
  66. package/dist/local/cloudflare-checkup/output.d.ts +6 -0
  67. package/dist/local/cloudflare-checkup/output.js +9 -0
  68. package/dist/local/cloudflare-checkup/snapshot-file.d.ts +6 -2
  69. package/dist/local/cloudflare-checkup/snapshot-file.js +25 -11
  70. package/dist/local/cloudflare-checkup/submit-client.js +7 -1
  71. package/dist/types.d.ts +2 -1
  72. package/esm-only.cjs +3 -0
  73. package/package.json +7 -4
@@ -82,6 +82,14 @@ function publicApiError(fallback, response, body) {
82
82
  isCliAuthored: !reason,
83
83
  });
84
84
  }
85
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
86
+ /** com's 422 for a scan the scanner could not assess (apps/com/lib/agent/handlers.ts): it still carries the result. */
87
+ const unassessedScanSchema = z.object({
88
+ success: z.literal(false),
89
+ toolName: z.string(),
90
+ target: z.string(),
91
+ result: z.object({ status: z.literal('error') }).passthrough(),
92
+ });
85
93
  /** The versioned public tools API. The unversioned /api/agent/tools aliases sunset on 2027-09-01. */
86
94
  export const PUBLIC_TOOLS_PATH = '/api/v1/agent/tools';
87
95
  /**
@@ -100,6 +108,7 @@ export class VigilbaseClient {
100
108
  const url = `${this.origin}${path.startsWith('/') ? path : `/${path}`}`;
101
109
  const { response, text } = await requestText(this.config, url, {
102
110
  ...init,
111
+ option: '--base-url',
103
112
  headers: {
104
113
  Accept: 'application/json',
105
114
  'User-Agent': this.config.userAgent,
@@ -142,7 +151,16 @@ export class VigilbaseClient {
142
151
  throw new VigilbaseApiError('Invalid tool execution response. Check the API URL or retry later.', { status: response.status });
143
152
  }
144
153
  if (!parsed.success) {
145
- throw publicApiError(SCAN_FAILED_MESSAGE, response, body);
154
+ const unassessed = response.status === 422 ? unassessedScanSchema.safeParse(body) : null;
155
+ if (!unassessed?.success)
156
+ throw publicApiError(SCAN_FAILED_MESSAGE, response, body);
157
+ // A scan the scanner could not assess keeps its result, as One's route does.
158
+ const { toolName: name, target: scanned, result } = unassessed.data;
159
+ const toolRunId = typeof result.toolRunId === 'string' && UUID.test(result.toolRunId) ? result.toolRunId : undefined;
160
+ return {
161
+ success: false, toolName: name, target: scanned, grade: null, gradeScore: null, result,
162
+ error: null, status: 422, code: 'scan_unassessed', ...(toolRunId ? { toolRunId } : {}),
163
+ };
146
164
  }
147
165
  return parsed;
148
166
  }
@@ -7,13 +7,13 @@ export declare const cloudflareScopeSchema: z.ZodObject<{
7
7
  }, z.core.$strip>;
8
8
  export type CloudflareScope = z.infer<typeof cloudflareScopeSchema>;
9
9
  export declare const workspaceSectionSchema: z.ZodEnum<{
10
+ zones: "zones";
11
+ settings: "settings";
10
12
  overview: "overview";
11
13
  reports: "reports";
12
- zones: "zones";
13
14
  "rule-analyzer": "rule-analyzer";
14
15
  accounts: "accounts";
15
16
  checkups: "checkups";
16
- settings: "settings";
17
17
  }>;
18
18
  export type WorkspaceSection = z.infer<typeof workspaceSectionSchema>;
19
19
  export declare const accountResourceSchema: z.ZodEnum<{
@@ -23,12 +23,15 @@ export type CliConfigLegacy = CliConfig & {
23
23
  export declare function parseOrigin(raw: string, option: string): string;
24
24
  /** Default request timeout; also used when library callers omit `timeoutMs`. */
25
25
  export declare const DEFAULT_TIMEOUT_MS = 60000;
26
+ /** localhost, 127.0.0.0/8 or [::1]: the hosts every origin option accepts for local development. */
27
+ export declare function isLoopbackHostname(hostname: string): boolean;
26
28
  export declare function resolvePublicBaseUrl(explicit?: string | null): string;
27
29
  export declare function resolveOneBaseUrl(explicit?: string | null): string;
28
30
  /** Validate at transport boundaries too: library callers may construct config objects. */
29
31
  export declare function validateOneOrigin(raw: string): string;
30
32
  /** Production Identity: the only issuer trusted unless --auth-url / VIGILBASE_AUTH_URL names another. */
31
33
  export declare const DEFAULT_IDENTITY_ISSUER = "https://auth.vigilbase.com";
34
+ /** Internal: accepted when named explicitly, but not offered to users (it isn't deployed publicly). */
32
35
  export declare const STAGING_IDENTITY_ISSUER = "https://identity-staging.vigilbase.com";
33
36
  export declare function resolveIdentityBaseUrl(raw: string): string;
34
37
  /**
@@ -38,6 +41,11 @@ export declare function resolveIdentityBaseUrl(raw: string): string;
38
41
  */
39
42
  export declare function trustedIdentityIssuer(config: Pick<CliConfig, 'oneBaseUrl' | 'identityBaseUrl'>): string;
40
43
  export declare function resolveClientId(explicit?: string | null): string;
44
+ /** Why a config directory that isn't absolute was refused, naming the variable it came from. */
45
+ export declare function relativeConfigDirProblem(configDir: string, env?: NodeJS.ProcessEnv): {
46
+ reason: string;
47
+ remedy: string;
48
+ };
41
49
  /** The config directory, which must be absolute. */
42
50
  export declare function resolveConfigDir(explicit?: string | null, env?: NodeJS.ProcessEnv): string;
43
51
  export declare function createConfig(options?: {
@@ -48,6 +56,14 @@ export declare function createConfig(options?: {
48
56
  identityBaseUrl?: string | null;
49
57
  configDir?: string | null;
50
58
  timeoutMs?: number;
59
+ /**
60
+ * Which origins to validate now; both by default. The CLI leaves out those a command never contacts, so a bad
61
+ * value for them cannot fail it. Every One and Identity transport validates its origin again at use.
62
+ */
63
+ checkOrigins?: {
64
+ public: boolean;
65
+ one: boolean;
66
+ };
51
67
  }): CliConfigLegacy;
52
68
  /**
53
69
  * Config for `vigilbase local …` and `vigilbase cloudflare-checkup`. Local
@@ -20,41 +20,63 @@ export function parseOrigin(raw, option) {
20
20
  const timeoutSchema = z.number().int().min(1).max(2_147_483_647);
21
21
  /** Default request timeout; also used when library callers omit `timeoutMs`. */
22
22
  export const DEFAULT_TIMEOUT_MS = 60_000;
23
+ /** localhost, 127.0.0.0/8 or [::1]: the hosts every origin option accepts for local development. */
24
+ export function isLoopbackHostname(hostname) {
25
+ return hostname === 'localhost' || hostname === '[::1]' || /^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(hostname);
26
+ }
27
+ /** An option given as an empty string is a mistake (an unset shell variable), not a request for the default. */
28
+ function explicitValue(raw, option) {
29
+ if (raw === undefined || raw === null)
30
+ return undefined;
31
+ if (!raw.trim())
32
+ throw new Error(`${option} is empty. Pass an origin or leave the option out.`);
33
+ return raw.trim();
34
+ }
35
+ /** The first set value with the name it came from; blank variables count as unset. */
36
+ function firstSet(sources) {
37
+ for (const [name, value] of sources)
38
+ if (value?.trim())
39
+ return [name, value.trim()];
40
+ return undefined;
41
+ }
23
42
  export function resolvePublicBaseUrl(explicit) {
24
- const raw = explicit?.trim() ||
25
- process.env.VIGILBASE_BASE_URL?.trim() ||
26
- process.env.VIGILBASE_API_URL?.trim() ||
27
- process.env.VIGILBASE_PUBLIC_URL?.trim() ||
28
- DEFAULT_PUBLIC_BASE_URL;
29
- return parseOrigin(raw, '--base-url / VIGILBASE_BASE_URL');
43
+ const [name, raw] = firstSet([
44
+ ['--base-url', explicitValue(explicit, '--base-url')],
45
+ ['VIGILBASE_BASE_URL', process.env.VIGILBASE_BASE_URL],
46
+ ['VIGILBASE_API_URL', process.env.VIGILBASE_API_URL],
47
+ ['VIGILBASE_PUBLIC_URL', process.env.VIGILBASE_PUBLIC_URL],
48
+ ]) ?? ['--base-url', DEFAULT_PUBLIC_BASE_URL];
49
+ const origin = parseOrigin(raw, name);
50
+ const url = new URL(origin);
51
+ if (url.protocol !== 'https:' && !isLoopbackHostname(url.hostname)) {
52
+ throw new Error(`${name} requires HTTPS; HTTP is allowed only for loopback development.`);
53
+ }
54
+ return origin;
30
55
  }
31
56
  export function resolveOneBaseUrl(explicit) {
32
- const raw = explicit?.trim() ||
33
- process.env.VIGILBASE_ONE_URL?.trim() ||
34
- DEFAULT_ONE_BASE_URL;
35
- return validateOneOrigin(raw);
57
+ return validateOneOrigin(explicitValue(explicit, '--one-url') || process.env.VIGILBASE_ONE_URL?.trim() || DEFAULT_ONE_BASE_URL);
36
58
  }
37
59
  /** Validate at transport boundaries too: library callers may construct config objects. */
38
60
  export function validateOneOrigin(raw) {
39
61
  const origin = parseOrigin(raw, '--one-url / VIGILBASE_ONE_URL');
40
62
  const url = new URL(origin);
41
- const loopback = url.hostname === 'localhost' || url.hostname === '[::1]' || /^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(url.hostname);
42
- if (url.protocol !== 'https:' && !loopback) {
63
+ if (url.protocol !== 'https:' && !isLoopbackHostname(url.hostname)) {
43
64
  throw new Error('--one-url / VIGILBASE_ONE_URL requires HTTPS; HTTP is allowed only for loopback development.');
44
65
  }
45
66
  return origin;
46
67
  }
47
68
  /** Production Identity: the only issuer trusted unless --auth-url / VIGILBASE_AUTH_URL names another. */
48
69
  export const DEFAULT_IDENTITY_ISSUER = 'https://auth.vigilbase.com';
70
+ /** Internal: accepted when named explicitly, but not offered to users (it isn't deployed publicly). */
49
71
  export const STAGING_IDENTITY_ISSUER = 'https://identity-staging.vigilbase.com';
50
72
  const IDENTITY_OPTION = '--auth-url / VIGILBASE_AUTH_URL';
51
73
  function isIdentityLoopback(origin) {
52
- return ['localhost', '127.0.0.1', '[::1]'].includes(new URL(origin).hostname);
74
+ return isLoopbackHostname(new URL(origin).hostname);
53
75
  }
54
76
  export function resolveIdentityBaseUrl(raw) {
55
77
  const origin = parseOrigin(raw, IDENTITY_OPTION);
56
78
  if (origin !== DEFAULT_IDENTITY_ISSUER && origin !== STAGING_IDENTITY_ISSUER && !isIdentityLoopback(origin)) {
57
- throw new Error(`${IDENTITY_OPTION} must be ${DEFAULT_IDENTITY_ISSUER}, ${STAGING_IDENTITY_ISSUER} or a loopback development origin.`);
79
+ throw new Error(`${IDENTITY_OPTION} must be ${DEFAULT_IDENTITY_ISSUER} or a loopback development origin.`);
58
80
  }
59
81
  return origin;
60
82
  }
@@ -85,18 +107,54 @@ function configDirFromEnvironment(explicit, env) {
85
107
  const custom = explicit?.trim() || env.VIGILBASE_CONFIG_DIR?.trim();
86
108
  if (custom)
87
109
  return custom;
110
+ // Set but blank: '' is not absolute, so the store rejects it rather than falling back to another directory.
111
+ if (env.VIGILBASE_CONFIG_DIR !== undefined)
112
+ return '';
88
113
  const xdg = env.XDG_CONFIG_HOME?.trim();
89
114
  return xdg && isAbsolute(xdg) ? join(xdg, 'vigilbase') : join(homedir(), '.config', 'vigilbase');
90
115
  }
116
+ /** Why a config directory that isn't absolute was refused, naming the variable it came from. */
117
+ export function relativeConfigDirProblem(configDir, env = process.env) {
118
+ if (!configDir && env.VIGILBASE_CONFIG_DIR !== undefined) {
119
+ return { reason: 'VIGILBASE_CONFIG_DIR is empty', remedy: 'Set it to an absolute path or unset it.' };
120
+ }
121
+ if (env.VIGILBASE_CONFIG_DIR === undefined && !isAbsolute(homedir()) && configDir === join(homedir(), '.config', 'vigilbase')) {
122
+ const xdg = env.XDG_CONFIG_HOME?.trim() ? ', and a relative XDG_CONFIG_HOME is ignored' : '';
123
+ return { reason: `HOME (${homedir()}) is not an absolute path${xdg}`, remedy: 'Set HOME or VIGILBASE_CONFIG_DIR to an absolute path.' };
124
+ }
125
+ return { reason: `${configDir} is not an absolute path`, remedy: 'Set VIGILBASE_CONFIG_DIR to an absolute path.' };
126
+ }
91
127
  /** The config directory, which must be absolute. */
92
128
  export function resolveConfigDir(explicit, env = process.env) {
93
129
  const configDir = configDirFromEnvironment(explicit, env);
94
- if (!isAbsolute(configDir))
95
- throw new Error('VIGILBASE_CONFIG_DIR must be an absolute path.');
130
+ if (!isAbsolute(configDir)) {
131
+ const { reason, remedy } = relativeConfigDirProblem(configDir, env);
132
+ throw new Error(`${reason}. ${remedy}`);
133
+ }
96
134
  return configDir;
97
135
  }
136
+ /** The origin when it is valid, otherwise the value as given: left for the transport that uses it to reject. */
137
+ function checkedLater(resolve, raw) {
138
+ try {
139
+ return resolve();
140
+ }
141
+ catch {
142
+ return raw;
143
+ }
144
+ }
98
145
  export function createConfig(options) {
99
- const publicBaseUrl = resolvePublicBaseUrl(options?.publicBaseUrl ?? options?.baseUrl);
146
+ const checks = options?.checkOrigins ?? { public: true, one: true };
147
+ const explicitPublic = options?.publicBaseUrl ?? options?.baseUrl;
148
+ const publicBaseUrl = checks.public
149
+ ? resolvePublicBaseUrl(explicitPublic)
150
+ : checkedLater(() => resolvePublicBaseUrl(explicitPublic), explicitPublic?.trim() || process.env.VIGILBASE_BASE_URL?.trim() ||
151
+ process.env.VIGILBASE_API_URL?.trim() || process.env.VIGILBASE_PUBLIC_URL?.trim() || DEFAULT_PUBLIC_BASE_URL);
152
+ const oneBaseUrl = checks.one
153
+ ? resolveOneBaseUrl(options?.oneBaseUrl)
154
+ : checkedLater(() => resolveOneBaseUrl(options?.oneBaseUrl), options?.oneBaseUrl?.trim() || process.env.VIGILBASE_ONE_URL?.trim() || DEFAULT_ONE_BASE_URL);
155
+ const authUrl = checks.one
156
+ ? explicitValue(options?.identityBaseUrl, '--auth-url') || process.env.VIGILBASE_AUTH_URL?.trim()
157
+ : options?.identityBaseUrl?.trim() || process.env.VIGILBASE_AUTH_URL?.trim();
100
158
  const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
101
159
  if (!timeoutSchema.safeParse(timeoutMs).success) {
102
160
  throw new Error('Request timeout must be an integer between 1 and 2147483647 milliseconds.');
@@ -105,8 +163,8 @@ export function createConfig(options) {
105
163
  publicBaseUrl,
106
164
  /** Alias used by the current public tools client. */
107
165
  baseUrl: publicBaseUrl,
108
- oneBaseUrl: resolveOneBaseUrl(options?.oneBaseUrl),
109
- ...(options?.identityBaseUrl || process.env.VIGILBASE_AUTH_URL ? { identityBaseUrl: resolveIdentityBaseUrl(options?.identityBaseUrl || process.env.VIGILBASE_AUTH_URL) } : {}),
166
+ oneBaseUrl,
167
+ ...(authUrl ? { identityBaseUrl: checks.one ? resolveIdentityBaseUrl(authUrl) : checkedLater(() => resolveIdentityBaseUrl(authUrl), authUrl) } : {}),
110
168
  clientId: resolveClientId(options?.clientId),
111
169
  configDir: configDirFromEnvironment(options?.configDir, process.env),
112
170
  userAgent: `@vigilbase/cli/${CLI_VERSION} (+https://vigilbase.com; node/${process.version})`,
@@ -5,7 +5,7 @@ export type Writer = {
5
5
  };
6
6
  /** A scan that ended without a result: com's copy (apps/com/lib/tools/execute-internal-scan.ts). */
7
7
  export declare const SCAN_FAILED_MESSAGE = "The scan couldn't be completed. Try again later.";
8
- /** Untrusted API data must never become terminal control sequences or bidi overrides. */
8
+ /** Untrusted API data must never become terminal control sequences, bidi overrides or invisible text. */
9
9
  export declare function sanitizeTerminal(value: string): string;
10
10
  /** One sanitised line, cut to `maxLength` characters with an ellipsis. */
11
11
  export declare function singleLine(value: string, maxLength?: number): string;
@@ -1,10 +1,18 @@
1
1
  import { stripVTControlCharacters } from 'node:util';
2
+ import { LOCAL_ERROR_CODES } from '../local/cloudflare-checkup/errors.js';
2
3
  /** A scan that ended without a result: com's copy (apps/com/lib/tools/execute-internal-scan.ts). */
3
4
  export const SCAN_FAILED_MESSAGE = "The scan couldn't be completed. Try again later.";
4
5
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
- /** Untrusted API data must never become terminal control sequences or bidi overrides. */
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. */
6
14
  export function sanitizeTerminal(value) {
7
- 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, '');
8
16
  }
9
17
  /** One sanitised line, cut to `maxLength` characters with an ellipsis. */
10
18
  export function singleLine(value, maxLength = 300) {
@@ -25,7 +33,7 @@ export function createWriter(stdout = process.stdout, stderr = process.stderr) {
25
33
  }
26
34
  export function printJson(writer, value) {
27
35
  // Keep JSON lossless while preventing literal terminal/bidi controls in rendered output.
28
- 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('')));
29
37
  }
30
38
  function asRecord(value) {
31
39
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -156,6 +164,9 @@ function collectFindings(result) {
156
164
  if (!header)
157
165
  continue;
158
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;
159
170
  if (status !== 'missing' && status !== 'misconfigured' && header.present !== false) {
160
171
  continue;
161
172
  }
@@ -191,30 +202,49 @@ function collectFindings(result) {
191
202
  return findings;
192
203
  }
193
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
+ }
194
220
  /** Compact human lines for a scan result (no UI/skill links, no summary dump). */
195
221
  function summarizeResultCompact(result) {
196
222
  if (!result)
197
223
  return [];
224
+ // Server text never starts a line of its own: each message line is indented like the rest.
198
225
  const lines = [];
199
226
  const message = typeof result.message === 'string' ? result.message : null;
200
227
  if (message)
201
- lines.push(message);
228
+ lines.push(...sanitizeTerminal(message).split('\n').map((line) => line.trim()).filter(Boolean));
202
229
  const counts = summarizeCounts(result);
203
230
  if (counts)
204
231
  lines.push(counts);
205
232
  const findings = collectFindings(result);
206
233
  for (const finding of findings.slice(0, MAX_FINDING_LINES)) {
207
- const sev = finding.severity ? ` ${finding.severity}` : '';
234
+ const sev = finding.severity ? ` ${singleLine(finding.severity, 20)}` : '';
208
235
  if (finding.recommendation) {
209
- lines.push(`✗${sev} ${finding.title} — ${finding.recommendation}`);
236
+ lines.push(`✗${sev} ${singleLine(finding.title)} — ${singleLine(finding.recommendation)}`);
210
237
  }
211
238
  else {
212
- lines.push(`✗${sev} ${finding.title}`);
239
+ lines.push(`✗${sev} ${singleLine(finding.title)}`);
213
240
  }
214
241
  }
215
242
  if (findings.length > MAX_FINDING_LINES) {
216
243
  lines.push(`… +${findings.length - MAX_FINDING_LINES} more (use --json)`);
217
244
  }
245
+ const report = reportUrl(result.shareableUrl);
246
+ if (report)
247
+ lines.push(`Report: ${report}`);
218
248
  return lines;
219
249
  }
220
250
  export function formatToolsList(catalog, mode, writer, options) {
@@ -222,11 +252,12 @@ export function formatToolsList(catalog, mode, writer, options) {
222
252
  printJson(writer, catalog);
223
253
  return;
224
254
  }
225
- 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(' ');
226
257
  writer.stdout(`public ${publicNames || '(none)'}`);
227
258
  const authRequired = options?.authRequiredTools ?? options?.sensitiveTools ?? [];
228
259
  if (authRequired.length) {
229
- writer.stdout(`auth-req ${authRequired.join(' ')}`);
260
+ writer.stdout(`auth-req ${authRequired.map((name) => singleLine(name, 100)).join(' ')}`);
230
261
  }
231
262
  if (typeof options?.isAuthenticated === 'boolean') {
232
263
  writer.stdout(options.isAuthenticated ? 'auth signed in' : 'auth signed out');
@@ -234,23 +265,24 @@ export function formatToolsList(catalog, mode, writer, options) {
234
265
  }
235
266
  export function formatToolDescription(tool, mode, writer, skillMarkdown) {
236
267
  if (mode === 'json') {
237
- printJson(writer, { ...tool, skillMarkdown: skillMarkdown ?? null });
268
+ printJson(writer, { success: true, ...tool, skillMarkdown: skillMarkdown ?? null });
238
269
  return;
239
270
  }
240
- writer.stdout(`${tool.toolName} ${tool.name}`);
241
- const blurb = tool.description.length > 100
242
- ? `${tool.description.slice(0, 97).trimEnd()}…`
243
- : tool.description;
244
- writer.stdout(blurb);
245
- 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>`);
246
277
  if (skillMarkdown) {
247
278
  writer.stdout();
248
279
  writer.stdout(skillMarkdown.trimEnd());
249
280
  }
250
281
  }
251
282
  /**
252
- * Why a scan failed, or null when it has a result. A scan com could not grade comes back
253
- * inside a successful response as `status: "error"` with a message and no grade.
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.
254
286
  */
255
287
  function scanFailure(result) {
256
288
  if (result.success && result.result?.status !== 'error')
@@ -273,24 +305,37 @@ export function formatExecuteResult(result, mode, writer) {
273
305
  printJson(writer, failure === null ? result : { ...result, success: false, error: failure, grade: null, gradeScore: null });
274
306
  return;
275
307
  }
276
- const tool = result.toolName ?? 'tool';
277
- const target = result.target ?? '';
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) : '';
278
311
  const grade = failure === null && result.grade != null
279
- ? `${result.grade}${result.gradeScore != null ? ` ${result.gradeScore}` : ''}`
312
+ ? `${gradeLetter}${result.gradeScore != null ? ` ${singleLine(String(result.gradeScore), 10)}` : ''}`
280
313
  : 'n/a';
281
314
  if (failure !== null) {
282
315
  writer.stderr(`✗ ${tool} · ${target} · ${grade}`);
283
316
  writer.stderr(` ${failure}`);
284
317
  return;
285
318
  }
286
- writer.stdout(`✓ ${tool} · ${target} · ${grade}`);
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}`);
287
322
  for (const line of summarizeResultCompact(result.result)) {
288
323
  writer.stdout(` ${line}`);
289
324
  }
290
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
+ }
291
332
  export function formatError(error, mode, writer) {
292
333
  let message = error instanceof Error ? error.message : String(error);
293
334
  let reason = '';
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.';
294
339
  // Server bodies can contain credentials, customer data, or arbitrary error text, so an error that
295
340
  // carries one shows only text the CLI wrote, or the reason the public client accepted.
296
341
  if (error && typeof error === 'object' && 'body' in error && error.body != null && 'status' in error && typeof error.status === 'number') {
@@ -308,6 +353,8 @@ export function formatError(error, mode, writer) {
308
353
  success: false,
309
354
  error: message,
310
355
  };
356
+ if (zodIssues)
357
+ payload.code = 'INVALID_ARGUMENT';
311
358
  if (error && typeof error === 'object') {
312
359
  if ('status' in error && typeof error.status === 'number' &&
313
360
  Number.isInteger(error.status) && error.status >= 0 && error.status <= 599) {
@@ -339,9 +386,13 @@ export function formatError(error, mode, writer) {
339
386
  printJson(writer, payload);
340
387
  return;
341
388
  }
342
- 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})` : ''}`);
343
392
  if (typeof payload.retryAfterSec === 'number') {
344
- 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`);
345
396
  }
346
397
  if (typeof payload.hint === 'string') {
347
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
  }>;