@voiden/runner 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +748 -0
  2. package/dist/blockSchemaRegistry.d.ts +49 -0
  3. package/dist/blockSchemaRegistry.d.ts.map +1 -0
  4. package/dist/blockSchemaRegistry.js +68 -0
  5. package/dist/blockSchemaRegistry.js.map +1 -0
  6. package/dist/cliElectron.d.ts +55 -0
  7. package/dist/cliElectron.d.ts.map +1 -0
  8. package/dist/cliElectron.js +120 -0
  9. package/dist/cliElectron.js.map +1 -0
  10. package/dist/headlessContext.d.ts +18 -0
  11. package/dist/headlessContext.d.ts.map +1 -0
  12. package/dist/headlessContext.js +85 -0
  13. package/dist/headlessContext.js.map +1 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +945 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/parser.d.ts +24 -0
  19. package/dist/parser.d.ts.map +1 -0
  20. package/dist/parser.js +87 -0
  21. package/dist/parser.js.map +1 -0
  22. package/dist/parserRegistry.d.ts +17 -0
  23. package/dist/parserRegistry.d.ts.map +1 -0
  24. package/dist/parserRegistry.js +27 -0
  25. package/dist/parserRegistry.js.map +1 -0
  26. package/dist/plugins/community.d.ts +42 -0
  27. package/dist/plugins/community.d.ts.map +1 -0
  28. package/dist/plugins/community.js +105 -0
  29. package/dist/plugins/community.js.map +1 -0
  30. package/dist/plugins/loader.d.ts +17 -0
  31. package/dist/plugins/loader.d.ts.map +1 -0
  32. package/dist/plugins/loader.js +120 -0
  33. package/dist/plugins/loader.js.map +1 -0
  34. package/dist/plugins/registry.d.ts +30 -0
  35. package/dist/plugins/registry.d.ts.map +1 -0
  36. package/dist/plugins/registry.js +52 -0
  37. package/dist/plugins/registry.js.map +1 -0
  38. package/dist/plugins/store.d.ts +19 -0
  39. package/dist/plugins/store.d.ts.map +1 -0
  40. package/dist/plugins/store.js +68 -0
  41. package/dist/plugins/store.js.map +1 -0
  42. package/dist/report/csv.d.ts +6 -0
  43. package/dist/report/csv.d.ts.map +1 -0
  44. package/dist/report/csv.js +71 -0
  45. package/dist/report/csv.js.map +1 -0
  46. package/dist/report/mail.d.ts +17 -0
  47. package/dist/report/mail.d.ts.map +1 -0
  48. package/dist/report/mail.js +84 -0
  49. package/dist/report/mail.js.map +1 -0
  50. package/dist/runner.d.ts +41 -0
  51. package/dist/runner.d.ts.map +1 -0
  52. package/dist/runner.js +193 -0
  53. package/dist/runner.js.map +1 -0
  54. package/dist/runtimeVars.d.ts +55 -0
  55. package/dist/runtimeVars.d.ts.map +1 -0
  56. package/dist/runtimeVars.js +248 -0
  57. package/dist/runtimeVars.js.map +1 -0
  58. package/dist/session.d.ts +10 -0
  59. package/dist/session.d.ts.map +1 -0
  60. package/dist/session.js +32 -0
  61. package/dist/session.js.map +1 -0
  62. package/dist/types.d.ts +45 -0
  63. package/dist/types.d.ts.map +1 -0
  64. package/dist/types.js +3 -0
  65. package/dist/types.js.map +1 -0
  66. package/package.json +43 -0
package/dist/index.js ADDED
@@ -0,0 +1,945 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
4
+ import { resolve, basename, join } from 'path';
5
+ import { readdir } from 'fs/promises';
6
+ import chalk from 'chalk';
7
+ import { runVoidFile } from './runner.js';
8
+ import { loadEnabledPlugins } from './plugins/loader.js';
9
+ import { exportToCsv } from './report/csv.js';
10
+ import { sendMailReport } from './report/mail.js';
11
+ import { CORE_PLUGINS, findPlugin } from './plugins/registry.js';
12
+ import { fetchCommunityPlugins, findCommunityPlugin, hasCommunityRunner, installCommunityRunner, } from './plugins/community.js';
13
+ import { installPlugin, uninstallPlugin, setPluginEnabled, getAllInstalledPlugins, readStore, STORE_DIR, } from './plugins/store.js';
14
+ import { appendSessionResults, loadSessionResults, clearSession, } from './session.js';
15
+ // ─────────────────────────────────────────────────────────────────────────────
16
+ // Helpers
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+ function loadEnvFile(envPath) {
19
+ const content = readFileSync(envPath, 'utf-8');
20
+ const env = {};
21
+ const lines = content.split('\n');
22
+ for (let i = 0; i < lines.length; i++) {
23
+ const line = lines[i].trim();
24
+ if (!line || line.startsWith('#'))
25
+ continue;
26
+ const eq = line.indexOf('=');
27
+ if (eq === -1)
28
+ throw new Error(`Malformed line ${i + 1} in .env file: missing "="`);
29
+ const key = line.slice(0, eq).trim();
30
+ const val = line.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
31
+ if (!key)
32
+ throw new Error(`Malformed line ${i + 1} in .env file: empty key`);
33
+ env[key] = val;
34
+ }
35
+ return env;
36
+ }
37
+ function formatBytes(bytes) {
38
+ if (bytes < 1024)
39
+ return `${bytes}B`;
40
+ if (bytes < 1024 * 1024)
41
+ return `${(bytes / 1024).toFixed(1)}KB`;
42
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
43
+ }
44
+ function formatDuration(ms) {
45
+ if (ms < 1000)
46
+ return `${ms}ms`;
47
+ return `${(ms / 1000).toFixed(2)}s`;
48
+ }
49
+ /** Recursively collect all .void files under a directory. */
50
+ async function collectVoidFiles(inputPath) {
51
+ const abs = resolve(inputPath);
52
+ if (!existsSync(abs))
53
+ return [];
54
+ const stat = statSync(abs);
55
+ if (stat.isFile()) {
56
+ return abs.endsWith('.void') ? [abs] : [];
57
+ }
58
+ if (stat.isDirectory()) {
59
+ const entries = await readdir(abs, { withFileTypes: true });
60
+ const results = [];
61
+ for (const entry of entries) {
62
+ const full = resolve(abs, entry.name);
63
+ if (entry.isDirectory()) {
64
+ results.push(...(await collectVoidFiles(full)));
65
+ }
66
+ else if (entry.isFile() && entry.name.endsWith('.void')) {
67
+ results.push(full);
68
+ }
69
+ }
70
+ return results;
71
+ }
72
+ return [];
73
+ }
74
+ /** Expand a list of paths/globs into resolved .void file paths. */
75
+ async function resolveFiles(patterns) {
76
+ const resolved = [];
77
+ for (const pattern of patterns) {
78
+ if (pattern.includes('*')) {
79
+ const dir = resolve(pattern.replace(/\/?\*.*$/, '') || '.');
80
+ const entries = await readdir(dir, { withFileTypes: true });
81
+ for (const entry of entries) {
82
+ if (entry.isFile() && entry.name.endsWith('.void')) {
83
+ resolved.push(resolve(dir, entry.name));
84
+ }
85
+ }
86
+ }
87
+ else {
88
+ resolved.push(...(await collectVoidFiles(pattern)));
89
+ }
90
+ }
91
+ return resolved;
92
+ }
93
+ // ─────────────────────────────────────────────────────────────────────────────
94
+ // Spinner
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
97
+ function startSpinner(label) {
98
+ if (!process.stdout.isTTY)
99
+ return () => { };
100
+ let frame = 0;
101
+ const interval = setInterval(() => {
102
+ const spin = chalk.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length]);
103
+ process.stdout.write(`\r ${spin} ${chalk.gray(label)} `);
104
+ frame++;
105
+ }, 80);
106
+ return () => {
107
+ clearInterval(interval);
108
+ process.stdout.write('\r' + ' '.repeat(label.length + 10) + '\r');
109
+ };
110
+ }
111
+ // ─────────────────────────────────────────────────────────────────────────────
112
+ // Run output formatters
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+ const DIVIDER = chalk.gray('─'.repeat(64));
115
+ function printRunHeader(fileCount, pluginCount) {
116
+ console.log();
117
+ console.log(chalk.bold.white(' voiden-runner') +
118
+ chalk.gray(` · ${fileCount} file${fileCount !== 1 ? 's' : ''}`) +
119
+ chalk.gray(` · ${pluginCount} plugin${pluginCount !== 1 ? 's' : ''} active`));
120
+ console.log(DIVIDER);
121
+ }
122
+ // ─────────────────────────────────────────────────────────────────────────────
123
+ // Report entry renderer
124
+ // ─────────────────────────────────────────────────────────────────────────────
125
+ function renderReportEntries(entries, verbose) {
126
+ const assertions = entries.filter(e => e.type === 'assertion');
127
+ const logs = entries.filter(e => e.type === 'log');
128
+ const sections = entries.filter(e => e.type === 'section');
129
+ // Assertions — always shown (mirrors the test panel in the app)
130
+ if (assertions.length > 0) {
131
+ const passed = assertions.filter(e => e.type === 'assertion' && e.passed).length;
132
+ const failed = assertions.length - passed;
133
+ console.log(` assertions: ${chalk.green(`${passed} passed`)}` +
134
+ (failed > 0 ? chalk.red(` · ${failed} failed`) : ''));
135
+ for (const e of assertions) {
136
+ if (e.type !== 'assertion')
137
+ continue;
138
+ const icon = e.passed ? chalk.green(' ✓') : chalk.red(' ✗');
139
+ let line = ` ${icon} ${e.message}`;
140
+ if (!e.passed && e.actual !== undefined && e.expected !== undefined) {
141
+ line += chalk.gray(` (got ${JSON.stringify(e.actual)}, expected ${e.operator ?? '=='} ${JSON.stringify(e.expected)})`);
142
+ }
143
+ console.log(line);
144
+ }
145
+ }
146
+ // Script logs — only shown in verbose mode (same as app behaviour: logs visible in console panel)
147
+ if (verbose && logs.length > 0) {
148
+ const levelIcon = {
149
+ info: chalk.blue('ℹ'),
150
+ debug: chalk.gray('•'),
151
+ warn: chalk.yellow('⚠'),
152
+ error: chalk.red('✗'),
153
+ log: chalk.gray('·'),
154
+ };
155
+ for (const e of logs) {
156
+ if (e.type !== 'log')
157
+ continue;
158
+ const icon = (e.level ? levelIcon[e.level] : undefined) ?? chalk.gray('·');
159
+ console.log(chalk.gray(` ${icon} ${e.message}`));
160
+ }
161
+ }
162
+ // Section titles — shown when verbose, useful for grouping named test blocks
163
+ if (verbose) {
164
+ for (const e of sections) {
165
+ if (e.type !== 'section')
166
+ continue;
167
+ console.log(chalk.bold.gray(` ── ${e.title} ──`));
168
+ }
169
+ }
170
+ }
171
+ function printKeyValue(label, obj) {
172
+ if (!obj || Object.keys(obj).length === 0)
173
+ return;
174
+ console.log(chalk.gray(` ${label}:`));
175
+ for (const [k, v] of Object.entries(obj)) {
176
+ console.log(chalk.gray(` ${chalk.dim(k + ':')} ${v}`));
177
+ }
178
+ }
179
+ function printBody(label, body) {
180
+ if (!body)
181
+ return;
182
+ console.log(chalk.gray(` ${label}:`));
183
+ for (const line of body.split('\n')) {
184
+ console.log(chalk.gray(` ${line}`));
185
+ }
186
+ }
187
+ function printRequestResult(result, filePath, index, total, showReq, showRes, verbose) {
188
+ const icon = result.success ? chalk.green(' ✓') : chalk.red(' ✗');
189
+ const counter = chalk.gray(`[${index}/${total}]`);
190
+ const fileName = chalk.bold(basename(filePath));
191
+ console.log();
192
+ console.log(`${counter} ${fileName}`);
193
+ const proto = chalk.cyan(result.protocol.toUpperCase().padEnd(4));
194
+ const method = result.method ? chalk.bold(result.method.padEnd(6)) + ' ' : ' ';
195
+ const url = chalk.underline(result.url || '—');
196
+ const time = chalk.gray(formatDuration(result.durationMs));
197
+ let statusPart = '';
198
+ if (result.status !== undefined) {
199
+ const statusColor = result.success ? chalk.green : chalk.red;
200
+ statusPart = statusColor(` ${result.status} ${result.statusText ?? ''}`);
201
+ }
202
+ else if (result.connected !== undefined) {
203
+ statusPart = result.connected
204
+ ? chalk.green(' Connected')
205
+ : chalk.red(' Failed to connect');
206
+ }
207
+ let sizePart = '';
208
+ if (result.size !== undefined) {
209
+ sizePart = chalk.gray(` ${formatBytes(result.size)}`);
210
+ }
211
+ console.log(`${icon} ${proto} ${method}${url}${statusPart} ${time}${sizePart}`);
212
+ // ── Always show request details on failure (helps debug "fetch failed") ────
213
+ if (!result.success) {
214
+ if (result.error)
215
+ console.log(chalk.red(` ${result.error}`));
216
+ console.log(chalk.gray(' ↳ request sent:'));
217
+ console.log(chalk.gray(` url: ${result.url || '—'}`));
218
+ if (result.method)
219
+ console.log(chalk.gray(` method: ${result.method}`));
220
+ printKeyValue('headers', result.requestHeaders);
221
+ if (result.requestBody)
222
+ printBody('body', result.requestBody);
223
+ }
224
+ // ── Report entries (emitted by plugins) ───────────────────────────────────
225
+ if (result.reportEntries && result.reportEntries.length > 0) {
226
+ renderReportEntries(result.reportEntries, verbose);
227
+ }
228
+ // ── Legacy assertion fields ───────────────────────────────────────────────
229
+ if (!result.reportEntries && (result.assertionsPassed !== undefined || result.assertionsFailed !== undefined)) {
230
+ const p = result.assertionsPassed ?? 0;
231
+ const f = result.assertionsFailed ?? 0;
232
+ console.log(` assertions: ${chalk.green(`${p} passed`)}${f > 0 ? chalk.red(` · ${f} failed`) : ''}`);
233
+ }
234
+ // ── --show-req ────────────────────────────────────────────────────────────
235
+ if (showReq && result.success) {
236
+ console.log(chalk.gray(' ↳ request:'));
237
+ console.log(chalk.gray(` url: ${result.url || '—'}`));
238
+ if (result.method)
239
+ console.log(chalk.gray(` method: ${result.method}`));
240
+ printKeyValue('headers', result.requestHeaders);
241
+ if (result.requestBody)
242
+ printBody('body', result.requestBody);
243
+ }
244
+ // ── --show-res ────────────────────────────────────────────────────────────
245
+ if (showRes) {
246
+ console.log(chalk.gray(' ↳ response:'));
247
+ printKeyValue('headers', result.responseHeaders);
248
+ if (result.body)
249
+ printBody('body', result.body);
250
+ }
251
+ }
252
+ function printRunSummary(results, totalMs) {
253
+ const passed = results.filter(r => r.result.success).length;
254
+ const failed = results.length - passed;
255
+ console.log();
256
+ console.log(DIVIDER);
257
+ const passedStr = passed > 0 ? chalk.green(`${passed} passed`) : chalk.gray('0 passed');
258
+ const failedStr = failed > 0 ? chalk.red(`${failed} failed`) : chalk.gray('0 failed');
259
+ console.log(` ${chalk.bold('Summary')} ` +
260
+ `${results.length} request${results.length !== 1 ? 's' : ''} · ` +
261
+ `${passedStr} · ${failedStr} · ` +
262
+ chalk.gray(formatDuration(totalMs) + ' total'));
263
+ console.log(DIVIDER);
264
+ console.log();
265
+ }
266
+ function printRunSummaryJson(results, totalMs, activePlugins) {
267
+ const passed = results.filter(r => r.result.success).length;
268
+ const output = {
269
+ summary: {
270
+ total: results.length,
271
+ passed,
272
+ failed: results.length - passed,
273
+ totalDurationMs: totalMs,
274
+ activePlugins,
275
+ },
276
+ requests: results.map(r => ({ file: r.file, ...r.result })),
277
+ };
278
+ console.log(JSON.stringify(output, null, 2));
279
+ }
280
+ // ─────────────────────────────────────────────────────────────────────────────
281
+ // CLI
282
+ // ─────────────────────────────────────────────────────────────────────────────
283
+ program
284
+ .name('voiden-runner')
285
+ .description('Run .void files headlessly — REST, WebSocket, and gRPC')
286
+ .version('0.1.0');
287
+ // ── voiden-runner run ─────────────────────────────────────────────────────────
288
+ program
289
+ .command('run <paths...>')
290
+ .description('Run .void files — accepts files, directories (recursive), or glob patterns\n\n' +
291
+ ' Examples:\n' +
292
+ ' voiden-runner run auth.void\n' +
293
+ ' voiden-runner run ./requests/\n' +
294
+ ' voiden-runner run auth.void users.void ./smoke/\n' +
295
+ ' voiden-runner run ./ --env .env.staging --bail\n')
296
+ .option('-e, --env <path>', 'Path to .env or .yaml file for variable substitution')
297
+ .option('--env-var <key=value>', 'Individual environment variable override (can be used multiple times)', (val, memo) => {
298
+ memo.push(val);
299
+ return memo;
300
+ }, [])
301
+ .option('--show-req', 'Print sent request headers and body for each request')
302
+ .option('--show-res', 'Print response headers and body for each request')
303
+ .option('--bail', 'Stop immediately on first failure and exit 1 (CI fast-fail)')
304
+ .option('--stop-on-failure', 'Alias for --bail: stop on first failure, exit 1 (shell set -e friendly)')
305
+ .option('--fail-on-error', 'Exit with code 1 if any request fails (runs all files first)')
306
+ .option('--verbose', 'Print plugin and script logs')
307
+ .option('--json', 'Output results as JSON (suppresses normal output — useful for CI pipelines)')
308
+ .option('--no-session', 'Completely stateless run (do not load/save results or runtime variables)')
309
+ .option('--output-json <file>', 'Write the full result object to a JSON file — pass to the next CLI, script, or tool')
310
+ .option('--csv <path>', 'Export full report (request + response headers, bodies, assertions) to a CSV file')
311
+ .option('--mail', 'Send HTML report to address specified in VOIDEN_MAIL_TO env')
312
+ .option('--mail-to <address>', 'Send HTML report to this email address')
313
+ .option('--mail-from <address>', 'Sender address for the report email')
314
+ .option('--mail-subject <subject>', 'Email subject line (default: auto-generated summary)')
315
+ .option('--smtp-host <host>', 'SMTP server host')
316
+ .option('--smtp-port <port>', 'SMTP server port')
317
+ .option('--smtp-secure', 'Use TLS for SMTP (true/false)')
318
+ .option('--smtp-user <user>', 'SMTP username')
319
+ .option('--smtp-pass <pass>', 'SMTP password')
320
+ .action(async (paths, opts) => {
321
+ // Priority order (lowest → highest):
322
+ // system env (process.env) → --env file → --env-var overrides
323
+ //
324
+ // System env is the base so GitHub Actions secrets, GitLab CI variables,
325
+ // and any CI/CD platform vars are automatically available as {{KEY}}
326
+ // without needing an --env file.
327
+ const env = Object.fromEntries(Object.entries(process.env).filter(([, v]) => v !== undefined));
328
+ // 1. Load --env file (overrides system)
329
+ if (opts.env) {
330
+ const envPath = resolve(opts.env);
331
+ if (!existsSync(envPath)) {
332
+ console.error(chalk.red(`Env file not found: ${envPath}`));
333
+ process.exit(1);
334
+ }
335
+ try {
336
+ Object.assign(env, loadEnvFile(envPath));
337
+ }
338
+ catch (err) {
339
+ console.error(chalk.red(` ✗ ${err.message}`));
340
+ process.exit(1);
341
+ }
342
+ }
343
+ // 2. Individual --env-var overrides
344
+ if (opts.envVar && Array.isArray(opts.envVar)) {
345
+ for (const pair of opts.envVar) {
346
+ const eq = pair.indexOf('=');
347
+ if (eq === -1) {
348
+ console.error(chalk.red(` ✗ Invalid --env-var format: "${pair}" (expected key=value)`));
349
+ process.exit(1);
350
+ }
351
+ const key = pair.slice(0, eq).trim();
352
+ const val = pair.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
353
+ if (!key) {
354
+ console.error(chalk.red(` ✗ Invalid --env-var format: "${pair}" (key cannot be empty)`));
355
+ process.exit(1);
356
+ }
357
+ env[key] = val;
358
+ }
359
+ }
360
+ const resolvedFiles = await resolveFiles(paths);
361
+ if (resolvedFiles.length === 0) {
362
+ console.error(chalk.red('No .void files found at the given path(s)'));
363
+ process.exit(1);
364
+ }
365
+ // --stop-on-failure is a CI-friendly alias for --bail
366
+ const stopOnFailure = opts.bail || opts.stopOnFailure;
367
+ // Mail settings — read from CLI options or merged env
368
+ const mailTo = opts.mailTo || (opts.mail ? env.VOIDEN_MAIL_TO : undefined);
369
+ const mailFrom = opts.mailFrom || env.VOIDEN_MAIL_FROM;
370
+ const mailSubject = opts.mailSubject || env.VOIDEN_MAIL_SUBJECT;
371
+ // SMTP settings — read from CLI options or merged env
372
+ const smtpHost = opts.smtpHost || env.VOIDEN_SMTP_HOST || process.env.VOIDEN_SMTP_HOST;
373
+ const smtpPort = parseInt(opts.smtpPort || env.VOIDEN_SMTP_PORT || process.env.VOIDEN_SMTP_PORT || '0') || undefined;
374
+ const smtpSecure = opts.smtpSecure || (env.VOIDEN_SMTP_SECURE || process.env.VOIDEN_SMTP_SECURE) === 'true';
375
+ const smtpUser = opts.smtpUser || env.VOIDEN_SMTP_USER || process.env.VOIDEN_SMTP_USER;
376
+ const smtpPass = opts.smtpPass || env.VOIDEN_SMTP_PASS || process.env.VOIDEN_SMTP_PASS;
377
+ // Validate mail options up-front so we fail fast before running requests
378
+ if (opts.mail || opts.mailTo) {
379
+ if (!mailTo) {
380
+ console.error(chalk.red(' ✗ Mail error: no recipient found. Please provide --mail-to or set VOIDEN_MAIL_TO.'));
381
+ process.exit(1);
382
+ }
383
+ if (!opts.csv) {
384
+ console.error(chalk.red(' ✗ Mail error: no CSV generated. Mail requires --csv flag.'));
385
+ process.exit(1);
386
+ }
387
+ if (!smtpHost) {
388
+ console.error(chalk.red(' ✗ Mail keys are missing. Please provide SMTP configuration (VOIDEN_SMTP_HOST).'));
389
+ process.exit(1);
390
+ }
391
+ }
392
+ const runStart = Date.now();
393
+ let anyFailed = false;
394
+ const allResults = [];
395
+ // In-memory runtime variables — shared across all files in this run.
396
+ // Captured from {{$res.xxx}} runtime-variable blocks after each request.
397
+ // Available as {{process.KEY}} in subsequent requests and via voiden.variables.get().
398
+ const runtimeVars = {};
399
+ // Load persisted runtime variables if not disabled
400
+ const VARS_PATH = join(STORE_DIR, '.process.env.json');
401
+ if (!opts.noSession && existsSync(VARS_PATH)) {
402
+ try {
403
+ const data = JSON.parse(readFileSync(VARS_PATH, 'utf-8'));
404
+ Object.assign(runtimeVars, data);
405
+ if (opts.verbose)
406
+ console.log(chalk.gray(` [vars] Loaded ${Object.keys(data).length} persisted variables from ${VARS_PATH}`));
407
+ }
408
+ catch {
409
+ // Ignore if file is malformed
410
+ }
411
+ }
412
+ // Load plugins once for the entire session — not once per file.
413
+ const activePlugins = await loadEnabledPlugins(opts.verbose ?? false);
414
+ // Collect results
415
+ for (let i = 0; i < resolvedFiles.length; i++) {
416
+ const file = resolvedFiles[i];
417
+ const stopSpinner = opts.json ? () => { } : startSpinner(`[${i + 1}/${resolvedFiles.length}] ${basename(file)}`);
418
+ try {
419
+ const { results } = await runVoidFile(file, { env, verbose: opts.verbose, runtimeVars, activePlugins });
420
+ stopSpinner();
421
+ for (const { result } of results) {
422
+ if (!result.success)
423
+ anyFailed = true;
424
+ allResults.push({ file, result });
425
+ }
426
+ }
427
+ catch (err) {
428
+ stopSpinner();
429
+ anyFailed = true;
430
+ allResults.push({
431
+ file,
432
+ result: {
433
+ protocol: 'unknown',
434
+ url: '',
435
+ success: false,
436
+ durationMs: 0,
437
+ error: err?.message || String(err),
438
+ },
439
+ });
440
+ }
441
+ // --bail / --stop-on-failure: halt immediately, let shell set -e propagate
442
+ if (stopOnFailure && anyFailed) {
443
+ console.log();
444
+ console.log(chalk.red(` ✗ Stopped on first failure — ${resolvedFiles.length - i - 1} file(s) skipped`));
445
+ console.log(chalk.gray(' (exit code 1 — shell set -e will abort the parent script)'));
446
+ break;
447
+ }
448
+ }
449
+ // Save session results if not disabled
450
+ if (!opts.noSession) {
451
+ appendSessionResults(allResults);
452
+ }
453
+ const totalMs = Date.now() - runStart;
454
+ // Save runtime variables if not disabled
455
+ if (!opts.noSession && Object.keys(runtimeVars).length > 0) {
456
+ try {
457
+ mkdirSync(STORE_DIR, { recursive: true });
458
+ writeFileSync(VARS_PATH, JSON.stringify(runtimeVars, null, 2), 'utf-8');
459
+ if (!opts.json)
460
+ console.log(chalk.gray(` [vars] Saved ${Object.keys(runtimeVars).length} runtime variables to ${VARS_PATH}`));
461
+ }
462
+ catch (err) {
463
+ if (opts.verbose)
464
+ console.error(chalk.red(` [vars] Failed to save runtime variables: ${err?.message}`));
465
+ }
466
+ }
467
+ if (opts.json) {
468
+ printRunSummaryJson(allResults, totalMs, activePlugins);
469
+ }
470
+ else {
471
+ printRunHeader(resolvedFiles.length, activePlugins.length);
472
+ for (let i = 0; i < allResults.length; i++) {
473
+ const { file, result } = allResults[i];
474
+ printRequestResult(result, file, i + 1, allResults.length, opts.showReq ?? false, opts.showRes ?? false, opts.verbose ?? false);
475
+ }
476
+ printRunSummary(allResults, totalMs);
477
+ }
478
+ // ── CSV export ────────────────────────────────────────────────────────────
479
+ let savedCsvPath;
480
+ if (opts.csv) {
481
+ try {
482
+ savedCsvPath = exportToCsv(allResults, opts.csv);
483
+ console.log(chalk.green(` ✓ CSV report saved to ${savedCsvPath}`));
484
+ }
485
+ catch (err) {
486
+ console.error(chalk.red(` ✗ Failed to write CSV: ${err?.message ?? String(err)}`));
487
+ }
488
+ }
489
+ // ── Email report ──────────────────────────────────────────────────────────
490
+ if (mailTo) {
491
+ console.log(chalk.gray(` ↑ Sending report to ${mailTo} …`));
492
+ try {
493
+ await sendMailReport(allResults, totalMs, {
494
+ to: mailTo,
495
+ from: mailFrom,
496
+ subject: mailSubject,
497
+ smtpHost: smtpHost,
498
+ smtpPort: smtpPort,
499
+ smtpSecure: smtpSecure,
500
+ smtpUser: smtpUser,
501
+ smtpPass: smtpPass,
502
+ csvPath: savedCsvPath,
503
+ });
504
+ console.log(chalk.green(` ✓ Report sent to ${mailTo}`));
505
+ }
506
+ catch (err) {
507
+ console.error(chalk.red(` ✗ Failed to send email: ${err?.message ?? String(err)}`));
508
+ }
509
+ }
510
+ // ── Output JSON to file ───────────────────────────────────────────────────
511
+ if (opts.outputJson) {
512
+ const jsonData = {
513
+ summary: {
514
+ total: allResults.length,
515
+ passed: allResults.filter(r => r.result.success).length,
516
+ failed: allResults.filter(r => !r.result.success).length,
517
+ totalDurationMs: Date.now() - runStart,
518
+ activePlugins,
519
+ },
520
+ requests: allResults.map(r => ({ file: r.file, ...r.result })),
521
+ };
522
+ writeFileSync(opts.outputJson, JSON.stringify(jsonData, null, 2) + '\n', 'utf-8');
523
+ if (!opts.json)
524
+ console.log(chalk.gray(` ↳ Results written to ${opts.outputJson}`));
525
+ }
526
+ const shouldFail = (opts.failOnError || stopOnFailure) && anyFailed;
527
+ if (shouldFail && !opts.json) {
528
+ const failedCount = allResults.filter(r => !r.result.success).length;
529
+ console.log(chalk.red(` ✗ Run failed — ${failedCount} request${failedCount !== 1 ? 's' : ''} failed. Exiting with code 1.`));
530
+ console.log(chalk.gray(' (use this exit code in your shell script to abort on failure)'));
531
+ console.log();
532
+ }
533
+ process.exit(shouldFail ? 1 : 0);
534
+ });
535
+ // ── voiden-runner session ─────────────────────────────────────────────────────
536
+ const sessionCmd = program
537
+ .command('session')
538
+ .description('Manage the current run session');
539
+ sessionCmd
540
+ .command('clear')
541
+ .description('Clear all session data (results and runtime variables)')
542
+ .action(() => {
543
+ clearSession();
544
+ console.log(chalk.yellow(' ✓ Full session cleared (results and runtime variables wiped)'));
545
+ });
546
+ sessionCmd
547
+ .command('vars')
548
+ .description('List all persisted runtime variables')
549
+ .action(() => {
550
+ const VARS_PATH = join(STORE_DIR, '.process.env.json');
551
+ if (!existsSync(VARS_PATH)) {
552
+ console.log(chalk.gray(' No persisted runtime variables.'));
553
+ return;
554
+ }
555
+ try {
556
+ const vars = JSON.parse(readFileSync(VARS_PATH, 'utf-8'));
557
+ const keys = Object.keys(vars);
558
+ if (keys.length === 0) {
559
+ console.log(chalk.gray(' No persisted runtime variables.'));
560
+ return;
561
+ }
562
+ console.log();
563
+ console.log(chalk.bold(' Persisted Runtime Variables'));
564
+ console.log(DIVIDER);
565
+ for (const key of keys) {
566
+ const val = typeof vars[key] === 'object' ? JSON.stringify(vars[key]) : String(vars[key]);
567
+ console.log(` ${chalk.bold(key.padEnd(24))} ${chalk.gray(val)}`);
568
+ }
569
+ console.log(DIVIDER);
570
+ console.log();
571
+ }
572
+ catch {
573
+ console.error(chalk.red(' ✗ Failed to read runtime variables file.'));
574
+ }
575
+ });
576
+ sessionCmd
577
+ .command('status')
578
+ .description('Show summary of current session')
579
+ .action(() => {
580
+ const results = loadSessionResults();
581
+ const VARS_PATH = join(STORE_DIR, '.process.env.json');
582
+ const varsCount = existsSync(VARS_PATH) ? Object.keys(JSON.parse(readFileSync(VARS_PATH, 'utf-8'))).length : 0;
583
+ console.log();
584
+ console.log(chalk.bold(' Session Status'));
585
+ console.log(DIVIDER);
586
+ console.log(` Accumulated results: ${results.length} requests`);
587
+ console.log(` Runtime variables: ${varsCount}`);
588
+ console.log(DIVIDER);
589
+ console.log();
590
+ });
591
+ // ── voiden-runner report ──────────────────────────────────────────────────────
592
+ const reportCmd = program
593
+ .command('report')
594
+ .description('Show and generate reports from accumulated session results')
595
+ .option('--show-req', 'Print sent request headers and body for each request')
596
+ .option('--show-res', 'Print response headers and body for each request')
597
+ .option('--verbose', 'Print plugin and script logs')
598
+ .action((opts) => {
599
+ const results = loadSessionResults();
600
+ if (results.length === 0) {
601
+ console.error(chalk.red(' ✗ No results found in session. Run some .void files first.'));
602
+ return;
603
+ }
604
+ console.log();
605
+ console.log(chalk.bold(' Session History'));
606
+ console.log(DIVIDER);
607
+ let totalDurationMs = 0;
608
+ for (let i = 0; i < results.length; i++) {
609
+ const { file, result } = results[i];
610
+ totalDurationMs += result.durationMs;
611
+ printRequestResult(result, file, i + 1, results.length, opts.showReq ?? false, opts.showRes ?? false, opts.verbose ?? false);
612
+ }
613
+ printRunSummary(results, totalDurationMs);
614
+ });
615
+ reportCmd
616
+ .command('clear')
617
+ .description('Clear accumulated session results (history) only')
618
+ .action(() => {
619
+ const RESULTS_PATH = join(STORE_DIR, 'results.json');
620
+ if (existsSync(RESULTS_PATH)) {
621
+ unlinkSync(RESULTS_PATH);
622
+ console.log(chalk.yellow(' ✓ Session results cleared (runtime variables preserved)'));
623
+ }
624
+ else {
625
+ console.log(chalk.gray(' No session results to clear.'));
626
+ }
627
+ });
628
+ reportCmd
629
+ .command('generate')
630
+ .description('Generate reports from accumulated session results')
631
+ .alias('gen')
632
+ .option('-e, --env <path>', 'Path to .env or .yaml file for SMTP configuration')
633
+ .option('--csv <path>', 'Export session results to a CSV file')
634
+ .option('--mail', 'Send HTML summary + attached CSV using VOIDEN_MAIL_TO env (requires --csv)')
635
+ .option('--mail-to <address>', 'Send HTML summary + attached CSV to this email address (requires --csv)')
636
+ .option('--mail-from <address>', 'Sender address for the report email')
637
+ .option('--mail-subject <subject>', 'Email subject line')
638
+ .option('--smtp-host <host>', 'SMTP server host')
639
+ .option('--smtp-port <port>', 'SMTP server port')
640
+ .option('--smtp-secure', 'Use TLS for SMTP (true/false)')
641
+ .option('--smtp-user <user>', 'SMTP username')
642
+ .option('--smtp-pass <pass>', 'SMTP password')
643
+ .action(async (opts) => {
644
+ const results = loadSessionResults();
645
+ if (results.length === 0) {
646
+ console.error(chalk.red(' ✗ No results found in session. Run some .void files first.'));
647
+ process.exit(1);
648
+ }
649
+ // Load optional .env for report SMTP settings
650
+ const env = { ...process.env };
651
+ if (opts.env) {
652
+ const envPath = resolve(opts.env);
653
+ if (existsSync(envPath)) {
654
+ try {
655
+ Object.assign(env, loadEnvFile(envPath));
656
+ }
657
+ catch { }
658
+ }
659
+ }
660
+ const mailTo = opts.mailTo || (opts.mail ? env.VOIDEN_MAIL_TO : undefined);
661
+ const mailFrom = opts.mailFrom || env.VOIDEN_MAIL_FROM;
662
+ const mailSubject = opts.mailSubject || env.VOIDEN_MAIL_SUBJECT;
663
+ if (opts.mail || opts.mailTo) {
664
+ if (!mailTo) {
665
+ console.error(chalk.red(' ✗ Mail error: no recipient found. Please provide --mail-to or set VOIDEN_MAIL_TO.'));
666
+ process.exit(1);
667
+ }
668
+ if (!opts.csv) {
669
+ console.error(chalk.red(' ✗ Mail error: no CSV generated. Mail requires --csv flag.'));
670
+ process.exit(1);
671
+ }
672
+ const smtpHost = opts.smtpHost || env.VOIDEN_SMTP_HOST;
673
+ if (!smtpHost) {
674
+ console.error(chalk.red(' ✗ Mail keys are missing. Please provide SMTP configuration (VOIDEN_SMTP_HOST).'));
675
+ process.exit(1);
676
+ }
677
+ }
678
+ if (!opts.csv && !mailTo) {
679
+ console.log(chalk.gray(` Session has ${results.length} accumulated results. Specify --csv to generate a report.`));
680
+ return;
681
+ }
682
+ let savedCsvPath;
683
+ if (opts.csv) {
684
+ try {
685
+ savedCsvPath = exportToCsv(results, opts.csv);
686
+ console.log(chalk.green(` ✓ CSV report saved to ${savedCsvPath}`));
687
+ }
688
+ catch (err) {
689
+ console.error(chalk.red(` ✗ Failed to write CSV: ${err?.message ?? String(err)}`));
690
+ }
691
+ }
692
+ if (mailTo) {
693
+ // SMTP settings — read from CLI options or environment
694
+ const smtpHost = opts.smtpHost || env.VOIDEN_SMTP_HOST || process.env.VOIDEN_SMTP_HOST;
695
+ const smtpPort = parseInt(opts.smtpPort || env.VOIDEN_SMTP_PORT || process.env.VOIDEN_SMTP_PORT || '0') || undefined;
696
+ const smtpSecure = opts.smtpSecure || (env.VOIDEN_SMTP_SECURE || process.env.VOIDEN_SMTP_SECURE) === 'true';
697
+ const smtpUser = opts.smtpUser || env.VOIDEN_SMTP_USER || process.env.VOIDEN_SMTP_USER;
698
+ const smtpPass = opts.smtpPass || env.VOIDEN_SMTP_PASS || process.env.VOIDEN_SMTP_PASS;
699
+ if (!smtpHost) {
700
+ console.error(chalk.red(' ✗ SMTP configuration required for email reports.'));
701
+ console.log(chalk.gray(' Set VOIDEN_SMTP_HOST in your environment or use --smtp-host.'));
702
+ process.exit(1);
703
+ }
704
+ console.log(chalk.gray(` ↑ Sending session report to ${mailTo} …`));
705
+ try {
706
+ await sendMailReport(results, 0, {
707
+ to: mailTo,
708
+ from: mailFrom,
709
+ subject: mailSubject || `Voiden Session Report (${results.length} requests)`,
710
+ smtpHost: smtpHost,
711
+ smtpPort: smtpPort,
712
+ smtpSecure: smtpSecure,
713
+ smtpUser: smtpUser,
714
+ smtpPass: smtpPass,
715
+ csvPath: savedCsvPath,
716
+ });
717
+ console.log(chalk.green(` ✓ Report sent to ${mailTo}`));
718
+ }
719
+ catch (err) {
720
+ console.error(chalk.red(` ✗ Failed to send email: ${err?.message ?? String(err)}`));
721
+ }
722
+ }
723
+ });
724
+ // ── voiden-runner plugin ──────────────────────────────────────────────────────
725
+ const pluginCmd = program
726
+ .command('plugin')
727
+ .description('Manage plugins for .void file execution');
728
+ // voiden-runner plugin install [names...] --all
729
+ pluginCmd
730
+ .command('install [names...]')
731
+ .description('Install one or more plugins, or all core plugins\n\n' +
732
+ ' --all installs all core plugins only. Community plugins must be installed by name.\n\n' +
733
+ ' Examples:\n' +
734
+ ' voiden-runner plugin install --all\n' +
735
+ ' voiden-runner plugin install voiden-scripting\n' +
736
+ ' voiden-runner plugin install apyhub-explorer\n')
737
+ .option('--all', 'Install all core plugins (community plugins must be installed by name)')
738
+ .action(async (names, opts) => {
739
+ const communityPlugins = await fetchCommunityPlugins();
740
+ const targets = opts.all
741
+ ? CORE_PLUGINS.map(p => p.name)
742
+ : names;
743
+ if (targets.length === 0) {
744
+ console.error(chalk.red('Specify plugin name(s) or use --all'));
745
+ console.log(chalk.gray(' Core: ' + CORE_PLUGINS.map(p => p.name).join(', ')));
746
+ if (communityPlugins.length > 0) {
747
+ console.log(chalk.gray(' Community (install by name): ' + communityPlugins.map(p => p.id).join(', ')));
748
+ }
749
+ process.exit(1);
750
+ }
751
+ let installedCount = 0;
752
+ for (const name of targets) {
753
+ const coreDef = findPlugin(name);
754
+ const commDef = !coreDef ? findCommunityPlugin(name, communityPlugins) : undefined;
755
+ if (!coreDef && !commDef) {
756
+ console.log(chalk.yellow(` ⚠ Unknown plugin "${name}" — skipped`));
757
+ continue;
758
+ }
759
+ // Community plugins: download runner.js from the GitHub release first
760
+ if (commDef) {
761
+ process.stdout.write(` ↓ Downloading runner for ${chalk.bold(name)} …`);
762
+ try {
763
+ const result = await installCommunityRunner(commDef);
764
+ if (result === 'no-runner') {
765
+ process.stdout.write('\r' + chalk.yellow(` ⚠ No runner.js in release for "${name}" — skipped\n`));
766
+ continue;
767
+ }
768
+ process.stdout.write('\r' + ' '.repeat(60) + '\r'); // clear the line
769
+ }
770
+ catch (err) {
771
+ process.stdout.write('\r' + chalk.red(` ✗ Failed to download runner for "${name}": ${err?.message ?? String(err)}\n`));
772
+ continue;
773
+ }
774
+ }
775
+ const description = coreDef ? coreDef.description : commDef.description;
776
+ const fresh = installPlugin(name);
777
+ if (fresh) {
778
+ console.log(chalk.green(` ✓ Installed`) + chalk.bold(` ${name}`) + chalk.gray(` — ${description}`));
779
+ installedCount++;
780
+ }
781
+ else {
782
+ console.log(chalk.gray(` · Already installed`) + ` ${name}`);
783
+ }
784
+ }
785
+ if (installedCount > 0) {
786
+ console.log();
787
+ console.log(chalk.gray(` ${installedCount} plugin(s) installed. State saved to ~/.voiden/plugins.json`));
788
+ }
789
+ });
790
+ // voiden-runner plugin uninstall <name>
791
+ pluginCmd
792
+ .command('uninstall <name>')
793
+ .description('Remove an installed plugin\n\n Example:\n voiden-runner plugin uninstall voiden-scripting\n')
794
+ .action((name) => {
795
+ const removed = uninstallPlugin(name);
796
+ if (removed) {
797
+ console.log(chalk.green(` ✓ Uninstalled`) + ` ${name}`);
798
+ }
799
+ else {
800
+ console.log(chalk.yellow(` ⚠ Plugin "${name}" is not installed`));
801
+ }
802
+ });
803
+ // voiden-runner plugin enable [name] --all
804
+ pluginCmd
805
+ .command('enable [name]')
806
+ .description('Enable a previously disabled plugin\n\n' +
807
+ ' Examples:\n' +
808
+ ' voiden-runner plugin enable voiden-scripting\n' +
809
+ ' voiden-runner plugin enable --all\n')
810
+ .option('--all', 'Enable all disabled plugins (core and community)')
811
+ .action(async (name, opts) => {
812
+ if (opts.all) {
813
+ const store = readStore();
814
+ // Re-enable all explicitly disabled plugins (core + community)
815
+ const disabled = Object.entries(store.installedPlugins)
816
+ .filter(([, r]) => !r.enabled)
817
+ .map(([n]) => n);
818
+ // Also ensure all core plugins that were never in the store are treated as enabled (default)
819
+ const disabledCoreNotInStore = [];
820
+ if (disabled.length === 0 && disabledCoreNotInStore.length === 0) {
821
+ console.log(chalk.gray(' All plugins are already enabled.'));
822
+ return;
823
+ }
824
+ for (const n of disabled) {
825
+ setPluginEnabled(n, true);
826
+ console.log(chalk.green(` ✓ Enabled`) + ` ${n}`);
827
+ }
828
+ console.log(chalk.gray(` ${disabled.length} plugin(s) enabled.`));
829
+ return;
830
+ }
831
+ if (!name) {
832
+ console.error(chalk.red(' Specify a plugin name or use --all'));
833
+ process.exit(1);
834
+ }
835
+ const communityPlugins = await fetchCommunityPlugins();
836
+ const commDef = findCommunityPlugin(name, communityPlugins);
837
+ if (commDef && !hasCommunityRunner(name)) {
838
+ console.log(chalk.red(` ✗ Cannot enable "${name}" — runner not installed`));
839
+ console.log(chalk.gray(` Run: voiden-runner plugin install ${name}`));
840
+ process.exit(1);
841
+ }
842
+ setPluginEnabled(name, true);
843
+ console.log(chalk.green(` ✓ Enabled`) + ` ${name}`);
844
+ });
845
+ // voiden-runner plugin disable [name] --all
846
+ pluginCmd
847
+ .command('disable [name]')
848
+ .description('Disable a plugin without uninstalling it\n\n' +
849
+ ' Examples:\n' +
850
+ ' voiden-runner plugin disable voiden-scripting\n' +
851
+ ' voiden-runner plugin disable --all\n')
852
+ .option('--all', 'Disable all plugins (core and community)')
853
+ .action((name, opts) => {
854
+ if (opts.all) {
855
+ // Disable all core plugins
856
+ for (const def of CORE_PLUGINS) {
857
+ setPluginEnabled(def.name, false);
858
+ console.log(chalk.yellow(` · Disabled`) + ` ${def.name}`);
859
+ }
860
+ // Disable all installed community plugins
861
+ const store = readStore();
862
+ const communityNames = Object.keys(store.installedPlugins).filter(n => !findPlugin(n));
863
+ for (const n of communityNames) {
864
+ setPluginEnabled(n, false);
865
+ console.log(chalk.yellow(` · Disabled`) + ` ${n}`);
866
+ }
867
+ const total = CORE_PLUGINS.length + communityNames.length;
868
+ console.log(chalk.gray(` ${total} plugin(s) disabled.`));
869
+ return;
870
+ }
871
+ if (!name) {
872
+ console.error(chalk.red(' Specify a plugin name or use --all'));
873
+ process.exit(1);
874
+ }
875
+ setPluginEnabled(name, false);
876
+ console.log(chalk.yellow(` · Disabled`) + ` ${name}`);
877
+ if (findPlugin(name)) {
878
+ console.log(chalk.gray(` Core plugin disabled. Re-enable with: voiden-runner plugin enable ${name}`));
879
+ }
880
+ });
881
+ // voiden-runner plugin list
882
+ pluginCmd
883
+ .command('list')
884
+ .description('List all available and installed plugins')
885
+ .action(async () => {
886
+ const store = readStore();
887
+ const communityPlugins = await fetchCommunityPlugins();
888
+ console.log();
889
+ console.log(chalk.bold(' Core plugins') + chalk.gray(' (@voiden/core-extensions)'));
890
+ console.log(DIVIDER);
891
+ for (const def of CORE_PLUGINS) {
892
+ const record = store.installedPlugins[def.name];
893
+ const isDisabled = record !== undefined && !record.enabled;
894
+ const statusBadge = isDisabled
895
+ ? chalk.yellow(' · disabled')
896
+ : chalk.green(' ✓ enabled');
897
+ console.log(` ${chalk.bold(def.name.padEnd(24))}${statusBadge}`);
898
+ console.log(chalk.gray(` ${def.description}`));
899
+ }
900
+ // ── Community plugins ───────────────────────────────────────────────────
901
+ console.log();
902
+ if (communityPlugins.length === 0) {
903
+ console.log(chalk.bold(' Community plugins') + chalk.gray(' (could not fetch — check your connection)'));
904
+ console.log(DIVIDER);
905
+ }
906
+ else {
907
+ console.log(chalk.bold(' Community plugins') + chalk.gray(' (github.com/VoidenHQ/plugins)'));
908
+ console.log(DIVIDER);
909
+ for (const def of communityPlugins) {
910
+ const installed = store.installedPlugins[def.id];
911
+ let statusBadge;
912
+ if (!installed) {
913
+ statusBadge = chalk.gray(' not installed');
914
+ }
915
+ else if (installed.enabled) {
916
+ statusBadge = chalk.green(' ✓ enabled');
917
+ }
918
+ else {
919
+ statusBadge = chalk.yellow(' · disabled');
920
+ }
921
+ const runnerBadge = hasCommunityRunner(def.id) ? '' : chalk.gray(' [no runner]');
922
+ console.log(` ${chalk.bold(def.id.padEnd(24))}${statusBadge}${runnerBadge}` +
923
+ chalk.gray(` v${def.version}`) +
924
+ chalk.gray(` by ${def.author}`));
925
+ console.log(chalk.gray(` ${def.description}`));
926
+ }
927
+ }
928
+ const knownIds = new Set([
929
+ ...CORE_PLUGINS.map(p => p.name),
930
+ ...communityPlugins.map(p => p.id),
931
+ ]);
932
+ const extras = getAllInstalledPlugins().filter(p => !knownIds.has(p.name));
933
+ if (extras.length > 0) {
934
+ console.log();
935
+ console.log(chalk.bold(' Installed (external)'));
936
+ console.log(DIVIDER);
937
+ for (const p of extras) {
938
+ const badge = p.enabled ? chalk.green(' ✓ enabled') : chalk.yellow(' · disabled');
939
+ console.log(` ${chalk.bold(p.name.padEnd(24))}${badge}`);
940
+ }
941
+ }
942
+ console.log();
943
+ });
944
+ program.parse();
945
+ //# sourceMappingURL=index.js.map