@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
JavaScript
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