offgrid-ai 0.3.10 → 0.3.11

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/install.sh CHANGED
@@ -144,8 +144,8 @@ fi
144
144
  INSTALLED_VERSION=""
145
145
 
146
146
  if [[ -n "$NPM_BIN" && -x "$NPM_BIN/offgrid-ai" ]]; then
147
- # Get version
148
- INSTALLED_VERSION="$(OFFGRID_NO_UPDATE_CHECK=1 "$NPM_BIN/offgrid-ai" version 2>/dev/null || echo "")"
147
+ # Get plain version from `offgrid-ai version` output (`offgrid-ai vX.Y.Z`).
148
+ INSTALLED_VERSION="$(OFFGRID_NO_UPDATE_CHECK=1 "$NPM_BIN/offgrid-ai" version 2>/dev/null | sed -E 's/^offgrid-ai v//' || echo "")"
149
149
 
150
150
  if command -v offgrid-ai &>/dev/null; then
151
151
  # Already on PATH — nothing to do
@@ -179,7 +179,7 @@ if [[ -n "$NPM_BIN" && -x "$NPM_BIN/offgrid-ai" ]]; then
179
179
  else
180
180
  # Fallback: try to find it anywhere on PATH after install
181
181
  if command -v offgrid-ai &>/dev/null; then
182
- INSTALLED_VERSION="$(offgrid-ai version 2>/dev/null || echo "")"
182
+ INSTALLED_VERSION="$(OFFGRID_NO_UPDATE_CHECK=1 offgrid-ai version 2>/dev/null | sed -E 's/^offgrid-ai v//' || echo "")"
183
183
  ok "offgrid-ai ${INSTALLED_VERSION:+v${INSTALLED_VERSION} }installed at $(command -v offgrid-ai)"
184
184
  else
185
185
  echo ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "description": "Privacy-first CLI for running local LLMs — discover, configure, run, benchmark",
5
5
  "author": "Eeshan Srivastava (https://eeshans.com)",
6
6
  "type": "module",
@@ -5,7 +5,7 @@ import { readGgufMetadata } from "./gguf.mjs";
5
5
  // ── Detect model capabilities from GGUF metadata ──────────────────────────
6
6
 
7
7
  export function detectCapabilities(modelPath, mmprojPath) {
8
- const meta = existsSync(modelPath) ? readGgufMetadata(modelPath) : {};
8
+ const meta = safeReadGgufMetadata(modelPath);
9
9
  const name = basename(modelPath).toLowerCase();
10
10
 
11
11
  // Architecture
@@ -17,7 +17,7 @@ export function detectCapabilities(modelPath, mmprojPath) {
17
17
  const thinking = hasThinkingKwargs || nameHintsThinking;
18
18
 
19
19
  // Vision — mmproj present
20
- const vision = mmprojPath && existsSync(mmprojPath);
20
+ const vision = Boolean(mmprojPath && existsSync(mmprojPath));
21
21
 
22
22
  // MTP (multi-token prediction) — detect speculative decoding
23
23
  const mtp = /mtp/i.test(name) || architecture === "qwen3";
@@ -107,6 +107,15 @@ export function computeFlags(capabilities, modelPath, mmprojPath, draftModelPath
107
107
 
108
108
  // ── Internal helper ─────────────────────────────────────────────────────
109
109
 
110
+ function safeReadGgufMetadata(modelPath) {
111
+ if (!existsSync(modelPath)) return {};
112
+ try {
113
+ return readGgufMetadata(modelPath);
114
+ } catch {
115
+ return {};
116
+ }
117
+ }
118
+
110
119
  function numberMeta(meta, key) {
111
120
  const value = key ? meta[key] : undefined;
112
121
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
package/src/cli.mjs CHANGED
@@ -10,64 +10,40 @@ import { syncPiConfig, removeFromPiConfig, hasPiModel, launchPi, hasPi } from ".
10
10
  import { tailFriendly } from "./logs.mjs";
11
11
  import { estimateMemory } from "./estimate.mjs";
12
12
  import { pc, formatBytes, renderRows, renderSection, startInteractive, createPrompt, parseOptions } from "./ui.mjs";
13
+ import { checkForUpdate, currentPackageVersion, detectInvocation, updateCommand, runUpdateCommand } from "./updates.mjs";
14
+ import { removeInstallerPathEntries } from "./shell-path.mjs";
13
15
 
14
- // ── Update check ────────────────────────────────────────────────────────────
15
-
16
- const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
16
+ // ── Entry point ────────────────────────────────────────────────────────────
17
17
 
18
- async function checkForUpdate() {
19
- if (process.env.OFFGRID_NO_UPDATE_CHECK) return null;
20
- const { readFile, writeFile } = await import("node:fs/promises");
21
- const { join } = await import("node:path");
22
- const cacheFile = join(DATA_DIR, "update-cache.json");
18
+ async function offerUpdate(argv) {
19
+ const invocation = detectInvocation();
20
+ const update = await checkForUpdate({ force: invocation === "npx" });
21
+ if (!update) return false;
23
22
 
24
- // Read cached check
25
- let cached;
26
- try {
27
- cached = JSON.parse(await readFile(cacheFile, "utf8"));
28
- } catch { cached = null; }
23
+ const plan = updateCommand(invocation, argv);
24
+ console.log(pc.yellow(`\nUpdate available: v${update.latest}. You have v${update.current}.`));
25
+ console.log(pc.dim(`Run: ${plan.display}`));
26
+ console.log();
29
27
 
30
- // Skip if checked recently
31
- if (cached?.lastChecked && Date.now() - cached.lastChecked < UPDATE_CHECK_INTERVAL) {
32
- return cached.latestVersion && cached.latestVersion !== cached.currentVersion ? { current: cached.currentVersion, latest: cached.latestVersion } : null;
33
- }
28
+ if (!process.stdin.isTTY) return false;
34
29
 
35
- // Fetch latest version from npm registry
30
+ const prompt = createPrompt();
36
31
  try {
37
- const response = await fetch("https://registry.npmjs.org/offgrid-ai/latest", {
38
- signal: AbortSignal.timeout(3000),
39
- });
40
- if (!response.ok) return null;
41
- const body = await response.json();
42
- const latestVersion = body.version;
43
-
44
- // Get current version
45
- const { readFileSync } = await import("node:fs");
46
- const { dirname } = await import("node:path");
47
- const { fileURLToPath } = await import("node:url");
48
- const __dirname = dirname(fileURLToPath(import.meta.url));
49
- const currentVersion = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8")).version;
50
-
51
- // Cache the result
52
- await writeFile(cacheFile, JSON.stringify({ lastChecked: Date.now(), currentVersion, latestVersion }), "utf8");
53
-
54
- return latestVersion !== currentVersion ? { current: currentVersion, latest: latestVersion } : null;
55
- } catch {
56
- // Network error or timeout — fail silently
57
- return null;
32
+ const shouldUpdate = await prompt.yesNo("Update now?", false);
33
+ if (!shouldUpdate) return false;
34
+ await runUpdateCommand(plan);
35
+ if (plan.mode === "install-global") {
36
+ console.log(pc.green("Updated. Run offgrid-ai again to use the new version."));
37
+ }
38
+ return true;
39
+ } finally {
40
+ prompt.close();
58
41
  }
59
42
  }
60
43
 
61
- // ── Entry point ────────────────────────────────────────────────────────────
62
-
63
44
  export async function run(argv) {
64
45
  if (argv.length === 0) {
65
- const update = await checkForUpdate();
66
- if (update) {
67
- console.log(pc.yellow(`\nUpdate available: ${update.latest}. You have v${update.current}.`));
68
- console.log(pc.dim("Run: npm install -g offgrid-ai@latest"));
69
- console.log();
70
- }
46
+ if (await offerUpdate(argv)) return;
71
47
  return mainFlow();
72
48
  }
73
49
  const [command] = argv;
@@ -301,7 +277,10 @@ async function pickAndRun(prompt, profiles, newModels, managedItems) {
301
277
  }
302
278
 
303
279
  if (selected.startsWith("managed:")) {
304
- const [, backendId, modelId] = selected.split(":");
280
+ const managedSelection = selected.slice("managed:".length);
281
+ const separator = managedSelection.indexOf(":");
282
+ const backendId = separator === -1 ? managedSelection : managedSelection.slice(0, separator);
283
+ const modelId = separator === -1 ? "" : managedSelection.slice(separator + 1);
305
284
  const model = managedItems.find((m) => m.model.id === modelId && m.backendId === backendId)?.model;
306
285
  if (!model) throw new Error("Model not found.");
307
286
  const profile = normalizeProfile({
@@ -342,10 +321,9 @@ async function runProfile(profile, options = {}) {
342
321
  console.log(pc.green(`[ready] ${backend.label} at ${profile.baseUrl}`));
343
322
  } else {
344
323
  const ready = await serverReady(profile.baseUrl);
345
- if (ready && !options["reuse-existing"]) {
346
- console.log(pc.green(`[ready] Server already running at ${profile.baseUrl}`));
347
- console.log(pc.dim("Use --reuse-existing to reuse this server."));
348
- } else if (!ready) {
324
+ if (ready) {
325
+ console.log(pc.green(`[ready] Reusing server at ${profile.baseUrl}`));
326
+ } else {
349
327
  console.log(pc.dim(`Starting ${backend.label} for ${profile.label}...`));
350
328
  let state;
351
329
  try {
@@ -840,11 +818,15 @@ async function uninstallCommand(argv) {
840
818
  const { options } = parseOptions(argv);
841
819
  const force = options.force || options.f;
842
820
 
843
- if (!process.stdin.isTTY || force) {
844
- // Non-interactive / forced: remove everything
821
+ if (!process.stdin.isTTY && !force) {
822
+ throw new Error("Non-interactive uninstall requires --force to avoid accidental data loss.");
823
+ }
824
+
825
+ if (force) {
826
+ await stopTrackedServers();
845
827
  await removeDataDir();
846
- await removeSelf();
847
828
  await removeShellPath();
829
+ await removeSelf();
848
830
  return;
849
831
  }
850
832
 
@@ -881,8 +863,8 @@ async function uninstallCommand(argv) {
881
863
  // Remove the npm package
882
864
  const confirmUninstall = await prompt.yesNo("Uninstall offgrid-ai npm package?", true);
883
865
  if (confirmUninstall) {
884
- await removeSelf();
885
866
  await removeShellPath();
867
+ await removeSelf();
886
868
  } else {
887
869
  console.log(pc.dim("Cancelled."));
888
870
  }
@@ -891,6 +873,14 @@ async function uninstallCommand(argv) {
891
873
  }
892
874
  }
893
875
 
876
+ async function stopTrackedServers() {
877
+ const running = await runningProfiles();
878
+ for (const { profile } of running) {
879
+ const result = await stopProfile(profile);
880
+ console.log(result.stopped ? pc.green(`✓ ${result.message}`) : pc.dim(result.message));
881
+ }
882
+ }
883
+
894
884
  async function removeDataDir() {
895
885
  const dataDir = DATA_DIR;
896
886
  if (existsSync(dataDir)) {
@@ -907,47 +897,13 @@ async function removeDataDir() {
907
897
  }
908
898
 
909
899
  async function removeShellPath() {
910
- const { readFile, writeFile } = await import("node:fs/promises");
911
- const { homedir } = await import("node:os");
912
- const { promisify } = await import("node:util");
913
- const { execFile } = await import("node:child_process");
914
- let npmBin;
915
- try {
916
- const { stdout } = await promisify(execFile)("npm", ["bin", "-g"]);
917
- npmBin = stdout.trim();
918
- } catch { return; }
919
- if (!npmBin) return;
920
-
921
- const marker = "# Added by offgrid-ai installer";
922
- const pathLine = `export PATH="${npmBin}:$PATH"`;
923
- const rcFiles = [`${homedir()}/.zshrc`, `${homedir()}/.bashrc`, `${homedir()}/.bash_profile`];
924
- let cleaned = false;
925
-
926
- for (const rcFile of rcFiles) {
927
- if (!existsSync(rcFile)) continue;
928
- let content;
929
- try { content = await readFile(rcFile, "utf8"); } catch { continue; }
930
- if (!content.includes(npmBin) && !content.includes(marker)) continue;
931
-
932
- const lines = content.split("\n");
933
- const filtered = lines.filter((line, i) => {
934
- // Remove the marker comment line
935
- if (line.trim() === marker) return false;
936
- // Remove the PATH export line added by the installer
937
- if (line.trim() === pathLine) return false;
938
- // Also remove a blank line that immediately precedes the marker (formatter)
939
- if (i + 1 < lines.length && lines[i + 1].trim() === marker && line.trim() === "") return false;
940
- return true;
941
- });
942
-
943
- if (filtered.length !== lines.length) {
944
- await writeFile(rcFile, filtered.join("\n").replace(/\n{3,}/g, "\n\n") + "\n", "utf8");
945
- console.log(pc.green(`✓ Cleaned PATH from ${rcFile}`));
946
- cleaned = true;
947
- }
948
- }
949
- if (!cleaned) {
900
+ const cleaned = await removeInstallerPathEntries();
901
+ if (cleaned.length === 0) {
950
902
  console.log(pc.dim("No offgrid-ai PATH entries found in shell configs."));
903
+ return;
904
+ }
905
+ for (const rcFile of cleaned) {
906
+ console.log(pc.green(`✓ Cleaned PATH from ${rcFile}`));
951
907
  }
952
908
  }
953
909
 
@@ -1015,19 +971,13 @@ async function scanManagedModels() {
1015
971
  }
1016
972
 
1017
973
  async function printVersion() {
1018
- const { readFileSync } = await import("node:fs");
1019
- const { dirname, join } = await import("node:path");
1020
- const { fileURLToPath } = await import("node:url");
1021
- const __dirname = dirname(fileURLToPath(import.meta.url));
1022
- try {
1023
- const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
1024
- console.log(`offgrid-ai v${pkg.version}`);
1025
- const update = await checkForUpdate();
1026
- if (update) {
1027
- console.log(pc.yellow(`Update available: ${update.latest}. Run: npm install -g offgrid-ai@latest`));
1028
- }
1029
- } catch {
1030
- console.log("offgrid-ai v0.1.0");
974
+ const version = currentPackageVersion();
975
+ console.log(`offgrid-ai v${version}`);
976
+ const invocation = detectInvocation();
977
+ const update = await checkForUpdate({ force: invocation === "npx" });
978
+ if (update) {
979
+ const plan = updateCommand(invocation, ["version"]);
980
+ console.log(pc.yellow(`Update available: v${update.latest}. Run: ${plan.display}`));
1031
981
  }
1032
982
  }
1033
983
 
@@ -0,0 +1,58 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+
5
+ export const INSTALLER_PATH_MARKER = "# Added by offgrid-ai installer";
6
+
7
+ export function defaultShellConfigFiles(home = homedir()) {
8
+ return [`${home}/.zshrc`, `${home}/.bashrc`, `${home}/.bash_profile`];
9
+ }
10
+
11
+ export function removeInstallerPathBlock(content) {
12
+ const lines = content.split("\n");
13
+ const kept = [];
14
+ let changed = false;
15
+
16
+ for (let i = 0; i < lines.length; i++) {
17
+ const line = lines[i];
18
+ const next = lines[i + 1];
19
+
20
+ if (line.trim() === "" && next?.trim() === INSTALLER_PATH_MARKER) {
21
+ changed = true;
22
+ continue;
23
+ }
24
+
25
+ if (line.trim() === INSTALLER_PATH_MARKER) {
26
+ changed = true;
27
+ const following = lines[i + 1]?.trim() ?? "";
28
+ if (/^export\s+PATH=".+:\$PATH"$/u.test(following)) i += 1;
29
+ continue;
30
+ }
31
+
32
+ kept.push(line);
33
+ }
34
+
35
+ return {
36
+ changed,
37
+ content: kept.join("\n").replace(/\n{3,}/gu, "\n\n").replace(/\s+$/u, "") + "\n",
38
+ };
39
+ }
40
+
41
+ export async function removeInstallerPathEntries(rcFiles = defaultShellConfigFiles()) {
42
+ const cleaned = [];
43
+ for (const rcFile of rcFiles) {
44
+ if (!existsSync(rcFile)) continue;
45
+ let content;
46
+ try {
47
+ content = await readFile(rcFile, "utf8");
48
+ } catch {
49
+ continue;
50
+ }
51
+
52
+ const result = removeInstallerPathBlock(content);
53
+ if (!result.changed) continue;
54
+ await writeFile(rcFile, result.content, "utf8");
55
+ cleaned.push(rcFile);
56
+ }
57
+ return cleaned;
58
+ }
package/src/ui.mjs CHANGED
@@ -51,6 +51,7 @@ function handleCancel(value) {
51
51
  }
52
52
 
53
53
  export function renderRows(rows) {
54
+ if (rows.length === 0) return "";
54
55
  const width = Math.max(...rows.map(([key]) => stripVTControlCharacters(String(key)).length));
55
56
  return rows.map(([key, value]) => {
56
57
  const visible = stripVTControlCharacters(String(key)).length;
@@ -67,14 +68,21 @@ export function parseOptions(argv) {
67
68
  const options = {};
68
69
  for (let i = 0; i < argv.length; i++) {
69
70
  const item = argv[i];
71
+ if (item === "--") {
72
+ positional.push(...argv.slice(i + 1));
73
+ break;
74
+ }
70
75
  if (item.startsWith("--")) {
71
- const key = item.slice(2);
76
+ const [key, inlineValue] = item.slice(2).split(/=(.*)/u, 2);
72
77
  const next = argv[i + 1];
73
- if (next && !next.startsWith("--")) { options[key] = next; i += 1; }
78
+ if (inlineValue !== undefined) options[key] = inlineValue;
79
+ else if (next && !next.startsWith("-")) { options[key] = next; i += 1; }
74
80
  else options[key] = true;
81
+ } else if (/^-[A-Za-z]+$/u.test(item)) {
82
+ for (const key of item.slice(1)) options[key] = true;
75
83
  } else {
76
84
  positional.push(item);
77
85
  }
78
86
  }
79
87
  return { positional, options };
80
- }
88
+ }
@@ -0,0 +1,125 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { DATA_DIR } from "./config.mjs";
7
+
8
+ const PACKAGE_NAME = "offgrid-ai";
9
+ const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000;
10
+
11
+ export async function checkForUpdate({ now = Date.now(), fetchImpl = globalThis.fetch, force = false } = {}) {
12
+ if (process.env.OFFGRID_NO_UPDATE_CHECK) return null;
13
+
14
+ const currentVersion = currentPackageVersion();
15
+ const cacheFile = join(DATA_DIR, "update-cache.json");
16
+ const cached = await readUpdateCache(cacheFile);
17
+
18
+ if (!force && cached?.currentVersion === currentVersion && cached?.lastChecked && now - cached.lastChecked < UPDATE_CHECK_INTERVAL) {
19
+ return updateResult(currentVersion, cached.latestVersion);
20
+ }
21
+
22
+ try {
23
+ const response = await fetchImpl(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
24
+ signal: AbortSignal.timeout(3000),
25
+ });
26
+ if (!response.ok) return null;
27
+ const body = await response.json();
28
+ const latestVersion = typeof body?.version === "string" ? body.version : null;
29
+ if (!latestVersion) return null;
30
+
31
+ await mkdir(DATA_DIR, { recursive: true });
32
+ await writeFile(cacheFile, JSON.stringify({ lastChecked: now, currentVersion, latestVersion }, null, 2) + "\n", "utf8");
33
+
34
+ return updateResult(currentVersion, latestVersion);
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export function currentPackageVersion() {
41
+ const __dirname = dirname(fileURLToPath(import.meta.url));
42
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
43
+ return pkg.version;
44
+ }
45
+
46
+ export function isNewerVersion(candidate, current) {
47
+ return compareVersions(candidate, current) > 0;
48
+ }
49
+
50
+ export function compareVersions(a, b) {
51
+ const left = versionParts(a);
52
+ const right = versionParts(b);
53
+ const len = Math.max(left.length, right.length);
54
+ for (let i = 0; i < len; i++) {
55
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
56
+ if (diff !== 0) return diff;
57
+ }
58
+ return 0;
59
+ }
60
+
61
+ export function detectInvocation(env = process.env) {
62
+ const execPath = env.npm_execpath ?? "";
63
+ if (/(^|[\\/])npx-cli\.js$/u.test(execPath)) return "npx";
64
+ if (env.npm_command === "exec") return "npx";
65
+ return "global";
66
+ }
67
+
68
+ export function updateCommand(invocation = detectInvocation(), argv = []) {
69
+ if (invocation === "npx") {
70
+ const args = ["exec", "--yes", "--", `${PACKAGE_NAME}@latest`, ...argv];
71
+ return {
72
+ cmd: "npm",
73
+ args,
74
+ display: shellCommand("npm", args),
75
+ mode: "run-latest",
76
+ };
77
+ }
78
+
79
+ const args = ["install", "-g", `${PACKAGE_NAME}@latest`];
80
+ return {
81
+ cmd: "npm",
82
+ args,
83
+ display: shellCommand("npm", args),
84
+ mode: "install-global",
85
+ };
86
+ }
87
+
88
+ export function runUpdateCommand(plan) {
89
+ return new Promise((resolve, reject) => {
90
+ const child = spawn(plan.cmd, plan.args, { stdio: "inherit" });
91
+ child.on("error", reject);
92
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${plan.cmd} exited with code ${code}`)));
93
+ });
94
+ }
95
+
96
+ async function readUpdateCache(cacheFile) {
97
+ try {
98
+ return JSON.parse(await readFile(cacheFile, "utf8"));
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ function updateResult(currentVersion, latestVersion) {
105
+ return isNewerVersion(latestVersion, currentVersion)
106
+ ? { current: currentVersion, latest: latestVersion }
107
+ : null;
108
+ }
109
+
110
+ function versionParts(value) {
111
+ return String(value)
112
+ .replace(/^v/u, "")
113
+ .split(/[.-]/u)
114
+ .map((part) => Number.parseInt(part, 10))
115
+ .filter((part) => Number.isFinite(part));
116
+ }
117
+
118
+ function shellCommand(cmd, args) {
119
+ return [cmd, ...args.map(shellQuote)].join(" ");
120
+ }
121
+
122
+ function shellQuote(value) {
123
+ const text = String(value);
124
+ return /^[A-Za-z0-9_./:@=-]+$/u.test(text) ? text : JSON.stringify(text);
125
+ }