@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
package/dist/cli.js CHANGED
@@ -6,79 +6,81 @@ import { renderBanner, shouldShowBanner } from './lib/banner.js';
6
6
  import { createWriter, formatError } from './lib/format.js';
7
7
  import { HELP_TAGLINE, HELP_TEXT, HELP_USAGE, helpForCommand, isCatalogTool } from './lib/help.js';
8
8
  import { wasRendered } from './lib/tui.js';
9
- import { getCommandHandler } from './commands/index.js';
10
- import { LocalCheckupError } from './local/cloudflare-checkup/errors.js';
9
+ import { isOneAuthRequiredTool } from './lib/one-client.js';
10
+ import { COMMAND_HANDLERS, getCommandHandler } from './commands/index.js';
11
+ import { CheckupCancelledError, LocalCheckupError } from './local/cloudflare-checkup/errors.js';
11
12
  import { EXIT } from './types.js';
12
13
  import { CLI_VERSION } from './version.js';
13
14
  export { CLI_VERSION };
15
+ const OPTIONS = {
16
+ json: { type: 'boolean', short: 'j', default: false },
17
+ help: { type: 'boolean', short: 'h', default: false },
18
+ version: { type: 'boolean', short: 'V', default: false },
19
+ 'base-url': { type: 'string' },
20
+ 'one-url': { type: 'string' },
21
+ 'auth-url': { type: 'string' },
22
+ 'client-id': { type: 'string' },
23
+ timeout: { type: 'string' },
24
+ style: { type: 'string' },
25
+ 'no-skill': { type: 'boolean', default: false },
26
+ via: { type: 'string' },
27
+ public: { type: 'boolean', default: false },
28
+ token: { type: 'string' },
29
+ 'token-stdin': { type: 'boolean', default: false },
30
+ 'no-open': { type: 'boolean', default: false },
31
+ email: { type: 'string' },
32
+ role: { type: 'string' },
33
+ confirm: { type: 'boolean', default: false },
34
+ 'account-id': { type: 'string' },
35
+ tool: { type: 'string' },
36
+ limit: { type: 'string' },
37
+ 'target-id': { type: 'string' },
38
+ 'customer-id': { type: 'string' },
39
+ 'zone-id': { type: 'string' },
40
+ 'days': { type: 'string' },
41
+ 'offset': { type: 'string' },
42
+ 'status': { type: 'string' },
43
+ 'severity': { type: 'string' },
44
+ 'attack-category': { type: 'string' },
45
+ 'order-by': { type: 'string' },
46
+ 'order-direction': { type: 'string' },
47
+ 'asset-id': { type: 'string' },
48
+ 'provider': { type: 'string' },
49
+ 'dataset': { type: 'string' },
50
+ 'search': { type: 'string' },
51
+ 'start': { type: 'string' },
52
+ 'end': { type: 'string' },
53
+ 'sort': { type: 'string' },
54
+ 'frs-min': { type: 'string' },
55
+ 'before': { type: 'string' },
56
+ 'note': { type: 'string' },
57
+ 'cursor': { type: 'string' },
58
+ 'action-prefix': { type: 'string' },
59
+ 'outcome': { type: 'string' },
60
+ 'hours': { type: 'string' },
61
+ 'metrics': { type: 'string' },
62
+ 'min-weight': { type: 'string' },
63
+ 'range': { type: 'string' },
64
+ 'region': { type: 'string' },
65
+ 'issue-id': { type: 'string' },
66
+ 'revision': { type: 'string' },
67
+ all: { type: 'boolean', default: false },
68
+ light: { type: 'boolean', default: false },
69
+ profile: { type: 'string' },
70
+ 'cf-auth': { type: 'string' },
71
+ 'cf-bin': { type: 'string' },
72
+ out: { type: 'string' },
73
+ overwrite: { type: 'boolean', default: false },
74
+ 'dry-run': { type: 'boolean', default: false },
75
+ 'max-requests-per-5min': { type: 'string' },
76
+ yes: { type: 'boolean', default: false },
77
+ };
14
78
  function parseCli(argv) {
15
79
  const { values, positionals } = parseArgs({
16
80
  args: argv,
17
81
  allowPositionals: true,
18
82
  strict: true,
19
- options: {
20
- json: { type: 'boolean', short: 'j', default: false },
21
- help: { type: 'boolean', short: 'h', default: false },
22
- version: { type: 'boolean', short: 'V', default: false },
23
- 'base-url': { type: 'string' },
24
- 'one-url': { type: 'string' },
25
- 'auth-url': { type: 'string' },
26
- 'client-id': { type: 'string' },
27
- timeout: { type: 'string' },
28
- style: { type: 'string' },
29
- 'no-skill': { type: 'boolean', default: false },
30
- via: { type: 'string' },
31
- public: { type: 'boolean', default: false },
32
- token: { type: 'string' },
33
- 'token-stdin': { type: 'boolean', default: false },
34
- 'no-open': { type: 'boolean', default: false },
35
- email: { type: 'string' },
36
- role: { type: 'string' },
37
- confirm: { type: 'boolean', default: false },
38
- 'account-id': { type: 'string' },
39
- tool: { type: 'string' },
40
- limit: { type: 'string' },
41
- 'target-id': { type: 'string' },
42
- 'customer-id': { type: 'string' },
43
- 'zone-id': { type: 'string' },
44
- 'days': { type: 'string' },
45
- 'offset': { type: 'string' },
46
- 'status': { type: 'string' },
47
- 'severity': { type: 'string' },
48
- 'attack-category': { type: 'string' },
49
- 'order-by': { type: 'string' },
50
- 'order-direction': { type: 'string' },
51
- 'asset-id': { type: 'string' },
52
- 'provider': { type: 'string' },
53
- 'dataset': { type: 'string' },
54
- 'search': { type: 'string' },
55
- 'start': { type: 'string' },
56
- 'end': { type: 'string' },
57
- 'sort': { type: 'string' },
58
- 'frs-min': { type: 'string' },
59
- 'before': { type: 'string' },
60
- 'note': { type: 'string' },
61
- 'cursor': { type: 'string' },
62
- 'action-prefix': { type: 'string' },
63
- 'outcome': { type: 'string' },
64
- 'hours': { type: 'string' },
65
- 'metrics': { type: 'string' },
66
- 'min-weight': { type: 'string' },
67
- 'range': { type: 'string' },
68
- 'region': { type: 'string' },
69
- 'issue-id': { type: 'string' },
70
- 'revision': { type: 'string' },
71
- all: { type: 'boolean', default: false },
72
- light: { type: 'boolean', default: false },
73
- profile: { type: 'string' },
74
- 'cf-auth': { type: 'string' },
75
- 'cf-bin': { type: 'string' },
76
- out: { type: 'string' },
77
- overwrite: { type: 'boolean', default: false },
78
- 'dry-run': { type: 'boolean', default: false },
79
- 'max-requests-per-5min': { type: 'string' },
80
- yes: { type: 'boolean', default: false },
81
- },
83
+ options: OPTIONS,
82
84
  });
83
85
  return {
84
86
  positionals,
@@ -161,6 +163,111 @@ function parseTimeout(raw) {
161
163
  function resolveMode(jsonFlag) {
162
164
  return jsonFlag ? 'json' : 'text';
163
165
  }
166
+ /** Arguments before `--`: everything after it is a positional. */
167
+ function optionArguments(argv) {
168
+ const end = argv.indexOf('--');
169
+ return end === -1 ? argv : argv.slice(0, end);
170
+ }
171
+ /** JSON mode for an error from parsing itself: --json, --json=… or a short-flag cluster with j. */
172
+ function requestsJson(argv) {
173
+ return optionArguments(argv).some((arg) => /^--json(=|$)/.test(arg) || /^-[A-Za-z]*j[A-Za-z]*$/.test(arg));
174
+ }
175
+ /** Edit distance counting a swap of two neighbouring letters as one edit. */
176
+ function editDistance(a, b) {
177
+ const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i ? (j ? 0 : i) : j)));
178
+ for (let i = 1; i <= a.length; i++) {
179
+ for (let j = 1; j <= b.length; j++) {
180
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
181
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
182
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
183
+ }
184
+ }
185
+ return d[a.length][b.length];
186
+ }
187
+ /** The nearest candidate when it is close enough to be a typo: one edit for short words, two from six letters. */
188
+ function didYouMean(input, candidates) {
189
+ let best;
190
+ let bestDistance = Math.min(2, Math.max(1, Math.floor(input.length / 3))) + 1;
191
+ for (const candidate of candidates) {
192
+ const distance = editDistance(input, candidate);
193
+ if (distance < bestDistance)
194
+ [best, bestDistance] = [candidate, distance];
195
+ }
196
+ return best;
197
+ }
198
+ /** Commands with subcommands, whose help covers them all. */
199
+ const COMMAND_GROUPS = new Set(['account', 'billing', 'runs', 'runhistory', 'cf', 'cloudflare', 'watchdog', 'security',
200
+ 'customers', 'orgs', 'monitoring', 'support', 'local', 'cloudflare-checkup']);
201
+ const USAGE_POINTER = 'Run `vigilbase help` for usage.';
202
+ function cliError(message, code) {
203
+ return Object.assign(new Error(message), { code });
204
+ }
205
+ function unknownCommand(typed) {
206
+ const name = typed.toLowerCase();
207
+ const command = didYouMean(name, [...Object.keys(COMMAND_HANDLERS), 'help']);
208
+ const suggestion = command ?? (name === 'version' || name === 'json' ? `--${name}` : undefined);
209
+ return new Error(`Unknown command "${typed}".${suggestion ? ` Did you mean ${suggestion}?` : ''} ${USAGE_POINTER}`);
210
+ }
211
+ function unknownOption(argv, reported) {
212
+ const longNames = Object.keys(OPTIONS);
213
+ let name = reported;
214
+ let suggestion;
215
+ // parseArgs reads -help as -h -e -l -p: name the whole word and suggest the long option.
216
+ const word = optionArguments(argv).find((arg) => /^-[^-]{2,}$/.test(arg) && arg.includes(reported.slice(1)) &&
217
+ didYouMean(arg.slice(1), longNames));
218
+ if (word) {
219
+ name = word;
220
+ suggestion = `--${didYouMean(word.slice(1), longNames)}`;
221
+ }
222
+ else if (reported.startsWith('--')) {
223
+ const option = didYouMean(reported.slice(2).split('=')[0], longNames);
224
+ if (option)
225
+ suggestion = `--${option}`;
226
+ }
227
+ else {
228
+ const letter = reported.slice(1);
229
+ const short = Object.values(OPTIONS).find((option) => 'short' in option && option.short.toLowerCase() === letter.toLowerCase());
230
+ if (short && 'short' in short)
231
+ suggestion = `-${short.short}`;
232
+ }
233
+ return cliError(`Unknown option ${name}.${suggestion ? ` Did you mean ${suggestion}?` : ''} ${USAGE_POINTER}`, 'UNKNOWN_OPTION');
234
+ }
235
+ /** node:util parseArgs errors in the CLI's words, with the CLI's own codes. */
236
+ function argumentError(error, argv) {
237
+ const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
238
+ if (typeof code !== 'string' || !code.startsWith('ERR_PARSE_ARGS_'))
239
+ return error;
240
+ const message = error instanceof Error ? error.message : '';
241
+ if (code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION')
242
+ return unknownOption(argv, /'([^']+)'/.exec(message)?.[1] ?? '');
243
+ const flag = /--[a-z0-9-]+/.exec(message)?.[0];
244
+ if (!flag)
245
+ return cliError(`Invalid arguments. ${USAGE_POINTER}`, 'INVALID_ARGUMENT');
246
+ if (message.includes('does not take an argument'))
247
+ return cliError(`${flag} does not take a value.`, 'INVALID_ARGUMENT');
248
+ if (message.includes('argument missing'))
249
+ return cliError(`${flag} needs a value.`, 'INVALID_ARGUMENT');
250
+ if (message.includes('ambiguous')) {
251
+ return cliError(`${flag} needs a value. To pass one that starts with a dash, use ${flag}=<value>.`, 'INVALID_ARGUMENT');
252
+ }
253
+ return cliError(`Invalid value for ${flag}. ${USAGE_POINTER}`, 'INVALID_ARGUMENT');
254
+ }
255
+ /** Public tool commands: they contact One only to run a tool through it. */
256
+ const PUBLIC_TOOL_COMMANDS = new Set(['list', 'tools', 'run', 'headers', 'email', 'describe', 'skill', 'badge']);
257
+ /**
258
+ * The origins a command contacts, so settings for the others can't fail it. Doctor checks both. `list` reads a
259
+ * saved sign-in offline and only warns about one it can't use.
260
+ */
261
+ function originsUsed(command, rest, values) {
262
+ if (!PUBLIC_TOOL_COMMANDS.has(command))
263
+ return { public: command === 'doctor', one: true };
264
+ if (command !== 'run' && command !== 'headers' && command !== 'email')
265
+ return { public: true, one: false };
266
+ const toolName = command === 'headers' ? 'security-headers' : command === 'email' ? 'email-security' : rest[0] ?? '';
267
+ const isForcedPublic = values.isPublic || values.via === 'public';
268
+ const isViaOne = !isForcedPublic && (values.via === 'one' || isOneAuthRequiredTool(toolName));
269
+ return { public: !isViaOne, one: isViaOne };
270
+ }
164
271
  export async function runCli(options = {}) {
165
272
  const argv = options.argv ?? process.argv.slice(2);
166
273
  const stdoutStream = options.stdout ?? process.stdout;
@@ -169,10 +276,20 @@ export async function runCli(options = {}) {
169
276
  if (options.env) {
170
277
  process.env = options.env;
171
278
  }
279
+ let mode;
172
280
  try {
173
- const parsed = parseCli(argv);
174
- const mode = resolveMode(parsed.values.isJson);
175
- if (parsed.values.isVersion || parsed.values.isHelp || !parsed.positionals.length || parsed.positionals[0] === 'help') {
281
+ let parsed;
282
+ try {
283
+ parsed = parseCli(argv);
284
+ }
285
+ catch (error) {
286
+ throw argumentError(error, argv);
287
+ }
288
+ mode = resolveMode(parsed.values.isJson);
289
+ // Commands are matched in any case, as subcommands are.
290
+ const [typed, ...rest] = parsed.positionals;
291
+ const command = typed?.toLowerCase();
292
+ if (parsed.values.isVersion || parsed.values.isHelp || !command || command === 'help') {
176
293
  const informational = new Set(['isJson', 'isHelp', 'isVersion', 'base-url', 'one-url', 'auth-url', 'client-id', 'timeout']);
177
294
  for (const [key, value] of Object.entries(parsed.values)) {
178
295
  if (value === undefined || value === false || informational.has(key))
@@ -182,7 +299,8 @@ export async function runCli(options = {}) {
182
299
  }
183
300
  if (parsed.values.isVersion && parsed.positionals.length)
184
301
  throw new Error('Usage: vigilbase --version');
185
- if (parsed.positionals[0] === 'help' && parsed.positionals.length > 2)
302
+ // `help <group> <subcommand>` shows the group's help, as `<group> <subcommand> --help` does.
303
+ if (command === 'help' && rest.length > 1 && !COMMAND_GROUPS.has(rest[0].toLowerCase()))
186
304
  throw new Error(HELP_USAGE);
187
305
  }
188
306
  if (parsed.values.isVersion) {
@@ -201,15 +319,16 @@ export async function runCli(options = {}) {
201
319
  }
202
320
  return EXIT.OK;
203
321
  }
204
- const [command, ...rest] = parsed.positionals;
205
- const helpCommand = command === 'help' ? rest[0] : parsed.values.isHelp ? command : undefined;
322
+ // `help --help` is help's own help, like `help help`.
323
+ const helpTyped = command === 'help' ? rest[0] ?? (parsed.values.isHelp ? 'help' : undefined) : parsed.values.isHelp ? typed : undefined;
324
+ const helpCommand = helpTyped?.toLowerCase();
206
325
  if (parsed.values.isHelp ||
207
326
  !command ||
208
327
  command === 'help' ||
209
328
  command === '--help') {
210
329
  const specific = helpCommand ? helpForCommand(helpCommand) : null;
211
330
  if (helpCommand && !specific) {
212
- throw new Error(`Unknown command "${helpCommand}". Run \`vigilbase help\` for usage.`);
331
+ throw unknownCommand(helpTyped);
213
332
  }
214
333
  if (mode === 'json') {
215
334
  writer.stdout(JSON.stringify({
@@ -223,13 +342,21 @@ export async function runCli(options = {}) {
223
342
  writer.stdout(specific);
224
343
  }
225
344
  else {
226
- const prefix = shouldShowBanner(mode)
345
+ // Like --version, pipes and files get no banner.
346
+ const prefix = shouldShowBanner(mode) && stdoutStream.isTTY
227
347
  ? `${renderBanner({ version: CLI_VERSION, tagline: HELP_TAGLINE })}\n\n`
228
348
  : '';
229
349
  writer.stdout(`${prefix}${HELP_TEXT}`);
230
350
  }
231
351
  return EXIT.OK;
232
352
  }
353
+ const handler = getCommandHandler(command);
354
+ if (!handler) {
355
+ // Tools are not commands of their own (no aliases): point to how they run.
356
+ throw isCatalogTool(command)
357
+ ? new Error(`Unknown command "${typed}". Run the tool with \`vigilbase run ${command} <target>\`.`)
358
+ : unknownCommand(typed);
359
+ }
233
360
  // Local checkups validate --base-url themselves (resolveSubmitOrigin) and ignore VIGILBASE_* URLs.
234
361
  const config = command === 'local' || command === 'cloudflare-checkup'
235
362
  ? createLocalCommandConfig(parseTimeout(parsed.values.timeout))
@@ -239,15 +366,9 @@ export async function runCli(options = {}) {
239
366
  identityBaseUrl: parsed.values['auth-url'],
240
367
  clientId: parsed.values['client-id'],
241
368
  timeoutMs: parseTimeout(parsed.values.timeout),
369
+ checkOrigins: originsUsed(command, rest, parsed.values),
242
370
  });
243
371
  const publicClient = new VigilbaseClient(config);
244
- const handler = getCommandHandler(command);
245
- if (!handler) {
246
- // Tools are not commands of their own (no aliases): point to how they run.
247
- throw new Error(isCatalogTool(command)
248
- ? `Unknown command "${command}". Run the tool with \`vigilbase run ${command} <target>\`.`
249
- : `Unknown command "${command}". Run \`vigilbase help\` for usage.`);
250
- }
251
372
  return await handler({
252
373
  command,
253
374
  rest,
@@ -264,7 +385,7 @@ export async function runCli(options = {}) {
264
385
  });
265
386
  }
266
387
  catch (error) {
267
- const mode = argv.includes('--json') || argv.includes('-j') ? 'json' : 'text';
388
+ mode ??= requestsJson(argv) ? 'json' : 'text';
268
389
  // The terminal UI already showed it (■ and the hint) in its timeline.
269
390
  if (mode === 'json' || !wasRendered(error))
270
391
  formatError(error, mode, writer);
@@ -274,6 +395,9 @@ export async function runCli(options = {}) {
274
395
  if (error instanceof LocalCheckupError && error.isRateLimited) {
275
396
  return EXIT.RATE_LIMITED;
276
397
  }
398
+ // A cf run or install stopped by a signal: 128 + the signal number.
399
+ if (error instanceof CheckupCancelledError)
400
+ return error.exitCode;
277
401
  return EXIT.ERROR;
278
402
  }
279
403
  finally {
@@ -1,4 +1,4 @@
1
- import { invitationSchema, validateArguments } from './legacy-validation.js';
1
+ import { InvalidArgumentError, invitationValue, unknownSubcommand, validateArguments } from './legacy-validation.js';
2
2
  import { asArray, asRecord, ok, outputJson, outputLines, requireOneClient, withSpinner, } from './helpers.js';
3
3
  import { handleBilling } from './billing.js';
4
4
  export const handleAccount = async (ctx) => {
@@ -16,19 +16,23 @@ export const handleAccount = async (ctx) => {
16
16
  if (action === 'list' || action === 'ls')
17
17
  validateArguments(ctx, ctx.rest[1] === undefined ? 1 : 2);
18
18
  else if (action === 'create' || action === 'add') {
19
+ // The email may be positional or --email; help and both errors show the same usage.
20
+ const usage = 'Usage: vigilbase account invites create <email>|--email <email> [--role <role>] --confirm';
19
21
  if (ctx.values.email !== undefined && ctx.rest[2] !== undefined)
20
- throw new Error('Specify the invite email either positionally or with --email, not both.');
21
- validateArguments(ctx, ctx.values.email === undefined ? 3 : 2, ['email', 'role', 'isConfirm'], 'vigilbase account invites create <email> --confirm');
22
- invitation = invitationSchema.parse({ email: ctx.values.email ?? ctx.rest[2], role: ctx.values.role });
22
+ throw new InvalidArgumentError(`Give the email once, as <email> or --email <email>. ${usage}`);
23
+ if (!(ctx.values.email ?? ctx.rest[2] ?? '').trim())
24
+ throw new Error(`Missing <email>. ${usage}`);
25
+ validateArguments(ctx, ctx.values.email === undefined ? 3 : 2, ['email', 'role', 'isConfirm']);
26
+ invitation = invitationValue(ctx.values.email ?? ctx.rest[2], ctx.values.role);
23
27
  if (!ctx.values.isConfirm)
24
28
  throw new Error('Sending an organization invitation requires --confirm.');
25
29
  }
26
30
  else
27
- throw new Error('Usage: vigilbase account invites [list|create]');
31
+ throw unknownSubcommand(`account ${subcommand}`, ctx.rest[1] ?? '');
28
32
  }
29
33
  else {
30
34
  if (!['show', 'me', 'overview', 'services', 'service', 'org', 'organization', 'members', 'endpoints', 'endpoint', 'sessions', 'session'].includes(subcommand)) {
31
- throw new Error('Usage: vigilbase account [services|org|endpoints|sessions|invites|billing]');
35
+ throw unknownSubcommand('account', ctx.rest[0] ?? '');
32
36
  }
33
37
  validateArguments(ctx, ctx.rest[0] === undefined ? 0 : 1);
34
38
  }
@@ -143,7 +147,7 @@ export const handleAccount = async (ctx) => {
143
147
  const session = asRecord(raw);
144
148
  if (!session)
145
149
  continue;
146
- ctx.writer.stdout(`${String(session.id).slice(0, 12)}… ${String(session.ipAddress || '-')} ${String(session.createdAt || '')}`);
150
+ ctx.writer.stdout(`${String(session.id)} ${String(session.ipAddress || '-')} ${String(session.createdAt || '')}`);
147
151
  }
148
152
  return ok();
149
153
  }
@@ -1,4 +1,10 @@
1
1
  import { type CommandHandler } from './helpers.js';
2
+ /**
3
+ * A bearer token from --token, VIGILBASE_ONE_TOKEN or --token-stdin, checked before any request: fetch refuses
4
+ * header values with other characters, which would read as a connection failure.
5
+ */
6
+ export declare function validateBearerToken(token: string): string;
7
+ /** A piped token fails with the same messages as --token. Reading stops past 16 KiB, with room for a line ending. */
2
8
  export declare function readTokenFromStdin(input?: NodeJS.ReadableStream): Promise<string>;
3
9
  export declare const handleLogin: CommandHandler;
4
10
  export declare const handleLogout: CommandHandler;
@@ -1,13 +1,28 @@
1
1
  import { readIdentityTokenMetadata } from '../auth/token-metadata.js';
2
2
  import { discoverAuthProvider } from '../auth/provider.js';
3
- import { revokeIdentityCredentials, revokeIdentityGrant, revokeSavedCredentials, withCredentialsLock } from '../auth/refresh.js';
4
- import { checkCredentialsLocation, clearIdentityCredentials, clearOneCredentials, hasIgnoredIdentityCredentials, readReplacedIdentityGrant, readStoredCredentials } from '../auth/store.js';
3
+ import { boundIdentityCredentials, revokeIdentityCredentials, revokeIdentityGrant, revokeSavedCredentials, withCredentialsLock } from '../auth/refresh.js';
4
+ import { checkCredentialsLocation, clearIdentityCredentials, clearOneCredentials, hasCredentialsDirectory, hasIgnoredIdentityCredentials, readReplacedIdentityGrant, readStoredCredentials, savedForAnotherConfiguration, SignInEndedError } from '../auth/store.js';
5
5
  import { getOneAccessToken, readCredentials, requestDeviceCode, waitForDeviceAuthorization, writeOneCredentials, } from '../auth/index.js';
6
6
  import { VigilbaseOneClient, ONE_AUTH_REQUIRED_TOOLS } from '../lib/one-client.js';
7
7
  import { VigilbaseApiError } from '../lib/client.js';
8
8
  import { formatUserCode, isHeadlessEnvironment, openUrlInBrowser, resolveDeviceVerificationUrl, } from '../lib/open-url.js';
9
9
  import { shouldShowProgress, startProgress } from '../lib/progress.js';
10
10
  import { assertCommandFlags, ok, outputJson, outputLines, withSpinner, } from './helpers.js';
11
+ import { InvalidArgumentError, validateArguments } from './legacy-validation.js';
12
+ /**
13
+ * A bearer token from --token, VIGILBASE_ONE_TOKEN or --token-stdin, checked before any request: fetch refuses
14
+ * header values with other characters, which would read as a connection failure.
15
+ */
16
+ export function validateBearerToken(token) {
17
+ if (!token)
18
+ throw new InvalidArgumentError('The token must not be empty.');
19
+ if (token.length > 16384)
20
+ throw new InvalidArgumentError('The token exceeds 16 KiB.');
21
+ if (!/^[\x21-\x7E]+$/.test(token))
22
+ throw new InvalidArgumentError('The token must be a single value of printable ASCII characters.');
23
+ return token;
24
+ }
25
+ /** A piped token fails with the same messages as --token. Reading stops past 16 KiB, with room for a line ending. */
11
26
  export async function readTokenFromStdin(input = process.stdin) {
12
27
  if ('isTTY' in input && input.isTTY) {
13
28
  throw new Error('--token-stdin requires piped input. Pipe the token from your secret manager to vigilbase login --token-stdin.');
@@ -15,13 +30,21 @@ export async function readTokenFromStdin(input = process.stdin) {
15
30
  let token = '';
16
31
  for await (const chunk of input) {
17
32
  token += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
18
- if (Buffer.byteLength(token, 'utf8') > 16384)
19
- throw new Error('Token input exceeds 16 KiB.');
33
+ if (Buffer.byteLength(token, 'utf8') > 16386)
34
+ throw new InvalidArgumentError('The token exceeds 16 KiB.');
20
35
  }
21
- token = token.trim();
22
- if (!token || /\s/.test(token))
23
- throw new Error('Token input must contain exactly one nonempty token.');
24
- return token;
36
+ return validateBearerToken(token.trim());
37
+ }
38
+ /**
39
+ * Token and legacy logins revoke saved Identity grants before saving their session, and a grant bound to another
40
+ * One, issuer or client can't be revoked with this configuration. Stop before the token or device code is used.
41
+ */
42
+ async function assertSavedGrantsReplaceable(ctx) {
43
+ const { identity, shared } = await readStoredCredentials(ctx.config, () => { });
44
+ const other = [identity, shared].find((saved) => saved?.version === 2 && saved.one && !boundIdentityCredentials(ctx.config, saved.one))?.one;
45
+ if (!other)
46
+ return;
47
+ throw new Error(`A saved Identity sign-in belongs to ${other.issuer} for ${other.oneBaseUrl ?? other.resource} (client ID ${other.clientId}). Pass the matching --auth-url, --one-url and --client-id, or log out with them first.`);
25
48
  }
26
49
  /**
27
50
  * Revoke the Identity grant a new login replaces, before the new grant exists. Identity treats a revoked refresh
@@ -57,16 +80,16 @@ export const handleLogin = async (ctx) => {
57
80
  // Fail on Windows or an unsafe credentials directory or file before validating a token or starting device
58
81
  // authorization: once a device grant is approved, a failed save would leave it unsaved and unrevoked.
59
82
  await checkCredentialsLocation(ctx.config);
60
- if (ctx.values.isTokenStdin && (ctx.values.token !== undefined || ctx.env.VIGILBASE_ONE_TOKEN !== undefined)) {
83
+ if (ctx.values.isTokenStdin && (ctx.values.token !== undefined || ctx.env.VIGILBASE_ONE_TOKEN?.trim())) {
61
84
  throw new Error('Use --token-stdin without --token or VIGILBASE_ONE_TOKEN.');
62
85
  }
86
+ // An empty --token fails like any other invalid token; an empty VIGILBASE_ONE_TOKEN is unset.
63
87
  const explicitToken = ctx.values.isTokenStdin
64
88
  ? await readTokenFromStdin(ctx.stdin)
65
- : ctx.values.token?.trim() || ctx.env.VIGILBASE_ONE_TOKEN?.trim() || '';
66
- if (ctx.values.token !== undefined && !ctx.values.token.trim()) {
67
- throw new Error('The --token value must not be empty.');
68
- }
89
+ : ctx.values.token !== undefined ? validateBearerToken(ctx.values.token.trim()) : ctx.env.VIGILBASE_ONE_TOKEN?.trim() || '';
69
90
  if (explicitToken) {
91
+ validateBearerToken(explicitToken);
92
+ await assertSavedGrantsReplaceable(ctx);
70
93
  const oneClient = new VigilbaseOneClient(ctx.config, explicitToken);
71
94
  let user;
72
95
  const session = await withSpinner(ctx, 'Validating token', () => oneClient.getSession());
@@ -110,6 +133,8 @@ export const handleLogin = async (ctx) => {
110
133
  ctx.config.authProvider = await discoverAuthProvider(ctx.config);
111
134
  if (ctx.config.authProvider)
112
135
  await retirePreviousIdentityGrant(ctx);
136
+ else
137
+ await assertSavedGrantsReplaceable(ctx);
113
138
  const device = await withSpinner(ctx, 'Requesting device code', () => requestDeviceCode(ctx.config));
114
139
  const verifyUrl = resolveDeviceVerificationUrl(ctx.config.authProvider?.issuer ?? ctx.config.oneBaseUrl, device);
115
140
  const prettyCode = formatUserCode(device.user_code);
@@ -199,12 +224,14 @@ export const handleLogout = async (ctx) => {
199
224
  if (ctx.rest.length)
200
225
  throw new Error('Usage: vigilbase logout');
201
226
  assertCommandFlags(ctx, []);
227
+ const warnings = [];
202
228
  const cleared = await revokeSavedCredentials(ctx.config, (warning) => {
229
+ warnings.push(warning);
203
230
  if (ctx.mode !== 'json')
204
231
  ctx.writer.stderr(`Warning: ${warning}`);
205
232
  });
206
233
  if (ctx.mode === 'json') {
207
- outputJson(ctx, { success: true, cleared });
234
+ outputJson(ctx, { success: true, cleared, warnings });
208
235
  }
209
236
  else if (cleared) {
210
237
  ctx.writer.stdout('Signed out. Local One credentials removed.');
@@ -214,12 +241,28 @@ export const handleLogout = async (ctx) => {
214
241
  }
215
242
  return ok();
216
243
  };
244
+ /** "One <origin>, Identity <issuer>, client ID <id>" for a sign-in saved for another configuration. */
245
+ function describeSavedFor(savedFor) {
246
+ return [`One ${savedFor.oneBaseUrl}`, ...(savedFor.issuer ? [`Identity ${savedFor.issuer}`] : []), ...(savedFor.clientId ? [`client ID ${savedFor.clientId}`] : [])].join(', ');
247
+ }
217
248
  export const handleWhoami = async (ctx) => {
218
- if (ctx.rest.length)
219
- throw new Error('Usage: vigilbase whoami');
220
- assertCommandFlags(ctx, []);
221
- const legacyIdentityFileIgnored = await hasIgnoredIdentityCredentials(ctx.config);
222
- const accessToken = await getOneAccessToken(ctx.config);
249
+ validateArguments(ctx, 0);
250
+ // Like list and doctor, a directory that fails the checks with no credentials in it is signed out.
251
+ const hasDirectory = await hasCredentialsDirectory(ctx.config);
252
+ const legacyIdentityFileIgnored = hasDirectory && await hasIgnoredIdentityCredentials(ctx.config);
253
+ const savedFor = hasDirectory ? await savedForAnotherConfiguration(ctx.config, await readCredentials(ctx.config)) : null;
254
+ let accessToken = null;
255
+ let signInEnded = false;
256
+ try {
257
+ if (hasDirectory)
258
+ accessToken = await getOneAccessToken(ctx.config);
259
+ }
260
+ catch (error) {
261
+ // Identity ended the grant and its file was removed: signed out, which whoami reports as success.
262
+ if (!(error instanceof SignInEndedError))
263
+ throw error;
264
+ signInEnded = true;
265
+ }
223
266
  let liveUser = null;
224
267
  if (accessToken) {
225
268
  try {
@@ -233,16 +276,18 @@ export const handleWhoami = async (ctx) => {
233
276
  }
234
277
  }
235
278
  catch (error) {
236
- // A rejected session is signed out. Connectivity and server failures
237
- // must remain errors rather than claiming the cached session is valid.
238
- if (!(error instanceof VigilbaseApiError) || ![401, 403].includes(error.status)) {
279
+ // A rejected session is signed out, and so is a grant Identity ended when the 401 retry refreshed it.
280
+ // Connectivity and server failures must remain errors rather than claiming the cached session is valid.
281
+ if (error instanceof SignInEndedError)
282
+ signInEnded = true;
283
+ else if (!(error instanceof VigilbaseApiError) || ![401, 403].includes(error.status)) {
239
284
  throw error;
240
285
  }
241
286
  }
242
287
  }
243
288
  const signedIn = Boolean(liveUser);
244
289
  // Read after the token and session calls: either may have refreshed the sign-in, which saves a new expiry and scope.
245
- const credentials = await readCredentials(ctx.config);
290
+ const credentials = signedIn ? await readCredentials(ctx.config) : { one: null };
246
291
  const payload = {
247
292
  authenticated: signedIn,
248
293
  oneBaseUrl: ctx.config.oneBaseUrl,
@@ -250,6 +295,8 @@ export const handleWhoami = async (ctx) => {
250
295
  clientId: credentials.one?.clientId ?? null,
251
296
  resource: credentials.one?.resource ?? null,
252
297
  organizationId: credentials.one?.orgId ?? null,
298
+ savedFor,
299
+ signInEnded,
253
300
  user: liveUser,
254
301
  expiresAt: signedIn ? credentials.one?.expiresAt ?? null : null,
255
302
  scope: signedIn ? credentials.one?.scope ?? null : null,
@@ -263,9 +310,13 @@ export const handleWhoami = async (ctx) => {
263
310
  outputJson(ctx, payload);
264
311
  }
265
312
  else if (!signedIn) {
313
+ if (signInEnded)
314
+ ctx.writer.stderr('Your saved sign-in was ended by Vigilbase Identity and has been removed.');
266
315
  outputLines(ctx, [
267
316
  'Not signed in. Public tools still work.',
268
- 'Run `vigilbase login` to unlock auth-required tools (website-security, react2shell, fortibleed-check).',
317
+ savedFor
318
+ ? `A saved sign-in exists for ${describeSavedFor(savedFor)}; pass the matching options or run \`vigilbase login\`.`
319
+ : 'Run `vigilbase login` to unlock auth-required tools (website-security, react2shell, fortibleed-check).',
269
320
  ...(legacyIdentityFileIgnored ? [legacyNote] : []),
270
321
  ]);
271
322
  }
@@ -1,10 +1,10 @@
1
- import { validateArguments } from './legacy-validation.js';
1
+ import { unknownSubcommand, validateArguments } from './legacy-validation.js';
2
2
  import { openUrlInBrowser } from '../lib/open-url.js';
3
3
  import { asRecord, ok, outputJson, requireOneClient, withSpinner, } from './helpers.js';
4
4
  export const handleBilling = async (ctx) => {
5
5
  const subcommand = (ctx.rest[0] ?? 'summary').toLowerCase();
6
6
  if (!['summary', 'show', 'status', 'portal', 'open'].includes(subcommand))
7
- throw new Error('Usage: vigilbase billing [summary|portal]');
7
+ throw unknownSubcommand('billing', ctx.rest[0] ?? '');
8
8
  validateArguments(ctx, ctx.rest[0] === undefined ? 0 : 1, ['portal', 'open'].includes(subcommand) ? ['isNoOpen'] : []);
9
9
  const one = await requireOneClient(ctx);
10
10
  if (subcommand === 'summary' ||
@@ -1,3 +1,3 @@
1
1
  import { type CommandHandler } from './helpers.js';
2
- export declare const CLOUDFLARE_HELP = "Usage: vigilbase cf <command> [options]\nAlias: vigilbase cloudflare\n\nInventory and workspace:\n zones List zones (default); --customer-id, --account-id, --zone-id\n zone <zone-id> Zone detail; --customer-id selects live details, optional --account-id\n accounts List accessible accounts; --customer-id, --account-id\n account <account-id> Stored account detail; --customer-id\n workspace [section] Workspace bootstrap or section; --customer-id, --account-id, --zone-id\n Sections: overview, reports, zones, rule-analyzer, accounts, checkups, settings\n\nZone reads (--customer-id <uuid> required; IDs can use --zone-id):\n dns <zone-id> DNS records, TTL and proxy state\n waf <zone-id> WAF rules, expressions, analytics and suggestions\n speed-settings <zone-id> Performance settings (internal users only)\n\nAccount reads (optional --customer-id; IDs can use --account-id):\n audit-logs <account-id> Latest provider audit events (up to 100; service-read permission)\n zero-trust <account-id> Users, Access applications, tunnels and authentication analytics\n developer-platform <account-id>\n Workers, Workers analytics and Pages\n magic-transit <account-id> Prefixes, routes and traffic analytics\n Zero Trust, Developer Platform and Magic Transit require deployment-read permission.\n\nAnalytics and checkups:\n analytics --customer-id <uuid> [--account-id <id> | --zone-id <id>] [--days 1..30]\n Default 7 days; live zone analytics uses the most recent 23 hours.\n checkup [status] Latest completed checkup; --customer-id, --account-id\n checkup start Start and persist a scan; --customer-id, --account-id\n checkup progress <run-uuid> Read saved progress once\n checkup result <run-uuid> Read the stored Cloudflare checkup result\n checkups aliases checkup; list/show alias status; run aliases start.\n\nCommon options: --json, --one-url <origin>, --timeout <milliseconds>, --help\nCustomer IDs are UUIDs; account and zone IDs are 32 hexadecimal characters.\nOne login, tenant bindings and permissions apply to every command.\nCheckup start creates a scan run. These commands do not edit provider configuration.\nSee CLOUDFLARE.md for full grammar, API mapping, data limits and examples.";
2
+ export declare const CLOUDFLARE_HELP = "Usage: vigilbase cf <command> [options]\nAlias: vigilbase cloudflare\n\nInventory and workspace:\n zones List zones (default)\n --customer-id --account-id --zone-id\n zone <zone-id> Zone detail; --customer-id for live details,\n optional --account-id\n accounts List accessible accounts\n --customer-id --account-id\n account <account-id> Stored account detail; --customer-id\n workspace [section] Workspace bootstrap or one section\n --customer-id --account-id --zone-id\n Sections: overview, reports, zones, rule-analyzer,\n accounts, checkups, settings\n\nZone reads (--customer-id <uuid> required; IDs can use --zone-id):\n dns <zone-id> DNS records, TTL and proxy state\n waf <zone-id> WAF rules, expressions, analytics and\n suggestions\n speed-settings <zone-id> Performance settings (internal users only)\n\nAccount reads (optional --customer-id; IDs can use --account-id):\n audit-logs <account-id> Latest provider audit events (up to 100)\n Needs service-read permission.\n zero-trust <account-id> Users, Access applications, tunnels and\n authentication analytics\n developer-platform <account-id>\n Workers, Workers analytics and Pages\n magic-transit <account-id> Prefixes, routes and traffic analytics\n Zero Trust, Developer Platform and Magic Transit require deployment-read permission.\n\nAnalytics and checkups:\n analytics --customer-id <uuid> [--account-id <id> | --zone-id <id>]\n [--days 1..30]\n Default 7 days; live zone analytics uses the most recent 23 hours.\n checkup [status] Latest completed checkup\n --customer-id --account-id\n checkup start Start and persist a scan\n --customer-id --account-id\n checkup progress <run-uuid> Read saved progress once\n checkup result <run-uuid> Read the stored Cloudflare checkup result\n checkups aliases checkup; list/show alias status; run aliases start.\n\nCommon options: --json, --one-url <origin>, --timeout <milliseconds>, --help\nCustomer IDs are UUIDs; account and zone IDs are 32 hexadecimal characters.\nOne login, tenant bindings and permissions apply to every command.\nCheckup start creates a scan run. These commands do not edit provider configuration.\nSee CLOUDFLARE.md for full grammar, API mapping, data limits and examples.";
3
3
  export declare const handleCloudflare: CommandHandler;