@tiens.nguyen/gu-cli 1.0.806 → 1.0.807

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/gu-repl.mjs CHANGED
@@ -144,6 +144,7 @@ import { readSkillSource, sourceLabel, SKILL_SOURCE_KEY } from "./skill-source.m
144
144
  import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
145
145
  import { questionEchoText, thoughtPrintSeq } from "./terminal-layout.mjs";
146
146
  import { globalPrefixOf } from "./install-path.mjs";
147
+ import { firstGuOnPath, pathGuMismatch, guOnPathIsCurrent, updateAttemptRecord, repeatUpdateNotice } from "./update-verify.mjs";
147
148
  import { embedNotice, embedState } from "./embed-model-command.mjs";
148
149
  import {
149
150
  MODELFALLBACK_PREFIX, fallbackAfterTurn, fallbackHistoryLine, fallbackNotice, keyMissingNotice,
@@ -1107,12 +1108,21 @@ const contentWrite = (raw, chunk, enc, cb) => {
1107
1108
  // Any real output commits a held question first (see pendingEcho above).
1108
1109
  if (_uiDepth === 0 && pendingEcho !== null) flushPendingEcho();
1109
1110
  if (_uiDepth === 0 && (typeof chunk === "string" || Buffer.isBuffer(chunk))) {
1111
+ // #113 NEVER PULL A READER DOWN. If the user has scrolled back to read history, new output
1112
+ // (the model thinking, a tool's lines) is RECORDED but not drawn, and the view stays on the
1113
+ // same lines: the offset grows by the rows just added, so "↓ jump to bottom · N lines below"
1114
+ // counts up instead. Only the user brings the view home — typing, Enter, End, or a click on
1115
+ // the jump line. At the live tail (offset 0) output follows the bottom exactly as before.
1116
+ const holdView = barPinned() && scrolledBack();
1117
+ const rowsBefore = holdView ? screenRows().length : 0;
1110
1118
  transcript.append(String(chunk), { followCursor: !zoneTookOver });
1111
- // ALWAYS FOLLOW THE BOTTOM. New output means there is something newer to see, so a view
1112
- // that was scrolled back comes home first and the line lands where the user is looking.
1113
- // Scrolling up is therefore the ONLY way to leave the live tail — which is exactly when the
1114
- // "jump to bottom" line appears, and it disappears again the moment output arrives.
1115
- if (scrolledBack()) jumpToBottom();
1119
+ if (holdView) {
1120
+ scrollOffset += Math.max(0, screenRows().length - rowsBefore);
1121
+ paintBar(); // the jump line's "N lines below" changed; the history on screen did not
1122
+ if (typeof enc === "function") enc();
1123
+ else if (typeof cb === "function") cb();
1124
+ return true;
1125
+ }
1116
1126
  }
1117
1127
  if (_uiDepth === 0 && barPinned()) {
1118
1128
  // Content belongs in the scrolling area, not where the caret is parked.
@@ -2441,11 +2451,26 @@ async function refreshCliToken() {
2441
2451
  }
2442
2452
  const TOKEN_TICK_MS = 60 * 60 * 1000;
2443
2453
 
2454
+ // The last in-app CLI update attempt (task #21): lets the next start say WHY an update did not
2455
+ // take effect instead of offering the very same update again, forever.
2456
+ const UPDATE_ATTEMPT_FILE = join(homedir(), ".gonext", "last-cli-update.json");
2457
+ function readUpdateAttempt() {
2458
+ try { return JSON.parse(readFileSync(UPDATE_ATTEMPT_FILE, "utf8")); } catch { return null; }
2459
+ }
2460
+ function saveUpdateAttempt(rec) {
2461
+ try { mkdirSync(dirname(UPDATE_ATTEMPT_FILE), { recursive: true }); writeFileSync(UPDATE_ATTEMPT_FILE, JSON.stringify(rec, null, 2)); } catch { /* best effort */ }
2462
+ }
2463
+
2444
2464
  let cliNoticeShown = false;
2445
2465
  function announceCliUpdate() {
2446
2466
  if (cliNoticeShown || !availableUpdates.cli) return;
2447
2467
  if (following) return; // a turn owns the transcript — try again at the next prompt
2448
- const lines = updateNotice({ cli: availableUpdates.cli, running: REPL_VERSION });
2468
+ let lines = [];
2469
+ try {
2470
+ lines = repeatUpdateNotice({ last: readUpdateAttempt(), cli: availableUpdates.cli, running: REPL_VERSION,
2471
+ onPath: firstGuOnPath({ PATH: process.env.PATH }), home: homedir(), env: process.env });
2472
+ } catch { lines = []; }
2473
+ if (!lines.length) lines = updateNotice({ cli: availableUpdates.cli, running: REPL_VERSION });
2449
2474
  if (!lines.length) return;
2450
2475
  cliNoticeShown = true;
2451
2476
  console.log("");
@@ -2539,12 +2564,20 @@ async function runUpdate() {
2539
2564
  }
2540
2565
  const failed = describeInstallFailure(r);
2541
2566
  if (failed) throw new Error(failed);
2567
+ const onPath = firstGuOnPath({ PATH: process.env.PATH });
2542
2568
  // npm exiting 0 is not the outcome: read the version now on disk in THIS install.
2543
2569
  let onDisk = "";
2544
2570
  try { onDisk = JSON.parse(readFileSync(join(DIR, "package.json"), "utf8")).version ?? ""; } catch { /* unreadable: skip the check */ }
2545
2571
  const short = installedVersionShortfall(onDisk, cli);
2546
- if (short) throw new Error(short);
2572
+ // nvm-windows: `nvm use` in ANY terminal re-points C:\nvm4w\nodejs, so npm may have updated the
2573
+ // now-active Node's copy instead of this one. What counts is the gu the next `gu` starts.
2574
+ if (short && !guOnPathIsCurrent({ wanted: cli, onPath })) throw new Error(short);
2575
+ // …and THIS install is not necessarily the gu the next `gu` starts (task #21): another copy
2576
+ // earlier on PATH (e.g. a Homebrew Cellar bin dir from ~/.zshrc) keeps running the old one.
2577
+ const mismatch = pathGuMismatch({ wanted: cli, installedDir: DIR, onPath, env: process.env });
2578
+ if (mismatch) throw new Error(mismatch);
2547
2579
  });
2580
+ saveUpdateAttempt(updateAttemptRecord({ version: cli, running: REPL_VERSION, ok: true }));
2548
2581
  // NOT "updated" — the files changed, THIS process did not. Claiming otherwise is the one
2549
2582
  // way this feature could lie to someone.
2550
2583
  console.log(accent(` ✓ gu ${cli} installed`) +
@@ -2552,7 +2585,13 @@ async function runUpdate() {
2552
2585
  availableUpdates = { ...availableUpdates, cli: "", any: Boolean(availableUpdates.api) };
2553
2586
  }
2554
2587
  } catch (e) {
2555
- console.log(red(` update failed: ${e?.message ?? e}\n`));
2588
+ if (pick === "cli") {
2589
+ saveUpdateAttempt(updateAttemptRecord({ version: cli, running: REPL_VERSION, ok: false, reason: e?.message ?? String(e) }));
2590
+ console.log(red(` ✗ Update NOT successful — gu is still ${REPL_VERSION}`));
2591
+ console.log(red(` ${e?.message ?? e}\n`));
2592
+ } else {
2593
+ console.log(red(` update failed: ${e?.message ?? e}\n`));
2594
+ }
2556
2595
  }
2557
2596
  paintBar();
2558
2597
  }
@@ -2644,7 +2683,9 @@ const tokenLineText = () => {
2644
2683
  dim(" · ") +
2645
2684
  accent(`↓ out ${fmtTokens(lastTurnOutputTokens)}`)
2646
2685
  );
2647
- const total = dim(" · ") + dim("total ") + accent(`↓ ${fmtTokens(Math.max(globalOutputTokens, lastTurnTotalOutput))}`);
2686
+ // #112: the lifetime "total ↓ N" is no longer shown on the bar (the user's call) — hidden, not
2687
+ // deleted: globalOutputTokens is still tracked and still feeds /tokens reset.
2688
+ const total = "";
2648
2689
  // #323: what the last turn's prompt cost, LAST on the row after a "|" — "~" when the cached share
2649
2690
  // was assumed; "no price" when the model has none (never a misleading "| $0"). It survives a
2650
2691
  // narrow window: "total ↓" is dropped before it is (tokenRowWithPrice).
@@ -7829,6 +7870,13 @@ async function applyTransferDoc(value, opts = {}) {
7829
7870
  * bar and /model kept showing the old coder), and the "no embed model" / "no agent model"
7830
7871
  * warnings. /model already does this after a change; applying a preset must too.
7831
7872
  */
7873
+ function seedDefaultCoder(p) {
7874
+ // The ONE place the account-default coder fields are read (startup + after a preset apply).
7875
+ // Anything that wants THIS folder's coder uses sessionCodingModel / activeCoderName() instead.
7876
+ if (p?.codingModelId) defaultCodingModel = p.codingModelId;
7877
+ if (p?.codingBaseURL) defaultCodingUrl = p.codingBaseURL;
7878
+ }
7879
+
7832
7880
  async function refreshAfterPresetApply(value, codingDefault, result) {
7833
7881
  try {
7834
7882
  // The preset's coder IS the account default now — a folder pick would shadow it.
@@ -7843,9 +7891,9 @@ async function refreshAfterPresetApply(value, codingDefault, result) {
7843
7891
  // What the API now answers with is authoritative — read it back rather than assume.
7844
7892
  const probe = await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null);
7845
7893
  const p = probe?.payload ?? {};
7846
- if (p.codingModelId) defaultCodingModel = p.codingModelId;
7847
- if (p.codingBaseURL) defaultCodingUrl = p.codingBaseURL;
7848
- activeCoderWhere = whereOfActiveCoder(probe?.codingChoices ?? p.codingChoices, sessionCodingModel);
7894
+ seedDefaultCoder(p);
7895
+ // codingChoices lives on the envelope, never inside payload.
7896
+ activeCoderWhere = whereOfActiveCoder(probe?.codingChoices, sessionCodingModel);
7849
7897
  if (value?.agentModel?.name || p.agentMissing === false) agentMissing = false;
7850
7898
  else if (p.agentMissing === true) agentMissing = true;
7851
7899
  if (probe?.payload) noModelAtAll = nothingToAnswerWith(p);
@@ -12876,8 +12924,7 @@ async function main() {
12876
12924
  // Remember the default coder id for the token footer's global-total label, and the
12877
12925
  // default backend's coding URL so the GLOBAL output-token total is keyed to it (task
12878
12926
  // #125) when no /model override is active.
12879
- if (p.codingModelId) defaultCodingModel = p.codingModelId;
12880
- if (p.codingBaseURL) defaultCodingUrl = p.codingBaseURL;
12927
+ seedDefaultCoder(p);
12881
12928
  // Task #14: seed the bottom warnings from the AUTHORITATIVE payload. `agentMissing` is set
12882
12929
  // by the API when no agent model is configured (and it fell back to the coding model);
12883
12930
  // `ragEnabled` false means no embed model was chosen. Boot may have already set these from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.806",
3
+ "version": "1.0.807",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -115,6 +115,7 @@
115
115
  "add-server.mjs",
116
116
  "powershell-alias.mjs",
117
117
  "install-path.mjs",
118
+ "update-verify.mjs",
118
119
  "skills.mjs",
119
120
  "skill-source.mjs",
120
121
  "step-group.mjs",
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Did the gu update REALLY take effect? (task #21)
3
+ *
4
+ * Seen on a real Mac: `~/.zshrc` put /opt/homebrew/Cellar/node/<v>/bin first on PATH, so `gu`
5
+ * started the Cellar copy (1.0.797) while `npm install -g` wrote /opt/homebrew/lib/node_modules
6
+ * (1.0.806). The updater said "✓ installed", the next `gu` was still 1.0.797, and it asked again —
7
+ * forever. npm exiting 0 and the files in ONE prefix being new proves nothing about the gu the
8
+ * user's shell will start. So:
9
+ * · firstGuOnPath — the gu the next `gu` will actually run (first on PATH) and its version;
10
+ * · pathGuMismatch — the sentence to show when that gu is older than what we installed;
11
+ * · attempt memory — remember the last update, so the next start reports WHY instead of
12
+ * offering the same update again as if nothing had happened.
13
+ *
14
+ * ALL THREE PLATFORMS (follow-up: "make sure this won't happen on Ubuntu or Windows 11"):
15
+ * · macOS / Ubuntu: `<prefix>/bin/gu` is a SYMLINK to `<prefix>/lib/node_modules/@tiens.nguyen/
16
+ * gu-cli/gu-repl.mjs` — apt/NodeSource node (prefix /usr), nvm (~/.nvm/versions/node/<v>),
17
+ * Homebrew, a user prefix (~/.npm-global). Realpath tells us the package and its prefix.
18
+ * Ubuntu's usual failure is EACCES on the root-owned /usr prefix → the fix needs `sudo`.
19
+ * · Windows: PATH is `;`-separated, case-insensitive, and the shims (gu.cmd / gu.ps1 / gu) are
20
+ * small scripts, NOT symlinks — they sit directly in the prefix next to
21
+ * `<prefix>\node_modules\@tiens.nguyen\gu-cli`. Its usual failure is EPERM/EBUSY because the
22
+ * running gu's files are locked → the fix is "close every gu window first".
23
+ * Pure (fs + platform injected) so every branch is testable on any OS.
24
+ */
25
+ import { existsSync, realpathSync, readFileSync } from "node:fs";
26
+ import path from "node:path";
27
+
28
+ const PKG = "@tiens.nguyen/gu-cli";
29
+ const pathFor = (platform) => (platform === "win32" ? path.win32 : path.posix);
30
+
31
+ /** Numeric compare of x.y.z (pre-release suffix ignored). <0, 0, >0; NaN parts → 0. */
32
+ export function compareVersions(a = "", b = "") {
33
+ const p = (v) => String(v ?? "").split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
34
+ const x = p(a), y = p(b);
35
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
36
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
37
+ if (d) return d;
38
+ }
39
+ return 0;
40
+ }
41
+
42
+ const realFs = {
43
+ exists: (p) => existsSync(p),
44
+ realpath: (p) => realpathSync(p),
45
+ readVersion: (dir) => {
46
+ try { return JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")).version ?? ""; } catch { return ""; }
47
+ },
48
+ };
49
+
50
+ /** Windows shim names, in the order cmd.exe / PowerShell would pick them. */
51
+ const WIN_SHIMS = ["gu.exe", "gu.cmd", "gu.bat", "gu.ps1", "gu"];
52
+
53
+ /**
54
+ * The first `gu` on PATH → { bin, dir, version, prefix } or null.
55
+ * `dir` is the package folder the bin resolves to; `prefix` the npm prefix owning it ("" if none).
56
+ */
57
+ export function firstGuOnPath({ PATH = "", platform = process.platform, fs = realFs } = {}) {
58
+ const P = pathFor(platform);
59
+ const win = platform === "win32";
60
+ const dirs = String(PATH).split(win ? ";" : ":").map((d) => d.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean);
61
+ for (const d of dirs) {
62
+ if (win) {
63
+ const shim = WIN_SHIMS.map((n) => P.join(d, n)).find((b) => fs.exists(b));
64
+ if (!shim) continue;
65
+ // npm's Windows layout: shims in <prefix>, package in <prefix>\node_modules\@tiens.nguyen\gu-cli.
66
+ const dir = P.join(d, "node_modules", "@tiens.nguyen", "gu-cli");
67
+ const version = fs.readVersion(dir);
68
+ // nvm-windows: d is a JUNCTION (C:\nvm4w\nodejs → %LOCALAPPDATA%\nvm\v22.x). The running gu's
69
+ // own folder is the resolved target, so keep both spellings to compare against.
70
+ let realDir = dir;
71
+ try { realDir = (version && fs.realpath(dir)) || dir; } catch { /* keep dir */ }
72
+ return { bin: shim, dir, realDir, version, prefix: version ? d : "" };
73
+ }
74
+ const bin = P.join(d, "gu");
75
+ if (!fs.exists(bin)) continue;
76
+ let real;
77
+ try { real = fs.realpath(bin); } catch { continue; }
78
+ const dir = P.dirname(real);
79
+ const m = /^(.*)\/lib\/node_modules\/@tiens\.nguyen\/gu-cli$/.exec(dir);
80
+ return { bin, dir, realDir: dir, version: fs.readVersion(dir), prefix: m ? (m[1] || "/") : "" };
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /** EACCES / EPERM: the prefix is not writable by this user (root-owned /usr on Ubuntu). */
86
+ const isPermission = (reason) => /\b(EACCES|permission denied)\b/i.test(String(reason ?? ""));
87
+ /** EPERM / EBUSY on Windows: the running gu's files are locked. */
88
+ const isLocked = (reason) => /\b(EPERM|EBUSY|operation not permitted|resource busy)\b/i.test(String(reason ?? ""));
89
+
90
+ /**
91
+ * The command that puts the latest gu into the prefix owning the gu on PATH.
92
+ * `reason` (the last npm failure) picks the platform's fix: `sudo` for EACCES on Linux/macOS when
93
+ * the prefix is outside the user's home (never for nvm / ~/.npm-global — sudo there makes it worse).
94
+ */
95
+ export function fixCommandFor(onPath, { platform = process.platform, reason = "", home = "" } = {}) {
96
+ const win = platform === "win32";
97
+ const base = `npm install -g ${PKG}@latest`;
98
+ const bad = win ? /["%`\n]/ : /["$`\\\n]/;
99
+ const cmd = onPath?.prefix && !bad.test(onPath.prefix) ? `${base} --prefix "${onPath.prefix}"` : base;
100
+ if (!win && isPermission(reason)) {
101
+ const inHome = home && onPath?.prefix && (onPath.prefix === home || onPath.prefix.startsWith(home + "/"));
102
+ if (!inHome) return `sudo ${cmd}`;
103
+ }
104
+ return cmd;
105
+ }
106
+
107
+ /**
108
+ * Is this gu from nvm-windows? Its PATH folder is %NVM_SYMLINK% (C:\nvm4w\nodejs by default), a
109
+ * junction into %NVM_HOME%\v<version>. Two things differ from a normal install: every Node version
110
+ * has its OWN global packages (gu included), and `nvm use` re-points that junction for EVERY open
111
+ * terminal at once — so switching Node makes a different (or missing) gu start next time.
112
+ */
113
+ export function isNvmWindows(onPath, env = {}) {
114
+ if (!onPath) return false;
115
+ const norm = (s) => String(s ?? "").toLowerCase().replace(/\//g, "\\").replace(/\\+$/, "");
116
+ const s = `${norm(onPath.bin)}\n${norm(onPath.realDir)}`;
117
+ const link = norm(env.NVM_SYMLINK), home = norm(env.NVM_HOME);
118
+ return Boolean((link && s.includes(link + "\\")) || (home && s.includes(home + "\\")) ||
119
+ /\\nvm4w\\|\\nvm\\v\d/.test(s));
120
+ }
121
+
122
+ const NVM_NOTE = "nvm-windows keeps a separate gu for each Node version, and `nvm use` switches EVERY terminal — " +
123
+ "after switching Node, install gu again for that version.";
124
+
125
+ /** What to do after running the fix command, per platform. */
126
+ export function fixFollowUp({ platform = process.platform, reason = "", nvm = false } = {}) {
127
+ if (platform === "win32") {
128
+ const base = isLocked(reason) || !reason
129
+ ? "Close EVERY gu window first (Windows can't replace files in use), run that in a new terminal, then start gu again."
130
+ : "Run that in a new terminal (close gu first), then start gu again.";
131
+ return nvm ? `${base} ${NVM_NOTE}` : base;
132
+ }
133
+ return "Run that in a terminal, then start gu again.";
134
+ }
135
+
136
+ const sameDir = (a, b, platform) =>
137
+ platform === "win32" ? String(a).toLowerCase().replace(/[\\/]+$/, "") === String(b).toLowerCase().replace(/[\\/]+$/, "")
138
+ : a === b;
139
+
140
+ /**
141
+ * After an install into `installedDir`: will the next `gu` start `wanted` (or newer)?
142
+ * → null when yes (or unknowable), otherwise the reason + the exact fix.
143
+ */
144
+ /** Will the next `gu` start `wanted` or newer? (What an update is really for.) */
145
+ export function guOnPathIsCurrent({ wanted = "", onPath = null } = {}) {
146
+ return Boolean(wanted && onPath?.version && compareVersions(onPath.version, wanted) >= 0);
147
+ }
148
+
149
+ export function pathGuMismatch({ wanted = "", installedDir = "", onPath = null, platform = process.platform, env = {} } = {}) {
150
+ if (!wanted || !onPath || !onPath.version) return null;
151
+ if (compareVersions(onPath.version, wanted) >= 0) return null;
152
+ const elsewhere = installedDir && !sameDir(installedDir, onPath.dir, platform) &&
153
+ !sameDir(installedDir, onPath.realDir ?? onPath.dir, platform);
154
+ const where = elsewhere ? `installed to ${installedDir}, but ` : "";
155
+ const profile = platform !== "win32"
156
+ ? "remove the older folder from PATH in your shell profile"
157
+ : isNvmWindows(onPath, env)
158
+ ? `run \`nvm use\` for the Node version you updated gu in. ${NVM_NOTE}`
159
+ : "remove the older folder from Path in Settings › System › About › Advanced system settings › Environment Variables";
160
+ return `${where}the gu on your PATH is ${onPath.bin} (v${onPath.version}) — the next \`gu\` would still be ` +
161
+ `v${onPath.version}. Fix: ${fixCommandFor(onPath, { platform })} (or ${profile})`;
162
+ }
163
+
164
+ /** The record saved after an update attempt. */
165
+ export function updateAttemptRecord({ version = "", running = "", ok = false, reason = "", at = Date.now() } = {}) {
166
+ return { version, running, ok: Boolean(ok), reason: String(reason ?? ""), at };
167
+ }
168
+
169
+ /**
170
+ * At startup, an update `cli` is available while `running` is old. If we ALREADY tried exactly
171
+ * this update from exactly this version, offering it again is the loop the user hit — say what
172
+ * happened instead. → notice lines like updateNotice ([] = nothing remembered, show the normal one).
173
+ */
174
+ export function repeatUpdateNotice({ last = null, cli = "", running = "", onPath = null,
175
+ platform = process.platform, home = "", env = {} } = {}) {
176
+ if (!last || !cli || !running) return [];
177
+ if (last.version !== cli || last.running !== running) return [];
178
+ if (compareVersions(running, cli) >= 0) return [];
179
+ const head = last.ok
180
+ ? `gu ${cli} was installed, but this gu is still ${running} — the update did NOT take effect`
181
+ : `gu ${cli} update did NOT succeed last time`;
182
+ const why = last.ok
183
+ ? (onPath && onPath.version && compareVersions(onPath.version, cli) < 0
184
+ ? `the gu on your PATH is ${onPath.bin} (v${onPath.version}), a different copy from the one updated`
185
+ : "a different gu copy than the one updated is starting")
186
+ : (last.reason || "unknown error");
187
+ const reason = last.ok ? "" : last.reason;
188
+ return [
189
+ { kind: "head", text: head },
190
+ { kind: "note", text: `Reason: ${why}` },
191
+ { kind: "cmd", text: fixCommandFor(onPath, { platform, reason, home }) },
192
+ { kind: "note", text: fixFollowUp({ platform, reason, nvm: platform === "win32" && isNvmWindows(onPath, env) }) },
193
+ ];
194
+ }