offgrid-ai 0.8.13 → 0.8.15

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 CHANGED
@@ -41,12 +41,14 @@ This installs offgrid-ai and anything else it needs. Then open a new terminal wi
41
41
  offgrid-ai
42
42
  ```
43
43
 
44
- If you already have Node.js installed, you can also use:
44
+ If you already have Node.js installed, you can also install with npm:
45
45
 
46
46
  ```bash
47
47
  npm install -g offgrid-ai@latest --prefer-online
48
48
  ```
49
49
 
50
+ The curl installer is recommended for first-time setup because it also verifies the global npm bin directory is on your PATH. The npm package itself does not run install scripts or mutate shell config during `npm install`.
51
+
50
52
  ### 2. Pick a model
51
53
 
52
54
  The first time you run offgrid-ai, it looks for models already on your machine. If it does not find any, it tells you how to get one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "offgrid-ai",
3
- "version": "0.8.13",
3
+ "version": "0.8.15",
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",
@@ -36,7 +36,6 @@
36
36
  "release:check": "bash scripts/release-check.sh",
37
37
  "release:check:fast": "bash scripts/release-check.sh --skip-install --skip-manual",
38
38
  "prepack": "npm run check:privacy",
39
- "postinstall": "node src/postinstall.mjs",
40
39
  "pretest": "npm run lint"
41
40
  },
42
41
  "dependencies": {
package/src/benchmark.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdir, writeFile, readFile, readdir } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { createHash } from "node:crypto";
4
- import { join, resolve } from "node:path";
4
+ import { basename, join, relative, resolve } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { spawn, execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
@@ -250,6 +250,8 @@ const BENCH_COLORS = {
250
250
  thinking: pc.magenta,
251
251
  text: pc.green,
252
252
  tool: pc.yellow,
253
+ success: pc.green,
254
+ warning: pc.yellow,
253
255
  toolOutput: pc.dim,
254
256
  error: pc.red,
255
257
  info: pc.cyan,
@@ -268,9 +270,9 @@ function formatTokens(n) {
268
270
  return String(Math.round(n));
269
271
  }
270
272
 
271
- function estimatedTokensFromText(text) {
272
- // Simple heuristic: ~4 chars per token for code/English.
273
- return Math.max(1, Math.ceil(text.length / 4));
273
+ function estimatedTokensFromBytes(bytes) {
274
+ // Simple heuristic: ~4 bytes per token for code/English.
275
+ return Math.max(1, Math.ceil(bytes / 4));
274
276
  }
275
277
 
276
278
  function clearStatusLine() {
@@ -296,24 +298,24 @@ function renderStreamEvent(parsed, state, opts = {}) {
296
298
 
297
299
  switch (type) {
298
300
  case "session":
299
- console.log(BENCH_COLORS.dim(`[session] ${parsed.id}`));
301
+ printFinalLine(BENCH_COLORS.info("Pi benchmark started"));
302
+ if (parsed.id) printFinalLine(BENCH_COLORS.dim(` Session ${parsed.id}`));
300
303
  break;
301
304
  case "agent_start":
302
- console.log(BENCH_COLORS.dim("[agent_start]"));
303
305
  break;
304
306
  case "turn_start": {
305
307
  state.turn += 1;
306
- state.status.mode = "thinking";
307
- state.status.toolName = null;
308
- state.status.bytes = 0;
309
- state.status.text = "";
310
- printFinalLine(BENCH_COLORS.info(`[turn ${state.turn}]`));
308
+ state.turnHadToolError = false;
309
+ resetStatus(state, "thinking");
310
+ printFinalLine("");
311
+ printFinalLine(BENCH_COLORS.info(`Turn ${state.turn}`));
311
312
  break;
312
313
  }
313
314
  case "message_start": {
314
315
  const msg = parsed.message;
315
- if (msg?.role === "assistant" && msg.provider && msg.model) {
316
- console.log(BENCH_COLORS.info(`[assistant] ${msg.provider}/${msg.model}`));
316
+ if (!state.modelPrinted && msg?.role === "assistant" && msg.provider && msg.model) {
317
+ state.modelPrinted = true;
318
+ printFinalLine(BENCH_COLORS.dim(` Model ${msg.provider}/${msg.model}`));
317
319
  }
318
320
  break;
319
321
  }
@@ -321,90 +323,189 @@ function renderStreamEvent(parsed, state, opts = {}) {
321
323
  const evt = parsed.assistantMessageEvent;
322
324
  if (!evt) return;
323
325
  const subtype = String(evt.type ?? "").replace(/_/gu, "");
324
- if (subtype === "thinkingstart" || subtype === "thinkingdelta") {
326
+ if (subtype === "thinkingstart") {
327
+ resetStatus(state, "thinking");
328
+ } else if (subtype === "thinkingdelta") {
325
329
  if (verbose) process.stdout.write(BENCH_COLORS.thinking(evt.delta || ""));
326
- state.status.mode = "thinking";
327
- updateStatusFromDelta(state, evt.delta);
328
- } else if (subtype === "textstart" || subtype === "textdelta") {
330
+ updateStatusFromDelta(state, evt.delta, "thinking");
331
+ } else if (subtype === "textstart") {
332
+ resetStatus(state, "text");
333
+ } else if (subtype === "textdelta") {
329
334
  if (verbose) process.stdout.write(BENCH_COLORS.text(evt.delta || ""));
330
- state.status.mode = "text";
331
- updateStatusFromDelta(state, evt.delta);
335
+ updateStatusFromDelta(state, evt.delta, "text");
332
336
  } else if (subtype === "toolcallstart") {
333
- if (!verbose) printFinalLine(BENCH_COLORS.tool("[tool_call_start]"));
337
+ resetStatus(state, "tool");
334
338
  } else if (subtype === "toolcalldelta") {
335
339
  if (verbose) process.stdout.write(BENCH_COLORS.tool(evt.delta || ""));
336
- state.status.mode = "tool";
337
- updateStatusFromDelta(state, evt.delta);
338
- } else if (subtype === "toolcallend") {
339
- if (!verbose) printFinalLine(BENCH_COLORS.tool("[tool_call_end]"));
340
+ updateStatusFromDelta(state, evt.delta, "tool");
340
341
  }
341
342
  break;
342
343
  }
343
- case "message_end": {
344
- const msg = parsed.message;
345
- if (msg?.role === "assistant" && Array.isArray(msg.content)) {
346
- for (const item of msg.content) {
347
- if (item.type === "toolCall") {
348
- const toolLine = formatToolCall(item);
349
- state.status.toolName = item.name;
350
- if (!verbose) printFinalLine(BENCH_COLORS.tool(toolLine));
351
- }
352
- }
353
- }
344
+ case "message_end":
354
345
  break;
355
- }
356
- case "tool_execution_start":
357
- state.status.mode = "exec";
358
- state.status.toolName = parsed.toolName;
359
- state.status.bytes = 0;
360
- state.status.text = "";
361
- printFinalLine(BENCH_COLORS.tool(`[exec] ${parsed.toolName}`));
346
+ case "tool_execution_start": {
347
+ state.activeTool = {
348
+ name: parsed.toolName,
349
+ args: parsed.args ?? {},
350
+ outputText: "",
351
+ };
352
+ resetStatus(state, "exec", parsed.toolName);
353
+ printFinalLine(BENCH_COLORS.tool(formatToolStart(parsed.toolName, parsed.args ?? {}, state)));
362
354
  break;
355
+ }
363
356
  case "tool_execution_update": {
364
- if (parsed.content) {
365
- if (verbose) process.stdout.write(BENCH_COLORS.toolOutput(parsed.content));
366
- state.status.mode = "exec";
367
- updateStatusFromDelta(state, parsed.content);
357
+ const text = toolResultText(parsed.partialResult ?? parsed.result ?? parsed);
358
+ if (text) {
359
+ if (verbose) process.stdout.write(BENCH_COLORS.toolOutput(text));
360
+ if (state.activeTool) state.activeTool.outputText = text;
361
+ updateStatusFromDelta(state, text, "exec");
368
362
  }
369
363
  break;
370
364
  }
371
- case "tool_execution_end":
372
- printFinalLine(BENCH_COLORS.tool(`[exec done] ${state.status.toolName || parsed.toolName}`));
365
+ case "tool_execution_end": {
366
+ const lines = formatToolEnd(parsed, state);
367
+ if (parsed.isError) state.turnHadToolError = true;
368
+ for (const line of lines) printFinalLine(line);
369
+ state.activeTool = null;
370
+ resetStatus(state, "idle");
373
371
  break;
372
+ }
374
373
  case "toolResult": {
375
- const errorFlag = parsed.isError ? BENCH_COLORS.error(" error") : "";
376
- printFinalLine(BENCH_COLORS.tool(`[result] ${parsed.toolName}${errorFlag}`));
374
+ if (parsed.isError) state.turnHadToolError = true;
375
+ const status = parsed.isError ? BENCH_COLORS.error("✗") : BENCH_COLORS.success("✓");
376
+ printFinalLine(`${status} ${parsed.toolName ?? "tool"}`);
377
377
  break;
378
378
  }
379
379
  case "turn_end": {
380
380
  const usage = parsed.message?.usage;
381
- if (usage) {
382
- const exact = usage.output ?? usage.totalTokens ?? 0;
383
- printFinalLine(BENCH_COLORS.info(`[turn ${state.turn}] completed · ${formatTokens(exact)} tokens`));
384
- } else {
385
- printFinalLine(BENCH_COLORS.info(`[turn ${state.turn}] completed`));
386
- }
381
+ const tokenPart = usage ? ` · ${formatTokens(usage.output ?? usage.totalTokens ?? 0)} tokens` : "";
382
+ const marker = state.turnHadToolError ? BENCH_COLORS.warning("⚠") : BENCH_COLORS.success("✓");
383
+ const suffix = state.turnHadToolError ? " · tool issue" : "";
384
+ printFinalLine(`${marker} turn ${state.turn}${tokenPart}${suffix}`);
387
385
  break;
388
386
  }
389
387
  case "agent_end":
390
388
  clearStatusLine();
391
- console.log(BENCH_COLORS.dim("[agent_end]"));
389
+ printFinalLine(BENCH_COLORS.info("Pi benchmark finished"));
392
390
  break;
393
391
  default:
394
392
  break;
395
393
  }
396
394
  }
397
395
 
398
- function updateStatusFromDelta(state, delta) {
396
+ function resetStatus(state, mode, toolName = null) {
397
+ state.status.mode = mode;
398
+ state.status.toolName = toolName;
399
+ state.status.bytes = 0;
400
+ state.status.tokens = 0;
401
+ }
402
+
403
+ function updateStatusFromDelta(state, delta, mode = state.status.mode) {
399
404
  if (!delta) return;
405
+ state.status.mode = mode;
400
406
  state.status.bytes += Buffer.byteLength(delta, "utf8");
401
- state.status.text = (state.status.text || "") + delta;
402
- state.status.tokens = estimatedTokensFromText(state.status.text);
407
+ state.status.tokens = estimatedTokensFromBytes(state.status.bytes);
403
408
  const label = state.status.toolName ? ` · ${state.status.toolName}` : "";
404
- const modeLabel = state.status.mode === "thinking" ? "thinking" : state.status.mode === "text" ? "text" : state.status.mode === "tool" ? "tool" : "exec";
409
+ const modeLabel = {
410
+ thinking: "thinking…",
411
+ text: "drafting response…",
412
+ tool: "preparing tool…",
413
+ exec: "running tool…",
414
+ }[state.status.mode] ?? "working…";
405
415
  const bytes = formatBytes(state.status.bytes);
406
416
  const tokens = formatTokens(state.status.tokens);
407
- printStatusLine(BENCH_COLORS.dim(`[turn ${state.turn}] ${modeLabel}${label} · ${bytes} (~${tokens} tokens)`));
417
+ printStatusLine(BENCH_COLORS.dim(`Turn ${state.turn} ${modeLabel}${label} · ${bytes} (~${tokens} tokens)`));
418
+ }
419
+
420
+ function formatToolStart(toolName, args, state) {
421
+ if (toolName === "read") return `→ read ${displayPath(args.path, state)}`;
422
+ if (toolName === "write") {
423
+ const size = args.content ? ` · ${formatBytes(Buffer.byteLength(String(args.content), "utf8"))}` : "";
424
+ return `→ write ${displayPath(args.path, state)}${size}`;
425
+ }
426
+ if (toolName === "edit") {
427
+ const count = Array.isArray(args.edits) ? args.edits.length : 0;
428
+ const suffix = count > 0 ? ` · ${count} replacement${count === 1 ? "" : "s"}` : "";
429
+ return `→ edit ${displayPath(args.path, state)}${suffix}`;
430
+ }
431
+ if (toolName === "bash") return `→ run ${truncateOneLine(args.command ?? "")}`;
432
+ return `→ ${toolName}${compactArgs(args)}`;
433
+ }
434
+
435
+ function formatToolEnd(parsed, state) {
436
+ const toolName = parsed.toolName ?? state.activeTool?.name ?? "tool";
437
+ const args = parsed.args ?? state.activeTool?.args ?? {};
438
+ const text = toolResultText(parsed.result) || state.activeTool?.outputText || "";
439
+ const marker = parsed.isError ? BENCH_COLORS.error("✗") : BENCH_COLORS.success("✓");
440
+
441
+ if (parsed.isError) {
442
+ return [`${marker} ${toolName} failed · ${firstUsefulLine(text)}`];
443
+ }
444
+
445
+ if (toolName === "write") return [`${marker} wrote ${displayPath(args.path, state)}${parsedWriteSize(text)}`];
446
+ if (toolName === "read") return [`${marker} read ${displayPath(args.path, state)}${text ? ` · ${formatBytes(Buffer.byteLength(text, "utf8"))}` : ""}`];
447
+ if (toolName === "edit") return [`${marker} edited ${displayPath(args.path, state)}`];
448
+ if (toolName === "bash") return formatBashResult(marker, text);
449
+
450
+ const summary = firstUsefulLine(text);
451
+ return [`${marker} ${toolName}${summary ? ` · ${summary}` : ""}`];
452
+ }
453
+
454
+ function formatBashResult(marker, text) {
455
+ const lines = meaningfulLines(text).slice(0, 2);
456
+ if (lines.length === 0) return [`${marker} command completed`];
457
+ return [`${marker} ${lines[0]}`, ...lines.slice(1).map((line) => BENCH_COLORS.dim(` ${line}`))];
458
+ }
459
+
460
+ function parsedWriteSize(text) {
461
+ const match = String(text).match(/Successfully wrote\s+([0-9,]+)\s+bytes/iu);
462
+ if (!match) return "";
463
+ const bytes = Number(match[1].replace(/,/gu, ""));
464
+ return Number.isFinite(bytes) ? ` · ${formatBytes(bytes)}` : "";
465
+ }
466
+
467
+ function toolResultText(result) {
468
+ const content = result?.content;
469
+ if (typeof content === "string") return content;
470
+ if (!Array.isArray(content)) return "";
471
+ return content
472
+ .map((item) => typeof item?.text === "string" ? item.text : "")
473
+ .filter(Boolean)
474
+ .join("\n");
475
+ }
476
+
477
+ function firstUsefulLine(text) {
478
+ return meaningfulLines(text)[0] ?? "no details";
479
+ }
480
+
481
+ function meaningfulLines(text) {
482
+ const lines = String(text ?? "")
483
+ .split(/\r?\n/u)
484
+ .map((line) => line.trim())
485
+ .filter(Boolean)
486
+ .filter((line) => !/^\^+$/u.test(line));
487
+ const errorLine = lines.find((line) => /(?:error|exception|failed|not found|command exited with code|validation failed)/iu.test(line));
488
+ if (errorLine) return [errorLine, ...lines.filter((line) => line !== errorLine)];
489
+ return lines;
490
+ }
491
+
492
+ function displayPath(value, state) {
493
+ if (!value) return "unknown";
494
+ const path = String(value);
495
+ const rel = state.cwd ? relative(state.cwd, path) : path;
496
+ if (rel && !rel.startsWith("..") && rel !== ".") return rel;
497
+ return basename(path) || path;
498
+ }
499
+
500
+ function compactArgs(args) {
501
+ const entries = Object.entries(args ?? {}).filter(([, value]) => value !== undefined && value !== null && value !== "");
502
+ if (entries.length === 0) return "";
503
+ return ` · ${truncateOneLine(entries.map(([key, value]) => `${key}=${String(value)}`).join(" "))}`;
504
+ }
505
+
506
+ function truncateOneLine(value, max = Math.max(60, Math.min(process.stdout.columns ?? 100, 140) - 12)) {
507
+ const text = String(value ?? "").replace(/\s+/gu, " ").trim();
508
+ return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text;
408
509
  }
409
510
 
410
511
  function formatBytes(bytes) {
@@ -463,7 +564,14 @@ export async function runBenchmarkInPi(profile, runDirectory, { signal } = {}) {
463
564
  const stderrHandle = await openFileHandle(stderrPath, "w");
464
565
 
465
566
  const verbose = Boolean(process.env.OFFGRID_BENCHMARK_VERBOSE);
466
- const renderState = { turn: 0, status: { mode: "idle", toolName: null, bytes: 0, text: "", tokens: 0 } };
567
+ const renderState = {
568
+ cwd: runDirectory,
569
+ turn: 0,
570
+ turnHadToolError: false,
571
+ modelPrinted: false,
572
+ activeTool: null,
573
+ status: { mode: "idle", toolName: null, bytes: 0, tokens: 0 },
574
+ };
467
575
 
468
576
  function appendResponse(text) {
469
577
  responseBuffer += text;
package/src/updates.mjs CHANGED
@@ -1,27 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
- import { readFile, writeFile, mkdir } from "node:fs/promises";
3
2
  import { readFileSync } from "node:fs";
4
- import { dirname, join } from "node:path";
5
3
  import { fileURLToPath } from "node:url";
6
- import { DATA_DIR } from "./config.mjs";
7
4
 
8
5
  const PACKAGE_NAME = "offgrid-ai";
9
- const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000;
10
6
 
11
- export async function checkForUpdate({ now = Date.now(), fetchImpl = globalThis.fetch, force = false } = {}) {
7
+ export async function checkForUpdate({ fetchImpl = globalThis.fetch } = {}) {
12
8
  if (process.env.OFFGRID_NO_UPDATE_CHECK) return null;
13
9
 
14
10
  const currentVersion = currentPackageVersion();
15
- const cacheFile = join(DATA_DIR, "update-cache.json");
16
- const cached = await readUpdateCache(cacheFile);
17
-
18
- // Use cache if fresh (within 24h) and still applies to current version
19
- if (!force && cached?.lastChecked && now - cached.lastChecked < UPDATE_CHECK_INTERVAL) {
20
- if (cached.currentVersion === currentVersion) {
21
- return updateResult(currentVersion, cached.latestVersion);
22
- }
23
- // Cache is from a different installed version — invalidate and refetch
24
- }
25
11
 
26
12
  try {
27
13
  const response = await fetchImpl(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
@@ -32,9 +18,6 @@ export async function checkForUpdate({ now = Date.now(), fetchImpl = globalThis.
32
18
  const latestVersion = typeof body?.version === "string" ? body.version : null;
33
19
  if (!latestVersion) return null;
34
20
 
35
- await mkdir(DATA_DIR, { recursive: true });
36
- await writeFile(cacheFile, JSON.stringify({ lastChecked: now, currentVersion, latestVersion }, null, 2) + "\n", "utf8");
37
-
38
21
  return updateResult(currentVersion, latestVersion);
39
22
  } catch {
40
23
  return null;
@@ -42,8 +25,8 @@ export async function checkForUpdate({ now = Date.now(), fetchImpl = globalThis.
42
25
  }
43
26
 
44
27
  export function currentPackageVersion() {
45
- const __dirname = dirname(fileURLToPath(import.meta.url));
46
- const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
28
+ const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
29
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
47
30
  return pkg.version;
48
31
  }
49
32
 
@@ -97,14 +80,6 @@ export function runUpdateCommand(plan) {
97
80
  });
98
81
  }
99
82
 
100
- async function readUpdateCache(cacheFile) {
101
- try {
102
- return JSON.parse(await readFile(cacheFile, "utf8"));
103
- } catch {
104
- return null;
105
- }
106
- }
107
-
108
83
  function updateResult(currentVersion, latestVersion) {
109
84
  return isNewerVersion(latestVersion, currentVersion)
110
85
  ? { current: currentVersion, latest: latestVersion }
@@ -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
- }