UNPKG

@tensorify.io/cli

Version:

Official CLI for Tensorify.io - Build, test, and deploy machine learning plugins

1,606 lines (1,449 loc) • 60.7 kB
import { Command } from "commander"; import chalk from "chalk"; import * as fs from "fs"; import * as path from "path"; import { execSync } from "child_process"; import { build } from "esbuild"; import FormData from "form-data"; import axios from "axios"; import { validatePlugin, NodeType, HandlePosition, HandleViewType, EdgeType, InputHandle, OutputHandle, } from "@tensorify.io/sdk"; import { getAuthToken, getConfig } from "../auth/session-storage"; import { authService } from "../auth/auth-service"; interface PublishOptions { access: "public" | "private"; directory?: string; backend?: string; frontend?: string; dev?: boolean; offline?: boolean; generateOffline?: boolean; } interface PackageJson { name: string; version: string; private?: boolean; repository?: { type: string; url: string; }; "tensorify-settings": { "sdk-version": string; pluginType?: string; }; scripts: { build: string; [key: string]: string; }; main?: string; // Added for new logic [key: string]: any; } interface ManifestJson { name: string; version: string; entrypointClassName: string; description?: string; author?: string; keywords?: string[]; // Added for keywords pluginType?: string; // Added for NodeType category from SDK inputHandles?: InputHandle[]; // Added for input handles outputHandles?: OutputHandle[]; // Added for output handles visual?: any; // Visual configuration from plugin settingsFields?: any[]; // Settings fields from plugin settingsGroups?: any[]; // Settings groups from plugin capabilities?: any[]; // Plugin capabilities requirements?: any; // Plugin requirements emits?: { variables: any[]; imports: any[] }; [key: string]: any; } /** * Publish command implementation */ export const publishCommand = new Command() .name("publish") .description("Publish a Tensorify plugin to the registry") .option("--access <type>", "Set access level (public|private)", "public") .option("--directory <path>", "Plugin directory path", process.cwd()) .option("--backend <url>", "Backend API URL", "https://backend.tensorify.io") .option( "--frontend <url>", "Frontend API URL", "https://plugins.tensorify.io" ) .option("-d, --dev", "Use development environment") .option( "--offline", "Use offline development mode (no S3 upload, implies --dev)" ) .option( "--generate-offline", "Generate/regenerate offline artifacts only (no upload or webhook; implies --offline)" ) .action(async (options: PublishOptions) => { try { console.log(chalk.blue("šŸš€ Starting plugin publish process...\n")); const publisher = new PluginPublisher(options); await publisher.publish(); } catch (error) { console.error( chalk.red("āŒ Publish failed:"), error instanceof Error ? error.message : error ); process.exit(1); } }); /** * Plugin publisher class that handles the complete publishing workflow */ class PluginPublisher { private options: PublishOptions; private directory: string; private packageJson: PackageJson; private manifestJson: ManifestJson; private authToken: string; private sdkVersion: string; private username: string; // Added to store the username private keywords: string[]; // Added to store keywords private readme: string; private offlineBaseDir: string | null; constructor(options: PublishOptions) { this.options = this.resolveOptions(options); this.directory = path.resolve(this.options.directory || process.cwd()); this.packageJson = {} as PackageJson; this.manifestJson = {} as ManifestJson; this.authToken = ""; this.sdkVersion = this.getSDKVersion(); this.username = ""; // Initialize username this.keywords = []; // Initialize keywords this.readme = ""; // Initialize readme this.offlineBaseDir = null; } /** * Resolve options with development environment configuration */ private resolveOptions(options: PublishOptions): PublishOptions { // Determine if we should use dev environment // Priority: explicit --dev flag > saved config > NODE_ENV let isDev = options.dev; // --offline implies dev if (options.offline) { isDev = true; } if (options.generateOffline) { options.offline = true; isDev = true; } if (!isDev) { // We'll resolve this async in the publish method isDev = process.env.NODE_ENV === "development"; } // Set default URLs based on environment const resolvedOptions = { ...options }; if (isDev) { // Override URLs for development environment if not explicitly set if ( !options.backend || options.backend === "https://backend.tensorify.io" ) { resolvedOptions.backend = "http://localhost:3001"; } if ( !options.frontend || options.frontend === "https://plugins.tensorify.io" ) { resolvedOptions.frontend = "http://localhost:3004"; } } return resolvedOptions; } /** * Get the SDK version from CLI's package.json dependencies */ private getSDKVersion(): string { try { // Use npm list to get the installed SDK version directly const npmListOutput = execSync( `npm list --json --depth=0 "@tensorify.io/sdk"`, { cwd: this.directory, encoding: "utf8", stdio: "pipe" } ).toString(); const parsedOutput = JSON.parse(npmListOutput); // Navigate to find the SDK version in the dependencies const sdkDependency = parsedOutput.dependencies?.["@tensorify.io/sdk"]; if (sdkDependency && sdkDependency.version) { console.log( chalk.green( `āœ… SDK version detected: ${sdkDependency.version} (via npm list)` ) ); return sdkDependency.version; } // Fallback if npm list doesn't provide the version for some reason console.warn( chalk.yellow( "āš ļø Could not determine SDK version via npm list, falling back to 0.0.1" ) ); return "0.0.1"; } catch (error) { console.warn( chalk.yellow( `āš ļø Could not determine SDK version via npm list: ${ error instanceof Error ? error.message : String(error) }. Using fallback 0.0.1` ) ); return "0.0.1"; } } /** * Clean up temporary files created during publish process */ private async cleanupTempFiles(): Promise<void> { // In offline mode, keep generated files for fast iteration if (this.options.offline) { console.log( chalk.gray( "🧩 Offline mode: skipping cleanup of dist and manifest.json" ) ); return; } const filesToCleanup = [ path.join(this.directory, "manifest.json"), path.join(this.directory, "dist"), path.join(this.directory, "dist/bundle.js"), ]; for (const filePath of filesToCleanup) { try { if (fs.existsSync(filePath)) { const stats = fs.statSync(filePath); if (stats.isDirectory()) { fs.rmSync(filePath, { recursive: true, force: true }); } else { fs.unlinkSync(filePath); } } } catch (error) { // Silently ignore cleanup errors to not interfere with main process console.warn(chalk.yellow(`āš ļø Could not clean up ${filePath}`)); } } } /** * Main publish workflow */ async publish(): Promise<void> { try { // Resolve development environment from saved config if not explicitly set await this.resolveDevelopmentEnvironment(); // Resolve offline directory if needed if (this.options.offline) { this.offlineBaseDir = await this.getOfflinePluginsBaseDir(); console.log( chalk.cyan(`šŸ“ Using offline plugins dir: ${this.offlineBaseDir}`) ); } // Fast path: generate offline artifacts only if (this.options.generateOffline) { console.log(chalk.blue("šŸ› ļø Generating offline artifacts only...\n")); await this.validatePrerequisites(); await this.validatePluginStructure(); await this.buildAndBundle(); await this.saveFilesOffline(true); console.log(chalk.green("āœ… Offline artifacts generated")); return; } // Step 1: Check backend service health if (!this.options.offline) { await this.checkBackendHealth(); } else { console.log( chalk.gray("ā­ļø Offline mode: skipping backend health check") ); } // Step 2: Pre-flight validation await this.validatePrerequisites(); // Step 3: Authentication check and fetch user profile await this.checkAuthenticationAndFetchProfile(); // Step 4: Validate plugin await this.validatePluginStructure(); // Step 5: Validate plugin name (namespace) await this.validatePluginName(); // Step 6: Validate access level consistency await this.validateAccessLevel(); // Step 7: Check version conflicts if (!this.options.offline) { await this.checkVersionConflicts(); } else { console.log( chalk.gray("ā­ļø Offline mode: skipping version conflict check") ); } // Step 8: Build and bundle await this.buildAndBundle(); // Step 9: Upload or save files depending on mode if (this.options.offline) { await this.saveFilesOffline(); } else { await this.uploadFiles(); } console.log(chalk.green("āœ… Plugin published successfully!")); } finally { // Always clean up temporary files, regardless of success or failure await this.cleanupTempFiles(); } } /** * Resolve development environment configuration */ private async resolveDevelopmentEnvironment(): Promise<void> { // Production is the default environment // Only use development if --dev flag is explicitly provided if (!this.options.dev && !this.options.offline) { this.options.dev = false; // Default to production console.log(chalk.cyan("šŸ”§ Using production environment (default)")); } else if (this.options.dev || this.options.offline) { console.log(chalk.cyan("šŸ”§ Using development environment (--dev flag)")); } else { console.log(chalk.cyan("šŸ”§ Using production environment")); } // Now, apply the URLs based on the resolved this.options.dev if (this.options.dev || this.options.offline) { // Override URLs for development environment if not explicitly set if ( !this.options.backend || this.options.backend === "https://backend.tensorify.io" ) { this.options.backend = "http://localhost:3001"; } if ( !this.options.frontend || this.options.frontend === "https://plugins.tensorify.io" ) { this.options.frontend = "http://localhost:3004"; } } else { // Ensure production URLs are used if not in dev mode if ( !this.options.backend || this.options.backend === "http://localhost:3001" ) { this.options.backend = "https://backend.tensorify.io"; } if ( !this.options.frontend || this.options.frontend === "http://localhost:3004" ) { this.options.frontend = "https://plugins.tensorify.io"; } } } /** * Validate basic prerequisites before attempting to publish */ private async validatePrerequisites(): Promise<void> { console.log(chalk.yellow("šŸ” Validating prerequisites...")); // Check if directory exists if (!fs.existsSync(this.directory)) { throw new Error(`Directory does not exist: ${this.directory}`); } // Check for essential files const essentialFiles = ["package.json"]; const missingEssentialFiles = essentialFiles.filter( (file) => !fs.existsSync(path.join(this.directory, file)) ); if (missingEssentialFiles.length > 0) { console.error(chalk.red(`āŒ Missing essential files:`)); missingEssentialFiles.forEach((file) => { console.error(chalk.red(` • ${file}`)); }); throw new Error("Essential files are missing"); } // Load package.json for further validation try { const packageJsonContent = fs.readFileSync( path.join(this.directory, "package.json"), "utf-8" ); this.packageJson = JSON.parse(packageJsonContent); } catch (error) { throw new Error( "Invalid package.json file. Please check for syntax errors." ); } // Check for TypeScript source files (more intelligent check) const hasValidEntryPoint = this.validateEntryPointExists(); if (!hasValidEntryPoint) { console.error(chalk.red(`āŒ No valid TypeScript entry point found`)); console.error(chalk.yellow("\nšŸ’” Expected one of:")); console.error(chalk.gray(" • src/index.ts (recommended)")); console.error(chalk.gray(" • index.ts")); console.error( chalk.gray(" • A TypeScript file matching package.json main field") ); console.error(chalk.gray(" • Any .ts file in src/ directory")); console.error(chalk.yellow("\nšŸ’” Tips:")); console.error( chalk.gray(" • Use 'create-tensorify-plugin' to scaffold a new plugin") ); console.error( chalk.gray(" • Make sure you're in the correct plugin directory") ); console.error( chalk.gray(" • Check if the plugin was properly initialized") ); throw new Error("No valid TypeScript entry point found"); } // manifest.json will be generated dynamically from package.json // Check if TypeScript is available try { execSync("npx tsc --version", { cwd: this.directory, stdio: "pipe" }); } catch (error) { console.error(chalk.red("āŒ TypeScript not available")); console.error(chalk.yellow("\nšŸ’” Tips:")); console.error( chalk.gray(" • Run 'npm install' to install dependencies") ); console.error( chalk.gray(" • Make sure typescript is listed in devDependencies") ); throw new Error("TypeScript is required but not available"); } console.log(chalk.green("āœ… Prerequisites validated\n")); } /** * Check if a valid TypeScript entry point exists */ private validateEntryPointExists(): boolean { try { this.resolveEntryPoint(); return true; } catch (error) { return false; } } /** * Check if backend service is healthy and available */ private async checkBackendHealth(): Promise<void> { console.log(chalk.yellow("šŸ” Checking backend service health...")); try { const healthUrl = `${this.options.backend}/health`; const response = await axios.get(healthUrl, { timeout: 10000, // 10 seconds timeout headers: { "User-Agent": "tensorify-cli", }, }); if (response.status === 200) { console.log(chalk.green("āœ… Backend service is healthy\n")); return; } // If we get here, the service responded but not with 200 throw new Error(`Backend service returned status: ${response.status}`); } catch (error) { console.error(chalk.red("āŒ Backend service is not available")); if (axios.isAxiosError(error)) { if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") { console.error( chalk.yellow( "šŸ”§ Tensorify servers are currently under maintenance to provide you with a smoother experience. Please try again in a few hours." ) ); } else if (error.response?.status === 503) { console.error( chalk.yellow( "šŸ”§ Tensorify servers are currently under maintenance to provide you with a smoother experience. Please try again in a few hours." ) ); } else { console.error(chalk.red(`Network error: ${error.message}`)); } } else { console.error( chalk.red( `Health check failed: ${ error instanceof Error ? error.message : error }` ) ); } console.error(chalk.gray("\nšŸ’” Tips:")); console.error(chalk.gray(" • Check your internet connection")); console.error(chalk.gray(" • Verify the backend URL is correct")); console.error(chalk.gray(" • Try again in a few minutes")); console.error( chalk.gray(` • Current backend URL: ${this.options.backend}\n`) ); throw new Error( "Backend service is not available. Please try again later." ); } } /** * Dynamically load and instantiate the plugin to extract configuration */ private async loadPluginInstance( packageJson: PackageJson, entrypointClassName: string ): Promise<any | null> { try { console.log(chalk.blue("šŸ” Attempting to load plugin dynamically...")); // Always build first to ensure we have the latest version console.log( chalk.yellow("šŸ”Ø Building plugin to extract configuration...") ); await this.buildPlugin(); // Determine the built file path // If main points to src/, convert to dist/, otherwise use as-is let builtFile = packageJson.main || "dist/index.js"; if (builtFile.startsWith("src/") && builtFile.endsWith(".ts")) { // Convert src/index.ts -> dist/index.js builtFile = builtFile .replace(/^src\//, "dist/") .replace(/\.ts$/, ".js"); } else if (builtFile.endsWith(".ts")) { // Convert any .ts to .js builtFile = builtFile.replace(/\.ts$/, ".js"); } const pluginPath = path.join(this.directory, builtFile); console.log(chalk.gray(`šŸ“‚ Looking for plugin at: ${pluginPath}`)); // Verify the built file exists after building if (!fs.existsSync(pluginPath)) { console.warn(chalk.yellow(`āš ļø Built file not found at ${pluginPath}`)); // Try alternative locations const alternatives = [ "dist/index.js", "lib/index.js", "build/index.js", ]; for (const alt of alternatives) { const altPath = path.join(this.directory, alt); if (fs.existsSync(altPath)) { console.log( chalk.yellow(`šŸ“‚ Found plugin at alternative location: ${alt}`) ); builtFile = alt; break; } } if (!fs.existsSync(path.join(this.directory, builtFile))) { console.warn( chalk.yellow("āš ļø No built file found in common locations") ); return null; } } // Dynamically import the plugin class const absolutePath = path.resolve(this.directory, builtFile); console.log(chalk.gray(`šŸ“„ Importing from: ${absolutePath}`)); const pluginModule = await import(absolutePath); console.log( chalk.gray( `šŸ” Available exports: ${Object.keys(pluginModule).join(", ")}` ) ); // Try to find the plugin class with multiple strategies let PluginClass = pluginModule.default || pluginModule[entrypointClassName]; // If not found, try to auto-detect plugin classes if (!PluginClass) { console.log( chalk.yellow( `šŸ” Auto-detecting plugin class (${entrypointClassName} not found)...` ) ); // Look for any class that extends TensorifyPlugin or has getDefinition method const pluginCandidates = Object.values(pluginModule).filter( (exp: any) => typeof exp === "function" && exp.prototype && (exp.prototype.getDefinition || exp.prototype.getTranslationCode) ); if (pluginCandidates.length > 0) { PluginClass = pluginCandidates[0]; console.log( chalk.green(`āœ… Auto-detected plugin class: ${PluginClass.name}`) ); } } if (!PluginClass) { console.warn( chalk.yellow( `āš ļø Could not find plugin class "${entrypointClassName}" in ${builtFile}` ) ); console.warn( chalk.gray( `Available exports: ${Object.keys(pluginModule).join(", ")}` ) ); return null; } console.log( chalk.green( `āœ… Found plugin class: ${PluginClass.name || entrypointClassName}` ) ); // Instantiate the plugin const pluginInstance = new PluginClass(); if (!pluginInstance.getDefinition) { console.warn( chalk.yellow("āš ļø Plugin instance does not have getDefinition method") ); return null; } console.log(chalk.green("āœ… Plugin instance created successfully")); return pluginInstance; } catch (error) { console.warn( chalk.yellow( `āš ļø Failed to load plugin dynamically: ${error instanceof Error ? error.message : String(error)}` ) ); return null; } } /** * Build the plugin using TypeScript compiler */ private async buildPlugin(): Promise<void> { try { const buildCommand = this.packageJson.scripts?.build || "tsc"; execSync(buildCommand, { cwd: this.directory, stdio: "pipe", }); console.log(chalk.green("āœ… Plugin built successfully")); } catch (error) { throw new Error( `Failed to build plugin: ${error instanceof Error ? error.message : String(error)}` ); } } /** * Generate manifest.json content from package.json with dynamic plugin configuration */ private async generateManifestFromPackageJson( packageJson: PackageJson ): Promise<ManifestJson> { // Extract entrypoint class name from package.json or use default const tensorifySettings: any = packageJson["tensorify-settings"] || {}; const entrypointClassName = tensorifySettings.entrypointClassName || "TensorifyPlugin"; // Try to load the plugin dynamically to extract actual configuration const pluginInstance = await this.loadPluginInstance( packageJson, entrypointClassName ); let pluginDefinition = null; let dynamicConfig: { visual?: any; inputHandles?: any[]; outputHandles?: any[]; settingsFields?: any[]; settingsGroups?: any[]; capabilities?: any[]; requirements?: any; nodeType?: any; emits?: { variables?: any[]; imports?: any[] }; } = {}; if (pluginInstance) { try { console.log(chalk.blue("šŸ”„ Extracting plugin definition...")); pluginDefinition = pluginInstance.getDefinition(); console.log( chalk.green("āœ… Successfully extracted dynamic plugin configuration") ); console.log( chalk.gray( `šŸ“Š Plugin definition keys: ${Object.keys(pluginDefinition).join(", ")}` ) ); // Log specific configuration details if (pluginDefinition.inputHandles) { console.log( chalk.gray( `šŸ“„ Input handles: ${pluginDefinition.inputHandles.length} found` ) ); } if (pluginDefinition.outputHandles) { console.log( chalk.gray( `šŸ“¤ Output handles: ${pluginDefinition.outputHandles.length} found` ) ); } if (pluginDefinition.settingsFields) { console.log( chalk.gray( `āš™ļø Settings fields: ${pluginDefinition.settingsFields.length} found` ) ); } if (pluginDefinition.visual) { console.log( chalk.gray( `šŸŽØ Visual config: ${pluginDefinition.visual.icons?.primary?.value || "no icon"} icon` ) ); } dynamicConfig = { visual: pluginDefinition.visual, inputHandles: pluginDefinition.inputHandles, outputHandles: pluginDefinition.outputHandles, settingsFields: pluginDefinition.settingsFields, settingsGroups: pluginDefinition.settingsGroups, capabilities: pluginDefinition.capabilities, requirements: pluginDefinition.requirements, nodeType: pluginDefinition.nodeType, emits: pluginDefinition.emits || { variables: [], imports: [] }, }; console.log( chalk.green( `šŸŽÆ Dynamic config extracted with ${Object.keys(dynamicConfig).filter((k) => (dynamicConfig as any)[k]).length} properties` ) ); } catch (error) { console.warn( chalk.yellow( `āš ļø Failed to extract plugin definition: ${error instanceof Error ? error.message : String(error)}` ) ); } } else { console.warn( chalk.yellow( "āš ļø Plugin instance is null - falling back to static configuration" ) ); console.warn(chalk.gray(`šŸ’” This could be due to:`)); console.warn( chalk.gray( ` • Class name mismatch (expected: "${entrypointClassName}")` ) ); console.warn(chalk.gray(` • Build failure or missing dist files`)); console.warn( chalk.gray(` • Plugin not extending TensorifyPlugin properly`) ); } // Extract plugin type from multiple sources const tensorifyConfig: any = packageJson["tensorify"] || {}; const pluginTypeRaw = dynamicConfig.nodeType || tensorifySettings.pluginType || tensorifyConfig.pluginType; if (!pluginTypeRaw) { throw new Error( "Missing pluginType. Please set 'tensorify-settings.pluginType' in package.json (or return nodeType via getDefinition)." ); } // Normalize to contracts expected format (lowercase with underscores) // Support common aliases by converting camelCase to snake_case and lowercasing const lower = pluginTypeRaw.toString().trim(); const snake = lower // insert underscore between lowercase-to-uppercase boundaries .replace(/([a-z0-9])([A-Z])/g, "$1_$2") .replace(/\s+/g, "_") .toLowerCase(); const pluginType = snake as string; // Validate that the plugin type is a valid NodeType (use contracts source of truth) try { const { NodeTypeEnum } = await import("@tensorify.io/sdk/contracts"); const validNodeTypes = (NodeTypeEnum as any).options as string[]; if (!validNodeTypes.includes(pluginType)) { throw new Error( `Invalid pluginType "${pluginType}". Expected one of: ${validNodeTypes.join(", ")}` ); } } catch (e) { // Fallback to SDK enum if contracts import fails const validNodeTypes = Object.values(NodeType); if (!(validNodeTypes as any).includes(pluginType as any)) { throw new Error( `Invalid pluginType "${pluginType}". Expected one of: ${validNodeTypes.join(", ")}` ); } } // Create fallback configurations if dynamic loading failed const fallbackVisual = { containerType: "DEFAULT", size: { width: 240, height: 140 }, styling: { borderRadius: 8, borderWidth: 2, shadowLevel: 1, theme: "auto", }, icons: { primary: { type: "LUCIDE", value: "box" }, showIconBackground: true, }, labels: { title: packageJson.name.split("/")[1] || packageJson.name, showLabels: true, }, }; const fallbackInputHandles = [ { id: "input1", position: HandlePosition.LEFT, viewType: HandleViewType.DEFAULT, required: true, label: "Input 1", edgeType: EdgeType.DEFAULT, dataType: "any", description: "Primary input tensor", }, ]; const fallbackOutputHandles = [ { id: "output1", position: HandlePosition.RIGHT, viewType: HandleViewType.DEFAULT, label: "Output 1", edgeType: EdgeType.DEFAULT, dataType: "any", description: "Primary output tensor", }, ]; return { name: packageJson.name, version: packageJson.version, description: packageJson.description, author: packageJson.author, main: packageJson.main || "dist/index.js", entrypointClassName: entrypointClassName, keywords: packageJson.keywords || [], pluginType: pluginType as NodeType, tensorify: tensorifyConfig.pluginType ? { pluginType: tensorifyConfig.pluginType } : undefined, scripts: { build: packageJson.scripts?.build || "tsc", }, tensorifySettings: { sdkVersion: tensorifySettings["sdk-version"] || "latest", }, // Enhanced metadata for app.tensorify.io metadata: { repository: packageJson.repository?.url, license: packageJson.license, homepage: packageJson.homepage, bugs: packageJson.bugs, }, // Use dynamic configuration if available, otherwise fallback to static visual: dynamicConfig.visual || fallbackVisual, inputHandles: dynamicConfig.inputHandles || fallbackInputHandles, outputHandles: dynamicConfig.outputHandles || fallbackOutputHandles, settingsFields: dynamicConfig.settingsFields || [], settingsGroups: dynamicConfig.settingsGroups || [], capabilities: dynamicConfig.capabilities || [], requirements: dynamicConfig.requirements || { minSdkVersion: "1.0.0", dependencies: [], }, emits: { variables: (dynamicConfig.emits?.variables as any[]) || [], imports: (dynamicConfig.emits?.imports as any[]) || [], }, }; } /** * Check if user is authenticated and fetch profile */ private async checkAuthenticationAndFetchProfile(): Promise<void> { console.log( chalk.yellow("šŸ” Checking authentication and fetching user profile...") ); let token = await getAuthToken(); if (!token) { console.log( chalk.yellow("šŸ”‘ No authentication found. Starting login flow...") ); try { await authService.login(this.options.dev); token = await getAuthToken(); if (!token) { throw new Error("Authentication failed. Unable to retrieve token."); } console.log( chalk.green("āœ… Login successful! Continuing with publish...") ); } catch (error) { throw new Error( `Authentication failed: ${ error instanceof Error ? error.message : "Unknown error" }` ); } } this.authToken = token; try { // Check if using test token for integration tests if ( process.env.NODE_ENV === "development" && process.env.TENSORIFY_TEST_TOKEN ) { // Use test username for integration tests this.username = "testing-bot-tensorify-dev"; console.log( chalk.green(`āœ… Authenticated as: @${this.username} (test mode) `) ); } else { const userProfile = await authService.getUserProfile(this.options.dev); if (!userProfile || !userProfile.username) { // Assuming username field exists throw new Error("Could not retrieve username from user profile."); } this.username = userProfile.username; console.log( chalk.green(`āœ… Authenticated as: @${this.username} `) ); } } catch (error) { throw new Error( `Failed to fetch user profile: ${ error instanceof Error ? error.message : "Unknown error" }` ); } } /** * Validate plugin structure using SDK validation rules */ private async validatePluginStructure(): Promise<void> { console.log(chalk.yellow("šŸ” Validating plugin structure...")); // First, load package.json and generate manifest.json dynamically this.packageJson = JSON.parse( fs.readFileSync(path.join(this.directory, "package.json"), "utf-8") ); this.manifestJson = await this.generateManifestFromPackageJson( this.packageJson ); // Write the generated manifest.json to disk for validation const manifestPath = path.join(this.directory, "manifest.json"); fs.writeFileSync(manifestPath, JSON.stringify(this.manifestJson, null, 2)); console.log(chalk.green("šŸ“„ Generated manifest.json from package.json")); // Enforce prev/next handles using manifest + SDK contracts const errors: any[] = []; try { const { normalizeUiManifest } = await import( "@tensorify.io/sdk/contracts" ); const uiManifest = normalizeUiManifest({ name: this.manifestJson.name, version: this.manifestJson.version, description: this.manifestJson.description, author: this.manifestJson.author, main: this.manifestJson.main, entrypointClassName: this.manifestJson.entrypointClassName, keywords: this.manifestJson.keywords, pluginType: this.manifestJson.pluginType, frontendConfigs: { id: this.manifestJson.name, name: this.manifestJson.name, category: this.manifestJson.pluginType, nodeType: this.manifestJson.pluginType, visual: this.manifestJson.visual, inputHandles: this.manifestJson.inputHandles || [], outputHandles: this.manifestJson.outputHandles || [], settingsFields: this.manifestJson.settingsFields || [], settingsGroups: this.manifestJson.settingsGroups || [], }, capabilities: this.manifestJson.capabilities || [], requirements: this.manifestJson.requirements || {}, } as any); // Use result to avoid TS unused var if (!uiManifest) throw new Error("Manifest normalization failed"); } catch (e: any) { const message = e?.issues?.map((i: any) => i.message).join(", ") || e?.message; errors.push({ type: "schema_error", message }); } const validationResult = { isValid: errors.length === 0, errors, warnings: [], }; if (!validationResult.isValid) { this.displayValidationErrors(validationResult); throw new Error("Plugin validation failed. Please fix the errors above."); } // Extract keywords from manifest.json if available if ( this.manifestJson.keywords && Array.isArray(this.manifestJson.keywords) ) { this.keywords = this.manifestJson.keywords; console.log( chalk.green(` šŸ“š Keywords detected: ${this.keywords.join(", ")}`) ); } console.log(chalk.green("āœ… Plugin structure validated\n")); } /** * Display structured validation errors with detailed information */ private displayValidationErrors(validationResult: any): void { console.error(chalk.red("\nāŒ Plugin validation failed!\n")); // Group errors by file for better organization const errorsByFile = this.groupErrorsByFile(validationResult.errors); // Display errors grouped by file Object.entries(errorsByFile).forEach(([file, errors]) => { if (file === "unknown") { console.error(chalk.red("šŸ”§ General Errors:")); } else { console.error(chalk.red(`šŸ“„ Errors in ${chalk.bold(file)}:`)); } errors.forEach((error: any, index: number) => { this.displaySingleError(error, index + 1); }); console.error(""); // Empty line between file groups }); // Display summary const totalErrors = validationResult.errors.length; const fileCount = Object.keys(errorsByFile).length; console.error( chalk.red( `šŸ“Š Summary: ${chalk.bold(totalErrors)} error${ totalErrors !== 1 ? "s" : "" } found across ${chalk.bold(fileCount)} file${ fileCount !== 1 ? "s" : "" }\n` ) ); // Display helpful tips this.displayValidationTips(validationResult.errors); } /** * Group validation errors by file for better organization */ private groupErrorsByFile(errors: any[]): Record<string, any[]> { const grouped: Record<string, any[]> = {}; errors.forEach((error) => { const file = error.file || "unknown"; if (!grouped[file]) { grouped[file] = []; } grouped[file].push(error); }); return grouped; } /** * Display a single validation error with detailed formatting */ private displaySingleError(error: any, index: number): void { const errorIcon = this.getErrorIcon(error.type); const errorTypeColor = this.getErrorTypeColor(error.type); console.error( chalk.red( ` ${index}. ${errorIcon} ${errorTypeColor( `[${error.type.toUpperCase()}]` )} ${error.message}` ) ); // Display additional details if available if (error.details && Array.isArray(error.details)) { this.displayZodErrorDetails(error.details); } // Display file location if available if (error.file) { console.error(chalk.gray(` šŸ“ File: ${error.file}`)); } // Provide specific suggestions based on error type this.displayErrorSuggestions(error); } /** * Display detailed Zod validation errors with paths and expected values */ private displayZodErrorDetails(zodErrors: any[]): void { zodErrors.forEach((zodError, index) => { const path = zodError.path && zodError.path.length > 0 ? zodError.path.join(".") : "root"; console.error( chalk.gray(` └─ ${index + 1}. Path: ${chalk.cyan(path)}`) ); console.error(chalk.gray(` Issue: ${zodError.message}`)); if (zodError.received) { console.error( chalk.gray( ` Received: ${chalk.red(JSON.stringify(zodError.received))}` ) ); } if (zodError.expected) { console.error( chalk.gray(` Expected: ${chalk.green(zodError.expected)}`) ); } }); } /** * Get appropriate icon for error type */ private getErrorIcon(errorType: string): string { switch (errorType) { case "missing_file": return "šŸ“„"; case "invalid_content": return "šŸ”§"; case "schema_error": return "šŸ“‹"; case "interface_error": return "šŸ”—"; case "version_mismatch": return "šŸ”„"; default: return "āš ļø"; } } /** * Get appropriate color for error type */ private getErrorTypeColor(errorType: string): (text: string) => string { switch (errorType) { case "missing_file": return chalk.magenta; case "invalid_content": return chalk.yellow; case "schema_error": return chalk.red; case "interface_error": return chalk.blue; case "version_mismatch": return chalk.cyan; default: return chalk.red; } } /** * Display error-specific suggestions and fixes */ private displayErrorSuggestions(error: any): void { let suggestion = ""; switch (error.type) { case "missing_file": if (error.file === "package.json") { suggestion = "Create a package.json file with the required fields"; } else if (error.file === "manifest.json") { suggestion = "Create a manifest.json file with plugin metadata"; } else if (error.file === "index.ts") { suggestion = "Create an index.ts file with your plugin implementation"; } else if (error.file === "icon.svg") { suggestion = "Create an icon.svg file for your plugin icon"; } break; case "schema_error": if (error.file === "package.json") { suggestion = "Check package.json structure against Tensorify requirements"; } else if (error.file === "manifest.json") { suggestion = "Verify manifest.json fields match the required schema"; } break; case "interface_error": suggestion = "Ensure your class implements INode interface and matches manifest.json"; break; case "version_mismatch": suggestion = `Update SDK version to ${this.sdkVersion} in package.json`; break; case "invalid_content": suggestion = "Check file syntax and structure"; break; } if (suggestion) { console.error(chalk.gray(` šŸ’” Suggestion: ${suggestion}`)); } } /** * Display general validation tips and resources */ private displayValidationTips(errors: any[]): void { console.error(chalk.yellow("šŸ’” Need help fixing these errors?")); console.error( chalk.gray(" • Check the documentation: https://docs.tensorify.io") ); console.error( chalk.gray( " • Use 'npx create-tensorify-plugin' to create a valid template" ) ); console.error( chalk.gray( " • Ensure all required files exist and follow the correct schema" ) ); // Show specific tips based on error types present const errorTypes = [...new Set(errors.map((e) => e.type))]; if (errorTypes.includes("schema_error")) { console.error( chalk.gray( " • Schema errors: Double-check JSON syntax and required fields" ) ); } if (errorTypes.includes("version_mismatch")) { console.error( chalk.gray( ` • Version mismatch: Update to SDK version ${this.sdkVersion}` ) ); } if (errorTypes.includes("interface_error")) { console.error( chalk.gray( " • Interface errors: Ensure class name matches manifest.json" ) ); } } /** * Validate access level consistency */ private async validateAccessLevel(): Promise<void> { console.log(chalk.yellow("šŸ”’ Validating access level...")); const isPrivatePackage = this.packageJson.private === true; const requestedAccess = this.options.access; // Check consistency between package.json private flag and requested access if (requestedAccess === "public" && isPrivatePackage) { throw new Error( 'Cannot publish as public: package.json has "private": true. If the other versions were published as private then try with "--access=private" flag.' ); } if (requestedAccess === "private" && !isPrivatePackage) { throw new Error( 'Cannot publish as private: package.json has "private": false or not set' ); } // For public packages, ensure repository URL exists if (requestedAccess === "public" && !this.packageJson.repository?.url) { throw new Error( "Public plugins must have a repository URL in package.json" ); } console.log(chalk.green(`āœ… Access level validated: ${requestedAccess}\n`)); } /** * Check version conflicts with existing plugins */ private async checkVersionConflicts(): Promise<void> { console.log(chalk.yellow("šŸ”„ Checking version conflicts...")); try { const response = await axios.post( `${this.options.frontend}/api/plugins/version-check`, { slug: `${this.packageJson.name}:${this.packageJson.version}`, access: this.options.access, }, { headers: { Authorization: `Bearer ${this.authToken}`, "Content-Type": "application/json", }, } ); if (response.data.conflict) { throw new Error( `Version conflict: ${this.packageJson.name}@${this.packageJson.version} already exists with ${response.data.existingAccess} access` ); } if (response.data.accessMismatch) { throw new Error( `Access level mismatch: Previous versions were ${response.data.previousAccess}, but requesting ${this.options.access}` ); } console.log(chalk.green("āœ… No version conflicts found\n")); } catch (error) { if (axios.isAxiosError(error)) { throw new Error( `Version check failed: ${error.response?.data?.message}` ); } throw error; } } /** * Build and bundle the plugin */ private async buildAndBundle(): Promise<void> { console.log(chalk.yellow("šŸ”Ø Building and bundling plugin...")); const buildDir = path.join(this.directory, "dist"); const bundleFile = path.join(buildDir, "bundle.js"); // Create dist directory if (!fs.existsSync(buildDir)) { fs.mkdirSync(buildDir, { recursive: true }); } // Step 1: Run build script (TypeScript compilation) console.log(chalk.blue(" šŸ“¦ Running build script...")); try { execSync(this.packageJson.scripts.build, { cwd: this.directory, stdio: "pipe", }); console.log(chalk.green(" āœ… Build completed")); } catch (error) { throw new Error( `Build failed: ${ error instanceof Error ? error.message : "Unknown error" }` ); } // Step 2: Determine entry point intelligently const entryPoint = this.resolveEntryPoint(); console.log( chalk.blue(` šŸ“¦ Creating bundle from entry point: ${entryPoint}`) ); try { await build({ entryPoints: [entryPoint], bundle: true, outfile: bundleFile, format: "iife", globalName: "PluginBundle", target: "es2020", external: ["fs", "path", "crypto", "os", "util", "stream", "events"], // Keep external minify: false, keepNames: true, sourcemap: false, treeShaking: false, packages: "bundle", platform: "neutral", define: { "process.env.NODE_ENV": '"production"', global: "globalThis", process: "{}", }, banner: { js: ` // Polyfills for Node.js built-ins in isolated-vm const polyfills = { fs: { existsSync: () => false, readFileSync: () => '', writeFileSync: () => {}, mkdirSync: () => {}, statSync: () => ({ isDirectory: () => false, isFile: () => false }) }, path: { join: (...args) => args.filter(Boolean).join('/'), resolve: (...args) => args.filter(Boolean).join('/'), dirname: (p) => p.split('/').slice(0, -1).join('/') || '/', basename: (p) => p.split('/').pop() || '', extname: (p) => { const parts = p.split('.'); return parts.length > 1 ? '.' + parts.pop() : ''; } }, crypto: { createHash: () => ({ update: () => ({}), digest: () => 'mock-hash' }) }, os: { platform: () => 'neutral', tmpdir: () => '/tmp' }, util: { promisify: (fn) => fn }, stream: {}, events: { EventEmitter: class EventEmitter { on() {} emit() {} removeListener() {} } } }; // Override require to use polyfills globalThis.require = (id) => { if (polyfills[id]) return polyfills[id]; throw new Error('Module not found: ' + id); }; // Also set them on globalThis for direct access Object.assign(globalThis, polyfills); `.trim(), }, }); console.log(chalk.green(" āœ… Bundle created")); } catch (error) { throw new Error( `Bundling failed: ${ error instanceof Error ? error.message : "Unknown error" }` ); } console.log(chalk.green("āœ… Build and bundle completed\n")); } /** * Intelligently resolve the entry point for bundling */ private resolveEntryPoint(): string { // Try multiple strategies to find the entry point // Strategy 1: Check if there's already a built main file and trace it back to TypeScript const mainField = this.packageJson.main; if (mainField) { const mainPath = path.resolve(this.directory, mainField); // Try to find the TypeScript source equivalent // e.g., "dist/index.js" -> "src/index.ts" const possibleTsFiles = [ // If main is dist/index.js, try src/index.ts mainField.replace(/^dist\//, "src/").replace(/\.js$/, ".ts"), // If main is lib/index.js, try src/index.ts mainField.replace(/^lib\//, "src/").replace(/\.js$/, ".ts"), // Direct replacement .js -> .ts mainField.replace(/\.js$/, ".ts"), // Try adding .ts extension `${mainField}.ts`, ]; for (const tsFile of possibleTsFiles) { const tsPath = path.resolve(this.directory, tsFile); if (fs.existsSync(tsPath)) { return tsPath; } } // If main file exists as built JS, use it directly if (fs.existsSync(mainPath)) { return mainPath; } } // Strategy 2: Look for common TypeScript entry points const commonEntryPoints = [ "src/index.ts", "index.ts", "src/main.ts", "main.ts", ]; for (const entryPoint of commonEntryPoints) { const entryPath = path.resolve(this.directory, entryPoint); if (fs.existsSync(entryPath)) { return entryPath; } } // Strategy 3: Look for any TypeScript file in src directory const srcDir = path.join(this.directory, "src"); if (fs.existsSync(srcDir)) { const tsFiles = fs .readdirSync(srcDir) .filter((file) => file.endsWith(".ts")); if (tsFiles.length > 0) { const firstTsFile = path.join(srcDir, tsFiles[0]); console.log( chalk.yellow(` āš ļø Using first TypeScript file found: ${tsFiles[0]}`) ); return firstTsFile; } } // Fallback: throw descriptive error throw new Er