offgrid-ai 0.8.14 → 0.9.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/src/cli.mjs CHANGED
@@ -10,7 +10,7 @@ import { uninstallCommand } from "./commands/uninstall.mjs";
10
10
 
11
11
  async function offerUpdate(argv) {
12
12
  const invocation = detectInvocation();
13
- const update = await checkForUpdate({ force: false });
13
+ const update = await checkForUpdate();
14
14
  if (!update) return false;
15
15
 
16
16
  const plan = updateCommand(invocation, argv);
@@ -56,7 +56,7 @@ async function printVersion() {
56
56
  const version = currentPackageVersion();
57
57
  console.log(`offgrid-ai v${version}`);
58
58
  const invocation = detectInvocation();
59
- const update = await checkForUpdate({ force: false });
59
+ const update = await checkForUpdate();
60
60
  if (update) {
61
61
  const plan = updateCommand(invocation, ["version"]);
62
62
  console.log(pc.yellow(`Update available: v${update.latest}. Run: ${plan.display}`));
@@ -27,8 +27,8 @@ export async function mainFlow() {
27
27
  const { models: ggufModels, drafters } = await scanGgufModels();
28
28
  const managedModels = await scanManagedModels();
29
29
  const profiles = await loadProfiles();
30
- const hasAnyBackend = llamaBinary || managedModels.some((item) => item.models.length > 0);
31
- const hasAnyModels = ggufModels.length > 0 || managedModels.some((item) => item.models.length > 0);
30
+ const hasAnyBackend = llamaBinary || managedModels.some((item) => item.status === "ok" && item.models.length > 0);
31
+ const hasAnyModels = ggufModels.length > 0 || managedModels.some((item) => item.status === "ok" && item.models.length > 0);
32
32
 
33
33
  const piInstalled = await hasPi();
34
34
  const needsLlama = ggufModels.length > 0 || profiles.some((profile) => backendFor(profile.backend).type === "local-server");
@@ -90,8 +90,12 @@ function printFoundModels(ggufModels, managedModels, llamaBinary) {
90
90
  console.log(pc.green(`✓ Found ${ggufModels.length} GGUF model${ggufModels.length === 1 ? "" : "s"}`));
91
91
  if (!llamaBinary) console.log(pc.yellow("Install the managed llama.cpp runtime to run these GGUF models."));
92
92
  }
93
- for (const { backendId, models } of managedModels) {
94
- if (models.length > 0) console.log(pc.green(`✓ ${BACKENDS[backendId].label}: ${models.length} model${models.length === 1 ? "" : "s"}`));
93
+ for (const { backendId, models, status, reason } of managedModels) {
94
+ if (status === "unavailable") {
95
+ console.log(pc.yellow(`${BACKENDS[backendId].label}: unavailable${reason ? ` — ${reason}` : ""}`));
96
+ } else if (models.length > 0) {
97
+ console.log(pc.green(`✓ ${BACKENDS[backendId].label}: ${models.length} model${models.length === 1 ? "" : "s"}`));
98
+ }
95
99
  }
96
100
  }
97
101
 
package/src/config.mjs CHANGED
@@ -46,8 +46,13 @@ export async function loadConfig() {
46
46
  try {
47
47
  const raw = await readFile(CONFIG_PATH, "utf8");
48
48
  return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
49
- } catch {
50
- return { ...DEFAULT_CONFIG };
49
+ } catch (error) {
50
+ if (error?.code === "ENOENT") return { ...DEFAULT_CONFIG };
51
+ throw new Error(
52
+ `Failed to read config at ${CONFIG_PATH}: ${error.message}. ` +
53
+ `Fix or remove the file, then try again.`,
54
+ { cause: error }
55
+ );
51
56
  }
52
57
  }
53
58
 
@@ -99,6 +104,7 @@ export async function findLlamaServer() {
99
104
  if (existsSync(candidate)) return candidate;
100
105
  } catch { /* Homebrew not installed or llama.cpp not brewed */ }
101
106
 
107
+ // No llama-server found — caller must present actionable error or onboarding.
102
108
  return null;
103
109
  }
104
110
 
@@ -70,7 +70,7 @@ export async function hasPi() {
70
70
  // ── Internals ──────────────────────────────────────────────────────────────
71
71
 
72
72
  async function activeProviderProfiles(currentProfile) {
73
- const allProfiles = await loadProfiles().catch(() => []);
73
+ const allProfiles = await loadProfiles();
74
74
  const byAlias = new Map();
75
75
  for (const item of [...allProfiles, currentProfile]) {
76
76
  if (item.providerId !== currentProfile.providerId) continue;
package/src/managed.mjs CHANGED
@@ -10,9 +10,9 @@ export async function scanManagedModels() {
10
10
  const backend = BACKENDS[backendId];
11
11
  try {
12
12
  const models = await backend.scanModels();
13
- results.push({ backendId, models });
14
- } catch {
15
- // Managed backends are optional and may not be running.
13
+ results.push({ backendId, models, status: "ok" });
14
+ } catch (error) {
15
+ results.push({ backendId, models: [], status: "unavailable", reason: error?.message ?? String(error) });
16
16
  }
17
17
  }
18
18
  return results;
@@ -18,7 +18,8 @@ export function normalizeCatalog(catalog) {
18
18
  const profiledPaths = new Set(profiles.map((profile) => profile.modelPath).filter(Boolean));
19
19
  const newModels = ggufModels.filter((model) => !profiledPaths.has(model.path));
20
20
  const managedItems = [];
21
- for (const { backendId, models } of managedModels) {
21
+ for (const { backendId, models, status } of managedModels) {
22
+ if (status === "unavailable") continue;
22
23
  const profiledAliases = new Set(
23
24
  profiles
24
25
  .filter((profile) => profile.backend === backendId)
package/src/process.mjs CHANGED
@@ -195,47 +195,55 @@ export async function waitForReady(profile, pid, rawLogPath) {
195
195
  // ── Internals ──────────────────────────────────────────────────────────────
196
196
 
197
197
  async function serverModelIds(baseUrl) {
198
- const body = await fetchJson(`${baseUrl.replace(/\/$/, "")}/models`);
199
- return (Array.isArray(body?.data) ? body.data : [])
198
+ const result = await fetchJson(`${baseUrl.replace(/\/+$/u, "")}/models`);
199
+ if (!result.ok) return [];
200
+ return (Array.isArray(result.data?.data) ? result.data.data : [])
200
201
  .map((model) => String(model?.id ?? "").trim())
201
202
  .filter(Boolean);
202
203
  }
203
204
 
204
205
  async function ollamaLoadedModelIds(profile) {
205
- const body = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/ps`);
206
- return (Array.isArray(body?.models) ? body.models : [])
206
+ const result = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/ps`);
207
+ if (!result.ok) return [];
208
+ return (Array.isArray(result.data?.models) ? result.data.models : [])
207
209
  .flatMap((model) => [model?.name, model?.model])
208
210
  .map((id) => String(id ?? "").trim())
209
211
  .filter(Boolean);
210
212
  }
211
213
 
212
214
  async function omlxLoadedModelIds(profile) {
213
- const status = await fetchJson(`${profile.baseUrl.replace(/\/$/, "")}/models/status`);
214
- const fromStatus = (Array.isArray(status?.models) ? status.models : [])
215
- .filter((model) => model?.loaded === true)
216
- .flatMap((model) => [model?.id, model?.name, model?.model, model?.alias])
217
- .map((id) => String(id ?? "").trim())
218
- .filter(Boolean);
219
- if (Number(status?.loaded_count) === 0) return fromStatus;
220
-
221
- const summary = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/status`);
222
- const fromSummary = (Array.isArray(summary?.loaded_models) ? summary.loaded_models : [])
223
- .map((id) => String(id ?? "").trim())
224
- .filter(Boolean);
215
+ const statusResult = await fetchJson(`${profile.baseUrl.replace(/\/+$/u, "")}/models/status`);
216
+ const fromStatus = statusResult.ok
217
+ ? (Array.isArray(statusResult.data?.models) ? statusResult.data.models : [])
218
+ .filter((model) => model?.loaded === true)
219
+ .flatMap((model) => [model?.id, model?.name, model?.model, model?.alias])
220
+ .map((id) => String(id ?? "").trim())
221
+ .filter(Boolean)
222
+ : [];
223
+ if (!statusResult.ok || Number(statusResult.data?.loaded_count) === 0) return fromStatus;
224
+
225
+ const summaryResult = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/status`);
226
+ const fromSummary = summaryResult.ok
227
+ ? (Array.isArray(summaryResult.data?.loaded_models) ? summaryResult.data.loaded_models : [])
228
+ .map((id) => String(id ?? "").trim())
229
+ .filter(Boolean)
230
+ : [];
225
231
  return [...fromStatus, ...fromSummary];
226
232
  }
227
233
 
228
234
  async function fetchJson(url) {
229
235
  try {
230
236
  const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
231
- if (!response.ok) return null;
232
- return await response.json();
233
- } catch {
234
- return null;
237
+ if (!response.ok) return { ok: false, reason: "http", status: response.status, data: null };
238
+ const data = await response.json();
239
+ return { ok: true, data };
240
+ } catch (error) {
241
+ if (error?.name === "AbortError" || error?.name === "TimeoutError") return { ok: false, reason: "timeout", data: null };
242
+ return { ok: false, reason: "network", data: null };
235
243
  }
236
244
  }
237
245
 
238
- function apiRootUrl(baseUrl) {
246
+ export function apiRootUrl(baseUrl) {
239
247
  try {
240
248
  const url = new URL(baseUrl);
241
249
  url.pathname = url.pathname.replace(/\/v1\/?$/u, "") || "/";
package/src/runtime.mjs CHANGED
@@ -33,6 +33,12 @@ export async function offerManagedLlamaRuntimeUpdate(prompt, { fetchImpl = globa
33
33
  return true;
34
34
  }
35
35
 
36
+ /**
37
+ * Check for the latest llama.cpp release on GitHub.
38
+ * Returns null if the check fails (network error, timeout, etc.).
39
+ * Callers must treat null as "check failed, skip prompt" — do not
40
+ * treat null as "no update available."
41
+ */
36
42
  export async function latestLlamaRelease(fetchImpl = globalThis.fetch) {
37
43
  try {
38
44
  const response = await fetchImpl(RELEASE_API, { signal: AbortSignal.timeout(5000) });
@@ -47,6 +53,11 @@ export async function latestLlamaRelease(fetchImpl = globalThis.fetch) {
47
53
  }
48
54
  }
49
55
 
56
+ /**
57
+ * Read the installed runtime version from disk.
58
+ * Returns null if not installed or if the version file is missing/corrupt.
59
+ * Callers must treat null as "runtime not installed" — not as a hidden default.
60
+ */
50
61
  export async function installedRuntime() {
51
62
  try {
52
63
  return JSON.parse(await readFile(VERSION_PATH, "utf8"));
@@ -1,106 +0,0 @@
1
- #!/usr/bin/env node
2
- import { existsSync, readFileSync, appendFileSync } from "node:fs";
3
- import { mkdirSync } from "node:fs";
4
- import { dirname, join } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
-
7
- if (process.env.CI || process.env.OFFGRID_SKIP_POSTINSTALL) process.exit(0);
8
-
9
- // npm v9+ sets npm_config_global="true" for global installs. Some contexts (Hermes,
10
- // older npm, or wrapped installs) may not set it, so also check the prefix.
11
- const isGlobalInstall = process.env.npm_config_global === "true" || isLikelyGlobalPrefix(process.env.npm_config_prefix, process.env.HOME);
12
- if (!isGlobalInstall) process.exit(0);
13
-
14
- const prefix = process.env.npm_config_prefix;
15
- if (!prefix) {
16
- console.log("offgrid-ai postinstall: npm global prefix not detected; skipping PATH update.");
17
- process.exit(0);
18
- }
19
-
20
- const npmBin = join(prefix, "bin");
21
- const marker = "# Added by offgrid-ai installer";
22
- const pathLine = `export PATH="${npmBin}:$PATH"`;
23
-
24
- const home = process.env.HOME;
25
- if (!home) {
26
- console.log("offgrid-ai postinstall: HOME not set; cannot update shell PATH.");
27
- process.exit(0);
28
- }
29
-
30
- // Detect target shell config file. Prefer the file matching the user's shell.
31
- const shell = process.env.SHELL ?? "";
32
- const isZsh = /\bzsh$/u.test(shell);
33
- const isBash = /\bbash$/u.test(shell);
34
-
35
- let rcCandidates;
36
- if (isZsh) {
37
- rcCandidates = [".zshrc", ".zprofile", ".profile"];
38
- } else if (isBash) {
39
- rcCandidates = [".bashrc", ".bash_profile", ".profile"];
40
- } else {
41
- // Fallback: try common files, prefer existing ones
42
- rcCandidates = [".bashrc", ".bash_profile", ".zshrc", ".zprofile", ".profile"];
43
- }
44
-
45
- const rcPaths = rcCandidates.map((name) => join(home, name));
46
- let rcFile = rcPaths.find((file) => existsSync(file));
47
- if (!rcFile) {
48
- // No existing rc file: create the one matching the user's shell.
49
- const defaultName = isZsh ? ".zshrc" : isBash ? ".bashrc" : ".bashrc";
50
- rcFile = join(home, defaultName);
51
- }
52
-
53
- let content = "";
54
- try {
55
- content = existsSync(rcFile) ? readFileSync(rcFile, "utf8") : "";
56
- } catch (err) {
57
- console.log(`offgrid-ai postinstall: could not read ${rcFile}: ${err.message}`);
58
- process.exit(0);
59
- }
60
-
61
- if (!content.includes(npmBin)) {
62
- try {
63
- mkdirSync(dirname(rcFile), { recursive: true });
64
- appendFileSync(rcFile, `${content.endsWith("\n") || content.length === 0 ? "" : "\n"}\n${marker}\n${pathLine}\n`, "utf8");
65
- const version = currentPackageVersion();
66
- console.log("");
67
- console.log(`offgrid-ai v${version} installed and added to PATH`);
68
- console.log(` Config file: ${rcFile}`);
69
- console.log(` Bin path: ${npmBin}`);
70
- console.log("");
71
- console.log("To use it right now in this terminal, run:");
72
- console.log(` source ${rcFile}`);
73
- console.log("");
74
- console.log("Or open a new terminal window/tab.");
75
- } catch (err) {
76
- console.log(`offgrid-ai postinstall: could not write to ${rcFile}: ${err.message}`);
77
- }
78
- } else {
79
- console.log(`offgrid-ai is installed in ${npmBin}`);
80
- console.log(`Open a new terminal if the command is not found yet.`);
81
- }
82
-
83
- if (process.getuid?.() === 0) {
84
- console.log("Warning: offgrid-ai was installed as root. PATH was added to root's shell config, not the regular user's.");
85
- console.log("If you installed with sudo, run the installer as your normal user instead, or manually add the line above to your user's shell config.");
86
- }
87
-
88
- function currentPackageVersion() {
89
- try {
90
- const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"));
91
- return pkg.version;
92
- } catch {
93
- return "";
94
- }
95
- }
96
-
97
- function isLikelyGlobalPrefix(prefix, home) {
98
- if (!prefix || !home) return false;
99
- const normalized = prefix.replace(/\\/gu, "/");
100
- const homeNormalized = home.replace(/\\/gu, "/");
101
- // Common global prefixes: /usr/local, /opt/homebrew, nvm paths, ~/.npm-global, ~/.local
102
- return normalized.startsWith("/usr/") ||
103
- normalized.startsWith("/opt/") ||
104
- normalized.startsWith(homeNormalized) ||
105
- /\/versions\/node\/v?\d/u.test(normalized);
106
- }