ollanet 0.1.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/LICENSE +21 -0
- package/README.md +248 -0
- package/dist/chat-store.js +131 -0
- package/dist/chats.js +127 -0
- package/dist/cli.js +81 -0
- package/dist/config.js +225 -0
- package/dist/hosts.js +383 -0
- package/dist/paths.js +24 -0
- package/dist/prompt.js +451 -0
- package/dist/scan.js +166 -0
- package/package.json +52 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { shortName, } from "./hosts.js";
|
|
3
|
+
import { defaultConfigPath } from "./paths.js";
|
|
4
|
+
function emptyConfig() {
|
|
5
|
+
return {
|
|
6
|
+
hosts: [],
|
|
7
|
+
discovery: {},
|
|
8
|
+
defaultModels: {},
|
|
9
|
+
defaults: {},
|
|
10
|
+
machineDefaults: {},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const CONFIG_PATH = process.env.OLLANET_CONFIG ??
|
|
14
|
+
process.env.OLLAMA_CONFIG ??
|
|
15
|
+
defaultConfigPath();
|
|
16
|
+
export async function loadConfig() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = await readFile(CONFIG_PATH, "utf8");
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
const machineDefaults = normalizeMachineDefaults(parsed.machineDefaults);
|
|
21
|
+
const defaultModels = {
|
|
22
|
+
...normalizeStringMap(parsed.defaultModels),
|
|
23
|
+
// Allow `machineDefaults.<host>.model` as an alternate place to set defaults.
|
|
24
|
+
...Object.fromEntries(Object.entries(machineDefaults)
|
|
25
|
+
.filter(([, v]) => typeof v.model === "string" && v.model.trim())
|
|
26
|
+
.map(([k, v]) => [k, v.model.trim()])),
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
hosts: Array.isArray(parsed.hosts) ? parsed.hosts : [],
|
|
30
|
+
discovery: normalizeDiscovery(parsed.discovery),
|
|
31
|
+
defaultModels,
|
|
32
|
+
defaults: normalizeSettings(parsed.defaults),
|
|
33
|
+
machineDefaults,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
const code = err.code;
|
|
38
|
+
if (code === "ENOENT") {
|
|
39
|
+
return emptyConfig();
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Failed to read config at ${CONFIG_PATH}: ${err instanceof Error ? err.message : err}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function normalizeDiscovery(input) {
|
|
45
|
+
if (!input || typeof input !== "object")
|
|
46
|
+
return {};
|
|
47
|
+
const src = input;
|
|
48
|
+
const out = {};
|
|
49
|
+
if (typeof src.localhost === "boolean")
|
|
50
|
+
out.localhost = src.localhost;
|
|
51
|
+
if (typeof src.tailscale === "boolean")
|
|
52
|
+
out.tailscale = src.tailscale;
|
|
53
|
+
if (typeof src.lan === "boolean")
|
|
54
|
+
out.lan = src.lan;
|
|
55
|
+
if (Array.isArray(src.cidrs)) {
|
|
56
|
+
out.cidrs = src.cidrs.filter((c) => typeof c === "string" && c.includes("/"));
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
function normalizeStringMap(map) {
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const [key, value] of Object.entries(map ?? {})) {
|
|
63
|
+
if (typeof value !== "string" || !value.trim())
|
|
64
|
+
continue;
|
|
65
|
+
out[key.trim().toLowerCase()] = value.trim();
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
function normalizeMachineDefaults(map) {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const [key, value] of Object.entries(map ?? {})) {
|
|
72
|
+
if (!value || typeof value !== "object")
|
|
73
|
+
continue;
|
|
74
|
+
out[key.trim().toLowerCase()] = {
|
|
75
|
+
...normalizeSettings(value),
|
|
76
|
+
...(typeof value.model === "string" && value.model.trim()
|
|
77
|
+
? { model: value.model.trim() }
|
|
78
|
+
: {}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
export function normalizeSettings(input) {
|
|
84
|
+
if (!input || typeof input !== "object")
|
|
85
|
+
return {};
|
|
86
|
+
const src = input;
|
|
87
|
+
const out = {};
|
|
88
|
+
const temperature = asFiniteNumber(src.temperature ?? src.temp);
|
|
89
|
+
if (temperature != null)
|
|
90
|
+
out.temperature = temperature;
|
|
91
|
+
const numPredict = asFiniteNumber(src.num_predict ?? src.numPredict);
|
|
92
|
+
if (numPredict != null)
|
|
93
|
+
out.num_predict = Math.trunc(numPredict);
|
|
94
|
+
const numCtx = asFiniteNumber(src.num_ctx ?? src.numCtx);
|
|
95
|
+
if (numCtx != null)
|
|
96
|
+
out.num_ctx = Math.trunc(numCtx);
|
|
97
|
+
const keepAlive = src.keep_alive ?? src.keepAlive;
|
|
98
|
+
if (typeof keepAlive === "string" && keepAlive.trim()) {
|
|
99
|
+
out.keep_alive = keepAlive.trim();
|
|
100
|
+
}
|
|
101
|
+
else if (typeof keepAlive === "number" && Number.isFinite(keepAlive)) {
|
|
102
|
+
out.keep_alive = keepAlive;
|
|
103
|
+
}
|
|
104
|
+
const format = parseFormat(src.format);
|
|
105
|
+
if (format !== undefined)
|
|
106
|
+
out.format = format;
|
|
107
|
+
if (typeof src.system === "string" && src.system.trim()) {
|
|
108
|
+
out.system = src.system;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
function asFiniteNumber(value) {
|
|
113
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
114
|
+
return value;
|
|
115
|
+
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) {
|
|
116
|
+
return Number(value);
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
export function parseFormat(value) {
|
|
121
|
+
if (value == null || value === "")
|
|
122
|
+
return undefined;
|
|
123
|
+
if (value === "json")
|
|
124
|
+
return "json";
|
|
125
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
if (typeof value === "string") {
|
|
129
|
+
const trimmed = value.trim();
|
|
130
|
+
if (trimmed === "json")
|
|
131
|
+
return "json";
|
|
132
|
+
if (trimmed.startsWith("{")) {
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(trimmed);
|
|
135
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
136
|
+
return parsed;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
throw new Error(`Invalid --format JSON schema: ${trimmed.slice(0, 80)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`Invalid format "${value}". Use "json" or a JSON schema object/string.`);
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
export function hostLookupKeys(host) {
|
|
148
|
+
return [
|
|
149
|
+
shortName(host),
|
|
150
|
+
host.hostname,
|
|
151
|
+
host.dnsName,
|
|
152
|
+
host.dnsName.split(".")[0] ?? "",
|
|
153
|
+
host.ip,
|
|
154
|
+
]
|
|
155
|
+
.map((k) => k.toLowerCase())
|
|
156
|
+
.filter(Boolean);
|
|
157
|
+
}
|
|
158
|
+
/** Resolve a default model for a host using short name, hostname, DNS, or IP keys. */
|
|
159
|
+
export function defaultModelForHost(config, host) {
|
|
160
|
+
for (const key of hostLookupKeys(host)) {
|
|
161
|
+
const hit = config.defaultModels[key];
|
|
162
|
+
if (hit)
|
|
163
|
+
return hit;
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
export function machineSettingsForHost(config, host) {
|
|
168
|
+
for (const key of hostLookupKeys(host)) {
|
|
169
|
+
const hit = config.machineDefaults[key];
|
|
170
|
+
if (hit) {
|
|
171
|
+
const { model: _model, ...settings } = hit;
|
|
172
|
+
return settings;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
/** Later layers win. Undefined values are skipped. */
|
|
178
|
+
export function mergeSettings(...layers) {
|
|
179
|
+
const out = {};
|
|
180
|
+
for (const layer of layers) {
|
|
181
|
+
if (!layer)
|
|
182
|
+
continue;
|
|
183
|
+
if (layer.temperature != null)
|
|
184
|
+
out.temperature = layer.temperature;
|
|
185
|
+
if (layer.num_predict != null)
|
|
186
|
+
out.num_predict = layer.num_predict;
|
|
187
|
+
if (layer.num_ctx != null)
|
|
188
|
+
out.num_ctx = layer.num_ctx;
|
|
189
|
+
if (layer.keep_alive != null)
|
|
190
|
+
out.keep_alive = layer.keep_alive;
|
|
191
|
+
if (layer.format !== undefined)
|
|
192
|
+
out.format = layer.format;
|
|
193
|
+
if (layer.system != null)
|
|
194
|
+
out.system = layer.system;
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
export function settingsFromEnv() {
|
|
199
|
+
return normalizeSettings({
|
|
200
|
+
temperature: process.env.OLLAMA_TEMPERATURE,
|
|
201
|
+
num_predict: process.env.OLLAMA_NUM_PREDICT,
|
|
202
|
+
num_ctx: process.env.OLLAMA_NUM_CTX,
|
|
203
|
+
keep_alive: process.env.OLLAMA_KEEP_ALIVE,
|
|
204
|
+
format: process.env.OLLAMA_FORMAT,
|
|
205
|
+
system: process.env.OLLAMA_SYSTEM,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
export function settingsSummary(settings) {
|
|
209
|
+
const parts = [];
|
|
210
|
+
if (settings.temperature != null)
|
|
211
|
+
parts.push(`temp=${settings.temperature}`);
|
|
212
|
+
if (settings.num_predict != null)
|
|
213
|
+
parts.push(`num_predict=${settings.num_predict}`);
|
|
214
|
+
if (settings.num_ctx != null)
|
|
215
|
+
parts.push(`num_ctx=${settings.num_ctx}`);
|
|
216
|
+
if (settings.keep_alive != null)
|
|
217
|
+
parts.push(`keep_alive=${settings.keep_alive}`);
|
|
218
|
+
if (settings.format !== undefined) {
|
|
219
|
+
parts.push(settings.format === "json" ? "format=json" : "format=<schema>");
|
|
220
|
+
}
|
|
221
|
+
return parts.join(" ");
|
|
222
|
+
}
|
|
223
|
+
export function configPath() {
|
|
224
|
+
return CONFIG_PATH;
|
|
225
|
+
}
|
package/dist/hosts.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
export const OLLAMA_PORT = Number(process.env.OLLAMA_PORT ?? 11434);
|
|
7
|
+
function cleanDnsName(dnsName) {
|
|
8
|
+
if (!dnsName)
|
|
9
|
+
return "";
|
|
10
|
+
return dnsName.replace(/\.$/, "");
|
|
11
|
+
}
|
|
12
|
+
function pickIpv4(ips) {
|
|
13
|
+
return ips?.find((ip) => !ip.includes(":"));
|
|
14
|
+
}
|
|
15
|
+
export function shortName(host) {
|
|
16
|
+
if (host.dnsName) {
|
|
17
|
+
return host.dnsName.split(".")[0] ?? host.hostname;
|
|
18
|
+
}
|
|
19
|
+
return host.hostname;
|
|
20
|
+
}
|
|
21
|
+
export function ollamaBaseUrl(host, port = host.port || OLLAMA_PORT) {
|
|
22
|
+
const hostPart = host.ip.includes(":") ? `[${host.ip}]` : host.ip;
|
|
23
|
+
return `http://${hostPart}:${port}`;
|
|
24
|
+
}
|
|
25
|
+
function makeHost(partial) {
|
|
26
|
+
return {
|
|
27
|
+
hostname: partial.hostname,
|
|
28
|
+
dnsName: partial.dnsName ?? "",
|
|
29
|
+
ip: partial.ip,
|
|
30
|
+
online: partial.online ?? true,
|
|
31
|
+
os: partial.os ?? "unknown",
|
|
32
|
+
isSelf: partial.isSelf ?? false,
|
|
33
|
+
source: partial.source,
|
|
34
|
+
port: partial.port ?? OLLAMA_PORT,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function dedupeKey(host) {
|
|
38
|
+
return `${host.ip}:${host.port}`;
|
|
39
|
+
}
|
|
40
|
+
function mergeHosts(hosts) {
|
|
41
|
+
const byKey = new Map();
|
|
42
|
+
for (const host of hosts) {
|
|
43
|
+
const key = dedupeKey(host);
|
|
44
|
+
const existing = byKey.get(key);
|
|
45
|
+
if (!existing) {
|
|
46
|
+
byKey.set(key, host);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
// Prefer named / richer metadata when merging duplicates.
|
|
50
|
+
byKey.set(key, {
|
|
51
|
+
...existing,
|
|
52
|
+
hostname: existing.hostname === existing.ip && host.hostname !== host.ip
|
|
53
|
+
? host.hostname
|
|
54
|
+
: existing.hostname,
|
|
55
|
+
dnsName: existing.dnsName || host.dnsName,
|
|
56
|
+
online: existing.online || host.online,
|
|
57
|
+
os: existing.os !== "unknown" ? existing.os : host.os,
|
|
58
|
+
isSelf: existing.isSelf || host.isSelf,
|
|
59
|
+
source: existing.source === "direct" ? host.source : existing.source,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return [...byKey.values()].sort((a, b) => {
|
|
63
|
+
if (a.isSelf !== b.isSelf)
|
|
64
|
+
return a.isSelf ? -1 : 1;
|
|
65
|
+
return shortName(a).localeCompare(shortName(b));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function localhostTargets() {
|
|
69
|
+
const hostname = os.hostname() || "localhost";
|
|
70
|
+
return [
|
|
71
|
+
makeHost({
|
|
72
|
+
hostname,
|
|
73
|
+
dnsName: "localhost",
|
|
74
|
+
ip: "127.0.0.1",
|
|
75
|
+
source: "localhost",
|
|
76
|
+
isSelf: true,
|
|
77
|
+
online: true,
|
|
78
|
+
os: process.platform,
|
|
79
|
+
}),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
function parseHostEntry(entry) {
|
|
83
|
+
if (typeof entry === "string") {
|
|
84
|
+
const trimmed = entry.trim();
|
|
85
|
+
if (!trimmed)
|
|
86
|
+
return null;
|
|
87
|
+
return makeHost({
|
|
88
|
+
hostname: trimmed,
|
|
89
|
+
ip: trimmed,
|
|
90
|
+
source: "config",
|
|
91
|
+
online: true,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const address = (entry.host ?? entry.ip ?? "").trim();
|
|
95
|
+
if (!address)
|
|
96
|
+
return null;
|
|
97
|
+
const name = (entry.name ?? address).trim();
|
|
98
|
+
return makeHost({
|
|
99
|
+
hostname: name,
|
|
100
|
+
dnsName: address.includes(".") ? address : "",
|
|
101
|
+
ip: address,
|
|
102
|
+
source: "config",
|
|
103
|
+
online: true,
|
|
104
|
+
port: typeof entry.port === "number" ? entry.port : OLLAMA_PORT,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function envHostTargets() {
|
|
108
|
+
const raw = process.env.OLLANET_HOSTS ?? "";
|
|
109
|
+
return raw
|
|
110
|
+
.split(/[,;\s]+/)
|
|
111
|
+
.map((part) => part.trim())
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.map((address) => makeHost({
|
|
114
|
+
hostname: address,
|
|
115
|
+
ip: address,
|
|
116
|
+
source: "env",
|
|
117
|
+
online: true,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
function peerToTarget(peer, isSelf) {
|
|
121
|
+
const ip = pickIpv4(peer.TailscaleIPs);
|
|
122
|
+
if (!ip)
|
|
123
|
+
return null;
|
|
124
|
+
return makeHost({
|
|
125
|
+
hostname: peer.HostName ?? ip,
|
|
126
|
+
dnsName: cleanDnsName(peer.DNSName),
|
|
127
|
+
ip,
|
|
128
|
+
online: peer.Online ?? false,
|
|
129
|
+
os: peer.OS ?? "unknown",
|
|
130
|
+
isSelf,
|
|
131
|
+
source: "tailscale",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async function tryTailscaleTargets(includeOffline) {
|
|
135
|
+
try {
|
|
136
|
+
const { stdout } = await execFileAsync("tailscale", ["status", "--json"], {
|
|
137
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
138
|
+
windowsHide: true,
|
|
139
|
+
});
|
|
140
|
+
const status = JSON.parse(stdout);
|
|
141
|
+
const hosts = [];
|
|
142
|
+
if (status.Self) {
|
|
143
|
+
const self = peerToTarget(status.Self, true);
|
|
144
|
+
if (self)
|
|
145
|
+
hosts.push(self);
|
|
146
|
+
}
|
|
147
|
+
for (const peer of Object.values(status.Peer ?? {})) {
|
|
148
|
+
const target = peerToTarget(peer, false);
|
|
149
|
+
if (!target)
|
|
150
|
+
continue;
|
|
151
|
+
if (!includeOffline && !target.online && !target.isSelf)
|
|
152
|
+
continue;
|
|
153
|
+
hosts.push(target);
|
|
154
|
+
}
|
|
155
|
+
const label = status.CurrentTailnet?.Name ??
|
|
156
|
+
status.MagicDNSSuffix ??
|
|
157
|
+
status.CurrentTailnet?.MagicDNSSuffix;
|
|
158
|
+
return { hosts, label };
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return { hosts: [] };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function ipv4ToInt(ip) {
|
|
165
|
+
return ip.split(".").reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0;
|
|
166
|
+
}
|
|
167
|
+
function intToIpv4(value) {
|
|
168
|
+
return [
|
|
169
|
+
(value >>> 24) & 255,
|
|
170
|
+
(value >>> 16) & 255,
|
|
171
|
+
(value >>> 8) & 255,
|
|
172
|
+
value & 255,
|
|
173
|
+
].join(".");
|
|
174
|
+
}
|
|
175
|
+
function cidrToIps(cidr) {
|
|
176
|
+
const [base, bitsRaw] = cidr.split("/");
|
|
177
|
+
if (!base || !bitsRaw)
|
|
178
|
+
return [];
|
|
179
|
+
const bits = Number(bitsRaw);
|
|
180
|
+
if (!Number.isInteger(bits) || bits < 16 || bits > 30)
|
|
181
|
+
return [];
|
|
182
|
+
const baseInt = ipv4ToInt(base);
|
|
183
|
+
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
|
184
|
+
const network = (baseInt & mask) >>> 0;
|
|
185
|
+
const size = 2 ** (32 - bits);
|
|
186
|
+
const ips = [];
|
|
187
|
+
// Skip network + broadcast addresses.
|
|
188
|
+
for (let i = 1; i < size - 1; i += 1) {
|
|
189
|
+
ips.push(intToIpv4((network + i) >>> 0));
|
|
190
|
+
}
|
|
191
|
+
return ips;
|
|
192
|
+
}
|
|
193
|
+
function guessLocalCidrs() {
|
|
194
|
+
const cidrs = new Set();
|
|
195
|
+
for (const entries of Object.values(os.networkInterfaces())) {
|
|
196
|
+
for (const entry of entries ?? []) {
|
|
197
|
+
if (String(entry.family) !== "IPv4")
|
|
198
|
+
continue;
|
|
199
|
+
if (entry.internal)
|
|
200
|
+
continue;
|
|
201
|
+
const ip = entry.address;
|
|
202
|
+
if (!ip || ip.startsWith("127."))
|
|
203
|
+
continue;
|
|
204
|
+
// Prefer a /24 around each interface address for scan speed.
|
|
205
|
+
const parts = ip.split(".").map(Number);
|
|
206
|
+
if (parts.length !== 4)
|
|
207
|
+
continue;
|
|
208
|
+
cidrs.add(`${parts[0]}.${parts[1]}.${parts[2]}.0/24`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return [...cidrs];
|
|
212
|
+
}
|
|
213
|
+
async function mapPool(items, concurrency, worker) {
|
|
214
|
+
const results = new Array(items.length);
|
|
215
|
+
let next = 0;
|
|
216
|
+
async function run() {
|
|
217
|
+
while (next < items.length) {
|
|
218
|
+
const index = next;
|
|
219
|
+
next += 1;
|
|
220
|
+
results[index] = await worker(items[index]);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(items.length, 1)) }, () => run()));
|
|
224
|
+
return results;
|
|
225
|
+
}
|
|
226
|
+
async function tcpOpen(ip, port, timeoutMs) {
|
|
227
|
+
return await new Promise((resolve) => {
|
|
228
|
+
const socket = net.connect({ host: ip, port });
|
|
229
|
+
const timer = setTimeout(() => {
|
|
230
|
+
socket.destroy();
|
|
231
|
+
resolve(false);
|
|
232
|
+
}, timeoutMs);
|
|
233
|
+
socket.once("connect", () => {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
socket.destroy();
|
|
236
|
+
resolve(true);
|
|
237
|
+
});
|
|
238
|
+
socket.once("error", () => {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
resolve(false);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
async function lanScanTargets(cidrs, port) {
|
|
245
|
+
const timeoutMs = Number(process.env.OLLANET_LAN_TIMEOUT_MS ?? 200);
|
|
246
|
+
const concurrency = Number(process.env.OLLANET_LAN_CONCURRENCY ?? 64);
|
|
247
|
+
const ips = [...new Set(cidrs.flatMap((cidr) => cidrToIps(cidr)))];
|
|
248
|
+
if (ips.length === 0)
|
|
249
|
+
return [];
|
|
250
|
+
const flags = await mapPool(ips, concurrency, async (ip) => ({
|
|
251
|
+
ip,
|
|
252
|
+
open: await tcpOpen(ip, port, timeoutMs),
|
|
253
|
+
}));
|
|
254
|
+
return flags
|
|
255
|
+
.filter((f) => f.open)
|
|
256
|
+
.map((f) => makeHost({
|
|
257
|
+
hostname: f.ip,
|
|
258
|
+
ip: f.ip,
|
|
259
|
+
source: "lan",
|
|
260
|
+
online: true,
|
|
261
|
+
port,
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
function looksLikeAddress(query) {
|
|
265
|
+
const q = query.trim();
|
|
266
|
+
if (!q)
|
|
267
|
+
return false;
|
|
268
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(q))
|
|
269
|
+
return true;
|
|
270
|
+
if (q.includes(":") && !q.includes(" "))
|
|
271
|
+
return true; // hostname:port or ipv6-ish
|
|
272
|
+
if (/^[a-z0-9][a-z0-9._-]*$/i.test(q))
|
|
273
|
+
return true;
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
function parseDirectAddress(query) {
|
|
277
|
+
const q = query.trim();
|
|
278
|
+
if (!looksLikeAddress(q))
|
|
279
|
+
return null;
|
|
280
|
+
let hostname = q;
|
|
281
|
+
let ip = q;
|
|
282
|
+
let port = OLLAMA_PORT;
|
|
283
|
+
const ipv4WithPort = q.match(/^(\d{1,3}(?:\.\d{1,3}){3}):(\d+)$/);
|
|
284
|
+
if (ipv4WithPort) {
|
|
285
|
+
ip = ipv4WithPort[1];
|
|
286
|
+
hostname = ip;
|
|
287
|
+
port = Number(ipv4WithPort[2]);
|
|
288
|
+
}
|
|
289
|
+
else if (!q.includes("://") && q.includes(".") === false && q.includes(":")) {
|
|
290
|
+
// host:port without dots (rare) — skip to avoid mangling IPv6
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
const hostPort = q.match(/^([a-z0-9][a-z0-9._-]*):(\d+)$/i);
|
|
294
|
+
if (hostPort) {
|
|
295
|
+
hostname = hostPort[1];
|
|
296
|
+
ip = hostname;
|
|
297
|
+
port = Number(hostPort[2]);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return makeHost({
|
|
301
|
+
hostname,
|
|
302
|
+
ip,
|
|
303
|
+
source: "direct",
|
|
304
|
+
online: true,
|
|
305
|
+
port,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
/** Discover candidate Ollama hosts from localhost, config, env, Tailscale, and optional LAN scan. */
|
|
309
|
+
export async function discoverHosts(opts = {}) {
|
|
310
|
+
const discovery = opts.discovery ?? {};
|
|
311
|
+
const sources = [];
|
|
312
|
+
const collected = [];
|
|
313
|
+
const labels = [];
|
|
314
|
+
if (discovery.localhost !== false) {
|
|
315
|
+
collected.push(...localhostTargets());
|
|
316
|
+
sources.push("localhost");
|
|
317
|
+
}
|
|
318
|
+
const fromConfig = (opts.hosts ?? [])
|
|
319
|
+
.map(parseHostEntry)
|
|
320
|
+
.filter((h) => h != null);
|
|
321
|
+
if (fromConfig.length > 0) {
|
|
322
|
+
collected.push(...fromConfig);
|
|
323
|
+
sources.push("config");
|
|
324
|
+
}
|
|
325
|
+
const fromEnv = envHostTargets();
|
|
326
|
+
if (fromEnv.length > 0) {
|
|
327
|
+
collected.push(...fromEnv);
|
|
328
|
+
sources.push("env");
|
|
329
|
+
}
|
|
330
|
+
if (discovery.tailscale !== false) {
|
|
331
|
+
const { hosts, label } = await tryTailscaleTargets(opts.includeOffline ?? false);
|
|
332
|
+
if (hosts.length > 0) {
|
|
333
|
+
collected.push(...hosts);
|
|
334
|
+
sources.push("tailscale");
|
|
335
|
+
if (label)
|
|
336
|
+
labels.push(label);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const lanEnabled = opts.lanScan ?? discovery.lan === true;
|
|
340
|
+
if (lanEnabled) {
|
|
341
|
+
const cidrs = discovery.cidrs && discovery.cidrs.length > 0 ? discovery.cidrs : guessLocalCidrs();
|
|
342
|
+
const lanHosts = await lanScanTargets(cidrs, OLLAMA_PORT);
|
|
343
|
+
if (lanHosts.length > 0) {
|
|
344
|
+
collected.push(...lanHosts);
|
|
345
|
+
sources.push("lan");
|
|
346
|
+
labels.push(...cidrs);
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
sources.push("lan");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const hosts = mergeHosts(collected);
|
|
353
|
+
const networkLabel = labels[0] ??
|
|
354
|
+
(sources.includes("tailscale")
|
|
355
|
+
? "tailscale"
|
|
356
|
+
: sources.includes("lan")
|
|
357
|
+
? "lan"
|
|
358
|
+
: sources.join("+") || "local");
|
|
359
|
+
return { hosts, sources, networkLabel };
|
|
360
|
+
}
|
|
361
|
+
/** Resolve a machine name against discovered hosts, or treat it as a direct address. */
|
|
362
|
+
export function resolveHost(targets, query) {
|
|
363
|
+
const q = query.trim().toLowerCase().replace(/\.$/, "");
|
|
364
|
+
if (!q) {
|
|
365
|
+
throw new Error("Machine name is empty.");
|
|
366
|
+
}
|
|
367
|
+
const matches = targets.filter((host) => {
|
|
368
|
+
const names = [host.hostname, host.dnsName, shortName(host), host.ip].map((n) => n.toLowerCase());
|
|
369
|
+
return names.includes(q) || names.some((n) => n.startsWith(`${q}.`));
|
|
370
|
+
});
|
|
371
|
+
if (matches.length === 1)
|
|
372
|
+
return matches[0];
|
|
373
|
+
if (matches.length > 1) {
|
|
374
|
+
const list = matches.map((h) => `${shortName(h)} (${h.ip})`).join(", ");
|
|
375
|
+
throw new Error(`Ambiguous machine "${query}". Matches: ${list}`);
|
|
376
|
+
}
|
|
377
|
+
const direct = parseDirectAddress(query);
|
|
378
|
+
if (direct)
|
|
379
|
+
return direct;
|
|
380
|
+
const available = targets.map((h) => shortName(h)).join(", ") || "(none)";
|
|
381
|
+
throw new Error(`Unknown machine "${query}". Known hosts: ${available}\n` +
|
|
382
|
+
`Tip: pass an IP/hostname directly, add it under config.hosts, or set OLLANET_HOSTS.`);
|
|
383
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/** Package root: parent of `src/` in a checkout, parent of `dist/` when installed. */
|
|
5
|
+
export const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
6
|
+
export function projectPath(...parts) {
|
|
7
|
+
return path.join(PROJECT_ROOT, ...parts);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* True when running from an installed copy (npx cache, global install, or as a
|
|
11
|
+
* dependency) rather than a source checkout. Installed copies always live under
|
|
12
|
+
* a `node_modules` directory.
|
|
13
|
+
*/
|
|
14
|
+
export const IS_INSTALLED = PROJECT_ROOT.split(path.sep).includes("node_modules");
|
|
15
|
+
/** Per-user data dir used when installed (config + chat transcripts). */
|
|
16
|
+
export const USER_DIR = path.join(os.homedir(), ".ollanet");
|
|
17
|
+
/** Default config location: repo-local in a checkout, `~/.ollanet/` when installed. */
|
|
18
|
+
export function defaultConfigPath() {
|
|
19
|
+
return IS_INSTALLED ? path.join(USER_DIR, "config.json") : projectPath("config.json");
|
|
20
|
+
}
|
|
21
|
+
/** Default transcript dir: repo-local in a checkout, `~/.ollanet/` when installed. */
|
|
22
|
+
export function defaultResponsesDir() {
|
|
23
|
+
return IS_INSTALLED ? path.join(USER_DIR, "responses") : projectPath("responses");
|
|
24
|
+
}
|