ollanet 0.6.23 → 0.7.0

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/dist/funnel.js ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tailscale Funnel for `ollanet serve --funnel`.
3
+ *
4
+ * Shells out to the `tailscale` CLI (override with OLLANET_TAILSCALE_BIN; a
5
+ * .js/.mjs/.cjs path runs under node). Every call has a timeout, and the exec
6
+ * functions are injectable so tests never touch a real tailnet.
7
+ */
8
+ import { spawn, spawnSync } from "node:child_process";
9
+ export const FUNNEL_PORTS = [443, 8443, 10000];
10
+ export const HTTPS_ADMIN_URL = "https://login.tailscale.com/admin/dns";
11
+ export const FUNNEL_DOCS_URL = "https://tailscale.com/kb/1223/funnel";
12
+ const ENABLE_URL_RE = /https:\/\/login\.tailscale\.com\/[^\s"'<>]+/;
13
+ function tailscaleCommand() {
14
+ const bin = process.env.OLLANET_TAILSCALE_BIN?.trim() || "tailscale";
15
+ if (/\.(c|m)?js$/i.test(bin))
16
+ return { cmd: process.execPath, prefix: [bin] };
17
+ return { cmd: bin, prefix: [] };
18
+ }
19
+ export const defaultExec = (args, opts) => new Promise((resolve) => {
20
+ const { cmd, prefix } = tailscaleCommand();
21
+ let stdout = "";
22
+ let stderr = "";
23
+ let timedOut = false;
24
+ let settled = false;
25
+ const child = spawn(cmd, [...prefix, ...args], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
26
+ const finish = (r) => {
27
+ if (settled)
28
+ return;
29
+ settled = true;
30
+ clearTimeout(timer);
31
+ resolve({ code: null, stdout, stderr, timedOut, missing: false, ...r });
32
+ };
33
+ const check = () => {
34
+ if (opts.until && opts.until.test(`${stdout}\n${stderr}`))
35
+ child.kill();
36
+ };
37
+ child.stdout.on("data", (d) => {
38
+ stdout += d;
39
+ check();
40
+ });
41
+ child.stderr.on("data", (d) => {
42
+ stderr += d;
43
+ check();
44
+ });
45
+ child.on("error", (err) => {
46
+ finish({ missing: err.code === "ENOENT", stderr: stderr || err.message });
47
+ });
48
+ child.on("close", (code) => finish({ code }));
49
+ const timer = setTimeout(() => {
50
+ timedOut = true;
51
+ child.kill();
52
+ }, opts.timeoutMs);
53
+ });
54
+ export const defaultExecSync = (args, opts) => {
55
+ const { cmd, prefix } = tailscaleCommand();
56
+ const r = spawnSync(cmd, [...prefix, ...args], {
57
+ encoding: "utf8",
58
+ timeout: opts.timeoutMs,
59
+ windowsHide: true,
60
+ });
61
+ const err = r.error;
62
+ return {
63
+ code: r.status,
64
+ stdout: r.stdout ?? "",
65
+ stderr: r.stderr ?? err?.message ?? "",
66
+ timedOut: err?.code === "ETIMEDOUT",
67
+ missing: err?.code === "ENOENT",
68
+ };
69
+ };
70
+ /** A Funnel setup problem with a message meant for the person at the terminal. */
71
+ export class FunnelError extends Error {
72
+ enableUrl;
73
+ constructor(message, enableUrl) {
74
+ super(message);
75
+ this.enableUrl = enableUrl;
76
+ }
77
+ }
78
+ function firstLine(text) {
79
+ return text.split(/\r?\n/).find((l) => l.trim())?.trim() ?? "";
80
+ }
81
+ export function hasFunnelCap(self) {
82
+ const keys = [...Object.keys(self?.CapMap ?? {}), ...(self?.Capabilities ?? [])];
83
+ return keys.some((k) => {
84
+ const key = k.toLowerCase();
85
+ return key === "funnel" || key.startsWith("https://tailscale.com/cap/funnel");
86
+ });
87
+ }
88
+ /** `tailscale status --json`: installed, running, MagicDNS name, HTTPS certs, Funnel attr. */
89
+ export async function preflight(exec = defaultExec) {
90
+ const r = await exec(["status", "--json"], { timeoutMs: 10_000 });
91
+ if (r.missing) {
92
+ throw new FunnelError("tailscale CLI not found. Install Tailscale (https://tailscale.com/download) or set OLLANET_TAILSCALE_BIN.");
93
+ }
94
+ if (r.timedOut)
95
+ throw new FunnelError("tailscale status --json timed out.");
96
+ let status;
97
+ try {
98
+ status = JSON.parse(r.stdout);
99
+ }
100
+ catch {
101
+ throw new FunnelError(`tailscale status --json failed${r.stderr ? `: ${firstLine(r.stderr)}` : ` (exit ${r.code})`}`);
102
+ }
103
+ if (status.BackendState !== "Running") {
104
+ throw new FunnelError(`Tailscale is not running (state: ${status.BackendState ?? "unknown"}). Run: tailscale up`);
105
+ }
106
+ const dnsName = (status.Self?.DNSName ?? "").replace(/\.$/, "");
107
+ if (!dnsName) {
108
+ throw new FunnelError(`This device has no MagicDNS name. Turn on MagicDNS at ${HTTPS_ADMIN_URL}, then retry.`);
109
+ }
110
+ if (!Array.isArray(status.CertDomains) || status.CertDomains.length === 0) {
111
+ throw new FunnelError(`HTTPS certificates are off for your tailnet. Turn on "HTTPS Certificates" at ${HTTPS_ADMIN_URL}, then retry.`);
112
+ }
113
+ return { dnsName, funnelAllowed: hasFunnelCap(status.Self) };
114
+ }
115
+ export function funnelOnArgs(funnelPort, target) {
116
+ return ["funnel", "--bg", `--https=${funnelPort}`, target];
117
+ }
118
+ export function funnelOffArgs(funnelPort) {
119
+ return ["funnel", `--https=${funnelPort}`, "off"];
120
+ }
121
+ export function publicUrl(dnsName, funnelPort) {
122
+ return `https://${dnsName}${funnelPort === 443 ? "" : `:${funnelPort}`}/`;
123
+ }
124
+ /**
125
+ * What `tailscale funnel status --json` serves at https://<dns>:<port>/.
126
+ * Returns the proxy target, null when nothing is configured there, or
127
+ * undefined when the status could not be read.
128
+ */
129
+ export async function currentHandler(exec, dnsName, funnelPort) {
130
+ const r = await exec(["funnel", "status", "--json"], { timeoutMs: 10_000 });
131
+ if (r.code !== 0)
132
+ return undefined;
133
+ let parsed;
134
+ try {
135
+ parsed = JSON.parse(r.stdout || "{}");
136
+ }
137
+ catch {
138
+ return undefined;
139
+ }
140
+ const web = parsed.Web?.[`${dnsName}:${funnelPort}`];
141
+ const root = web?.Handlers?.["/"];
142
+ if (!web || !root)
143
+ return null;
144
+ return root.Proxy ?? root.Path ?? root.Text ?? "(non-proxy handler)";
145
+ }
146
+ function isOurTarget(handler, localPort) {
147
+ const m = /^(?:https?:\/\/)?(127\.0\.0\.1|localhost|\[::1\])?:?(\d+)\/?$/i.exec(handler.trim());
148
+ return !!m && Number(m[2]) === localPort;
149
+ }
150
+ export async function startFunnel(opts) {
151
+ const exec = opts.exec ?? defaultExec;
152
+ const execSync = opts.execSync ?? defaultExecSync;
153
+ if (!FUNNEL_PORTS.includes(opts.funnelPort)) {
154
+ throw new FunnelError(`--funnel-port must be one of ${FUNNEL_PORTS.join(", ")} (got ${opts.funnelPort}).`);
155
+ }
156
+ const pre = await preflight(exec);
157
+ const url = publicUrl(pre.dnsName, opts.funnelPort);
158
+ const existing = await currentHandler(exec, pre.dnsName, opts.funnelPort);
159
+ if (existing && isOurTarget(existing, opts.localPort)) {
160
+ return { publicUrl: url, added: false, stop: () => { } };
161
+ }
162
+ if (existing) {
163
+ throw new FunnelError(`${url} already serves ${existing}. ollanet will not replace it. ` +
164
+ `Pick another --funnel-port (${FUNNEL_PORTS.join(", ")}) or remove it with: tailscale funnel --https=${opts.funnelPort} off`);
165
+ }
166
+ const r = await exec(funnelOnArgs(opts.funnelPort, opts.target), {
167
+ timeoutMs: opts.timeoutMs ?? 30_000,
168
+ until: /To enable, visit|login\.tailscale\.com\/f\/funnel/i,
169
+ });
170
+ const output = `${r.stdout}\n${r.stderr}`;
171
+ const enableUrl = ENABLE_URL_RE.exec(output)?.[0];
172
+ if (enableUrl || (!pre.funnelAllowed && r.code !== 0)) {
173
+ throw new FunnelError(enableUrl
174
+ ? `Funnel is not enabled for this device. Approve it at ${enableUrl} , then run this again.`
175
+ : `Funnel is not enabled for this device. Add the "funnel" node attribute in your tailnet policy (${FUNNEL_DOCS_URL}), then run this again.`, enableUrl);
176
+ }
177
+ if (r.missing)
178
+ throw new FunnelError("tailscale CLI not found.");
179
+ if (r.code !== 0) {
180
+ throw new FunnelError(r.timedOut
181
+ ? "tailscale funnel did not finish in time. Check tailscale funnel status."
182
+ : `tailscale funnel failed: ${firstLine(r.stderr) || firstLine(r.stdout) || `exit ${r.code}`}`);
183
+ }
184
+ let stopped = false;
185
+ return {
186
+ publicUrl: url,
187
+ added: true,
188
+ stop: () => {
189
+ if (stopped)
190
+ return;
191
+ stopped = true;
192
+ execSync(funnelOffArgs(opts.funnelPort), { timeoutMs: 10_000 });
193
+ },
194
+ };
195
+ }
package/dist/help.js CHANGED
@@ -13,6 +13,7 @@ const LOADERS = {
13
13
  bench: () => import("./bench.js"),
14
14
  alias: () => import("./alias.js"),
15
15
  mcp: () => import("./mcp.js"),
16
+ serve: () => import("./serve.js"),
16
17
  };
17
18
  const ALIASES = {
18
19
  "ls-servers": "scan",
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * const { servers } = await scanNetwork({ lanScan: false });
9
9
  */
10
10
  export { scanNetwork } from "./scan.js";
11
- export type { ScanOptions, ScanPayload, ScannedServer, ScanFailure } from "./scan.js";
11
+ export type { ScanOptions, ScanPayload, ScannedServer, ScanFailure, ScanFailureCode, } from "./scan.js";
12
12
  export { lastScan, lastScanPath, loadLastScan } from "./scan-store.js";
13
13
  export type { StoredScan } from "./scan-store.js";
14
14
  export { discoverHosts, foldLocalHosts, resolveHost, ollamaBaseUrl, shortName, ollamaPort, } from "./hosts.js";
package/dist/mcp.js CHANGED
@@ -22,7 +22,7 @@ import { removeModel } from "./rm.js";
22
22
  import { lastScan } from "./scan-store.js";
23
23
  import { scanNetwork } from "./scan.js";
24
24
  import { showModel } from "./show.js";
25
- import { isHelpFlag, printHelp } from "./argv.js";
25
+ import { isHelpFlag, isQuiet, printHelp } from "./argv.js";
26
26
  // Pin to the oldest widely-supported MCP revision. Clients may request a newer
27
27
  // date; we still answer with this — legal negotiation, max compatibility.
28
28
  const PROTOCOL_VERSION = "2024-11-05";
@@ -56,7 +56,7 @@ const TOOLS = [
56
56
  description: "Discover configured or selected Ollama hosts and list their models. " +
57
57
  "A responding host is reachable, not automatically trusted. " +
58
58
  "Optional LAN TCP scan is off unless lan=true (CLI --lan). " +
59
- "all=true also probes offline Tailscale peers.",
59
+ "all=true also lists offline Tailscale peers as offline (they are not probed).",
60
60
  inputSchema: {
61
61
  type: "object",
62
62
  properties: {
@@ -66,7 +66,7 @@ const TOOLS = [
66
66
  },
67
67
  all: {
68
68
  type: "boolean",
69
- description: "Also probe offline Tailscale peers (default false)",
69
+ description: "Also list offline Tailscale peers, marked offline and not probed (default false)",
70
70
  },
71
71
  last: {
72
72
  type: "boolean",
@@ -547,7 +547,9 @@ export async function main() {
547
547
  printHelp(helpText());
548
548
  }
549
549
  const version = await packageVersion();
550
- console.error(`ollanet mcp ${version} (stdio) — tools: ${TOOLS.map((t) => t.name).join(", ")}`);
550
+ if (!isQuiet()) {
551
+ console.error(`ollanet mcp ${version} (stdio) — tools: ${TOOLS.map((t) => t.name).join(", ")}`);
552
+ }
551
553
  const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
552
554
  for await (const line of rl) {
553
555
  const trimmed = line.trim();
package/dist/prompt.d.ts CHANGED
@@ -14,6 +14,14 @@
14
14
  import { type AppConfig, type GenerateSettings } from "./config.js";
15
15
  export declare function helpText(): string;
16
16
  export declare function readStdinIfPiped(): Promise<string>;
17
+ /**
18
+ * `--stdin` / `-`: read stdin to EOF with no time limit, so slow producers
19
+ * (`slow-cmd | ollanet prompt host --stdin`) work. Waits for the writer to
20
+ * close the pipe (Ctrl+D / Ctrl+Z on a terminal).
21
+ */
22
+ export declare function readStdinToEnd(): Promise<string>;
23
+ /** Read the prompt from stdin: to EOF with --stdin, else the 100ms heuristic. */
24
+ export declare function readPromptStdin(explicit: boolean): Promise<string>;
17
25
  export interface PromptRunOptions {
18
26
  machine?: string;
19
27
  model?: string;
package/dist/prompt.js CHANGED
@@ -17,7 +17,7 @@ import { configPath, defaultModelForHost, loadConfig, lookupAlias, machineSettin
17
17
  import { envInt, findDiscoveredHost, ollamaBaseUrl, resolveHost, shortName, } from "./hosts.js";
18
18
  import { ollamaChat } from "./ollama-chat.js";
19
19
  import { assemblePrompt, readPromptFile } from "./prompt-input.js";
20
- import { consumeSettingsFlag, failUsage, isHelpFlag, printHelp, takeFlag, takeValue } from "./argv.js";
20
+ import { consumeSettingsFlag, failUsage, isHelpFlag, isQuiet, printHelp, takeFlag, takeValue, } from "./argv.js";
21
21
  import { listTargets, resolveTarget } from "./target.js";
22
22
  /** Prompt/chat HTTP timeout (ms). 0 = no timeout. Separate from scan's OLLAMA_TIMEOUT_MS. */
23
23
  function promptTimeoutMs() {
@@ -28,6 +28,7 @@ export function helpText() {
28
28
  ollanet prompt <machine> [model] <prompt...>
29
29
  ollanet prompt --chat <hash> <prompt...>
30
30
  ollanet prompt <machine> --chat <hash> <prompt...>
31
+ <command> | ollanet prompt <machine> [model] --stdin
31
32
 
32
33
  Examples:
33
34
  ollanet prompt localhost "Explain MagicDNS"
@@ -62,8 +63,12 @@ Options:
62
63
  --think Enable model thinking (qwen3 etc.); streams to stderr
63
64
  --no-think Disable thinking (default) so tokens go to the reply
64
65
  --file <path> Prompt from a .txt or .md file (joins argv / stdin)
66
+ --stdin, - Wait for stdin to close and use it (no 100ms cutoff;
67
+ for slow producers). Without it, piped stdin is used
68
+ only if data arrives within 100ms.
65
69
  --no-stream Wait for the full response
66
- --json Emit final response JSON (implies --no-stream)`;
70
+ --json Emit final response JSON (implies --no-stream)
71
+ -q, --quiet Reply only; no host line, chat id, or stats on stderr`;
67
72
  }
68
73
  function usage() {
69
74
  failUsage(helpText());
@@ -138,6 +143,7 @@ function parseArgs(argv) {
138
143
  let stream = true;
139
144
  let json = false;
140
145
  let save = true;
146
+ let stdin = false;
141
147
  const settings = {};
142
148
  const positional = [];
143
149
  while (args.length > 0) {
@@ -146,6 +152,10 @@ function parseArgs(argv) {
146
152
  continue;
147
153
  if (isHelpFlag(arg))
148
154
  printHelp(helpText());
155
+ if (arg === "--stdin" || arg === "-") {
156
+ stdin = true;
157
+ continue;
158
+ }
149
159
  if (arg === "--no-stream") {
150
160
  stream = false;
151
161
  continue;
@@ -212,7 +222,7 @@ function parseArgs(argv) {
212
222
  }
213
223
  // Continue (--chat): all positionals are the prompt. Optional machine override
214
224
  // via --machine, or a leading discovered-host name peeled in main().
215
- return { machine, model, promptParts, file, stream, json, save, chatId, settings };
225
+ return { machine, model, promptParts, file, stream, json, save, stdin, chatId, settings };
216
226
  }
217
227
  export async function readStdinIfPiped() {
218
228
  if (stdinStream.isTTY)
@@ -247,12 +257,29 @@ export async function readStdinIfPiped() {
247
257
  stdinStream.destroy();
248
258
  return "";
249
259
  }
260
+ return readStdinToEnd();
261
+ }
262
+ /**
263
+ * `--stdin` / `-`: read stdin to EOF with no time limit, so slow producers
264
+ * (`slow-cmd | ollanet prompt host --stdin`) work. Waits for the writer to
265
+ * close the pipe (Ctrl+D / Ctrl+Z on a terminal).
266
+ */
267
+ export async function readStdinToEnd() {
250
268
  const chunks = [];
251
269
  for await (const chunk of stdinStream) {
252
270
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
253
271
  }
254
272
  return Buffer.concat(chunks).toString("utf8").trim();
255
273
  }
274
+ /** Read the prompt from stdin: to EOF with --stdin, else the 100ms heuristic. */
275
+ export async function readPromptStdin(explicit) {
276
+ if (!explicit)
277
+ return readStdinIfPiped();
278
+ const text = await readStdinToEnd();
279
+ if (!text)
280
+ throw new Error("--stdin was given but stdin was empty.");
281
+ return text;
282
+ }
256
283
  async function generateTopic(opts) {
257
284
  const fallback = topicFromPrompt(opts.prompt);
258
285
  try {
@@ -417,7 +444,15 @@ export async function main() {
417
444
  const parsed = parseArgs(process.argv.slice(2));
418
445
  const { stream, json, save } = parsed;
419
446
  let promptParts = parsed.promptParts;
420
- const fromStdin = await readStdinIfPiped();
447
+ let fromStdin;
448
+ try {
449
+ fromStdin = await readPromptStdin(parsed.stdin);
450
+ }
451
+ catch (err) {
452
+ console.error(err instanceof Error ? err.message : err);
453
+ process.exitCode = 1;
454
+ return;
455
+ }
421
456
  let fromFile = "";
422
457
  if (parsed.file) {
423
458
  try {
@@ -478,7 +513,7 @@ export async function main() {
478
513
  settings: parsed.settings,
479
514
  writeStdout: !json,
480
515
  stream: json ? false : stream,
481
- quiet: json,
516
+ quiet: json || isQuiet(),
482
517
  config,
483
518
  });
484
519
  if (json) {
@@ -499,7 +534,7 @@ export async function main() {
499
534
  : "",
500
535
  chunk.total_duration ? formatDuration(Number(chunk.total_duration)) : "",
501
536
  ].filter(Boolean);
502
- if (stats.length > 0) {
537
+ if (stats.length > 0 && !isQuiet()) {
503
538
  console.error(`(${stats.join(" · ")})`);
504
539
  }
505
540
  }
package/dist/pull.js CHANGED
@@ -9,7 +9,7 @@
9
9
  import { expandMachineModel, loadConfig } from "./config.js";
10
10
  import { envInt, ollamaBaseUrl, shortName } from "./hosts.js";
11
11
  import { ollamaPull } from "./ollama-chat.js";
12
- import { failUsage, isHelpFlag, printHelp, takeFlag } from "./argv.js";
12
+ import { failUsage, isHelpFlag, isQuiet, printHelp, takeFlag } from "./argv.js";
13
13
  import { resolveTarget } from "./target.js";
14
14
  /** Pull HTTP timeout (ms). 0 = none. Large models can take hours. */
15
15
  function pullTimeoutMs() {
@@ -36,7 +36,8 @@ Options:
36
36
  --model <name> Model to pull (e.g. gemma3:12b)
37
37
  --insecure Allow HTTP / self-signed model registries
38
38
  --no-stream Wait for a single final response (no progress chunks)
39
- --json Emit result JSON on stdout`;
39
+ --json Emit result JSON on stdout
40
+ -q, --quiet Result line only; no progress or next-step hint on stderr`;
40
41
  }
41
42
  function usage() {
42
43
  failUsage(helpText());
@@ -189,7 +190,7 @@ export async function main() {
189
190
  insecure: parsed.insecure,
190
191
  stream: parsed.stream,
191
192
  writeStdout: true,
192
- quiet: false,
193
+ quiet: isQuiet(),
193
194
  config,
194
195
  });
195
196
  if (parsed.json) {
@@ -197,5 +198,6 @@ export async function main() {
197
198
  return;
198
199
  }
199
200
  process.stdout.write(`pulled ${result.model} on ${result.machine}\n`);
200
- process.stderr.write(`${result.next.hint}\n${result.next.site}\n`);
201
+ if (!isQuiet())
202
+ process.stderr.write(`${result.next.hint}\n${result.next.site}\n`);
201
203
  }
package/dist/scan.d.ts CHANGED
@@ -4,7 +4,16 @@
4
4
  * Usage: ollanet scan [--lan] [--json] [--all] [--last]
5
5
  */
6
6
  import { type AppConfig } from "./config.js";
7
+ /**
8
+ * Turn a failed /api/tags probe into a short reason. Node's fetch reports
9
+ * socket errors as a bare "fetch failed" with the real code on `cause`.
10
+ */
11
+ export declare function describeProbeError(err: unknown, timeoutMs: number): {
12
+ error: string;
13
+ code: ScanFailureCode;
14
+ };
7
15
  export interface ScanOptions {
16
+ /** List offline Tailscale peers as `offline` failures (they are not probed). */
8
17
  includeOffline?: boolean;
9
18
  /** TCP-scan local /24s for Ollama (port 11434 by default). Off unless set. */
10
19
  lanScan?: boolean;
@@ -41,10 +50,21 @@ export interface ScannedServer {
41
50
  tuned: boolean;
42
51
  }>;
43
52
  }
53
+ /**
54
+ * Why a host did not answer as Ollama. `offline` = Tailscale peer marked
55
+ * offline (not probed); `http` / `not-ollama` = something answered, but not
56
+ * an Ollama API; `error` = anything else (see `error` text).
57
+ */
58
+ export type ScanFailureCode = "timeout" | "refused" | "reset" | "unreachable" | "dns" | "http" | "not-ollama" | "offline" | "error";
44
59
  export interface ScanFailure {
45
60
  name: string;
46
61
  ip: string;
62
+ /** Human-readable reason, e.g. "timeout after 2500ms", "connection refused (ECONNREFUSED)". */
47
63
  error: string;
64
+ /** Port probed (added in 0.7.0; absent in older saved scans). */
65
+ port?: number;
66
+ /** Short machine-readable reason (added in 0.7.0; absent in older saved scans). */
67
+ code?: ScanFailureCode;
48
68
  }
49
69
  export interface ScanPayload {
50
70
  network: string;
package/dist/scan.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Usage: ollanet scan [--lan] [--json] [--all] [--last]
5
5
  */
6
6
  import { configFromPartial, loadConfig } from "./config.js";
7
- import { discoverHosts, envInt, ollamaPort, shortName, } from "./hosts.js";
7
+ import { discoverHosts, envInt, ollamaBaseUrl, ollamaPort, shortName, } from "./hosts.js";
8
8
  import { lastScanPath, loadLastScan, saveLastScan } from "./scan-store.js";
9
9
  import { looksTuned } from "./tuned.js";
10
10
  import { isHelpFlag, printHelp } from "./argv.js";
@@ -27,7 +27,7 @@ function formatBytes(bytes) {
27
27
  return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
28
28
  }
29
29
  async function fetchModels(host) {
30
- const url = `http://${host.ip.includes(":") ? `[${host.ip}]` : host.ip}:${host.port}/api/tags`;
30
+ const url = `${ollamaBaseUrl(host)}/api/tags`;
31
31
  const timeoutMs = scanTimeoutMs();
32
32
  const controller = new AbortController();
33
33
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -37,9 +37,17 @@ async function fetchModels(host) {
37
37
  headers: { Accept: "application/json" },
38
38
  });
39
39
  if (!res.ok) {
40
- throw new Error(`HTTP ${res.status} ${res.statusText}`);
40
+ throw new ProbeError(res.status === 404
41
+ ? "HTTP 404, no /api/tags (not Ollama?)"
42
+ : `HTTP ${res.status} ${res.statusText}`.trim(), "http");
43
+ }
44
+ let body;
45
+ try {
46
+ body = (await res.json());
47
+ }
48
+ catch {
49
+ throw new ProbeError("non-JSON reply (not Ollama?)", "not-ollama");
41
50
  }
42
- const body = (await res.json());
43
51
  return { models: body.models ?? [], url };
44
52
  }
45
53
  finally {
@@ -61,21 +69,91 @@ async function mapPool(items, concurrency, worker) {
61
69
  return results;
62
70
  }
63
71
  async function scanHost(host) {
64
- const url = `http://${host.ip}:${host.port}/api/tags`;
72
+ const url = `${ollamaBaseUrl(host)}/api/tags`;
65
73
  if (!host.online && !host.isSelf && host.source === "tailscale") {
66
- return { host, ok: false, models: [], error: "offline", url };
74
+ return { host, ok: false, models: [], error: "offline", code: "offline", url };
67
75
  }
68
76
  try {
69
77
  const { models } = await fetchModels(host);
70
78
  return { host, ok: true, models, url };
71
79
  }
72
80
  catch (err) {
73
- const name = err instanceof Error ? err.name : "";
74
- const message = err instanceof Error ? err.message : String(err);
75
- const error = name === "AbortError" || message.toLowerCase().includes("abort")
76
- ? `timeout after ${scanTimeoutMs()}ms`
77
- : message;
78
- return { host, ok: false, models: [], error, url };
81
+ const { error, code } = describeProbeError(err, scanTimeoutMs());
82
+ return { host, ok: false, models: [], error, code, url };
83
+ }
84
+ }
85
+ /** Probe failure with a short reason code (see {@link ScanFailureCode}). */
86
+ class ProbeError extends Error {
87
+ code;
88
+ constructor(message, code) {
89
+ super(message);
90
+ this.code = code;
91
+ }
92
+ }
93
+ const SOCKET_CODES = {
94
+ ECONNREFUSED: { code: "refused", label: "connection refused" },
95
+ ECONNRESET: { code: "reset", label: "connection reset" },
96
+ UND_ERR_SOCKET: { code: "reset", label: "connection closed" },
97
+ EHOSTUNREACH: { code: "unreachable", label: "host unreachable" },
98
+ ENETUNREACH: { code: "unreachable", label: "network unreachable" },
99
+ EHOSTDOWN: { code: "unreachable", label: "host down" },
100
+ ENOTFOUND: { code: "dns", label: "DNS lookup failed" },
101
+ EAI_AGAIN: { code: "dns", label: "DNS lookup failed" },
102
+ ETIMEDOUT: { code: "timeout", label: "connect timed out" },
103
+ UND_ERR_CONNECT_TIMEOUT: { code: "timeout", label: "connect timed out" },
104
+ };
105
+ /**
106
+ * Turn a failed /api/tags probe into a short reason. Node's fetch reports
107
+ * socket errors as a bare "fetch failed" with the real code on `cause`.
108
+ */
109
+ export function describeProbeError(err, timeoutMs) {
110
+ if (err instanceof ProbeError)
111
+ return { error: err.message, code: err.code };
112
+ const name = err instanceof Error ? err.name : "";
113
+ const message = err instanceof Error ? err.message : String(err);
114
+ if (name === "AbortError" || name === "TimeoutError" || message.toLowerCase().includes("abort")) {
115
+ return { error: `timeout after ${timeoutMs}ms`, code: "timeout" };
116
+ }
117
+ const cause = err instanceof Error ? err.cause : undefined;
118
+ const sysCode = [cause, err]
119
+ .map((e) => (e && typeof e === "object" && "code" in e ? String(e.code) : ""))
120
+ .find(Boolean);
121
+ const known = sysCode ? SOCKET_CODES[sysCode] : undefined;
122
+ if (known && sysCode)
123
+ return { error: `${known.label} (${sysCode})`, code: known.code };
124
+ const causeMessage = cause instanceof Error ? cause.message : "";
125
+ const detail = causeMessage && causeMessage !== message ? `${message}: ${causeMessage}` : message;
126
+ return { error: sysCode ? `${detail} (${sysCode})` : detail, code: "error" };
127
+ }
128
+ function failureLabel(f, port) {
129
+ const ip = f.ip.includes(":") ? `[${f.ip}]` : f.ip;
130
+ const addr = f.port != null && f.port !== port ? `${ip}:${f.port}` : f.ip;
131
+ return f.name && f.name !== f.ip ? `${f.name} (${addr})` : addr;
132
+ }
133
+ /**
134
+ * Compact "who didn't answer" block. Offline Tailscale peers (only listed with
135
+ * --all, never probed) collapse to a count; the rest show name, address, reason.
136
+ */
137
+ function printFailures(payload) {
138
+ const failures = payload.failures ?? [];
139
+ const offline = failures.filter((f) => f.error === "offline");
140
+ const probed = failures.filter((f) => f.error !== "offline");
141
+ const limit = 5;
142
+ if (probed.length === 1) {
143
+ const f = probed[0];
144
+ console.log(`No answer from 1 host: ${failureLabel(f, payload.port)}, ${f.error}`);
145
+ }
146
+ else if (probed.length > 1) {
147
+ console.log(`No answer from ${probed.length} hosts:`);
148
+ for (const f of probed.slice(0, limit)) {
149
+ console.log(` - ${failureLabel(f, payload.port)}: ${f.error}`);
150
+ }
151
+ if (probed.length > limit) {
152
+ console.log(` … ${probed.length - limit} more (ollanet scan --json lists all)`);
153
+ }
154
+ }
155
+ if (offline.length > 0) {
156
+ console.log(`Offline Tailscale peers (not probed): ${offline.length}`);
79
157
  }
80
158
  }
81
159
  function printPayload(payload, cached) {
@@ -93,12 +171,9 @@ function printPayload(payload, cached) {
93
171
  if (payload.servers.length === 0) {
94
172
  console.log("No Ollama servers found.");
95
173
  console.log("Tip: add hosts to config.json, set OLLANET_HOSTS, pass --lan, or prompt an IP directly.");
96
- const interesting = (payload.failures ?? []).filter((f) => f.error !== "offline");
97
- if (interesting.length > 0) {
98
- console.log("\nNon-timeout failures (first few):");
99
- for (const f of interesting.slice(0, 5)) {
100
- console.log(` - ${f.name} (${f.ip}): ${f.error}`);
101
- }
174
+ if ((payload.failures ?? []).length > 0) {
175
+ console.log("");
176
+ printFailures(payload);
102
177
  }
103
178
  return;
104
179
  }
@@ -126,6 +201,7 @@ function printPayload(payload, cached) {
126
201
  console.log("");
127
202
  }
128
203
  console.log(`Found ${payload.servers.length} Ollama server(s) with ${payload.servers.reduce((n, s) => n + s.models.length, 0)} model entrie(s).`);
204
+ printFailures(payload);
129
205
  }
130
206
  function toPayload(results, networkLabel, sources) {
131
207
  return {
@@ -144,7 +220,7 @@ function toPayload(results, networkLabel, sources) {
144
220
  os: r.host.os,
145
221
  source: r.host.source,
146
222
  self: r.host.isSelf,
147
- endpoint: `http://${r.host.ip}:${r.host.port}`,
223
+ endpoint: ollamaBaseUrl(r.host),
148
224
  models: r.models.map((m) => ({
149
225
  name: m.name,
150
226
  size: m.size,
@@ -161,6 +237,8 @@ function toPayload(results, networkLabel, sources) {
161
237
  name: shortName(r.host),
162
238
  ip: r.host.ip,
163
239
  error: r.error ?? "unknown",
240
+ port: r.host.port,
241
+ code: r.code ?? "error",
164
242
  })),
165
243
  };
166
244
  }
@@ -202,7 +280,7 @@ models. --lan is an opt-in TCP sweep of local /24s on port 11434.
202
280
 
203
281
  Options:
204
282
  --json Machine-readable output
205
- --all Also probe offline Tailscale peers
283
+ --all Also list offline Tailscale peers (marked offline, not probed)
206
284
  --lan TCP-scan local LAN CIDRs for open Ollama ports
207
285
  --last Reprint the last saved scan (no network)
208
286
  --cached Same as --last`;