UNPKG

veas

Version:

Veas CLI - Command-line interface for Veas platform

678 lines 29.2 kB
import * as crypto from 'node:crypto'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { confirm, spinner, text } from '@clack/prompts'; import glob from 'fast-glob'; import * as yaml from 'js-yaml'; import pc from 'picocolors'; import { AuthManager } from '../auth/auth-manager.js'; import { VeasConfigParser } from '../config/veas-config-parser.js'; import { MCPClient } from '../mcp/mcp-client.js'; import { logger } from '../utils/logger.js'; export async function docsSync(options) { const authManager = AuthManager.getInstance(); const session = await authManager.getSession(); if (!session) { logger.error('Not logged in. Please run "veas login" first.'); process.exit(1); } try { const configParser = new VeasConfigParser(options.config); const config = await configParser.load(); const syncer = new DocsSyncer(config, configParser, options); if (options.watch) { await syncer.watch(); } else { await syncer.sync(); } } catch (error) { logger.error(`Sync failed: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); } } class DocsSyncer { config; configParser; options; publicationId; remoteArticles = new Map(); localFiles = new Map(); folderIds = new Map(); mcpClient; constructor(config, configParser, options) { this.config = config; this.configParser = configParser; this.options = options; this.mcpClient = MCPClient.getInstance(); } async sync() { const isInteractive = process.stdout.isTTY; const s = isInteractive ? spinner() : null; if (s) s.start('Initializing docs sync...'); else logger.info('Initializing docs sync...'); try { await this.ensurePublication(); await this.ensureFolders(); if (s) s.message('Scanning local files...'); else logger.info('Scanning local files...'); await this.collectLocalFiles(); if (s) s.message('Fetching remote articles...'); else logger.info('Fetching remote articles...'); await this.fetchRemoteArticles(); if (s) s.message('Planning sync operations...'); else logger.info('Planning sync operations...'); const operations = await this.planOperations(); if (this.options.dryRun) { if (s) s.stop('Dry run complete'); else logger.info('Dry run complete'); await this.displayDryRunSummary(operations); return { created: operations.create.length, updated: operations.update.length, archived: operations.archive.length, skipped: operations.skip.length, errors: [], }; } if (s) s.message('Syncing articles...'); else logger.info('Syncing articles...'); const result = await this.executeSync(operations); if (s) s.stop(`Sync complete: ${result.created} created, ${result.updated} updated, ${result.archived} archived`); else logger.info(`Sync complete: ${result.created} created, ${result.updated} updated, ${result.archived} archived`); if (result.errors.length > 0) { logger.warn('Sync completed with errors:'); for (const err of result.errors) { logger.error(` - ${err}`); } } return result; } catch (error) { if (s) s.stop('Sync failed'); else logger.error('Sync failed'); throw error; } } async watch() { logger.info('Starting watch mode...'); await this.sync(); const chokidar = await import('chokidar'); const roots = this.configParser.getSyncRoots(); const watchPaths = roots.map(r => r.absolutePath); const watcher = chokidar.watch(watchPaths, { ignored: this.config.sync.exclude || [], persistent: true, awaitWriteFinish: { stabilityThreshold: this.config.sync.watch?.debounce || 1000, pollInterval: 100, }, }); let syncTimeout = null; const scheduleSync = () => { if (syncTimeout) clearTimeout(syncTimeout); syncTimeout = setTimeout(() => { logger.info('File changes detected, syncing...'); this.sync().catch(err => logger.error('Sync error:', err)); }, this.config.sync.watch?.debounce || 1000); }; watcher.on('change', scheduleSync).on('add', scheduleSync).on('unlink', scheduleSync); logger.info('Watching for changes... (Press Ctrl+C to stop)'); process.on('SIGINT', () => { logger.info('Stopping watch mode...'); watcher.close(); process.exit(0); }); } async ensurePublication() { if (this.config.publication?.organization_slug && !this.config.publication?.organization_id) { logger.debug(`Resolving organization slug: ${this.config.publication.organization_slug}`); const userResponse = await this.mcpClient.callToolSafe('mcp-project-manager_get_user_info', {}); if (userResponse.success) { let userData = userResponse.data; if (userData?.content?.[0]?.data) { userData = userData.content[0].data; } if (userData?.organizations?.length > 0) { const org = userData.organizations.find((o) => o.slug === this.config.publication.organization_slug || o.organization_slug === this.config.publication.organization_slug); if (org) { this.config.publication.organization_id = org.id || org.organization_id; logger.info(`Resolved organization slug "${this.config.publication.organization_slug}" to ID: ${this.config.publication.organization_id}`); } else { logger.warn(`Organization with slug "${this.config.publication.organization_slug}" not found`); } } } } if (!this.config.publication?.organization_id || this.config.publication.organization_id === 'anonymous') { logger.debug('Attempting to infer organization from existing publications...'); const listResponse = await this.mcpClient.callToolSafe('mcp-articles_list_publications', { limit: 5, }); if (listResponse.success) { let responseData = listResponse.data; if (responseData?.content?.[0]?.data) { responseData = responseData.content[0].data; } const publications = responseData?.publications || []; const orgCounts = new Map(); for (const pub of publications) { if (pub.organization_id && pub.organization_id !== 'anonymous') { orgCounts.set(pub.organization_id, (orgCounts.get(pub.organization_id) || 0) + 1); } } let mostCommonOrgId; let maxCount = 0; for (const [orgId, count] of orgCounts) { if (count > maxCount) { mostCommonOrgId = orgId; maxCount = count; } } if (mostCommonOrgId) { if (!this.config.publication) { this.config.publication = { name: '' }; } this.config.publication.organization_id = mostCommonOrgId; logger.info(`Using organization_id from existing publications: ${mostCommonOrgId}`); } else { logger.info('No organization found - creating personal publication'); } } } if (!this.config.publication?.name) { if (!process.stdout.isTTY) { throw new Error('Publication name is required. Please provide it in the configuration file or run in interactive mode.'); } const name = await text({ message: 'Enter publication name:', placeholder: 'My Project Documentation', validate: value => { if (!value.trim()) return 'Publication name is required'; return; }, }); if (typeof name === 'symbol') { throw new Error('Publication name is required'); } this.config.publication = { ...this.config.publication, name: name, }; } const response = await this.mcpClient.callToolSafe('mcp-articles_list_publications', { filters: { name_contains: this.config.publication.name, }, limit: 10, }); let responseData = response.data; if (responseData?.content?.[0]?.data) { responseData = responseData.content[0].data; } const publications = response.success ? responseData?.publications || [] : []; const exactMatch = publications.find((p) => p.name === this.config.publication.name); if (exactMatch) { this.publicationId = exactMatch.id; logger.info(`Using existing publication: ${exactMatch.name}`); return; } let shouldCreate = true; if (process.stdout.isTTY) { const confirmed = await confirm({ message: `Publication "${this.config.publication.name}" not found. Create it?`, }); if (typeof confirmed === 'symbol') { throw new Error('Publication creation cancelled'); } shouldCreate = confirmed; if (!shouldCreate) { throw new Error('Publication is required for sync'); } } else { logger.info(`Publication "${this.config.publication.name}" not found. Creating it...`); } const slug = this.config.publication.slug || this.slugify(this.config.publication.name); const createParams = { name: this.config.publication.name, slug, is_active: true, is_public: true, }; if (this.config.publication.description) { createParams.description = this.config.publication.description; } if (this.config.publication.organization_id) { const orgId = this.config.publication.organization_id; const isValidUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(orgId); if (isValidUUID && orgId !== 'anonymous') { createParams.organization_id = orgId; logger.debug(`Creating publication with organization_id: ${orgId}`); } else if (orgId === 'anonymous' || !isValidUUID) { logger.warn(`Invalid organization_id "${orgId}" - creating personal publication without organization`); } } else { logger.debug(`Creating personal publication (no organization_id)`); } logger.debug(`Create params:`, JSON.stringify(createParams, null, 2)); const createResponse = await this.mcpClient.callToolSafe('mcp-articles_create_publication', createParams); if (!createResponse.success) { throw new Error(`Failed to create publication: ${createResponse.error}`); } let publicationData = createResponse.data; if (publicationData?.content?.[0]?.data) { publicationData = publicationData.content[0].data; } this.publicationId = publicationData?.id || publicationData?.publication?.id || publicationData?.data?.publication?.id; if (!this.publicationId) { logger.error('Failed to get publication ID from response:', JSON.stringify(createResponse.data, null, 2)); throw new Error('Failed to get publication ID from created publication'); } logger.info(`Created new publication: ${this.config.publication.name} (ID: ${this.publicationId})`); } async ensureFolders() { const response = await this.mcpClient.callToolSafe('list_folders', { publication_id: this.publicationId, include_article_counts: false, }); let responseData = response.data; if (responseData?.content?.[0]?.data) { responseData = responseData.content[0].data; } const folders = response.success ? responseData?.folders || [] : []; const existingFolders = new Map(); for (const folder of folders) { existingFolders.set(folder.name, folder); } const allFolders = new Map(); const roots = this.configParser.getSyncRoots(); for (const { root } of roots) { const folders = root.folders || this.config.sync.folders || []; for (const folder of folders) { if (!allFolders.has(folder.remote)) { allFolders.set(folder.remote, folder); } } } if (this.config.sync.folders) { for (const folder of this.config.sync.folders) { if (!allFolders.has(folder.remote)) { allFolders.set(folder.remote, folder); } } } for (const [remoteName, folderConfig] of allFolders) { const existing = existingFolders.get(remoteName); if (existing) { this.folderIds.set(remoteName, existing.id); logger.debug(`Using existing folder: ${remoteName}`); } else { try { const folderResponse = await this.mcpClient.callToolSafe('create_folder', { publication_id: this.publicationId, name: remoteName, description: folderConfig.description || null, }); if (folderResponse.success) { let folderData = folderResponse.data; if (folderData?.content?.[0]?.data) { folderData = folderData.content[0].data; } const folderId = folderData?.id || folderData?.folder?.id; if (folderId) { this.folderIds.set(remoteName, folderId); } else { throw new Error(`Failed to get folder ID from response`); } } else { throw new Error(`Failed to create folder: ${folderResponse.error}`); } logger.info(`Created folder: ${remoteName}`); } catch (error) { logger.warn(`Failed to create folder "${remoteName}": ${error instanceof Error ? error.message : String(error)}`); } } } } async collectLocalFiles() { const roots = this.configParser.getSyncRoots(); for (const { root, absolutePath } of roots) { const patterns = root.include || this.config.sync.include || ['**/*.md', '**/*.mdx']; const excludePatterns = root.exclude || this.config.sync.exclude || []; const files = await glob(patterns, { cwd: absolutePath, absolute: true, ignore: excludePatterns, }); for (const filePath of files) { if (this.options.folder) { const relativePath = path.relative(absolutePath, filePath); const parts = relativePath.split(path.sep); if (parts[0] !== this.options.folder) { continue; } } const content = await fs.readFile(filePath, 'utf8'); const relativePath = path.relative(absolutePath, filePath); const metadata = this.extractMetadata(content, relativePath); const remoteFolder = this.configParser.getRemoteFolder(filePath, absolutePath, root); const hash = this.hashContent(content); const uniqueKey = path.join(path.relative(process.cwd(), absolutePath), relativePath); this.localFiles.set(uniqueKey, { path: filePath, relativePath, remoteFolder, content, metadata, hash, }); } } logger.debug(`Found ${this.localFiles.size} local files across ${roots.length} roots`); } extractMetadata(content, relativePath) { const metadata = { title: this.extractTitle(content, relativePath), ...this.config.sync.metadata?.defaults, }; if (this.config.sync.metadata?.frontmatter) { const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/); if (frontMatterMatch) { try { const frontMatter = yaml.load(frontMatterMatch[1] || ''); Object.assign(metadata, frontMatter); } catch (_error) { logger.warn(`Failed to parse front matter in ${relativePath}`); } } } return metadata; } extractTitle(content, relativePath) { const h1Match = content.match(/^#\s+(.+)$/m); if (h1Match?.[1]) { return h1Match[1].trim(); } const basename = path.basename(relativePath, path.extname(relativePath)); return basename.replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); } hashContent(content) { return crypto.createHash('sha256').update(content).digest('hex'); } async fetchRemoteArticles() { const response = await this.mcpClient.callToolSafe('mcp-articles_list_articles', { filters: { publication_id: this.publicationId, }, limit: 1000, }); let responseData = response.data; if (responseData?.content?.[0]?.data) { responseData = responseData.content[0].data; } const articles = response.success ? responseData?.articles || [] : []; for (const article of articles) { const key = article.slug || article.title; this.remoteArticles.set(key, article); } logger.debug(`Found ${this.remoteArticles.size} remote articles`); } async planOperations() { const operations = { create: [], update: [], archive: [], skip: [], }; for (const file of this.localFiles.values()) { const remoteKey = this.generateRemoteKey(file); const remoteArticle = this.remoteArticles.get(remoteKey); if (!remoteArticle) { if (this.config.sync.behavior?.missing_remote === 'skip') { operations.skip.push(file); } else if (this.config.sync.behavior?.missing_remote === 'warn') { logger.warn(`Local file has no remote article: ${file.relativePath}`); operations.skip.push(file); } else { operations.create.push(file); } } else { this.remoteArticles.delete(remoteKey); if (this.options.force || this.config.sync.behavior?.update_strategy === 'always') { operations.update.push({ file, article: remoteArticle }); } else if (this.config.sync.behavior?.update_strategy === 'never') { operations.skip.push(file); } else { if (this.hasContentChanged(file, remoteArticle)) { operations.update.push({ file, article: remoteArticle }); } else { operations.skip.push(file); } } } } for (const article of this.remoteArticles.values()) { if (this.config.sync.behavior?.missing_local === 'archive') { operations.archive.push(article); } else if (this.config.sync.behavior?.missing_local === 'delete') { operations.archive.push(article); } else if (this.config.sync.behavior?.missing_local === 'warn') { logger.warn(`Remote article has no local file: ${article.title}`); } } return operations; } generateRemoteKey(file) { return this.slugify(file.metadata.title); } hasContentChanged(_file, _remoteArticle) { return true; } async displayDryRunSummary(operations) { console.log(`\n${pc.bold('Dry Run Summary:')}`); console.log(pc.green(` Create: ${operations.create.length} articles`)); console.log(pc.yellow(` Update: ${operations.update.length} articles`)); console.log(pc.red(` Archive: ${operations.archive.length} articles`)); console.log(pc.gray(` Skip: ${operations.skip.length} articles`)); if (operations.create.length > 0) { console.log(`\n${pc.green('Articles to create:')}`); for (const file of operations.create) { console.log(` - ${file.relativePath} → ${file.metadata.title}`); } } if (operations.update.length > 0) { console.log(`\n${pc.yellow('Articles to update:')}`); for (const { file, article } of operations.update) { console.log(` - ${file.relativePath} → ${article.title}`); } } if (operations.archive.length > 0) { console.log(`\n${pc.red('Articles to archive:')}`); for (const article of operations.archive) { console.log(` - ${article.title}`); } } } async executeSync(operations) { const result = { created: 0, updated: 0, archived: 0, skipped: operations.skip.length, errors: [], }; for (const file of operations.create) { try { const folderId = file.remoteFolder ? this.folderIds.get(file.remoteFolder) : undefined; const createResponse = await this.mcpClient.callToolSafe('mcp-articles_create_article', { title: file.metadata.title, content: file.content, status: file.metadata.status || 'published', publication_id: this.publicationId, folder_id: folderId || null, }); if (createResponse.success) { let articleData = createResponse.data; if (articleData?.content?.[0]?.data) { articleData = articleData.content[0].data; } const articleId = articleData?.id || articleData?.article?.id; result.created++; logger.debug(`Created: ${file.metadata.title}`); if (file.metadata.tags && file.metadata.tags.length > 0 && articleId) { await this.addTagsToArticle(articleId, file.metadata.tags); } } else { throw new Error(`Failed to create article: ${createResponse.error}`); } } catch (error) { result.errors.push(`Failed to create ${file.relativePath}: ${error instanceof Error ? error.message : String(error)}`); } } for (const { file, article } of operations.update) { try { const updateData = { title: file.metadata.title, content: file.content, status: file.metadata.status, }; if (this.config.sync.behavior?.preserve_remote) { for (const field of this.config.sync.behavior.preserve_remote) { delete updateData[field]; } } const updateResponse = await this.mcpClient.callToolSafe('mcp-articles_update_article', { article_id: article.id, ...updateData, }); if (updateResponse.success) { result.updated++; logger.debug(`Updated: ${file.metadata.title}`); } else { throw new Error(`Failed to update article: ${updateResponse.error}`); } } catch (error) { result.errors.push(`Error updating ${file.relativePath}: ${error instanceof Error ? error.message : String(error)}`); } } for (const article of operations.archive) { try { const archiveResponse = await this.mcpClient.callToolSafe('mcp-articles_update_article', { article_id: article.id, status: 'archived', }); if (archiveResponse.success) { result.archived++; logger.debug(`Archived: ${article.title}`); } else { throw new Error(`Failed to archive article: ${archiveResponse.error}`); } } catch (error) { result.errors.push(`Error archiving ${article.title}: ${error instanceof Error ? error.message : String(error)}`); } } return result; } async addTagsToArticle(articleId, tagNames) { try { for (const tagName of tagNames) { try { const searchResponse = await this.mcpClient.callToolSafe('search_tags', { search_term: tagName, filters: { publication_id: this.publicationId, }, }); if (searchResponse.success && (!searchResponse.data?.tags || searchResponse.data.tags.length === 0)) { await this.mcpClient.callToolSafe('create_tag', { data: { name: tagName, publication_id: this.publicationId, }, }); } } catch (_error) { } } const tagIds = []; for (const tagName of tagNames) { const listResponse = await this.mcpClient.callToolSafe('list_tags', { filters: { name_contains: tagName, publication_id: this.publicationId, }, }); if (listResponse.success && listResponse.data?.tags) { const tag = listResponse.data.tags.find((t) => t.name === tagName); if (tag) { tagIds.push(tag.id); } } } if (tagIds.length > 0) { await this.mcpClient.callToolSafe('mcp-articles_add_article_tags', { article_id: articleId, tag_ids: tagIds, }); } } catch (error) { logger.warn(`Failed to add tags to article: ${error instanceof Error ? error.message : String(error)}`); } } slugify(text) { return text .toLowerCase() .trim() .replace(/[^\w\s-]/g, '') .replace(/[\s_]+/g, '-') .replace(/^-+|-+$/g, ''); } } //# sourceMappingURL=docs-sync-mcp.js.map