voyageai-cli 1.27.0 → 1.29.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.
@@ -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
+ };