@juspay/neurolink 11.22.0 → 11.22.1

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/CHANGELOG.md CHANGED
@@ -1,8 +1,8 @@
1
- ## [11.22.0](https://github.com/juspay/neurolink/compare/v11.21.4...v11.22.0) (2026-08-23)
1
+ ## [11.22.1](https://github.com/juspay/neurolink/compare/v11.22.0...v11.22.1) (2026-08-23)
2
2
 
3
- ### Features
3
+ ### Bug Fixes
4
4
 
5
- - **(cli):** add a usage command for token spend from CLI session logs ([bc60a82](https://github.com/juspay/neurolink/commit/bc60a82fc614bbd5bb82831cb60084e60e88776c))
5
+ - **(cli):** bound the synchronous ollama and scanner subprocesses ([51109e3](https://github.com/juspay/neurolink/commit/51109e3eeb3a6665eb840cc7a3aa561bb115d4b7))
6
6
 
7
7
  ## [11.2.3](https://github.com/juspay/neurolink/compare/v11.2.2...v11.2.3) (2026-08-19)
8
8
 
@@ -29,10 +29,16 @@ export function addOllamaCommands(cli) {
29
29
  .demandCommand(1, "Please specify a command");
30
30
  }, () => { });
31
31
  }
32
+ /** See the note on the identically-named constant in ../utils/ollamaUtils.ts. */
33
+ const OLLAMA_QUERY_TIMEOUT_MS = 15_000;
32
34
  async function listModelsHandler() {
33
35
  const spinner = ora("Fetching installed models...").start();
34
36
  try {
35
- const res = spawnSync("ollama", ["list"], { encoding: "utf8" });
37
+ const res = spawnSync("ollama", ["list"], {
38
+ encoding: "utf8",
39
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
40
+ killSignal: "SIGKILL",
41
+ });
36
42
  if (res.error) {
37
43
  throw res.error;
38
44
  }
@@ -58,6 +64,9 @@ async function pullModelHandler(argv) {
58
64
  logger.always(chalk.blue(`Downloading model: ${model}`));
59
65
  logger.always(chalk.gray("This may take several minutes..."));
60
66
  try {
67
+ // Deliberately unbounded: a model pull is hundreds of megabytes and
68
+ // legitimately runs for many minutes. It also inherits stdio, so the user
69
+ // sees progress and can Ctrl-C — the two things a wedged query lacks.
61
70
  const res = spawnSync("ollama", ["pull", model], { stdio: "inherit" });
62
71
  if (res.error) {
63
72
  throw res.error;
@@ -91,7 +100,11 @@ async function removeModelHandler(argv) {
91
100
  }
92
101
  const spinner = ora(`Removing model ${model}...`).start();
93
102
  try {
94
- const res = spawnSync("ollama", ["rm", model], { encoding: "utf8" });
103
+ const res = spawnSync("ollama", ["rm", model], {
104
+ encoding: "utf8",
105
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
106
+ killSignal: "SIGKILL",
107
+ });
95
108
  if (res.error) {
96
109
  throw res.error;
97
110
  }
@@ -110,7 +123,11 @@ async function removeModelHandler(argv) {
110
123
  async function statusHandler() {
111
124
  const spinner = ora("Checking Ollama service status...").start();
112
125
  try {
113
- const res = spawnSync("ollama", ["list"], { encoding: "utf8" });
126
+ const res = spawnSync("ollama", ["list"], {
127
+ encoding: "utf8",
128
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
129
+ killSignal: "SIGKILL",
130
+ });
114
131
  if (res.error) {
115
132
  throw res.error;
116
133
  }
@@ -134,22 +151,42 @@ async function stopHandler() {
134
151
  try {
135
152
  if (process.platform === "darwin") {
136
153
  try {
137
- spawnSync("pkill", ["ollama"], { encoding: "utf8" });
154
+ spawnSync("pkill", ["ollama"], {
155
+ encoding: "utf8",
156
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
157
+ killSignal: "SIGKILL",
158
+ });
138
159
  }
139
160
  catch {
140
- spawnSync("killall", ["Ollama"], { encoding: "utf8" });
161
+ spawnSync("killall", ["Ollama"], {
162
+ encoding: "utf8",
163
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
164
+ killSignal: "SIGKILL",
165
+ });
141
166
  }
142
167
  }
143
168
  else if (process.platform === "linux") {
144
169
  try {
145
- spawnSync("systemctl", ["stop", "ollama"], { encoding: "utf8" });
170
+ spawnSync("systemctl", ["stop", "ollama"], {
171
+ encoding: "utf8",
172
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
173
+ killSignal: "SIGKILL",
174
+ });
146
175
  }
147
176
  catch {
148
- spawnSync("pkill", ["ollama"], { encoding: "utf8" });
177
+ spawnSync("pkill", ["ollama"], {
178
+ encoding: "utf8",
179
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
180
+ killSignal: "SIGKILL",
181
+ });
149
182
  }
150
183
  }
151
184
  else {
152
- spawnSync("taskkill", ["/F", "/IM", "ollama.exe"], { encoding: "utf8" });
185
+ spawnSync("taskkill", ["/F", "/IM", "ollama.exe"], {
186
+ encoding: "utf8",
187
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
188
+ killSignal: "SIGKILL",
189
+ });
153
190
  }
154
191
  spinner.succeed("Ollama service stopped");
155
192
  }
@@ -165,7 +202,11 @@ async function setupHandler() {
165
202
  const checkSpinner = ora("Checking Ollama installation...").start();
166
203
  let isInstalled = false;
167
204
  try {
168
- spawnSync("ollama", ["--version"], { encoding: "utf8" });
205
+ spawnSync("ollama", ["--version"], {
206
+ encoding: "utf8",
207
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
208
+ killSignal: "SIGKILL",
209
+ });
169
210
  isInstalled = true;
170
211
  checkSpinner.succeed("Ollama is installed");
171
212
  }
@@ -204,7 +245,11 @@ async function setupHandler() {
204
245
  // Check if service is running
205
246
  let serviceRunning = false;
206
247
  try {
207
- spawnSync("ollama", ["list"], { encoding: "utf8" });
248
+ spawnSync("ollama", ["list"], {
249
+ encoding: "utf8",
250
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
251
+ killSignal: "SIGKILL",
252
+ });
208
253
  serviceRunning = true;
209
254
  logger.always(chalk.green("\n✅ Ollama service is running"));
210
255
  }
@@ -1,7 +1,4 @@
1
1
  import type { CommandModule } from "yargs";
2
- /**
3
- * Factory for creating Ollama CLI commands using the Factory Pattern
4
- */
5
2
  export declare class OllamaCommandFactory {
6
3
  /**
7
4
  * Secure wrapper around spawnSync to prevent command injection.
@@ -10,6 +7,17 @@ export declare class OllamaCommandFactory {
10
7
  /**
11
8
  * Create the Ollama command group
12
9
  */
10
+ /**
11
+ * Every handler is registered `.bind(this)`.
12
+ *
13
+ * yargs invokes the handler as a plain function, so a bare `this.xHandler`
14
+ * reference arrives with `this` unset and the first `this.safeSpawn(...)`
15
+ * throws `TypeError: this.safeSpawn is not a function`. That made EVERY
16
+ * subcommand here fail on its first line — list-models, pull, remove, status
17
+ * and stop all reported their generic "Failed to ..." message regardless of
18
+ * whether Ollama was installed or running, which is exactly why the real
19
+ * cause stayed hidden.
20
+ */
13
21
  static createOllamaCommands(): CommandModule;
14
22
  /**
15
23
  * Handler for listing installed models
@@ -9,6 +9,17 @@ import { AIProviderName } from "../../types/index.js";
9
9
  /**
10
10
  * Factory for creating Ollama CLI commands using the Factory Pattern
11
11
  */
12
+ /** See the note on the identically-named constant in cli/utils/ollamaUtils.ts. */
13
+ const OLLAMA_QUERY_TIMEOUT_MS = 15_000;
14
+ /**
15
+ * Explicit opt-out from the query bound, for commands that are long by design.
16
+ *
17
+ * `spawnSync` treats 0 as "no timeout" (verified: a 3s child under
18
+ * `timeout: 0` runs to completion with no error, while `timeout: 1000` returns
19
+ * at 1.0s with ETIMEDOUT). Named rather than written as a bare 0 at the call
20
+ * site, because `timeout: 0` reads like "expire immediately".
21
+ */
22
+ const OLLAMA_NO_TIMEOUT = 0;
12
23
  export class OllamaCommandFactory {
13
24
  /**
14
25
  * Secure wrapper around spawnSync to prevent command injection.
@@ -16,6 +27,12 @@ export class OllamaCommandFactory {
16
27
  static safeSpawn(command, args, options = {}) {
17
28
  // Command validation is now handled by TypeScript with AllowedCommand type
18
29
  const defaultOptions = {
30
+ // spawnSync blocks the event loop, so an unresponsive daemon hangs the
31
+ // whole CLI with no output and no error. SIGKILL because spawnSync's
32
+ // timeout otherwise sends SIGTERM and keeps waiting. Callers with
33
+ // legitimately long commands override via `options`.
34
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
35
+ killSignal: "SIGKILL",
19
36
  ...options,
20
37
  encoding: "utf8", // Always enforce utf8 encoding
21
38
  };
@@ -24,31 +41,42 @@ export class OllamaCommandFactory {
24
41
  /**
25
42
  * Create the Ollama command group
26
43
  */
44
+ /**
45
+ * Every handler is registered `.bind(this)`.
46
+ *
47
+ * yargs invokes the handler as a plain function, so a bare `this.xHandler`
48
+ * reference arrives with `this` unset and the first `this.safeSpawn(...)`
49
+ * throws `TypeError: this.safeSpawn is not a function`. That made EVERY
50
+ * subcommand here fail on its first line — list-models, pull, remove, status
51
+ * and stop all reported their generic "Failed to ..." message regardless of
52
+ * whether Ollama was installed or running, which is exactly why the real
53
+ * cause stayed hidden.
54
+ */
27
55
  static createOllamaCommands() {
28
56
  return {
29
57
  command: "ollama <command>",
30
58
  describe: "Manage Ollama local AI models",
31
59
  builder: (yargs) => {
32
60
  return yargs
33
- .command("list-models", "List installed Ollama models", {}, this.listModelsHandler)
61
+ .command("list-models", "List installed Ollama models", {}, this.listModelsHandler.bind(this))
34
62
  .command("pull <model>", "Download an Ollama model", {
35
63
  model: {
36
64
  describe: "Model name to download",
37
65
  type: "string",
38
66
  demandOption: true,
39
67
  },
40
- }, this.pullModelHandler)
68
+ }, this.pullModelHandler.bind(this))
41
69
  .command("remove <model>", "Remove an Ollama model", {
42
70
  model: {
43
71
  describe: "Model name to remove",
44
72
  type: "string",
45
73
  demandOption: true,
46
74
  },
47
- }, this.removeModelHandler)
48
- .command("status", "Check Ollama service status", {}, this.statusHandler)
49
- .command("start", "Start Ollama service", {}, this.startHandler)
50
- .command("stop", "Stop Ollama service", {}, this.stopHandler)
51
- .command("setup", "Interactive Ollama setup", {}, this.setupHandler)
75
+ }, this.removeModelHandler.bind(this))
76
+ .command("status", "Check Ollama service status", {}, this.statusHandler.bind(this))
77
+ .command("start", "Start Ollama service", {}, this.startHandler.bind(this))
78
+ .command("stop", "Stop Ollama service", {}, this.stopHandler.bind(this))
79
+ .command("setup", "Interactive Ollama setup", {}, this.setupHandler.bind(this))
52
80
  .demandCommand(1, "Please specify a command");
53
81
  },
54
82
  handler: () => { }, // No-op handler as subcommands handle everything
@@ -89,8 +117,16 @@ export class OllamaCommandFactory {
89
117
  logger.always(chalk.blue(`Downloading model: ${model}`));
90
118
  logger.always(chalk.gray("This may take several minutes..."));
91
119
  try {
120
+ // A model pull is hundreds of megabytes and legitimately runs for many
121
+ // minutes, so it MUST opt out of safeSpawn's default query bound.
122
+ // Passing only `stdio` here is what made the first revision of this
123
+ // change kill downloads at 15s: the wrapper spreads `options` over its
124
+ // defaults, so an unmentioned `timeout` keeps the default rather than
125
+ // being absent. It inherits stdio, so the user sees progress and can
126
+ // interrupt it — the two things a wedged query lacks.
92
127
  const res = this.safeSpawn("ollama", ["pull", model], {
93
128
  stdio: "inherit",
129
+ timeout: OLLAMA_NO_TIMEOUT,
94
130
  });
95
131
  if (res.error || res.status !== 0) {
96
132
  throw res.error || new Error("pull failed");
@@ -211,25 +247,36 @@ export class OllamaCommandFactory {
211
247
  */
212
248
  static async stopHandler() {
213
249
  const spinner = ora("Stopping Ollama service...").start();
250
+ // `safeSpawn` RETURNS a result; it does not throw on a failed command. So
251
+ // the try/catch fallbacks that used to sit here never fired — `killall`
252
+ // and the pkill fallback were unreachable, and the handler reported
253
+ // "stopped" whatever happened. Bounding these calls made that worse rather
254
+ // than better: a timeout now yields `error: ETIMEDOUT` with `status: null`,
255
+ // which is still not a throw, so a wedged kill would be reported as a
256
+ // successful stop while Ollama stayed up.
257
+ //
258
+ // Branch on the result instead, which both restores the fallback and makes
259
+ // the success message conditional on something actually succeeding.
260
+ const ok = (r) => !r.error && r.status === 0;
214
261
  try {
262
+ let stopped;
215
263
  if (process.platform === "darwin") {
216
- try {
217
- this.safeSpawn("pkill", ["ollama"]);
218
- }
219
- catch {
220
- this.safeSpawn("killall", ["Ollama"]);
221
- }
264
+ stopped =
265
+ ok(this.safeSpawn("pkill", ["ollama"])) ||
266
+ ok(this.safeSpawn("killall", ["Ollama"]));
222
267
  }
223
268
  else if (process.platform === "linux") {
224
- try {
225
- this.safeSpawn("systemctl", ["stop", "ollama"]);
226
- }
227
- catch {
228
- this.safeSpawn("pkill", ["ollama"]);
229
- }
269
+ stopped =
270
+ ok(this.safeSpawn("systemctl", ["stop", "ollama"])) ||
271
+ ok(this.safeSpawn("pkill", ["ollama"]));
230
272
  }
231
273
  else {
232
- this.safeSpawn("taskkill", ["/F", "/IM", "ollama.exe"]);
274
+ stopped = ok(this.safeSpawn("taskkill", ["/F", "/IM", "ollama.exe"]));
275
+ }
276
+ if (!stopped) {
277
+ spinner.fail("Could not confirm Ollama service stopped");
278
+ logger.error(chalk.red("No stop command succeeded — it may not have been running, or it may still be up. Check with: ollama list"));
279
+ return;
233
280
  }
234
281
  spinner.succeed("Ollama service stopped");
235
282
  }
@@ -8,6 +8,15 @@ export declare class OllamaUtils {
8
8
  * Secure wrapper around spawnSync to prevent command injection.
9
9
  */
10
10
  static safeSpawn(command: AllowedCommand, args: string[], options?: SpawnSyncOptions): SpawnSyncReturns<string>;
11
+ /**
12
+ * Whether a `safeSpawn` call actually succeeded.
13
+ *
14
+ * `spawnSync` reports failure by RETURNING — `error` set (ENOENT, ETIMEDOUT)
15
+ * or a non-zero `status` — never by throwing. Every `try/catch` around one of
16
+ * these calls was therefore dead code, which is why several fallbacks in this
17
+ * file had never run.
18
+ */
19
+ private static spawnSucceeded;
11
20
  /**
12
21
  * Check if Ollama command line is available
13
22
  */
@@ -2,6 +2,32 @@ import { spawnSync, spawn, } from "child_process";
2
2
  import chalk from "chalk";
3
3
  import ora from "ora";
4
4
  import { logger } from "../../utils/logger.js";
5
+ /**
6
+ * Ceiling for a synchronous Ollama query (`list`, `--version`, `rm`, …).
7
+ *
8
+ * `spawnSync` blocks the event loop, so an unresponsive daemon does not make
9
+ * the CLI slow — it makes it unkillable by anything short of Ctrl-C, with no
10
+ * output and no error. These calls are local IPC that normally answer in
11
+ * milliseconds, so a ceiling this generous only ever fires on a genuine wedge.
12
+ *
13
+ * `killSignal: "SIGKILL"` is not decoration. `spawnSync`'s `timeout` sends
14
+ * SIGTERM by default and then keeps waiting, so a child that ignores SIGTERM
15
+ * hangs forever anyway and the timeout buys nothing.
16
+ *
17
+ * Long-running commands (`ollama pull`) must pass their own `timeout` — the
18
+ * spread below lets a caller override this — because a model download
19
+ * legitimately runs for many minutes.
20
+ */
21
+ const OLLAMA_QUERY_TIMEOUT_MS = 15_000;
22
+ /**
23
+ * Ceiling for the whole readiness loop, not one probe inside it.
24
+ *
25
+ * Ollama normally answers within seconds of starting; a wait longer than this
26
+ * means it is not coming up, and continuing to spin helps nobody. Sized to
27
+ * outlast a slow cold start while staying far below the ~15 minutes that 30
28
+ * attempts of bounded probes could otherwise reach.
29
+ */
30
+ const READINESS_DEADLINE_MS = 90_000;
5
31
  /**
6
32
  * Shared Ollama utilities for CLI commands
7
33
  */
@@ -11,11 +37,24 @@ export class OllamaUtils {
11
37
  */
12
38
  static safeSpawn(command, args, options = {}) {
13
39
  const defaultOptions = {
40
+ timeout: OLLAMA_QUERY_TIMEOUT_MS,
41
+ killSignal: "SIGKILL",
14
42
  ...options,
15
43
  encoding: "utf8", // Always enforce utf8 encoding
16
44
  };
17
45
  return spawnSync(command, args, defaultOptions);
18
46
  }
47
+ /**
48
+ * Whether a `safeSpawn` call actually succeeded.
49
+ *
50
+ * `spawnSync` reports failure by RETURNING — `error` set (ENOENT, ETIMEDOUT)
51
+ * or a non-zero `status` — never by throwing. Every `try/catch` around one of
52
+ * these calls was therefore dead code, which is why several fallbacks in this
53
+ * file had never run.
54
+ */
55
+ static spawnSucceeded(result) {
56
+ return !result.error && result.status === 0;
57
+ }
19
58
  /**
20
59
  * Check if Ollama command line is available
21
60
  */
@@ -70,7 +109,16 @@ export class OllamaUtils {
70
109
  */
71
110
  static async waitForOllamaReady(maxAttempts = 30, initialDelay = 500) {
72
111
  let delay = initialDelay;
112
+ // A per-call bound alone is not enough here, and adding one made this
113
+ // worse before it made it better: each attempt can now burn the full
114
+ // command bound plus the API bound, so 30 attempts is up to ~15 minutes of
115
+ // spinner rather than the seconds this loop was written to take. The
116
+ // per-attempt bound stops one wedged call; this stops the loop around it.
117
+ const deadline = Date.now() + READINESS_DEADLINE_MS;
73
118
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
119
+ if (Date.now() >= deadline) {
120
+ return false;
121
+ }
74
122
  try {
75
123
  // Try command line check first
76
124
  if (!this.isOllamaCommandReady()) {
@@ -117,11 +165,17 @@ export class OllamaUtils {
117
165
  try {
118
166
  if (process.platform === "darwin") {
119
167
  logger.always(chalk.gray("Starting Ollama on macOS..."));
120
- try {
121
- this.safeSpawn("open", ["-a", "Ollama"]);
168
+ // `safeSpawn` RETURNS a result; it does not throw on a failed command,
169
+ // so this catch only ever fired on a programming error and the
170
+ // `ollama serve` fallback beneath it was effectively unreachable.
171
+ // Bounding these calls made that worse: a timeout yields
172
+ // `error: ETIMEDOUT` with `status: null`, still not a throw, so a
173
+ // wedged launcher would have been reported as a successful start.
174
+ // Branch on the result so the fallback actually runs.
175
+ if (this.spawnSucceeded(this.safeSpawn("open", ["-a", "Ollama"]))) {
122
176
  logger.always(chalk.green("✅ Ollama app started"));
123
177
  }
124
- catch {
178
+ else {
125
179
  const child = spawn("ollama", ["serve"], {
126
180
  stdio: "ignore",
127
181
  detached: true,
@@ -135,11 +189,10 @@ export class OllamaUtils {
135
189
  }
136
190
  else if (process.platform === "linux") {
137
191
  logger.always(chalk.gray("Starting Ollama service on Linux..."));
138
- try {
139
- this.safeSpawn("systemctl", ["start", "ollama"]);
192
+ if (this.spawnSucceeded(this.safeSpawn("systemctl", ["start", "ollama"]))) {
140
193
  logger.always(chalk.green("✅ Ollama service started"));
141
194
  }
142
- catch {
195
+ else {
143
196
  const child = spawn("ollama", ["serve"], {
144
197
  stdio: "ignore",
145
198
  detached: true,
@@ -156,11 +209,16 @@ export class OllamaUtils {
156
209
  // Security Note: Windows shell=true usage is intentional here for 'start' command.
157
210
  // Arguments are controlled internally (no user input) and safeSpawn validates command names.
158
211
  // This is safer than alternative Windows process creation methods for this specific use case.
159
- this.safeSpawn("start", ["ollama", "serve"], {
212
+ const started = this.safeSpawn("start", ["ollama", "serve"], {
160
213
  stdio: "ignore",
161
214
  shell: true,
162
215
  });
163
- logger.always(chalk.green("✅ Ollama service started"));
216
+ if (this.spawnSucceeded(started)) {
217
+ logger.always(chalk.green("✅ Ollama service started"));
218
+ }
219
+ else {
220
+ logger.always(chalk.yellow("⚠️ Could not confirm Ollama started — check with: ollama list"));
221
+ }
164
222
  }
165
223
  // Wait for service to become ready with readiness probe
166
224
  const readinessSpinner = ora("Waiting for Ollama service to be ready...").start();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "11.22.0",
3
+ "version": "11.22.1",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {