ollanet 0.6.24 → 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/README.md +5 -4
- package/dist/argv.d.ts +18 -0
- package/dist/argv.js +35 -0
- package/dist/bench.js +8 -6
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +65 -26
- package/dist/compare.js +35 -15
- package/dist/funnel.d.ts +70 -0
- package/dist/funnel.js +195 -0
- package/dist/help.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/mcp.js +6 -4
- package/dist/prompt.d.ts +8 -0
- package/dist/prompt.js +41 -6
- package/dist/pull.js +6 -4
- package/dist/scan.d.ts +20 -0
- package/dist/scan.js +98 -20
- package/dist/serve-store.d.ts +52 -0
- package/dist/serve-store.js +215 -0
- package/dist/serve.d.ts +108 -0
- package/dist/serve.js +840 -0
- package/package.json +1 -1
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
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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:
|
|
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
|
|
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`;
|