voyageai-cli 1.30.0 → 1.30.2
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 +4 -4
- package/package.json +1 -1
- package/src/cli.js +8 -0
- package/src/commands/about.js +3 -3
- package/src/commands/chat.js +32 -11
- package/src/commands/code-search.js +751 -0
- package/src/commands/doctor.js +1 -1
- package/src/commands/export.js +124 -0
- package/src/commands/import.js +195 -0
- package/src/commands/index-workspace.js +243 -0
- package/src/commands/mcp-server.js +113 -3
- package/src/commands/playground.js +120 -4
- package/src/commands/quickstart.js +4 -4
- package/src/commands/workflow.js +132 -65
- package/src/lib/catalog.js +4 -2
- package/src/lib/code-search.js +315 -0
- package/src/lib/codegen.js +1 -1
- package/src/lib/explanations.js +3 -3
- package/src/lib/export/contexts/benchmark-export.js +27 -0
- package/src/lib/export/contexts/chat-export.js +41 -0
- package/src/lib/export/contexts/explore-export.js +22 -0
- package/src/lib/export/contexts/search-export.js +54 -0
- package/src/lib/export/contexts/workflow-export.js +80 -0
- package/src/lib/export/formats/clipboard-export.js +29 -0
- package/src/lib/export/formats/csv-export.js +45 -0
- package/src/lib/export/formats/json-export.js +50 -0
- package/src/lib/export/formats/markdown-export.js +189 -0
- package/src/lib/export/formats/mermaid-export.js +274 -0
- package/src/lib/export/formats/pdf-export.js +117 -0
- package/src/lib/export/formats/png-export.js +96 -0
- package/src/lib/export/formats/svg-export.js +116 -0
- package/src/lib/export/index.js +175 -0
- package/src/lib/github.js +226 -0
- package/src/lib/template-engine.js +154 -20
- package/src/lib/workflow-builder.js +753 -0
- package/src/lib/workflow-formatters.js +454 -0
- package/src/lib/workflow-input-cache.js +111 -0
- package/src/lib/workflow-scaffold.js +1 -1
- package/src/lib/workflow.js +297 -28
- package/src/mcp/install.js +280 -7
- package/src/mcp/schemas/index.js +170 -0
- package/src/mcp/server.js +19 -4
- package/src/mcp/tools/authoring.js +662 -0
- package/src/mcp/tools/code-search.js +620 -0
- package/src/mcp/tools/ingest.js +2 -5
- package/src/mcp/tools/retrieval.js +2 -15
- package/src/mcp/tools/workspace.js +452 -0
- package/src/mcp/utils.js +20 -0
- package/src/playground/announcements.md +52 -5
- package/src/playground/help/workflow-nodes.js +127 -2
- package/src/playground/index.html +17109 -12438
- package/src/playground/vendor/mermaid.min.js +2811 -0
- package/src/workflows/code-review.json +110 -0
- package/src/workflows/cost-analysis.json +5 -0
- package/src/workflows/rag-chat.json +165 -0
- package/src/workflows/tests/code-review.fresh-index.test.json +83 -0
- package/src/workflows/tests/code-review.happy-path.test.json +121 -0
- package/src/workflows/tests/code-review.no-question.test.json +70 -0
- package/src/workflows/tests/consistency-check.happy-path.test.json +28 -0
- package/src/workflows/tests/consistency-check.missing-source.test.json +26 -0
- package/src/workflows/tests/cost-analysis.happy-path.test.json +28 -0
- package/src/workflows/tests/enrich-and-ingest.happy-path.test.json +38 -0
- package/src/workflows/tests/enrich-and-ingest.notify-fails.test.json +38 -0
- package/src/workflows/tests/intelligent-ingest.all-filtered.test.json +26 -0
- package/src/workflows/tests/intelligent-ingest.happy-path.test.json +28 -0
- package/src/workflows/tests/kb-health-report.custom-queries.test.json +24 -0
- package/src/workflows/tests/kb-health-report.happy-path.test.json +26 -0
- package/src/workflows/tests/multi-collection-search.happy-path.test.json +40 -0
- package/src/workflows/tests/multi-collection-search.one-empty.test.json +28 -0
- package/src/workflows/tests/rag-chat.happy-path.test.json +26 -0
- package/src/workflows/tests/rag-chat.no-relevant-results.test.json +25 -0
- package/src/workflows/tests/research-and-summarize.happy-path.test.json +33 -0
- package/src/workflows/tests/research-and-summarize.no-results.test.json +29 -0
- package/src/workflows/tests/search-with-fallback.empty-both.test.json +24 -0
- package/src/workflows/tests/search-with-fallback.fallback-branch.test.json +24 -0
- package/src/workflows/tests/search-with-fallback.happy-path.test.json +27 -0
- package/src/workflows/tests/smart-ingest.duplicate-detected.test.json +34 -0
- package/src/workflows/tests/smart-ingest.happy-path.test.json +31 -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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render PNG from Mermaid diagram via Playwright.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} normalized
|
|
7
|
+
* @param {object} options
|
|
8
|
+
* @param {string} [options.resolution='2x'] - '1x' | '2x' | '3x'
|
|
9
|
+
* @param {string} [options.background='transparent'] - 'transparent' | 'dark' | 'light'
|
|
10
|
+
* @param {boolean} [options.includeWatermark=true]
|
|
11
|
+
* @param {boolean} [options.fitToContent=true]
|
|
12
|
+
* @returns {Promise<{ content: Buffer, mimeType: string }>}
|
|
13
|
+
*/
|
|
14
|
+
async function renderPng(normalized, options = {}) {
|
|
15
|
+
// If raw PNG buffer provided (from Electron canvas capture)
|
|
16
|
+
if (normalized._pngBuffer) {
|
|
17
|
+
return { content: normalized._pngBuffer, mimeType: 'image/png' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { workflowToMermaid } = require('./mermaid-export');
|
|
21
|
+
|
|
22
|
+
if (normalized._context !== 'workflow' && normalized._context !== 'benchmark') {
|
|
23
|
+
throw new Error(`PNG export not supported for context: ${normalized._context}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const mermaidSrc = normalized._context === 'workflow'
|
|
27
|
+
? workflowToMermaid(normalized, options)
|
|
28
|
+
: null;
|
|
29
|
+
|
|
30
|
+
if (!mermaidSrc) {
|
|
31
|
+
throw new Error('No Mermaid source available for PNG rendering');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const png = await renderMermaidToPng(mermaidSrc, options);
|
|
35
|
+
return { content: png, mimeType: 'image/png' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render Mermaid syntax to PNG using Playwright.
|
|
40
|
+
*/
|
|
41
|
+
async function renderMermaidToPng(mermaidSrc, options = {}) {
|
|
42
|
+
const { chromium } = require('playwright');
|
|
43
|
+
const resolution = options.resolution || '2x';
|
|
44
|
+
const scale = parseInt(resolution) || 2;
|
|
45
|
+
const background = options.background || 'transparent';
|
|
46
|
+
const bgColor = background === 'dark' ? '#1e1e1e' : background === 'light' ? '#ffffff' : 'transparent';
|
|
47
|
+
|
|
48
|
+
const browser = await chromium.launch({ headless: true });
|
|
49
|
+
try {
|
|
50
|
+
const page = await browser.newPage({
|
|
51
|
+
deviceScaleFactor: scale,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const html = `<!DOCTYPE html>
|
|
55
|
+
<html><head>
|
|
56
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
|
57
|
+
</head><body style="margin:0;padding:16px;background:${bgColor};">
|
|
58
|
+
<pre class="mermaid">${escapeHtml(mermaidSrc)}</pre>
|
|
59
|
+
<script>mermaid.initialize({ startOnLoad: true, theme: '${options.theme || 'dark'}' });</script>
|
|
60
|
+
</body></html>`;
|
|
61
|
+
|
|
62
|
+
await page.setContent(html, { waitUntil: 'networkidle' });
|
|
63
|
+
await page.waitForSelector('svg', { timeout: 10000 });
|
|
64
|
+
|
|
65
|
+
const fitToContent = options.fitToContent !== false;
|
|
66
|
+
|
|
67
|
+
let screenshotOpts = { type: 'png' };
|
|
68
|
+
if (background === 'transparent') {
|
|
69
|
+
screenshotOpts.omitBackground = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (fitToContent) {
|
|
73
|
+
const svgEl = await page.$('svg');
|
|
74
|
+
const box = await svgEl.boundingBox();
|
|
75
|
+
if (box) {
|
|
76
|
+
screenshotOpts.clip = {
|
|
77
|
+
x: Math.max(0, box.x - 8),
|
|
78
|
+
y: Math.max(0, box.y - 8),
|
|
79
|
+
width: box.width + 16,
|
|
80
|
+
height: box.height + 16,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const buffer = await page.screenshot(screenshotOpts);
|
|
86
|
+
return buffer;
|
|
87
|
+
} finally {
|
|
88
|
+
await browser.close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function escapeHtml(str) {
|
|
93
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { renderPng, renderMermaidToPng };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render an SVG string for export.
|
|
5
|
+
* In CLI context, this generates SVG from Mermaid via Playwright.
|
|
6
|
+
* In Electron/browser context, this serializes and cleans the DOM SVG.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} normalized - Normalized data (must have _context = 'workflow' or provide _svgContent)
|
|
9
|
+
* @param {object} options
|
|
10
|
+
* @param {string} [options.background='transparent'] - 'transparent' | 'dark' | 'light'
|
|
11
|
+
* @param {boolean} [options.includeWatermark=true]
|
|
12
|
+
* @param {boolean} [options.fitToContent=true]
|
|
13
|
+
* @returns {{ content: string, mimeType: string }}
|
|
14
|
+
*/
|
|
15
|
+
function renderSvg(normalized, options = {}) {
|
|
16
|
+
// If raw SVG content is provided (from browser/Electron capture), clean and return it
|
|
17
|
+
if (normalized._svgContent) {
|
|
18
|
+
const cleaned = cleanSvg(normalized._svgContent, options);
|
|
19
|
+
return { content: cleaned, mimeType: 'image/svg+xml' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// For CLI: generate SVG from Mermaid (async path handled by renderSvgAsync)
|
|
23
|
+
throw new Error('SVG export requires either _svgContent (browser) or use renderSvgAsync (CLI)');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Async SVG render — generates SVG from Mermaid using Playwright.
|
|
28
|
+
* @param {object} normalized
|
|
29
|
+
* @param {object} options
|
|
30
|
+
* @returns {Promise<{ content: string, mimeType: string }>}
|
|
31
|
+
*/
|
|
32
|
+
async function renderSvgAsync(normalized, options = {}) {
|
|
33
|
+
if (normalized._svgContent) {
|
|
34
|
+
return renderSvg(normalized, options);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Generate Mermaid source, then render via Playwright
|
|
38
|
+
const { workflowToMermaid } = require('./mermaid-export');
|
|
39
|
+
if (normalized._context !== 'workflow' && normalized._context !== 'benchmark') {
|
|
40
|
+
throw new Error(`SVG export not supported for context: ${normalized._context}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const mermaidSrc = normalized._context === 'workflow'
|
|
44
|
+
? workflowToMermaid(normalized, options)
|
|
45
|
+
: null;
|
|
46
|
+
|
|
47
|
+
if (!mermaidSrc) {
|
|
48
|
+
throw new Error('No Mermaid source available for SVG rendering');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const svg = await renderMermaidToSvg(mermaidSrc, options);
|
|
52
|
+
return { content: svg, mimeType: 'image/svg+xml' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render Mermaid syntax to SVG using Playwright.
|
|
57
|
+
*/
|
|
58
|
+
async function renderMermaidToSvg(mermaidSrc, options = {}) {
|
|
59
|
+
const { chromium } = require('playwright');
|
|
60
|
+
const background = options.background || 'transparent';
|
|
61
|
+
const bgColor = background === 'dark' ? '#1e1e1e' : background === 'light' ? '#ffffff' : 'transparent';
|
|
62
|
+
|
|
63
|
+
const browser = await chromium.launch({ headless: true });
|
|
64
|
+
try {
|
|
65
|
+
const page = await browser.newPage();
|
|
66
|
+
|
|
67
|
+
const html = `<!DOCTYPE html>
|
|
68
|
+
<html><head>
|
|
69
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
|
70
|
+
</head><body style="margin:0;background:${bgColor};">
|
|
71
|
+
<pre class="mermaid">${escapeHtml(mermaidSrc)}</pre>
|
|
72
|
+
<script>mermaid.initialize({ startOnLoad: true, theme: '${options.theme || 'dark'}' });</script>
|
|
73
|
+
</body></html>`;
|
|
74
|
+
|
|
75
|
+
await page.setContent(html, { waitUntil: 'networkidle' });
|
|
76
|
+
await page.waitForSelector('svg', { timeout: 10000 });
|
|
77
|
+
|
|
78
|
+
const svg = await page.evaluate(() => {
|
|
79
|
+
const el = document.querySelector('svg');
|
|
80
|
+
// Remove interactive attributes
|
|
81
|
+
el.removeAttribute('style');
|
|
82
|
+
const scripts = el.querySelectorAll('script');
|
|
83
|
+
scripts.forEach(s => s.remove());
|
|
84
|
+
return el.outerHTML;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const cleaned = cleanSvg(svg, options);
|
|
88
|
+
return cleaned;
|
|
89
|
+
} finally {
|
|
90
|
+
await browser.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Clean an SVG string: remove scripts, event handlers, optionally add watermark.
|
|
96
|
+
*/
|
|
97
|
+
function cleanSvg(svgStr, options = {}) {
|
|
98
|
+
// Strip event handlers
|
|
99
|
+
let svg = svgStr.replace(/\s+on\w+="[^"]*"/g, '');
|
|
100
|
+
// Strip <script> tags
|
|
101
|
+
svg = svg.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
102
|
+
|
|
103
|
+
if (options.includeWatermark !== false) {
|
|
104
|
+
// Insert watermark before closing </svg>
|
|
105
|
+
const watermark = '<text x="99%" y="99%" text-anchor="end" font-size="10" fill="#666" opacity="0.5">Generated by vai</text>';
|
|
106
|
+
svg = svg.replace('</svg>', `${watermark}</svg>`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return svg;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function escapeHtml(str) {
|
|
113
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { renderSvg, renderSvgAsync, renderMermaidToSvg, cleanSvg };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { renderJson, renderJsonl } = require('./formats/json-export');
|
|
4
|
+
const { renderCsv } = require('./formats/csv-export');
|
|
5
|
+
const { renderMarkdown } = require('./formats/markdown-export');
|
|
6
|
+
const { renderMermaid } = require('./formats/mermaid-export');
|
|
7
|
+
const { copyToClipboard } = require('./formats/clipboard-export');
|
|
8
|
+
const { renderSvgAsync } = require('./formats/svg-export');
|
|
9
|
+
const { renderPng } = require('./formats/png-export');
|
|
10
|
+
const { renderPdf } = require('./formats/pdf-export');
|
|
11
|
+
|
|
12
|
+
const { normalizeWorkflow, WORKFLOW_FORMATS } = require('./contexts/workflow-export');
|
|
13
|
+
const { normalizeSearch, SEARCH_FORMATS } = require('./contexts/search-export');
|
|
14
|
+
const { normalizeChat, CHAT_FORMATS } = require('./contexts/chat-export');
|
|
15
|
+
const { normalizeBenchmark, BENCHMARK_FORMATS } = require('./contexts/benchmark-export');
|
|
16
|
+
const { normalizeExplore, EXPLORE_FORMATS } = require('./contexts/explore-export');
|
|
17
|
+
|
|
18
|
+
// ════════════════════════════════════════════════════════════════════
|
|
19
|
+
// Context → Formats mapping
|
|
20
|
+
// ════════════════════════════════════════════════════════════════════
|
|
21
|
+
|
|
22
|
+
const FORMAT_MAP = {
|
|
23
|
+
workflow: WORKFLOW_FORMATS,
|
|
24
|
+
search: SEARCH_FORMATS,
|
|
25
|
+
chat: CHAT_FORMATS,
|
|
26
|
+
benchmark: BENCHMARK_FORMATS,
|
|
27
|
+
explore: EXPLORE_FORMATS,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const TRANSFORMERS = {
|
|
31
|
+
workflow: normalizeWorkflow,
|
|
32
|
+
search: normalizeSearch,
|
|
33
|
+
chat: normalizeChat,
|
|
34
|
+
benchmark: normalizeBenchmark,
|
|
35
|
+
explore: normalizeExplore,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const RENDERERS = {
|
|
39
|
+
json: renderJson,
|
|
40
|
+
jsonl: renderJsonl,
|
|
41
|
+
csv: renderCsv,
|
|
42
|
+
markdown: renderMarkdown,
|
|
43
|
+
mermaid: renderMermaid,
|
|
44
|
+
svg: renderSvgAsync,
|
|
45
|
+
png: renderPng,
|
|
46
|
+
pdf: renderPdf,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Formats that produce binary (Buffer) output
|
|
50
|
+
const BINARY_FORMATS = new Set(['png', 'pdf']);
|
|
51
|
+
|
|
52
|
+
const EXT_MAP = {
|
|
53
|
+
json: '.json',
|
|
54
|
+
jsonl: '.jsonl',
|
|
55
|
+
csv: '.csv',
|
|
56
|
+
markdown: '.md',
|
|
57
|
+
mermaid: '.mmd',
|
|
58
|
+
svg: '.svg',
|
|
59
|
+
png: '.png',
|
|
60
|
+
pdf: '.pdf',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ════════════════════════════════════════════════════════════════════
|
|
64
|
+
// Public API
|
|
65
|
+
// ════════════════════════════════════════════════════════════════════
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get supported formats for a given context.
|
|
69
|
+
* @param {string} context
|
|
70
|
+
* @returns {string[]}
|
|
71
|
+
*/
|
|
72
|
+
function getFormatsForContext(context) {
|
|
73
|
+
return FORMAT_MAP[context] || [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build a deterministic filename for the export.
|
|
78
|
+
* Pattern: {context}-{name}-{timestamp}.{ext}
|
|
79
|
+
* @param {string} context
|
|
80
|
+
* @param {object} data
|
|
81
|
+
* @param {string} format
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
function buildFilename(context, data, format) {
|
|
85
|
+
const name = (data.name || data.sessionId || data.query || context)
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
88
|
+
.replace(/^-|-$/g, '')
|
|
89
|
+
.slice(0, 40);
|
|
90
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
|
91
|
+
const ext = EXT_MAP[format] || '.txt';
|
|
92
|
+
return `${context}-${name}-${ts}${ext}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Main export entry point.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} params
|
|
99
|
+
* @param {string} params.context - 'workflow' | 'search' | 'chat' | 'benchmark' | 'explore'
|
|
100
|
+
* @param {string} params.format - 'json' | 'jsonl' | 'csv' | 'markdown' | 'mermaid' | 'clipboard'
|
|
101
|
+
* @param {object} params.data - Raw source data
|
|
102
|
+
* @param {object} [params.options] - Format & context specific options
|
|
103
|
+
* @returns {{ content: string, mimeType: string, suggestedFilename: string, format: string }}
|
|
104
|
+
*/
|
|
105
|
+
async function exportArtifact({ context, format, data, options = {} }) {
|
|
106
|
+
// Handle clipboard as a meta-format: pick the best underlying format and copy
|
|
107
|
+
const isClipboard = format === 'clipboard';
|
|
108
|
+
const effectiveFormat = isClipboard ? pickClipboardFormat(context) : format;
|
|
109
|
+
|
|
110
|
+
// Validate
|
|
111
|
+
const supported = getFormatsForContext(context);
|
|
112
|
+
if (!supported.includes(format)) {
|
|
113
|
+
throw new ExportError(
|
|
114
|
+
`Format "${format}" not supported for ${context}. Supported: ${supported.join(', ')}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Transform
|
|
119
|
+
const transformer = TRANSFORMERS[context];
|
|
120
|
+
if (!transformer) {
|
|
121
|
+
throw new ExportError(`Unknown export context: "${context}"`);
|
|
122
|
+
}
|
|
123
|
+
const normalized = transformer(data, options);
|
|
124
|
+
|
|
125
|
+
// Render
|
|
126
|
+
const renderer = RENDERERS[effectiveFormat];
|
|
127
|
+
if (!renderer) {
|
|
128
|
+
throw new ExportError(`No renderer for format: "${effectiveFormat}"`);
|
|
129
|
+
}
|
|
130
|
+
const output = await renderer(normalized, options);
|
|
131
|
+
const isBinary = BINARY_FORMATS.has(effectiveFormat);
|
|
132
|
+
|
|
133
|
+
// Clipboard side-effect (text only — binary clipboard handled by Electron)
|
|
134
|
+
if (isClipboard) {
|
|
135
|
+
const ok = copyToClipboard(typeof output.content === 'string' ? output.content : output.content.toString());
|
|
136
|
+
if (!ok) {
|
|
137
|
+
throw new ExportError('Failed to copy to clipboard — unsupported platform or missing clipboard tool');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
content: output.content,
|
|
143
|
+
mimeType: output.mimeType,
|
|
144
|
+
suggestedFilename: buildFilename(context, data, effectiveFormat),
|
|
145
|
+
format: effectiveFormat,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Pick the best text format for clipboard based on context.
|
|
151
|
+
*/
|
|
152
|
+
function pickClipboardFormat(context) {
|
|
153
|
+
switch (context) {
|
|
154
|
+
case 'workflow': return 'mermaid';
|
|
155
|
+
case 'search': return 'json';
|
|
156
|
+
case 'chat': return 'markdown';
|
|
157
|
+
case 'benchmark': return 'json';
|
|
158
|
+
case 'explore': return 'json';
|
|
159
|
+
default: return 'json';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
class ExportError extends Error {
|
|
164
|
+
constructor(message) {
|
|
165
|
+
super(message);
|
|
166
|
+
this.name = 'ExportError';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
exportArtifact,
|
|
172
|
+
getFormatsForContext,
|
|
173
|
+
buildFilename,
|
|
174
|
+
ExportError,
|
|
175
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub API fetcher for remote repository indexing.
|
|
5
|
+
* Uses native fetch (Node 18+) — no axios.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get GitHub auth token from env or vai config.
|
|
10
|
+
* @returns {string|null}
|
|
11
|
+
*/
|
|
12
|
+
function getAuthToken() {
|
|
13
|
+
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
|
|
14
|
+
try {
|
|
15
|
+
const { getConfigValue } = require('./config');
|
|
16
|
+
return getConfigValue('github.token') || null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if a source string is a GitHub URL or shorthand.
|
|
24
|
+
* @param {string} source
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
function isGitHubUrl(source) {
|
|
28
|
+
if (!source || typeof source !== 'string') return false;
|
|
29
|
+
if (source.includes('github.com')) return true;
|
|
30
|
+
// owner/repo shorthand (must have exactly one slash, no spaces, no path separators at start)
|
|
31
|
+
if (/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(source)) return true;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse a GitHub URL into owner and repo.
|
|
37
|
+
* Supports: https://github.com/owner/repo, github.com/owner/repo, owner/repo
|
|
38
|
+
* @param {string} source
|
|
39
|
+
* @returns {{ owner: string, repo: string }}
|
|
40
|
+
*/
|
|
41
|
+
function parseGitHubUrl(source) {
|
|
42
|
+
if (!source) throw new Error('Empty GitHub source');
|
|
43
|
+
|
|
44
|
+
// Strip trailing .git
|
|
45
|
+
source = source.replace(/\.git$/, '');
|
|
46
|
+
|
|
47
|
+
// Full URL
|
|
48
|
+
const urlMatch = source.match(/github\.com[/:]([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)/);
|
|
49
|
+
if (urlMatch) {
|
|
50
|
+
return { owner: urlMatch[1], repo: urlMatch[2] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// owner/repo shorthand
|
|
54
|
+
const shortMatch = source.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/);
|
|
55
|
+
if (shortMatch) {
|
|
56
|
+
return { owner: shortMatch[1], repo: shortMatch[2] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw new Error(`Cannot parse GitHub URL: ${source}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Make a GitHub API request with optional auth and backoff.
|
|
64
|
+
* @param {string} url
|
|
65
|
+
* @param {string|null} token
|
|
66
|
+
* @param {number} [retries=3]
|
|
67
|
+
* @returns {Promise<object>}
|
|
68
|
+
*/
|
|
69
|
+
async function githubFetch(url, token, retries = 3) {
|
|
70
|
+
const headers = { 'Accept': 'application/vnd.github.v3+json' };
|
|
71
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
72
|
+
|
|
73
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
74
|
+
const res = await fetch(url, { headers });
|
|
75
|
+
|
|
76
|
+
if (res.status === 403) {
|
|
77
|
+
const remaining = res.headers.get('x-ratelimit-remaining');
|
|
78
|
+
const resetAt = res.headers.get('x-ratelimit-reset');
|
|
79
|
+
if (remaining === '0' && resetAt) {
|
|
80
|
+
const waitMs = Math.max(0, (parseInt(resetAt) * 1000) - Date.now()) + 1000;
|
|
81
|
+
if (attempt < retries && waitMs < 120000) {
|
|
82
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`GitHub rate limit exceeded. Resets at ${new Date(parseInt(resetAt) * 1000).toISOString()}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (res.status === 404) {
|
|
90
|
+
throw new Error(`GitHub resource not found: ${url}. Is the repo public or do you have a valid GITHUB_TOKEN?`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
if (attempt < retries) {
|
|
95
|
+
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`GitHub API error ${res.status}: ${await res.text()}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return res.json();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fetch the recursive file tree for a repo.
|
|
107
|
+
* @param {string} owner
|
|
108
|
+
* @param {string} repo
|
|
109
|
+
* @param {string} branch
|
|
110
|
+
* @param {string|null} token
|
|
111
|
+
* @returns {Promise<Array<{path: string, size: number, sha: string}>>}
|
|
112
|
+
*/
|
|
113
|
+
async function fetchRepoTree(owner, repo, branch, token) {
|
|
114
|
+
const data = await githubFetch(
|
|
115
|
+
`https://api.github.com/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`,
|
|
116
|
+
token
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (!data.tree) throw new Error('No tree data returned from GitHub');
|
|
120
|
+
|
|
121
|
+
return data.tree
|
|
122
|
+
.filter(entry => entry.type === 'blob')
|
|
123
|
+
.map(entry => ({ path: entry.path, size: entry.size || 0, sha: entry.sha }));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fetch file contents from a GitHub repo.
|
|
128
|
+
* @param {string} owner
|
|
129
|
+
* @param {string} repo
|
|
130
|
+
* @param {string} filePath
|
|
131
|
+
* @param {string} branch
|
|
132
|
+
* @param {string|null} token
|
|
133
|
+
* @returns {Promise<string>}
|
|
134
|
+
*/
|
|
135
|
+
async function fetchFileContents(owner, repo, filePath, branch, token) {
|
|
136
|
+
const data = await githubFetch(
|
|
137
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${branch}`,
|
|
138
|
+
token
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (data.encoding === 'base64' && data.content) {
|
|
142
|
+
return Buffer.from(data.content, 'base64').toString('utf-8');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
throw new Error(`Unexpected encoding for ${filePath}: ${data.encoding}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Fetch changed files between two commits.
|
|
150
|
+
* @param {string} owner
|
|
151
|
+
* @param {string} repo
|
|
152
|
+
* @param {string} baseSha
|
|
153
|
+
* @param {string} headSha
|
|
154
|
+
* @param {string|null} token
|
|
155
|
+
* @returns {Promise<Array<{filename: string, status: string}>>}
|
|
156
|
+
*/
|
|
157
|
+
async function fetchChangedFiles(owner, repo, baseSha, headSha, token) {
|
|
158
|
+
const data = await githubFetch(
|
|
159
|
+
`https://api.github.com/repos/${owner}/${repo}/compare/${baseSha}...${headSha}`,
|
|
160
|
+
token
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return (data.files || []).map(f => ({ filename: f.filename, status: f.status }));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Fetch multiple files concurrently with a pool limit.
|
|
168
|
+
* @param {string} owner
|
|
169
|
+
* @param {string} repo
|
|
170
|
+
* @param {string[]} filePaths
|
|
171
|
+
* @param {string} branch
|
|
172
|
+
* @param {string|null} token
|
|
173
|
+
* @param {number} [concurrency=5]
|
|
174
|
+
* @returns {Promise<Array<{path: string, content: string}|{path: string, error: string}>>}
|
|
175
|
+
*/
|
|
176
|
+
async function fetchFilesBatch(owner, repo, filePaths, branch, token, concurrency = 5) {
|
|
177
|
+
const results = [];
|
|
178
|
+
let i = 0;
|
|
179
|
+
|
|
180
|
+
async function worker() {
|
|
181
|
+
while (i < filePaths.length) {
|
|
182
|
+
const idx = i++;
|
|
183
|
+
const fp = filePaths[idx];
|
|
184
|
+
try {
|
|
185
|
+
const content = await fetchFileContents(owner, repo, fp, branch, token);
|
|
186
|
+
results[idx] = { path: fp, content };
|
|
187
|
+
} catch (err) {
|
|
188
|
+
results[idx] = { path: fp, error: err.message };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const workers = [];
|
|
194
|
+
for (let w = 0; w < Math.min(concurrency, filePaths.length); w++) {
|
|
195
|
+
workers.push(worker());
|
|
196
|
+
}
|
|
197
|
+
await Promise.all(workers);
|
|
198
|
+
return results;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve a branch name to its HEAD commit SHA.
|
|
203
|
+
* @param {string} owner
|
|
204
|
+
* @param {string} repo
|
|
205
|
+
* @param {string} branch
|
|
206
|
+
* @param {string|null} token
|
|
207
|
+
* @returns {Promise<string>} commit SHA
|
|
208
|
+
*/
|
|
209
|
+
async function resolveCommitSha(owner, repo, branch, token) {
|
|
210
|
+
const data = await githubFetch(
|
|
211
|
+
`https://api.github.com/repos/${owner}/${repo}/commits/${branch}`,
|
|
212
|
+
token
|
|
213
|
+
);
|
|
214
|
+
return data.sha;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
getAuthToken,
|
|
219
|
+
isGitHubUrl,
|
|
220
|
+
parseGitHubUrl,
|
|
221
|
+
fetchRepoTree,
|
|
222
|
+
fetchFileContents,
|
|
223
|
+
fetchChangedFiles,
|
|
224
|
+
fetchFilesBatch,
|
|
225
|
+
resolveCommitSha,
|
|
226
|
+
};
|