voyageai-cli 1.28.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.
- package/README.md +82 -8
- package/package.json +2 -1
- package/src/commands/app.js +15 -0
- package/src/commands/benchmark.js +22 -8
- package/src/commands/chat.js +18 -0
- package/src/commands/chunk.js +10 -0
- package/src/commands/demo.js +4 -0
- package/src/commands/embed.js +13 -0
- package/src/commands/estimate.js +3 -0
- package/src/commands/eval.js +6 -0
- package/src/commands/explain.js +2 -0
- package/src/commands/generate.js +2 -0
- package/src/commands/ingest.js +4 -0
- package/src/commands/init.js +2 -0
- package/src/commands/mcp-server.js +2 -0
- package/src/commands/models.js +2 -0
- package/src/commands/ping.js +7 -0
- package/src/commands/pipeline.js +15 -0
- package/src/commands/playground.js +685 -8
- package/src/commands/query.js +16 -0
- package/src/commands/rerank.js +12 -0
- package/src/commands/scaffold.js +2 -0
- package/src/commands/search.js +11 -0
- package/src/commands/similarity.js +9 -0
- package/src/commands/store.js +4 -0
- package/src/commands/workflow.js +702 -13
- package/src/lib/capability-report.js +134 -0
- package/src/lib/chat.js +32 -1
- package/src/lib/config.js +2 -0
- package/src/lib/cost-display.js +107 -0
- package/src/lib/explanations.js +94 -0
- package/src/lib/llm.js +125 -18
- package/src/lib/npm-utils.js +265 -0
- package/src/lib/quality-audit.js +71 -0
- package/src/lib/security/blocked-domains.json +17 -0
- package/src/lib/security-audit.js +198 -0
- package/src/lib/telemetry.js +23 -1
- package/src/lib/workflow-registry.js +416 -0
- package/src/lib/workflow-scaffold.js +380 -0
- package/src/lib/workflow-test-runner.js +208 -0
- package/src/lib/workflow.js +559 -7
- package/src/playground/announcements.md +80 -0
- package/src/playground/assets/announcements/appstore.jpg +0 -0
- package/src/playground/assets/announcements/circuits.jpg +0 -0
- package/src/playground/assets/announcements/csvingest.jpg +0 -0
- package/src/playground/assets/announcements/green-wave.jpg +0 -0
- package/src/playground/help/workflow-nodes.js +472 -0
- package/src/playground/icons/V.png +0 -0
- package/src/playground/index.html +3634 -226
- package/src/workflows/consistency-check.json +4 -0
- package/src/workflows/cost-analysis.json +4 -0
- package/src/workflows/enrich-and-ingest.json +56 -0
- package/src/workflows/intelligent-ingest.json +66 -0
- package/src/workflows/kb-health-report.json +45 -0
- package/src/workflows/multi-collection-search.json +4 -0
- package/src/workflows/research-and-summarize.json +4 -0
- package/src/workflows/search-with-fallback.json +66 -0
- package/src/workflows/smart-ingest.json +4 -0
|
@@ -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 };
|
package/src/lib/telemetry.js
CHANGED
|
@@ -69,4 +69,26 @@ function send(event, extra = {}) {
|
|
|
69
69
|
} catch { /* telemetry should never break the CLI */ }
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
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 };
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { validateWorkflow, listBuiltinWorkflows, loadWorkflow, listExampleWorkflows } = require('./workflow');
|
|
6
|
+
const { findLocalNodeModules, findGlobalNodeModules, WORKFLOW_PREFIX, VAICLI_SCOPE, VAICLI_WORKFLOW_PREFIX, isOfficialPackage } = require('./npm-utils');
|
|
7
|
+
|
|
8
|
+
// In-memory cache for the duration of the process
|
|
9
|
+
let _registryCache = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Scan a node_modules directory for vai-workflow-* packages (both scoped and unscoped).
|
|
13
|
+
* @param {string} nodeModulesDir
|
|
14
|
+
* @returns {Array<{ name: string, packagePath: string, pkg: object, definition: object, errors: string[], warnings: string[], tier: string }>}
|
|
15
|
+
*/
|
|
16
|
+
function scanNodeModules(nodeModulesDir) {
|
|
17
|
+
const results = [];
|
|
18
|
+
if (!nodeModulesDir || !fs.existsSync(nodeModulesDir)) return results;
|
|
19
|
+
|
|
20
|
+
let entries;
|
|
21
|
+
try {
|
|
22
|
+
entries = fs.readdirSync(nodeModulesDir);
|
|
23
|
+
} catch {
|
|
24
|
+
return results;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Scan unscoped vai-workflow-* packages
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (!entry.startsWith(WORKFLOW_PREFIX)) continue;
|
|
30
|
+
|
|
31
|
+
const packagePath = path.join(nodeModulesDir, entry);
|
|
32
|
+
const pkgJsonPath = path.join(packagePath, 'package.json');
|
|
33
|
+
|
|
34
|
+
if (!fs.existsSync(pkgJsonPath)) continue;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
38
|
+
const result = validatePackage(packagePath, pkg);
|
|
39
|
+
result.tier = 'community';
|
|
40
|
+
results.push(result);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
results.push({
|
|
43
|
+
name: entry,
|
|
44
|
+
packagePath,
|
|
45
|
+
pkg: null,
|
|
46
|
+
definition: null,
|
|
47
|
+
errors: [`Failed to read package: ${err.message}`],
|
|
48
|
+
warnings: [],
|
|
49
|
+
tier: 'community',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Scan @vaicli/ scoped packages
|
|
55
|
+
const vaicliDir = path.join(nodeModulesDir, '@vaicli');
|
|
56
|
+
if (fs.existsSync(vaicliDir)) {
|
|
57
|
+
let scopedEntries;
|
|
58
|
+
try {
|
|
59
|
+
scopedEntries = fs.readdirSync(vaicliDir);
|
|
60
|
+
} catch {
|
|
61
|
+
scopedEntries = [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const entry of scopedEntries) {
|
|
65
|
+
if (!entry.startsWith(WORKFLOW_PREFIX)) continue;
|
|
66
|
+
|
|
67
|
+
const packagePath = path.join(vaicliDir, entry);
|
|
68
|
+
const pkgJsonPath = path.join(packagePath, 'package.json');
|
|
69
|
+
|
|
70
|
+
if (!fs.existsSync(pkgJsonPath)) continue;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
74
|
+
const result = validatePackage(packagePath, pkg);
|
|
75
|
+
result.tier = 'official';
|
|
76
|
+
results.push(result);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
results.push({
|
|
79
|
+
name: `${VAICLI_SCOPE}${entry}`,
|
|
80
|
+
packagePath,
|
|
81
|
+
pkg: null,
|
|
82
|
+
definition: null,
|
|
83
|
+
errors: [`Failed to read package: ${err.message}`],
|
|
84
|
+
warnings: [],
|
|
85
|
+
tier: 'official',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate a community workflow package.
|
|
96
|
+
* @param {string} packagePath - Path to the package directory
|
|
97
|
+
* @param {object} [pkg] - Parsed package.json (read if not provided)
|
|
98
|
+
* @returns {{ name: string, packagePath: string, pkg: object, definition: object|null, errors: string[], warnings: string[] }}
|
|
99
|
+
*/
|
|
100
|
+
function validatePackage(packagePath, pkg) {
|
|
101
|
+
const errors = [];
|
|
102
|
+
const warnings = [];
|
|
103
|
+
|
|
104
|
+
if (!pkg) {
|
|
105
|
+
try {
|
|
106
|
+
pkg = JSON.parse(fs.readFileSync(path.join(packagePath, 'package.json'), 'utf8'));
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return { name: path.basename(packagePath), packagePath, pkg: null, definition: null, errors: [`Cannot read package.json: ${err.message}`], warnings };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const name = pkg.name || path.basename(packagePath);
|
|
113
|
+
|
|
114
|
+
// Check vai field
|
|
115
|
+
if (!pkg.vai || typeof pkg.vai !== 'object') {
|
|
116
|
+
errors.push('Missing "vai" field in package.json');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check main field points to JSON
|
|
120
|
+
const mainFile = pkg.main || 'workflow.json';
|
|
121
|
+
const workflowPath = path.join(packagePath, mainFile);
|
|
122
|
+
|
|
123
|
+
if (!mainFile.endsWith('.json')) {
|
|
124
|
+
errors.push(`"main" field must point to a .json file (got "${mainFile}")`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!fs.existsSync(workflowPath)) {
|
|
128
|
+
errors.push(`Workflow file not found: ${mainFile}`);
|
|
129
|
+
return { name, packagePath, pkg, definition: null, errors, warnings };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Parse and validate workflow
|
|
133
|
+
let definition;
|
|
134
|
+
try {
|
|
135
|
+
definition = JSON.parse(fs.readFileSync(workflowPath, 'utf8'));
|
|
136
|
+
} catch (err) {
|
|
137
|
+
errors.push(`Invalid JSON in ${mainFile}: ${err.message}`);
|
|
138
|
+
return { name, packagePath, pkg, definition: null, errors, warnings };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const workflowErrors = validateWorkflow(definition);
|
|
142
|
+
errors.push(...workflowErrors);
|
|
143
|
+
|
|
144
|
+
// Compatibility check
|
|
145
|
+
if (pkg.vai) {
|
|
146
|
+
if (pkg.vai.workflowVersion && pkg.vai.workflowVersion !== '1.0') {
|
|
147
|
+
warnings.push(`Workflow version "${pkg.vai.workflowVersion}" may not be fully compatible`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (pkg.vai.minVaiVersion) {
|
|
151
|
+
try {
|
|
152
|
+
const { version: currentVersion } = require('../../package.json');
|
|
153
|
+
if (compareVersions(pkg.vai.minVaiVersion, currentVersion) > 0) {
|
|
154
|
+
warnings.push(`Requires vai >= ${pkg.vai.minVaiVersion} (you have ${currentVersion})`);
|
|
155
|
+
}
|
|
156
|
+
} catch { /* ignore version check failures */ }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Check that declared tools exist
|
|
160
|
+
if (Array.isArray(pkg.vai.tools)) {
|
|
161
|
+
const { ALL_TOOLS } = require('./workflow');
|
|
162
|
+
for (const tool of pkg.vai.tools) {
|
|
163
|
+
if (!ALL_TOOLS.has(tool)) {
|
|
164
|
+
warnings.push(`Declares unknown tool "${tool}" — may require a newer vai version`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { name, packagePath, pkg, definition, errors, warnings };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Simple semver comparison. Returns >0 if a > b, <0 if a < b, 0 if equal.
|
|
175
|
+
* @param {string} a
|
|
176
|
+
* @param {string} b
|
|
177
|
+
* @returns {number}
|
|
178
|
+
*/
|
|
179
|
+
function compareVersions(a, b) {
|
|
180
|
+
const pa = a.replace(/^v/, '').split('.').map(Number);
|
|
181
|
+
const pb = b.replace(/^v/, '').split('.').map(Number);
|
|
182
|
+
for (let i = 0; i < 3; i++) {
|
|
183
|
+
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
184
|
+
if (diff !== 0) return diff;
|
|
185
|
+
}
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Extract the workflow name (without prefix/scope) from a package name.
|
|
191
|
+
* @param {string} packageName - e.g. '@vaicli/vai-workflow-foo' or 'vai-workflow-foo'
|
|
192
|
+
* @returns {string} - e.g. 'foo'
|
|
193
|
+
*/
|
|
194
|
+
function extractWorkflowName(packageName) {
|
|
195
|
+
if (packageName.startsWith(VAICLI_WORKFLOW_PREFIX)) {
|
|
196
|
+
return packageName.slice(VAICLI_WORKFLOW_PREFIX.length);
|
|
197
|
+
}
|
|
198
|
+
if (packageName.startsWith(WORKFLOW_PREFIX)) {
|
|
199
|
+
return packageName.slice(WORKFLOW_PREFIX.length);
|
|
200
|
+
}
|
|
201
|
+
return packageName;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get the full workflow registry (built-in + official + community).
|
|
206
|
+
* Results are cached in-memory for the process duration.
|
|
207
|
+
* @param {{ force?: boolean }} options
|
|
208
|
+
* @returns {{ builtIn: Array, official: Array, community: Array }}
|
|
209
|
+
*/
|
|
210
|
+
function getRegistry(options = {}) {
|
|
211
|
+
if (_registryCache && !options.force) return _registryCache;
|
|
212
|
+
|
|
213
|
+
// Built-in workflows
|
|
214
|
+
const builtIn = listBuiltinWorkflows().map(w => ({
|
|
215
|
+
...w,
|
|
216
|
+
source: 'built-in',
|
|
217
|
+
}));
|
|
218
|
+
|
|
219
|
+
// Official and community workflows from local + global node_modules
|
|
220
|
+
const official = [];
|
|
221
|
+
const community = [];
|
|
222
|
+
const seen = new Set();
|
|
223
|
+
|
|
224
|
+
// Local first (higher priority)
|
|
225
|
+
const localNM = findLocalNodeModules();
|
|
226
|
+
if (localNM) {
|
|
227
|
+
for (const pkg of scanNodeModules(localNM)) {
|
|
228
|
+
if (!seen.has(pkg.name)) {
|
|
229
|
+
seen.add(pkg.name);
|
|
230
|
+
const target = pkg.tier === 'official' ? official : community;
|
|
231
|
+
target.push({ ...pkg, source: pkg.tier, scope: 'local' });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Global
|
|
237
|
+
const globalNM = findGlobalNodeModules();
|
|
238
|
+
if (globalNM) {
|
|
239
|
+
for (const pkg of scanNodeModules(globalNM)) {
|
|
240
|
+
if (!seen.has(pkg.name)) {
|
|
241
|
+
seen.add(pkg.name);
|
|
242
|
+
const target = pkg.tier === 'official' ? official : community;
|
|
243
|
+
target.push({ ...pkg, source: pkg.tier, scope: 'global' });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
_registryCache = { builtIn, official, community };
|
|
249
|
+
return _registryCache;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Clear the registry cache (used after install/uninstall).
|
|
254
|
+
*/
|
|
255
|
+
function clearRegistryCache() {
|
|
256
|
+
_registryCache = null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Resolve a workflow by name using the priority chain:
|
|
261
|
+
* 1. Local file path
|
|
262
|
+
* 2. Built-in template
|
|
263
|
+
* 3. Official package (local node_modules) — @vaicli/vai-workflow-*
|
|
264
|
+
* 4. Community package (local node_modules) — vai-workflow-*
|
|
265
|
+
* 5. Official package (global node_modules)
|
|
266
|
+
* 6. Community package (global node_modules)
|
|
267
|
+
*
|
|
268
|
+
* @param {string} name - Workflow name, package name, or file path
|
|
269
|
+
* @returns {{ definition: object, source: string, metadata: object|null }}
|
|
270
|
+
*/
|
|
271
|
+
function resolveWorkflow(name) {
|
|
272
|
+
// 1. Try as file path (if it exists or has extension)
|
|
273
|
+
if (name.includes('/') || name.includes('\\') || name.endsWith('.json')) {
|
|
274
|
+
// But not scoped package names like @vaicli/vai-workflow-foo
|
|
275
|
+
if (!name.startsWith('@')) {
|
|
276
|
+
try {
|
|
277
|
+
const definition = loadWorkflow(name);
|
|
278
|
+
return { definition, source: 'file', metadata: null };
|
|
279
|
+
} catch { /* fall through */ }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 2. Try built-in (loadWorkflow handles this) — skip for scoped names
|
|
284
|
+
if (!name.startsWith('@')) {
|
|
285
|
+
try {
|
|
286
|
+
const definition = loadWorkflow(name);
|
|
287
|
+
return { definition, source: 'built-in', metadata: null };
|
|
288
|
+
} catch { /* fall through */ }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 3-6. Try official then community packages
|
|
292
|
+
const registry = getRegistry();
|
|
293
|
+
|
|
294
|
+
// Helper to find in a list by exact name or prefixed name
|
|
295
|
+
// Note: We allow packages with errors as long as they have a valid definition
|
|
296
|
+
// (errors may be warnings like "missing vai field" that don't prevent execution)
|
|
297
|
+
const findInList = (list, searchName) => {
|
|
298
|
+
// Exact match first
|
|
299
|
+
let match = list.find(c => c.name === searchName && c.definition);
|
|
300
|
+
if (match) return match;
|
|
301
|
+
|
|
302
|
+
// Try with prefix for unscoped
|
|
303
|
+
if (!searchName.startsWith(WORKFLOW_PREFIX) && !searchName.startsWith('@')) {
|
|
304
|
+
const prefixed = WORKFLOW_PREFIX + searchName;
|
|
305
|
+
match = list.find(c => c.name === prefixed && c.definition);
|
|
306
|
+
if (match) return match;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return null;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// 3. Official local
|
|
313
|
+
const officialLocal = registry.official.filter(c => c.scope === 'local');
|
|
314
|
+
let match = findInList(officialLocal, name);
|
|
315
|
+
if (match) return makeResult(match, 'official');
|
|
316
|
+
|
|
317
|
+
// Also try @vaicli/vai-workflow-<name> if name is a short name
|
|
318
|
+
if (!name.startsWith('@') && !name.startsWith(WORKFLOW_PREFIX)) {
|
|
319
|
+
const scopedName = `@vaicli/${WORKFLOW_PREFIX}${name}`;
|
|
320
|
+
match = findInList(officialLocal, scopedName);
|
|
321
|
+
if (match) return makeResult(match, 'official');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 4. Community local
|
|
325
|
+
const communityLocal = registry.community.filter(c => c.scope === 'local');
|
|
326
|
+
match = findInList(communityLocal, name);
|
|
327
|
+
if (match) return makeResult(match, 'community');
|
|
328
|
+
|
|
329
|
+
// 5. Official global
|
|
330
|
+
const officialGlobal = registry.official.filter(c => c.scope === 'global');
|
|
331
|
+
match = findInList(officialGlobal, name);
|
|
332
|
+
if (match) return makeResult(match, 'official');
|
|
333
|
+
|
|
334
|
+
if (!name.startsWith('@') && !name.startsWith(WORKFLOW_PREFIX)) {
|
|
335
|
+
const scopedName = `@vaicli/${WORKFLOW_PREFIX}${name}`;
|
|
336
|
+
match = findInList(officialGlobal, scopedName);
|
|
337
|
+
if (match) return makeResult(match, 'official');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 6. Community global
|
|
341
|
+
const communityGlobal = registry.community.filter(c => c.scope === 'global');
|
|
342
|
+
match = findInList(communityGlobal, name);
|
|
343
|
+
if (match) return makeResult(match, 'community');
|
|
344
|
+
|
|
345
|
+
throw new Error(
|
|
346
|
+
`Workflow not found: "${name}"\n` +
|
|
347
|
+
`Provide a file path, built-in template name, or installed package name.\n` +
|
|
348
|
+
`See: vai workflow list`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Build a resolve result from a registry entry.
|
|
354
|
+
* @param {object} entry - Registry entry
|
|
355
|
+
* @param {string} source - 'official' or 'community'
|
|
356
|
+
* @returns {{ definition: object, source: string, metadata: object }}
|
|
357
|
+
*/
|
|
358
|
+
function makeResult(entry, source) {
|
|
359
|
+
return {
|
|
360
|
+
definition: entry.definition,
|
|
361
|
+
source,
|
|
362
|
+
metadata: {
|
|
363
|
+
package: entry.pkg,
|
|
364
|
+
path: entry.packagePath,
|
|
365
|
+
scope: entry.scope,
|
|
366
|
+
warnings: entry.warnings,
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Search installed workflows (official + community) by query.
|
|
373
|
+
* @param {string} query
|
|
374
|
+
* @returns {Array}
|
|
375
|
+
*/
|
|
376
|
+
function searchLocal(query) {
|
|
377
|
+
const registry = getRegistry();
|
|
378
|
+
const q = query.toLowerCase();
|
|
379
|
+
const all = [...registry.official, ...registry.community];
|
|
380
|
+
return all.filter(c => {
|
|
381
|
+
if (c.errors.length > 0) return false;
|
|
382
|
+
const name = (c.name || '').toLowerCase();
|
|
383
|
+
const desc = (c.pkg?.description || '').toLowerCase();
|
|
384
|
+
const tags = (c.pkg?.vai?.tags || []).join(' ').toLowerCase();
|
|
385
|
+
const cat = (c.pkg?.vai?.category || '').toLowerCase();
|
|
386
|
+
return name.includes(q) || desc.includes(q) || tags.includes(q) || cat.includes(q);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Get category counts from installed workflows (official + community).
|
|
392
|
+
* @returns {object} { category: count }
|
|
393
|
+
*/
|
|
394
|
+
function getCategories() {
|
|
395
|
+
const registry = getRegistry();
|
|
396
|
+
const counts = {};
|
|
397
|
+
const all = [...registry.official, ...registry.community];
|
|
398
|
+
for (const c of all) {
|
|
399
|
+
if (c.errors.length > 0) continue;
|
|
400
|
+
const cat = c.pkg?.vai?.category || 'utility';
|
|
401
|
+
counts[cat] = (counts[cat] || 0) + 1;
|
|
402
|
+
}
|
|
403
|
+
return counts;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
module.exports = {
|
|
407
|
+
getRegistry,
|
|
408
|
+
clearRegistryCache,
|
|
409
|
+
resolveWorkflow,
|
|
410
|
+
searchLocal,
|
|
411
|
+
getCategories,
|
|
412
|
+
validatePackage,
|
|
413
|
+
scanNodeModules,
|
|
414
|
+
compareVersions,
|
|
415
|
+
extractWorkflowName,
|
|
416
|
+
};
|