voyageai-cli 1.29.0 → 1.30.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 (45) hide show
  1. package/README.md +82 -8
  2. package/package.json +1 -1
  3. package/src/commands/benchmark.js +22 -8
  4. package/src/commands/chat.js +18 -0
  5. package/src/commands/chunk.js +10 -0
  6. package/src/commands/demo.js +4 -0
  7. package/src/commands/embed.js +13 -0
  8. package/src/commands/estimate.js +3 -0
  9. package/src/commands/eval.js +6 -0
  10. package/src/commands/explain.js +2 -0
  11. package/src/commands/generate.js +2 -0
  12. package/src/commands/ingest.js +4 -0
  13. package/src/commands/init.js +2 -0
  14. package/src/commands/mcp-server.js +2 -0
  15. package/src/commands/models.js +2 -0
  16. package/src/commands/ping.js +7 -0
  17. package/src/commands/pipeline.js +15 -0
  18. package/src/commands/playground.js +52 -6
  19. package/src/commands/query.js +16 -0
  20. package/src/commands/rerank.js +12 -0
  21. package/src/commands/scaffold.js +2 -0
  22. package/src/commands/search.js +11 -0
  23. package/src/commands/similarity.js +9 -0
  24. package/src/commands/store.js +4 -0
  25. package/src/commands/workflow.js +286 -0
  26. package/src/lib/capability-report.js +134 -0
  27. package/src/lib/chat.js +32 -1
  28. package/src/lib/config.js +2 -0
  29. package/src/lib/cost-display.js +107 -0
  30. package/src/lib/explanations.js +6 -0
  31. package/src/lib/llm.js +125 -18
  32. package/src/lib/quality-audit.js +71 -0
  33. package/src/lib/security/blocked-domains.json +17 -0
  34. package/src/lib/security-audit.js +198 -0
  35. package/src/lib/telemetry.js +23 -1
  36. package/src/lib/workflow-scaffold.js +61 -0
  37. package/src/lib/workflow-test-runner.js +208 -0
  38. package/src/lib/workflow.js +128 -2
  39. package/src/playground/announcements.md +9 -0
  40. package/src/playground/assets/announcements/appstore.jpg +0 -0
  41. package/src/playground/assets/announcements/circuits.jpg +0 -0
  42. package/src/playground/assets/announcements/csvingest.jpg +0 -0
  43. package/src/playground/assets/announcements/green-wave.jpg +0 -0
  44. package/src/playground/help/workflow-nodes.js +472 -0
  45. package/src/playground/index.html +1482 -184
@@ -0,0 +1,17 @@
1
+ [
2
+ "evil.com",
3
+ "malware.com",
4
+ "requestbin.com",
5
+ "webhook.site",
6
+ "pipedream.net",
7
+ "hookbin.com",
8
+ "requestcatcher.com",
9
+ "canarytokens.com",
10
+ "burpcollaborator.net",
11
+ "interact.sh",
12
+ "oastify.com",
13
+ "dnslog.cn",
14
+ "ceye.io",
15
+ "bxss.me",
16
+ "xss.ht"
17
+ ]
@@ -0,0 +1,198 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // ════════════════════════════════════════════════════════════════════
7
+ // Blocked Domains
8
+ // ════════════════════════════════════════════════════════════════════
9
+
10
+ const BLOCKED_DOMAINS = new Set(
11
+ JSON.parse(fs.readFileSync(path.join(__dirname, 'security', 'blocked-domains.json'), 'utf8'))
12
+ );
13
+
14
+ /**
15
+ * Check if a URL targets a blocked domain.
16
+ * @param {string} url
17
+ * @returns {boolean}
18
+ */
19
+ function isBlockedDomain(url) {
20
+ try {
21
+ const hostname = new URL(url).hostname;
22
+ return BLOCKED_DOMAINS.has(hostname);
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ // ════════════════════════════════════════════════════════════════════
29
+ // Capability Flags
30
+ // ════════════════════════════════════════════════════════════════════
31
+
32
+ /**
33
+ * Extract capability flags from a workflow definition.
34
+ * @param {object} definition
35
+ * @returns {Set<string>}
36
+ */
37
+ function extractCapabilities(definition) {
38
+ const caps = new Set();
39
+ if (!definition || !Array.isArray(definition.steps)) return caps;
40
+
41
+ for (const step of definition.steps) {
42
+ switch (step.tool) {
43
+ case 'http':
44
+ caps.add('NETWORK');
45
+ break;
46
+ case 'ingest':
47
+ caps.add('WRITE_DB');
48
+ break;
49
+ case 'aggregate':
50
+ caps.add('READ_DB');
51
+ if (step.inputs?.allowWrites) caps.add('WRITE_DB');
52
+ if (Array.isArray(step.inputs?.pipeline)) {
53
+ for (const stage of step.inputs.pipeline) {
54
+ const key = Object.keys(stage)[0];
55
+ if (key === '$out' || key === '$merge') caps.add('WRITE_DB');
56
+ }
57
+ }
58
+ break;
59
+ case 'generate':
60
+ caps.add('LLM');
61
+ break;
62
+ case 'loop':
63
+ caps.add('LOOP');
64
+ break;
65
+ case 'query':
66
+ case 'search':
67
+ case 'collections':
68
+ caps.add('READ_DB');
69
+ break;
70
+ }
71
+
72
+ // forEach counts as LOOP
73
+ if (step.forEach) caps.add('LOOP');
74
+ }
75
+
76
+ return caps;
77
+ }
78
+
79
+ // ════════════════════════════════════════════════════════════════════
80
+ // Security Audit
81
+ // ════════════════════════════════════════════════════════════════════
82
+
83
+ /**
84
+ * Run security audit on a workflow definition.
85
+ * @param {object} definition - Parsed workflow JSON
86
+ * @param {string} [packagePath] - Path to the package directory (for package-level checks)
87
+ * @returns {Array<{severity: string, message: string, stepId?: string}>}
88
+ */
89
+ function securityAudit(definition, packagePath) {
90
+ const findings = [];
91
+
92
+ if (!definition || !Array.isArray(definition.steps)) return findings;
93
+
94
+ for (const step of definition.steps) {
95
+ // HTTP step checks
96
+ if (step.tool === 'http') {
97
+ const url = step.inputs?.url;
98
+ if (typeof url === 'string') {
99
+ if (!url.includes('{{')) {
100
+ // Static URL — check against blocklist
101
+ if (isBlockedDomain(url)) {
102
+ findings.push({ severity: 'critical', message: `HTTP step "${step.id}" targets blocked domain`, stepId: step.id });
103
+ }
104
+ // Flag non-HTTPS
105
+ if (url.startsWith('http://')) {
106
+ findings.push({ severity: 'medium', message: `HTTP step "${step.id}" uses insecure HTTP`, stepId: step.id });
107
+ }
108
+ } else {
109
+ // Dynamic URLs always flagged for review
110
+ findings.push({ severity: 'high', message: `HTTP step "${step.id}" has dynamic URL (needs review)`, stepId: step.id });
111
+ }
112
+ }
113
+ }
114
+
115
+ // Aggregate: Flag write stages
116
+ if (step.tool === 'aggregate') {
117
+ if (step.inputs?.allowWrites) {
118
+ findings.push({ severity: 'high', message: `Aggregate step "${step.id}" allows write operations ($out/$merge)`, stepId: step.id });
119
+ }
120
+ const pipeline = step.inputs?.pipeline;
121
+ if (Array.isArray(pipeline)) {
122
+ for (const stage of pipeline) {
123
+ const key = Object.keys(stage)[0];
124
+ if (key === '$out' || key === '$merge') {
125
+ findings.push({ severity: 'critical', message: `Aggregate step "${step.id}" contains ${key} stage`, stepId: step.id });
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ // Generate: Check for prompt injection patterns
132
+ if (step.tool === 'generate') {
133
+ const prompt = step.inputs?.prompt || '';
134
+ const systemPrompt = step.inputs?.systemPrompt || '';
135
+ for (const text of [prompt, systemPrompt]) {
136
+ if (typeof text === 'string') {
137
+ if (/ignore\s+(previous|all)\s+instructions/i.test(text)) {
138
+ findings.push({ severity: 'high', message: `Suspicious prompt pattern in "${step.id}"`, stepId: step.id });
139
+ }
140
+ if (/system\s*:\s*/i.test(text)) {
141
+ findings.push({ severity: 'medium', message: `Prompt contains "system:" prefix in "${step.id}"`, stepId: step.id });
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ // Ingest: Flag dynamic db/collection names
148
+ if (step.tool === 'ingest') {
149
+ const db = step.inputs?.db;
150
+ const coll = step.inputs?.collection;
151
+ if (typeof db === 'string' && db.includes('{{')) {
152
+ findings.push({ severity: 'medium', message: `Ingest step "${step.id}" uses dynamic database name`, stepId: step.id });
153
+ }
154
+ if (typeof coll === 'string' && coll.includes('{{')) {
155
+ findings.push({ severity: 'medium', message: `Ingest step "${step.id}" uses dynamic collection name`, stepId: step.id });
156
+ }
157
+ }
158
+
159
+ // Loop: Check for unbounded iterations
160
+ if (step.tool === 'loop') {
161
+ if (!step.inputs?.maxIterations || step.inputs.maxIterations > 1000) {
162
+ findings.push({ severity: 'medium', message: `Loop step "${step.id}" has high/unbounded maxIterations`, stepId: step.id });
163
+ }
164
+ }
165
+ }
166
+
167
+ // Package-level checks
168
+ if (packagePath) {
169
+ try {
170
+ const files = fs.readdirSync(packagePath);
171
+ const jsFiles = files.filter(f => /\.(js|ts|mjs|cjs)$/.test(f) && f !== 'node_modules');
172
+ if (jsFiles.length > 0) {
173
+ findings.push({ severity: 'critical', message: `Package contains executable code: ${jsFiles.join(', ')}` });
174
+ }
175
+ } catch {
176
+ // Ignore if can't read directory
177
+ }
178
+
179
+ try {
180
+ const pkgPath = path.join(packagePath, 'package.json');
181
+ if (fs.existsSync(pkgPath)) {
182
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
183
+ const dangerousScripts = ['preinstall', 'install', 'postinstall', 'preuninstall', 'postuninstall'];
184
+ for (const script of dangerousScripts) {
185
+ if (pkg.scripts?.[script]) {
186
+ findings.push({ severity: 'critical', message: `Package has "${script}" lifecycle script` });
187
+ }
188
+ }
189
+ }
190
+ } catch {
191
+ // Ignore if can't read package.json
192
+ }
193
+ }
194
+
195
+ return findings;
196
+ }
197
+
198
+ module.exports = { securityAudit, extractCapabilities, isBlockedDomain };
@@ -69,4 +69,26 @@ function send(event, extra = {}) {
69
69
  } catch { /* telemetry should never break the CLI */ }
70
70
  }
71
71
 
72
- module.exports = { send, isEnabled };
72
+ /**
73
+ * Create a timer that auto-sends a telemetry event on completion.
74
+ * Usage:
75
+ * const done = telemetry.timer('cli_query', { model: 'voyage-4-large' });
76
+ * // ... do work ...
77
+ * done({ resultCount: 5 }); // sends event with durationMs calculated
78
+ *
79
+ * @param {string} event - Event name
80
+ * @param {object} [baseFields] - Fields known at start time
81
+ * @returns {function} done(extraFields) - Call to send the event
82
+ */
83
+ function timer(event, baseFields = {}) {
84
+ const start = Date.now();
85
+ return (extraFields = {}) => {
86
+ send(event, {
87
+ ...baseFields,
88
+ ...extraFields,
89
+ durationMs: Date.now() - start,
90
+ });
91
+ };
92
+ }
93
+
94
+ module.exports = { send, isEnabled, timer };
@@ -288,6 +288,67 @@ function scaffoldPackage(options) {
288
288
  fs.writeFileSync(path.join(outputDir, 'LICENSE'), license);
289
289
  files.push('LICENSE');
290
290
 
291
+ // Create tests/ directory with a sample test case
292
+ const testsDir = path.join(outputDir, 'tests');
293
+ fs.mkdirSync(testsDir, { recursive: true });
294
+
295
+ // Build sample mocks based on the tools used
296
+ const sampleMocks = {};
297
+ for (const tool of tools) {
298
+ if (tool === 'query' || tool === 'search') {
299
+ sampleMocks[tool] = { results: [{ text: 'Sample result', score: 0.95 }], resultCount: 1 };
300
+ } else if (tool === 'embed') {
301
+ sampleMocks[tool] = { embedding: [0.1, 0.2, 0.3], model: 'voyage-3-large', dimensions: 3 };
302
+ } else if (tool === 'rerank') {
303
+ sampleMocks[tool] = { results: [{ text: 'Reranked result', score: 0.98 }], resultCount: 1 };
304
+ } else if (tool === 'generate') {
305
+ sampleMocks[tool] = { text: 'Generated text response', model: 'mock-llm', provider: 'mock' };
306
+ }
307
+ }
308
+
309
+ // Build sample inputs from definition
310
+ const sampleInputs = {};
311
+ if (definition.inputs) {
312
+ for (const [key, schema] of Object.entries(definition.inputs)) {
313
+ if (schema.default !== undefined) {
314
+ sampleInputs[key] = schema.default;
315
+ } else if (schema.type === 'number') {
316
+ sampleInputs[key] = 10;
317
+ } else if (schema.type === 'boolean') {
318
+ sampleInputs[key] = true;
319
+ } else {
320
+ sampleInputs[key] = 'test value';
321
+ }
322
+ }
323
+ }
324
+
325
+ // Build expected steps
326
+ const expectedSteps = {};
327
+ if (definition.steps) {
328
+ for (const step of definition.steps) {
329
+ expectedSteps[step.id] = { status: 'completed' };
330
+ }
331
+ }
332
+
333
+ const sampleTestCase = {
334
+ name: 'basic workflow test',
335
+ inputs: sampleInputs,
336
+ mocks: sampleMocks,
337
+ expect: {
338
+ steps: expectedSteps,
339
+ noErrors: true,
340
+ },
341
+ };
342
+
343
+ fs.writeFileSync(path.join(testsDir, 'basic.test.json'), JSON.stringify(sampleTestCase, null, 2) + '\n');
344
+ files.push('tests/basic.test.json');
345
+
346
+ // Create fixtures directory with .gitkeep
347
+ const fixturesDir = path.join(testsDir, 'fixtures');
348
+ fs.mkdirSync(fixturesDir, { recursive: true });
349
+ fs.writeFileSync(path.join(fixturesDir, '.gitkeep'), '');
350
+ files.push('tests/fixtures/.gitkeep');
351
+
291
352
  return { dir: outputDir, files };
292
353
  }
293
354
 
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const { executeWorkflow } = require('./workflow');
6
+
7
+ /**
8
+ * Run a single workflow test case against a definition.
9
+ *
10
+ * @param {object} definition - Parsed workflow JSON
11
+ * @param {object} testCase - Test case object { name, inputs, mocks, expect }
12
+ * @returns {Promise<{ passed: boolean, assertions: Array<{pass: boolean, message: string}>, errors: string[] }>}
13
+ */
14
+ async function runWorkflowTest(definition, testCase) {
15
+ const results = { passed: true, assertions: [], errors: [] };
16
+
17
+ // Build mock executors from test case mocks
18
+ const mockExecutors = {};
19
+ for (const [tool, mockOutput] of Object.entries(testCase.mocks || {})) {
20
+ mockExecutors[tool] = async () => mockOutput;
21
+ }
22
+
23
+ let result;
24
+ try {
25
+ result = await executeWorkflow(definition, {
26
+ inputs: testCase.inputs || {},
27
+ _mockExecutors: mockExecutors,
28
+ });
29
+ } catch (err) {
30
+ results.passed = false;
31
+ results.errors.push(err.message);
32
+ return results;
33
+ }
34
+
35
+ // Check step statuses
36
+ if (testCase.expect && testCase.expect.steps) {
37
+ for (const [stepId, expected] of Object.entries(testCase.expect.steps)) {
38
+ const stepResult = result.steps.find(s => s.id === stepId);
39
+ if (!stepResult) {
40
+ results.assertions.push({ pass: false, message: `Step "${stepId}" not found in results` });
41
+ results.passed = false;
42
+ } else if (expected.status === 'completed') {
43
+ if (stepResult.skipped) {
44
+ results.assertions.push({ pass: false, message: `Step "${stepId}" was skipped, expected completed` });
45
+ results.passed = false;
46
+ } else if (stepResult.error) {
47
+ results.assertions.push({ pass: false, message: `Step "${stepId}" errored: ${stepResult.error}` });
48
+ results.passed = false;
49
+ } else {
50
+ results.assertions.push({ pass: true, message: `Step "${stepId}" completed` });
51
+ }
52
+ } else if (expected.status === 'skipped') {
53
+ if (stepResult.skipped) {
54
+ results.assertions.push({ pass: true, message: `Step "${stepId}" skipped` });
55
+ } else {
56
+ results.assertions.push({ pass: false, message: `Step "${stepId}" should have been skipped` });
57
+ results.passed = false;
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ // Check output shape
64
+ if (testCase.expect && testCase.expect.output) {
65
+ for (const [key, constraint] of Object.entries(testCase.expect.output)) {
66
+ const value = result.output && result.output[key];
67
+ if (constraint.type === 'array') {
68
+ if (!Array.isArray(value)) {
69
+ results.assertions.push({ pass: false, message: `output.${key} should be array, got ${typeof value}` });
70
+ results.passed = false;
71
+ continue;
72
+ }
73
+ }
74
+ if (constraint.type === 'string') {
75
+ if (typeof value !== 'string') {
76
+ results.assertions.push({ pass: false, message: `output.${key} should be string, got ${typeof value}` });
77
+ results.passed = false;
78
+ continue;
79
+ }
80
+ }
81
+ if (constraint.type === 'number') {
82
+ if (typeof value !== 'number') {
83
+ results.assertions.push({ pass: false, message: `output.${key} should be number, got ${typeof value}` });
84
+ results.passed = false;
85
+ continue;
86
+ }
87
+ }
88
+ if (constraint.type === 'object') {
89
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
90
+ results.assertions.push({ pass: false, message: `output.${key} should be object` });
91
+ results.passed = false;
92
+ continue;
93
+ }
94
+ }
95
+ if (constraint.minLength != null) {
96
+ if (!value || value.length < constraint.minLength) {
97
+ results.assertions.push({ pass: false, message: `output.${key} length ${value ? value.length : 0} < ${constraint.minLength}` });
98
+ results.passed = false;
99
+ continue;
100
+ }
101
+ }
102
+ // If we got here with a type check, it passed
103
+ if (constraint.type || constraint.minLength != null) {
104
+ results.assertions.push({ pass: true, message: `output.${key} matches expected shape` });
105
+ }
106
+ }
107
+ }
108
+
109
+ // Check noErrors flag
110
+ if (testCase.expect && testCase.expect.noErrors) {
111
+ const hasErrors = result.steps.some(s => s.error);
112
+ if (hasErrors) {
113
+ const errorSteps = result.steps.filter(s => s.error).map(s => `${s.id}: ${s.error}`);
114
+ results.assertions.push({ pass: false, message: `Expected no errors but found: ${errorSteps.join('; ')}` });
115
+ results.passed = false;
116
+ } else {
117
+ results.assertions.push({ pass: true, message: 'No step errors' });
118
+ }
119
+ }
120
+
121
+ return results;
122
+ }
123
+
124
+ /**
125
+ * Load test cases from a workflow package's tests/ directory.
126
+ *
127
+ * @param {string} packagePath - Path to the workflow package directory
128
+ * @returns {Array<object>} Array of test case objects
129
+ */
130
+ function loadTestCases(packagePath) {
131
+ const testsDir = path.join(packagePath, 'tests');
132
+ if (!fs.existsSync(testsDir)) {
133
+ return [];
134
+ }
135
+
136
+ const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.test.json'));
137
+ const testCases = [];
138
+
139
+ for (const file of files) {
140
+ const filePath = path.join(testsDir, file);
141
+ try {
142
+ const content = fs.readFileSync(filePath, 'utf8');
143
+ const testCase = JSON.parse(content);
144
+ testCase._file = file;
145
+ testCases.push(testCase);
146
+ } catch (err) {
147
+ testCases.push({
148
+ name: file,
149
+ _file: file,
150
+ _error: `Failed to load: ${err.message}`,
151
+ });
152
+ }
153
+ }
154
+
155
+ return testCases;
156
+ }
157
+
158
+ /**
159
+ * Run all test cases for a workflow package.
160
+ *
161
+ * @param {object} definition - Parsed workflow JSON
162
+ * @param {string} packagePath - Path to the workflow package directory
163
+ * @param {object} [options]
164
+ * @param {string} [options.testName] - Run only a specific test by name
165
+ * @returns {Promise<{ total: number, passed: number, failed: number, results: Array }>}
166
+ */
167
+ async function runAllTests(definition, packagePath, options = {}) {
168
+ let testCases = loadTestCases(packagePath);
169
+
170
+ if (options.testName) {
171
+ testCases = testCases.filter(t => t.name === options.testName);
172
+ }
173
+
174
+ const results = [];
175
+ let passed = 0;
176
+ let failed = 0;
177
+
178
+ for (const testCase of testCases) {
179
+ if (testCase._error) {
180
+ results.push({ name: testCase.name, file: testCase._file, passed: false, error: testCase._error, assertions: [] });
181
+ failed++;
182
+ continue;
183
+ }
184
+
185
+ const result = await runWorkflowTest(definition, testCase);
186
+ results.push({
187
+ name: testCase.name || testCase._file,
188
+ file: testCase._file,
189
+ passed: result.passed,
190
+ assertions: result.assertions,
191
+ errors: result.errors,
192
+ });
193
+
194
+ if (result.passed) {
195
+ passed++;
196
+ } else {
197
+ failed++;
198
+ }
199
+ }
200
+
201
+ return { total: testCases.length, passed, failed, results };
202
+ }
203
+
204
+ module.exports = {
205
+ runWorkflowTest,
206
+ loadTestCases,
207
+ runAllTests,
208
+ };