UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

412 lines • 18.4 kB
import { spawnSync, } from "child_process"; import chalk from "chalk"; import ora from "ora"; import inquirer from "inquirer"; import { logger } from "../../utils/logger.js"; import { OllamaUtils } from "../utils/ollamaUtils.js"; import { getTopModelChoices } from "../../utils/modelChoices.js"; import { AIProviderName } from "../../types/index.js"; /** * Factory for creating Ollama CLI commands using the Factory Pattern */ /** See the note on the identically-named constant in cli/utils/ollamaUtils.ts. */ const OLLAMA_QUERY_TIMEOUT_MS = 15_000; /** * Explicit opt-out from the query bound, for commands that are long by design. * * `spawnSync` treats 0 as "no timeout" (verified: a 3s child under * `timeout: 0` runs to completion with no error, while `timeout: 1000` returns * at 1.0s with ETIMEDOUT). Named rather than written as a bare 0 at the call * site, because `timeout: 0` reads like "expire immediately". */ const OLLAMA_NO_TIMEOUT = 0; export class OllamaCommandFactory { /** * Secure wrapper around spawnSync to prevent command injection. */ static safeSpawn(command, args, options = {}) { // Command validation is now handled by TypeScript with AllowedCommand type const defaultOptions = { // spawnSync blocks the event loop, so an unresponsive daemon hangs the // whole CLI with no output and no error. SIGKILL because spawnSync's // timeout otherwise sends SIGTERM and keeps waiting. Callers with // legitimately long commands override via `options`. timeout: OLLAMA_QUERY_TIMEOUT_MS, killSignal: "SIGKILL", ...options, encoding: "utf8", // Always enforce utf8 encoding }; return spawnSync(command, args, defaultOptions); } /** * Create the Ollama command group */ /** * Every handler is registered `.bind(this)`. * * yargs invokes the handler as a plain function, so a bare `this.xHandler` * reference arrives with `this` unset and the first `this.safeSpawn(...)` * throws `TypeError: this.safeSpawn is not a function`. That made EVERY * subcommand here fail on its first line — list-models, pull, remove, status * and stop all reported their generic "Failed to ..." message regardless of * whether Ollama was installed or running, which is exactly why the real * cause stayed hidden. */ static createOllamaCommands() { return { command: "ollama <command>", describe: "Manage Ollama local AI models", builder: (yargs) => { return yargs .command("list-models", "List installed Ollama models", {}, this.listModelsHandler.bind(this)) .command("pull <model>", "Download an Ollama model", { model: { describe: "Model name to download", type: "string", demandOption: true, }, }, this.pullModelHandler.bind(this)) .command("remove <model>", "Remove an Ollama model", { model: { describe: "Model name to remove", type: "string", demandOption: true, }, }, this.removeModelHandler.bind(this)) .command("status", "Check Ollama service status", {}, this.statusHandler.bind(this)) .command("start", "Start Ollama service", {}, this.startHandler.bind(this)) .command("stop", "Stop Ollama service", {}, this.stopHandler.bind(this)) .command("setup", "Interactive Ollama setup", {}, this.setupHandler.bind(this)) .demandCommand(1, "Please specify a command"); }, handler: () => { }, // No-op handler as subcommands handle everything }; } /** * Handler for listing installed models */ static async listModelsHandler() { const spinner = ora("Fetching installed models...").start(); try { const res = this.safeSpawn("ollama", ["list"]); if (res.error || res.status !== 0) { throw res.error || new Error(res.stderr); } spinner.succeed("Installed models:"); const output = res.stdout.trim(); if (output) { logger.always(output); } else { logger.always(chalk.yellow('No models installed. Use "neurolink ollama pull <model>" to download a model.')); } } catch (error) { spinner.fail("Failed to list models. Is Ollama installed?"); const errorMessage = error instanceof Error ? error.message : String(error); logger.error(chalk.red("Error:", errorMessage)); logger.always(chalk.blue("\nTip: Install Ollama from https://ollama.ai")); process.exit(1); } } /** * Handler for pulling/downloading models */ static async pullModelHandler(argv) { const { model } = argv; logger.always(chalk.blue(`Downloading model: ${model}`)); logger.always(chalk.gray("This may take several minutes...")); try { // A model pull is hundreds of megabytes and legitimately runs for many // minutes, so it MUST opt out of safeSpawn's default query bound. // Passing only `stdio` here is what made the first revision of this // change kill downloads at 15s: the wrapper spreads `options` over its // defaults, so an unmentioned `timeout` keeps the default rather than // being absent. It inherits stdio, so the user sees progress and can // interrupt it — the two things a wedged query lacks. const res = this.safeSpawn("ollama", ["pull", model], { stdio: "inherit", timeout: OLLAMA_NO_TIMEOUT, }); if (res.error || res.status !== 0) { throw res.error || new Error("pull failed"); } logger.always(chalk.green(`\n✅ Successfully downloaded ${model}`)); logger.always(chalk.blue(`\nTest it with: npx @juspay/neurolink generate "Hello!" --provider ollama --model ${model}`)); } catch (error) { logger.error(chalk.red(`\n❌ Failed to download ${model}`)); const errorMessage = error instanceof Error ? error.message : String(error); logger.error(chalk.red("Error:", errorMessage)); process.exit(1); } } /** * Handler for removing models */ static async removeModelHandler(argv) { const { model } = argv; const { confirm } = await inquirer.prompt([ { type: "confirm", name: "confirm", message: `Are you sure you want to remove model "${model}"?`, default: false, }, ]); if (!confirm) { logger.always(chalk.yellow("Removal cancelled.")); return; } const spinner = ora(`Removing model ${model}...`).start(); try { const res = this.safeSpawn("ollama", ["rm", model]); if (res.error || res.status !== 0) { throw res.error || new Error(res.stderr); } spinner.succeed(`Successfully removed ${model}`); } catch (error) { spinner.fail(`Failed to remove ${model}`); const errorMessage = error instanceof Error ? error.message : String(error); logger.error(chalk.red("Error:", errorMessage)); process.exit(1); } } /** * Handler for checking Ollama service status */ static async statusHandler() { const spinner = ora("Checking Ollama service status...").start(); try { const res = this.safeSpawn("ollama", ["list"]); if (res.error || res.status !== 0) { throw res.error || new Error(res.stderr); } spinner.succeed("Ollama service is running"); // Attempt to get model list with retry logic for JSON parsing let modelsData = null; for (let attempt = 1; attempt <= 3; attempt++) { try { const curlRes = this.safeSpawn("curl", [ "-s", "http://localhost:11434/api/tags", ]); if (!curlRes.error && curlRes.status === 0 && curlRes.stdout.trim()) { try { modelsData = JSON.parse(curlRes.stdout); break; // Success, exit retry loop } catch (jsonError) { logger.debug(`JSON parse failed on attempt ${attempt}: ${jsonError}`); if (attempt < 3) { // Brief delay before retry await new Promise((resolve) => setTimeout(resolve, 500)); continue; } // Final attempt failed, log warning but don't throw logger.debug("Failed to parse Ollama API response after 3 attempts"); } } } catch (curlError) { logger.debug(`Curl failed on attempt ${attempt}: ${curlError}`); if (attempt < 3) { await new Promise((resolve) => setTimeout(resolve, 500)); } } } // Display model count if we got valid data if (modelsData?.models && modelsData.models.length > 0) { logger.always(chalk.green(`\n${modelsData.models.length} models available`)); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.debug("Error:", errorMessage); spinner.fail("Ollama service is not running"); logger.always(chalk.yellow("\nStart Ollama with: ollama serve")); logger.always(chalk.blue("Or restart the Ollama app if using the desktop version")); process.exit(1); } } /** * Handler for starting Ollama service */ static async startHandler() { try { await OllamaUtils.startOllamaService(); } catch (error) { logger.error("Failed to start Ollama service:", error); throw error; } } /** * Handler for stopping Ollama service */ static async stopHandler() { const spinner = ora("Stopping Ollama service...").start(); // `safeSpawn` RETURNS a result; it does not throw on a failed command. So // the try/catch fallbacks that used to sit here never fired — `killall` // and the pkill fallback were unreachable, and the handler reported // "stopped" whatever happened. Bounding these calls made that worse rather // than better: a timeout now yields `error: ETIMEDOUT` with `status: null`, // which is still not a throw, so a wedged kill would be reported as a // successful stop while Ollama stayed up. // // Branch on the result instead, which both restores the fallback and makes // the success message conditional on something actually succeeding. const ok = (r) => !r.error && r.status === 0; try { let stopped; if (process.platform === "darwin") { stopped = ok(this.safeSpawn("pkill", ["ollama"])) || ok(this.safeSpawn("killall", ["Ollama"])); } else if (process.platform === "linux") { stopped = ok(this.safeSpawn("systemctl", ["stop", "ollama"])) || ok(this.safeSpawn("pkill", ["ollama"])); } else { stopped = ok(this.safeSpawn("taskkill", ["/F", "/IM", "ollama.exe"])); } if (!stopped) { spinner.fail("Could not confirm Ollama service stopped"); logger.error(chalk.red("No stop command succeeded — it may not have been running, or it may still be up. Check with: ollama list")); return; } spinner.succeed("Ollama service stopped"); } catch (err) { spinner.fail("Failed to stop Ollama service"); logger.error(chalk.red("It may not be running or requires manual stop")); logger.error(chalk.red(`Error details: ${err}`)); } } /** * Handler for interactive Ollama setup */ static async setupHandler() { logger.always(chalk.blue("🦙 Welcome to Ollama Setup!\n")); const checkSpinner = ora("Checking Ollama installation...").start(); let isInstalled = false; try { const res = this.safeSpawn("ollama", ["--version"]); if (!res.error && res.status === 0) { isInstalled = true; checkSpinner.succeed("Ollama is installed"); } else { throw new Error(res.stderr); } } catch { checkSpinner.fail("Ollama is not installed"); } if (!isInstalled) { logger.always(chalk.yellow("\nOllama needs to be installed first.")); logger.always(chalk.blue("\nInstallation instructions:")); if (process.platform === "darwin") { logger.always("\nFor macOS:"); logger.always(chalk.gray(" brew install ollama")); logger.always(chalk.gray(" # or download from https://ollama.ai")); } else if (process.platform === "linux") { logger.always("\nFor Linux:"); logger.always(chalk.gray(" curl -fsSL https://ollama.ai/install.sh | sh")); } else { logger.always("\nFor Windows:"); logger.always(chalk.gray(" Download from https://ollama.ai")); } const { proceedAnyway } = await inquirer.prompt([ { type: "confirm", name: "proceedAnyway", message: "Would you like to continue with setup anyway?", default: false, }, ]); if (!proceedAnyway) { logger.always(chalk.blue("\nInstall Ollama and run setup again!")); return; } } let serviceRunning = false; try { const res = this.safeSpawn("ollama", ["list"]); if (!res.error && res.status === 0) { serviceRunning = true; logger.always(chalk.green("\n✅ Ollama service is running")); } } catch { logger.always(chalk.yellow("\n⚠️ Ollama service is not running")); const { startService } = await inquirer.prompt([ { type: "confirm", name: "startService", message: "Would you like to start the Ollama service?", default: true, }, ]); if (startService) { await this.startHandler(); serviceRunning = true; } } if (serviceRunning) { logger.always(chalk.blue("\n📦 Popular Ollama models:")); logger.always(" • llama2 (7B) - General purpose"); logger.always(" • codellama (7B) - Code generation"); logger.always(" • mistral (7B) - Fast and efficient"); logger.always(" • tinyllama (1B) - Lightweight"); logger.always(" • phi (2.7B) - Microsoft's compact model"); const { downloadModel } = await inquirer.prompt([ { type: "confirm", name: "downloadModel", message: "Would you like to download a model?", default: true, }, ]); if (downloadModel) { const { selectedModel } = await inquirer.prompt([ { type: "select", name: "selectedModel", message: "Select a model to download:", choices: getTopModelChoices(AIProviderName.OLLAMA, 5).map((choice) => choice.value === "custom" ? { name: "Other (enter manually)", value: "other" } : choice), }, ]); let modelToDownload = selectedModel; if (selectedModel === "other") { const { customModel } = await inquirer.prompt([ { type: "input", name: "customModel", message: "Enter the model name:", validate: (input) => input.trim().length > 0 || "Model name is required", }, ]); modelToDownload = customModel; } await this.pullModelHandler({ model: modelToDownload }); } } logger.always(chalk.green("\n✅ Setup complete!\n")); logger.always(chalk.blue("Next steps:")); logger.always("1. List models: " + chalk.gray("neurolink ollama list-models")); logger.always("2. Generate text: " + chalk.gray('neurolink generate "Hello!" --provider ollama')); logger.always("3. Use specific model: " + chalk.gray('neurolink generate "Hello!" --provider ollama --model codellama')); logger.always(chalk.gray("\nFor more information, see: https://docs.neurolink.ai/providers/ollama")); } } //# sourceMappingURL=ollamaCommandFactory.js.map