@tiens.nguyen/gu-cli 1.0.834 → 1.0.836

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-path.mjs CHANGED
@@ -53,7 +53,7 @@ const isEntryPoint = (() => {
53
53
 
54
54
  if (isEntryPoint) {
55
55
  try {
56
- main();
56
+ await main();
57
57
  } catch (e) {
58
58
  // A postinstall that throws uninstalls the package. Report, swallow, succeed.
59
59
  console.log(`gu-cli: PATH setup skipped (${e.message}) — run \`npx @tiens.nguyen/gu-cli fix-path\` if \`gu\` is not found.`);
@@ -105,7 +105,7 @@ export function fixPathPrefix({ here = "", env = process.env, exists = existsSyn
105
105
  }
106
106
 
107
107
  function main() {
108
- if (platform() === "win32") return; // registry PATH + PowerShell alias: not here
108
+ if (platform() === "win32") return windowsNotes(); // registry PATH: report, never edit (#375)
109
109
  if (process.env.CI) return; // no user shell to help in CI
110
110
  const prefix = process.env.npm_config_prefix;
111
111
  if (!prefix) return; // not an npm install context
@@ -143,6 +143,23 @@ function main() {
143
143
  }
144
144
  }
145
145
 
146
+ /**
147
+ * Windows (#375, GitHub #156): the PATH lives in the registry and is the user's to change, so the
148
+ * install only SAYS whether npm's folder is on it and how to add it — the same words as the
149
+ * installer and `gu-cli fix-path`, which also offers to add it. npm shows this only with
150
+ * --foreground-scripts; the installer (install.ps1) is what most people see.
151
+ */
152
+ async function windowsNotes() {
153
+ if (process.env.CI || process.env.npm_config_global !== "true") return;
154
+ const prefix = process.env.npm_config_prefix;
155
+ if (!prefix) return;
156
+ const wp = await import("./win-path.mjs");
157
+ const status = wp.pathStatus({ dir: prefix, sessionPath: process.env.Path || process.env.PATH || "", ...wp.readSavedPaths() });
158
+ if (status.saved) return;
159
+ const { notes } = wp.installSummary({ version: "", location: "", dir: prefix, status });
160
+ console.log(`gu-cli: !! Setup notes:\n${notes.map((n) => ` \u2022 ${n}`).join("\n")}\n Or run: gu-cli fix-path`);
161
+ }
162
+
146
163
  /** The installer's home — the invoking user's, not root's, under sudo. */
147
164
  function realUserHome() {
148
165
  const sudoUser = process.env.SUDO_USER;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.834",
3
+ "version": "1.0.836",
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",
@@ -125,10 +125,13 @@
125
125
  "skills.mjs",
126
126
  "skill-source.mjs",
127
127
  "shell-details.mjs",
128
+ "proc-identity.mjs",
129
+ "win-path.mjs",
128
130
  "browser-command.mjs",
129
131
  "browser-bridge.mjs",
130
132
  "browser-extension/manifest.json",
131
133
  "browser-extension/background.js",
134
+ "browser-extension/actions.js",
132
135
  "browser-extension/popup.html",
133
136
  "browser-extension/popup.js",
134
137
  "browser-extension/icons/",
@@ -144,6 +147,7 @@
144
147
  "gu_skills.py",
145
148
  "gu_test_digest.py",
146
149
  "gu_browser.py",
150
+ "gu_powershell.py",
147
151
  "gu_hooks.py",
148
152
  "prompt_scaffold_check.py",
149
153
  "gonext_transcribe.py",
@@ -0,0 +1,53 @@
1
+ // IS THIS PID STILL THE PROCESS WE MEANT? (task #373, GitHub #154)
2
+ //
3
+ // gu keeps process ids in files — shells.json, the local API's pid file, the browser bridge's state —
4
+ // and those files outlive their processes. After a reboot or a crash the id is free, and the OS
5
+ // hands it to the next program (VS Code, Chrome); a SIGTERM to the saved id then lands on that.
6
+ // A process's START TIME is what tells two holders of one id apart, so it is what gu records and
7
+ // checks before it signals anything. POSIX only: `ps -o lstart=` reads the same on macOS and procps.
8
+
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ const MONTHS = "JanFebMarAprMayJunJulAugSepOctNovDec";
12
+
13
+ /** When `pid` started (epoch s); null = no such process; NaN = cannot tell (no ps, Windows). */
14
+ export function procStart(pid, { platform = process.platform } = {}) {
15
+ if (platform === "win32" || !(Number(pid) > 0)) return NaN;
16
+ const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
17
+ encoding: "utf8",
18
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
19
+ timeout: 5000,
20
+ });
21
+ if (r.error) return NaN;
22
+ const raw = String(r.stdout || "").trim().replace(/\s+/g, " ");
23
+ if (!raw) return null;
24
+ // "Wed Oct 14 23:26:17 2026", local time.
25
+ const m = raw.match(/^\w{3} (\w{3}) (\d{1,2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/);
26
+ const mon = m ? MONTHS.indexOf(m[1]) / 3 : -1;
27
+ if (!m || mon < 0 || !Number.isInteger(mon)) return NaN;
28
+ return new Date(Number(m[6]), mon, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])).getTime() / 1000;
29
+ }
30
+
31
+ /** The command line of `pid` ("" when there is none, or no ps). */
32
+ export function procCommand(pid, { platform = process.platform } = {}) {
33
+ if (platform === "win32" || !(Number(pid) > 0)) return "";
34
+ const r = spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 5000 });
35
+ return r.error ? "" : String(r.stdout || "").trim();
36
+ }
37
+
38
+ /**
39
+ * Is `pid` the process that was running at `startedAt` (epoch s, as recorded)?
40
+ * "same" — it started then (within `before` s before and `after` s after the record);
41
+ * "gone" — no process has that id;
42
+ * "other" — that id belongs to a process started at another time: never signal it;
43
+ * "unknown" — nothing to compare (Windows, no ps, nothing recorded).
44
+ * `before` is the room for a record written a while after its process started (a server that
45
+ * records itself once it listens); `after` is ps's whole-second rounding.
46
+ */
47
+ export function sameProcess(pid, startedAt, { before = 5, after = 5, startOf = procStart } = {}) {
48
+ const t = startOf(pid);
49
+ if (t === null) return "gone";
50
+ const rec = Number(startedAt);
51
+ if (!Number.isFinite(t) || startedAt == null || !Number.isFinite(rec)) return "unknown";
52
+ return t >= rec - before && t <= rec + after ? "same" : "other";
53
+ }
package/shell-details.mjs CHANGED
@@ -15,6 +15,9 @@ import { existsSync, openSync, readSync, closeSync, fstatSync, readFileSync, rea
15
15
  import { homedir } from "node:os";
16
16
  import { basename, dirname, isAbsolute, join, normalize } from "node:path";
17
17
  import { spawnSync } from "node:child_process";
18
+ import { procStart } from "./proc-identity.mjs";
19
+
20
+ export { procStart };
18
21
 
19
22
  export const shellsPath = (home = homedir()) => join(home, ".gonext", "shells.json");
20
23
 
@@ -40,10 +43,39 @@ export function readExit(rec) {
40
43
  }
41
44
  }
42
45
 
43
- /** Is the shell's process group (POSIX) or process (Windows) still there? */
44
- export function isAlive(rec, platform = process.platform) {
46
+ // A SAVED PGID IS ONLY OURS WHILE ITS LEADER IS THE PROCESS WE STARTED (#373, GitHub #154) — the
47
+ // same rule as the agent's _ws_group_owner. shells.json outlives its commands; after a reboot or a
48
+ // crash the id goes to the next program (VS Code is its own group leader), and "a group with that
49
+ // id exists" read as running and offered "Stop it" on someone else's app. The record's start time
50
+ // is the proof: the process whose pid == the pgid must have started then (± START_SLACK_S). With
51
+ // no such process, members left in the group are still ours — an id in use is never reused.
52
+ export const START_SLACK_S = 5;
53
+
54
+ /** The record's start (epoch s): `started`, else the older "YYYY-MM-DD HH:MM:SS" `started_at`. */
55
+ function recordStarted(rec) {
56
+ const n = Number(rec?.started);
57
+ if (rec?.started != null && Number.isFinite(n)) return n;
58
+ const m = String(rec?.started_at ?? "").match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
59
+ return m ? new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime() / 1000 : null;
60
+ }
61
+
62
+ /** "ours" | "leader-gone" | "other" (the id now belongs to another program) | "unknown". */
63
+ export function groupOwner(rec, { platform = process.platform, startOf = procStart } = {}) {
64
+ if (platform === "win32") return "ours";
65
+ const pgid = Number(rec?.pgid || rec?.pid || 0);
66
+ if (!pgid) return "unknown";
67
+ const leader = startOf(pgid);
68
+ if (leader === null) return "leader-gone";
69
+ const started = recordStarted(rec);
70
+ if (!Number.isFinite(leader) || started == null) return "unknown";
71
+ return Math.abs(leader - started) <= START_SLACK_S ? "ours" : "other";
72
+ }
73
+
74
+ /** Is the shell's process group (POSIX) or process (Windows) still there — and still ours? */
75
+ export function isAlive(rec, platform = process.platform, { startOf = procStart } = {}) {
45
76
  const pid = Number(rec?.pgid || rec?.pid || 0);
46
77
  if (!pid) return false;
78
+ if (groupOwner(rec, { platform, startOf }) === "other") return false;
47
79
  try {
48
80
  process.kill(platform === "win32" ? pid : -pid, 0);
49
81
  return true;
@@ -85,23 +117,17 @@ export function statusText(st) {
85
117
  return "ended (exit code unknown)";
86
118
  }
87
119
 
88
- /**
89
- * The bottom notice row's shell control, or null. Running shells first — that is the thing worth
90
- * a row. Otherwise the newest one that finished while this terminal was open and that the user
91
- * has not opened yet, so a build that ends between questions is not missed.
92
- */
93
- export function shellsNotice(entries, { viewed = new Set(), since = 0 } = {}) {
120
+ /** The bottom notice row's shell control while a shell is running, else null. */
121
+ export function shellsNotice(entries) {
122
+ // ONLY WHILE SOMETHING RUNS (#376, GitHub #157). A finished shell used to keep "shell exited 0
123
+ // (click)" on the row until it was opened — a done build still asking for attention. When one
124
+ // finishes, the agent hears about it (a recap turn, or the next step of the turn it is in), and
125
+ // /shells still lists every shell with its output.
94
126
  const running = entries.filter((e) => e.st.state === "running");
95
- if (running.length) {
96
- const one = running.length === 1;
97
- // No runtime here: it would change the row every second, and the bar repaints on change.
98
- return { tone: "accent", text: `${running.length} shell${one ? "" : "s"} running (click)` };
99
- }
100
- const done = entries.filter((e) => !viewed.has(String(e.rec.id)) && e.st.ended >= since);
101
- if (!done.length) return null;
102
- const last = done[done.length - 1];
103
- const ok = last.st.state === "exited";
104
- return { tone: ok ? "accent" : "warn", text: `shell ${statusText(last.st)} (click)` };
127
+ if (!running.length) return null;
128
+ const one = running.length === 1;
129
+ // No runtime here: it would change the row every second, and the bar repaints on change.
130
+ return { tone: "accent", text: `${running.length} shell${one ? "" : "s"} running (click)` };
105
131
  }
106
132
 
107
133
  const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
@@ -225,6 +251,29 @@ export function renderShellDetails({ rec, st, output = [], outputFrom = "", widt
225
251
  return rows;
226
252
  }
227
253
 
254
+ /**
255
+ * Shell details as a panel of EXACTLY `rows` rows (#376, GitHub #157) — drawn at the bottom, above
256
+ * the bar, and redrawn in place every second, so its height must not change while it is open
257
+ * (the in-place redraw walks up as many rows as it drew). The output keeps its NEWEST lines when
258
+ * space is short: a build's last lines are the news. Short content is padded with blank rows.
259
+ */
260
+ export function shellPanelRows({ rec, st, output = [], outputFrom = "", width = 80, gutter = " ", style = {}, rows = 20, footer = "" }) {
261
+ const head = renderShellDetails({ rec, st, output: [], outputFrom, width, gutter, style });
262
+ // renderShellDetails ends with "No output available" when given none; drop it — the real
263
+ // output (or that line) is placed below, in the room that is left.
264
+ head.pop();
265
+ const p = { dim: plain, ...style };
266
+ const room = Math.max(1, rows - head.length - (footer ? 1 : 0));
267
+ const inner = Math.max(20, width - gutter.length);
268
+ const out = output.length
269
+ ? output.slice(-room).map((l) => gutter + (l.length > inner ? l.slice(0, inner - 1) + "…" : l))
270
+ : [gutter + p.dim("No output available")];
271
+ const body = [...head, ...out].slice(0, Math.max(0, rows - (footer ? 1 : 0)));
272
+ while (body.length < rows - (footer ? 1 : 0)) body.push("");
273
+ if (footer) body.push(gutter + p.dim(footer));
274
+ return body.slice(0, rows);
275
+ }
276
+
228
277
  /** A picker row for one shell: status · runtime · command, clipped to `width`. */
229
278
  export function shellPickerLabel(rec, st, width = 80) {
230
279
  const head = `${statusText(st)} · ${fmtRuntime(st.runtime)} · `;
@@ -237,15 +286,17 @@ export function shellPickerLabel(rec, st, width = 80) {
237
286
  * Stop a shell's whole group and record that it was killed — the record is what the agent reads
238
287
  * next turn, so "killed" must not later read as "ended (exit code unknown)".
239
288
  */
240
- export async function killShell(rec, { path = shellsPath(), platform = process.platform } = {}) {
289
+ export async function killShell(rec, { path = shellsPath(), platform = process.platform, startOf = procStart } = {}) {
241
290
  const pid = Number(rec?.pgid || rec?.pid || 0);
242
- if (pid && isAlive(rec, platform)) {
291
+ // Only a group that is provably the one gu started is signalled — never "unknown", never "other".
292
+ const ours = ["ours", "leader-gone"].includes(groupOwner(rec, { platform, startOf }));
293
+ if (pid && ours && isAlive(rec, platform, { startOf })) {
243
294
  if (platform === "win32") {
244
295
  spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
245
296
  } else {
246
297
  try { process.kill(-pid, "SIGTERM"); } catch { /* gone already */ }
247
- for (let i = 0; i < 10 && isAlive(rec, platform); i++) await new Promise((r) => setTimeout(r, 100));
248
- if (isAlive(rec, platform)) try { process.kill(-pid, "SIGKILL"); } catch { /* gone */ }
298
+ for (let i = 0; i < 10 && isAlive(rec, platform, { startOf }); i++) await new Promise((r) => setTimeout(r, 100));
299
+ if (isAlive(rec, platform, { startOf })) try { process.kill(-pid, "SIGKILL"); } catch { /* gone */ }
249
300
  }
250
301
  }
251
302
  if (!existsSync(path)) return;
@@ -278,10 +329,14 @@ export function shellsToRecap(entries, { cwd = process.cwd(), since = 0 } = {})
278
329
  const up = dirname(raw);
279
330
  return up === raw ? raw : join(real(up), basename(raw));
280
331
  };
281
- const base = real(cwd).replace(/\/+$/, "");
332
+ // THE SAME PROJECT (#376): the git repository this terminal is in, else this folder. gu started
333
+ // in app/ while the agent ran the build at the repository root (its workspace): the shell's
334
+ // folder is ABOVE this one, and the recap never came, though the build was this terminal's.
335
+ // Not "any folder above" — the home folder is above every project.
336
+ const base = projectRoot(real(cwd)).replace(/\/+$/, "");
282
337
  const under = (wd) => {
283
- const w = real(wd);
284
- return w === base || w.startsWith(base + "/");
338
+ const w = real(wd).replace(/\/+$/, "");
339
+ return Boolean(w) && (w === base || w.startsWith(base + "/"));
285
340
  };
286
341
  return entries
287
342
  .filter(({ rec, st }) => st.state !== "running" && st.state !== "killed" && !rec.seen && !rec.announced
@@ -289,6 +344,17 @@ export function shellsToRecap(entries, { cwd = process.cwd(), since = 0 } = {})
289
344
  .map((e) => e.rec);
290
345
  }
291
346
 
347
+ /** The nearest folder at or above `dir` holding .git — or `dir` itself when there is none. */
348
+ export function projectRoot(dir, exists = existsSync) {
349
+ let d = String(dir ?? "");
350
+ for (;;) {
351
+ if (exists(join(d, ".git"))) return d;
352
+ const up = dirname(d);
353
+ if (up === d) return String(dir ?? "");
354
+ d = up;
355
+ }
356
+ }
357
+
292
358
  /** Mark these shells announced in shells.json, so neither the agent nor another terminal repeats it. */
293
359
  export function markAnnounced(ids, { path = shellsPath() } = {}) {
294
360
  const shells = readShells(path);
@@ -306,6 +372,9 @@ export function recapQuestion(entries) {
306
372
  const parts = entries.map(({ rec, st }) =>
307
373
  `shell ${rec.id} ${statusText(st)} after ${fmtRuntime(st.runtime)} (${String(rec.command ?? "").replace(/\s+/g, " ").trim().slice(0, 120)})`);
308
374
  const ids = entries.map(({ rec }) => `shell_output('${rec.id}')`).join(", ");
375
+ // #376: come back to the work, not only report on it — a failed build is the next thing to fix,
376
+ // a finished one may be the step before the next; a recap only when nothing is left to do.
309
377
  return `[gu: background ${entries.length === 1 ? "shell" : "shells"} finished — ${parts.join("; ")}] `
310
- + `Read the result with ${ids}, tell me briefly what happened, and continue with the next step if the task needs one.`;
378
+ + `Read the result with ${ids}. If it failed, find out why and fix it. If it was a step of the task you `
379
+ + `were doing, carry on with the next step now. If nothing is left to do, tell me in one or two lines what happened.`;
311
380
  }
@@ -1148,6 +1148,7 @@ export function trimWords(text, limit) {
1148
1148
  // grey ✗ with dim text. Judged by the LAST segment of a chain/pipeline (its exit status is
1149
1149
  // the one reported): `ps aux | grep foo` and `cd src && rg Foo` are both look-ups.
1150
1150
  const SEARCH_CMDS = new Set([
1151
+ // platform-ok: names to recognise in a command the agent ran — nothing here runs them.
1151
1152
  "grep", "egrep", "fgrep", "zgrep", "rg", "ag", "ack", "pgrep", "which", "whereis", "lsof",
1152
1153
  ]);
1153
1154
  export function isSoftFailCommand(cmd) {
package/win-path.mjs ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * IS gu ON THE WINDOWS PATH, AND IF NOT, WHAT DOES THE USER DO? (task #375, GitHub #156)
3
+ *
4
+ * On Windows, `npm i -g` puts gu.cmd in npm's global folder (%APPDATA%\npm by default; nvm-windows
5
+ * and a custom prefix put it elsewhere). When that folder is not on PATH, the next `gu` answers
6
+ * "'gu' is not recognized" and the user calls the install broken. Claude Code's installer says it
7
+ * plainly instead — "C:\Users\x\.local\bin is not in your PATH. Add it by opening: System
8
+ * Properties → Environment Variables → Edit User PATH → New → Add the path above. Then restart
9
+ * your terminal." — and gu now does the same, in the same words, from:
10
+ *
11
+ * - the installer (https://gu.getmarsic.cc/install.ps1, web-v2/public — its own PowerShell copy
12
+ * of this wording; tests/win-path.test.mjs holds the two together),
13
+ * - `gu-cli fix-path`, which also offers to add it (a yes, never silently),
14
+ * - npm's postinstall (shown only with --foreground-scripts: npm hides install-script output).
15
+ *
16
+ * WHY THE SAVED PATH AND NOT ONLY THIS ONE: a new terminal reads PATH from the registry (User +
17
+ * Machine), not from the process that installed. A folder in this session only — added by hand,
18
+ * or by an installer that ran here — is gone in the next window.
19
+ *
20
+ * Pure except readSavedPaths / addToUserPath, which run PowerShell.
21
+ */
22
+ import { spawnSync } from "node:child_process";
23
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+
27
+ /** `%APPDATA%\npm\` / `"C:/x"` / `c:\X` → one comparable form. %VARS% from `env` (case-insensitive). */
28
+ export function normalizeDir(dir, env = process.env) {
29
+ const lower = {};
30
+ for (const [k, v] of Object.entries(env || {})) lower[k.toLowerCase()] = v;
31
+ let d = String(dir ?? "").trim().replace(/^"(.*)"$/, "$1");
32
+ d = d.replace(/%([^%]+)%/g, (m, name) => lower[name.toLowerCase()] ?? m);
33
+ d = d.replace(/\//g, "\\").replace(/\\+$/, "");
34
+ if (/^[a-z]:$/i.test(d)) d += "\\";
35
+ return d.toLowerCase();
36
+ }
37
+
38
+ /** Does the ;-separated PATH value hold `dir`? */
39
+ export function pathHas(pathValue, dir, env = process.env) {
40
+ const want = normalizeDir(dir, env);
41
+ if (!want) return false;
42
+ return String(pathValue ?? "")
43
+ .split(";")
44
+ .some((p) => p.trim() && normalizeDir(p, env) === want);
45
+ }
46
+
47
+ /** { session, saved } — saved = the User or the Machine PATH in the registry. */
48
+ export function pathStatus({ dir, sessionPath = "", userPath = "", machinePath = "", env = process.env }) {
49
+ return {
50
+ session: pathHas(sessionPath, dir, env),
51
+ saved: pathHas(userPath, dir, env) || pathHas(machinePath, dir, env),
52
+ };
53
+ }
54
+
55
+ /** The user PATH value with `dir` appended once — no doubled or trailing `;`. */
56
+ export function userPathWith(userPath, dir, env = process.env) {
57
+ const parts = String(userPath ?? "")
58
+ .split(";")
59
+ .map((p) => p.trim())
60
+ .filter(Boolean);
61
+ if (parts.some((p) => normalizeDir(p, env) === normalizeDir(dir, env))) return parts.join(";");
62
+ return [...parts, dir].join(";");
63
+ }
64
+
65
+ /** npm's global folder from where this package lives: <prefix>\node_modules\@tiens.nguyen\gu-cli. */
66
+ export function windowsPrefixOf(packageDir) {
67
+ const parts = String(packageDir ?? "").replace(/\//g, "\\").replace(/\\+$/, "").split("\\");
68
+ const n = parts.length;
69
+ if (n < 4 || parts[n - 3].toLowerCase() !== "node_modules" || !parts[n - 2].startsWith("@")) return "";
70
+ const prefix = parts.slice(0, n - 3);
71
+ if (prefix.some((p) => p.toLowerCase() === "node_modules")) return ""; // a project's, not global
72
+ return prefix.join("\\");
73
+ }
74
+
75
+ /** Where npm put gu's Windows launcher in that folder (kept here: see powershell-alias.mjs on
76
+ * why gu-cli.mjs never names it). */
77
+ export const guLocation = (dir) => join(dir, "gu.cmd");
78
+
79
+ const ARROW = "\u2192";
80
+
81
+ /** The lines Claude Code's installer prints, in gu's words. The installer prints the same. */
82
+ export const NOTE_PATH = (dir) =>
83
+ `${dir} is not in your PATH. Add it by opening: System Properties ${ARROW} Environment Variables ` +
84
+ `${ARROW} Edit User PATH ${ARROW} New ${ARROW} Add the path above. Then restart your terminal.`;
85
+ export const NOTE_PATH_SESSION = (dir) =>
86
+ `${dir} is in your saved PATH, but not in this terminal yet. Open a new terminal to use gu.`;
87
+ export const NOTE_ALIAS =
88
+ "In PowerShell, `gu` is a built-in alias (Get-Unique) that wins over this program. Run " +
89
+ "`gu-cli fix-powershell` once to remove it; until then start gu with `gu.cmd`. cmd.exe is not affected.";
90
+
91
+ /** The one-liner to add `dir` to the user PATH in PowerShell, keeping %VARS% entries as they are. */
92
+ export function addToPathOneLiner(dir) {
93
+ const q = String(dir).replace(/'/g, "''");
94
+ return (
95
+ `$k = 'HKCU:\\Environment'; $p = (Get-Item $k).GetValue('Path', '', 'DoNotExpandEnvironmentNames'); ` +
96
+ `Set-ItemProperty $k Path (($p.TrimEnd(';') + ';${q}').TrimStart(';')) -Type ExpandString`
97
+ );
98
+ }
99
+
100
+ /**
101
+ * What to print after an install: { ok, lines, notes }. `notes` are the "Setup notes" bullets —
102
+ * empty when there is nothing for the user to do.
103
+ */
104
+ export function installSummary({ version, location, dir, status, powershell = false }) {
105
+ const notes = [];
106
+ if (!status.saved) notes.push(NOTE_PATH(dir), `Or run this in PowerShell: ${addToPathOneLiner(dir)}`);
107
+ else if (!status.session) notes.push(NOTE_PATH_SESSION(dir));
108
+ if (powershell) notes.push(NOTE_ALIAS);
109
+ const lines = [
110
+ "gu successfully installed!",
111
+ "",
112
+ ` Version: ${version || "?"}`,
113
+ "",
114
+ ` Location: ${location}`,
115
+ "",
116
+ // In PowerShell `gu` is Get-Unique until the alias is removed; gu.cmd is the same program.
117
+ ` Next: Run ${powershell ? "gu.cmd" : "gu"} to get started`,
118
+ ];
119
+ return { ok: status.saved, lines, notes };
120
+ }
121
+
122
+ /** Print an installSummary the way the installer does (yellow notes, `•` bullets). */
123
+ export function formatSummary({ lines, notes }, { color = false } = {}) {
124
+ const y = (s) => (color ? `\x1b[33m${s}\x1b[0m` : s);
125
+ const g = (s) => (color ? `\x1b[32m${s}\x1b[0m` : s);
126
+ const out = [g(lines[0]), ...lines.slice(1)];
127
+ if (notes.length) {
128
+ out.push("", y("!! Setup notes:"));
129
+ for (const n of notes) out.push(` \u2022 ${n}`);
130
+ }
131
+ return out.join("\n");
132
+ }
133
+
134
+ // ------------------------------------------------------------------ Windows only ------------
135
+
136
+ const powershellExe = () =>
137
+ join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
138
+
139
+ /** Run a PowerShell script from a FILE (no quoting across a command line), stdout as text. */
140
+ function runPs(script) {
141
+ const dir = mkdtempSync(join(tmpdir(), "gu-path-"));
142
+ const file = join(dir, "x.ps1");
143
+ try {
144
+ writeFileSync(file, "\ufeff" + script, "utf8"); // a BOM: Windows PowerShell 5.1 reads UTF-8 then
145
+ const r = spawnSync(powershellExe(), ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", file], {
146
+ encoding: "utf8",
147
+ timeout: 30_000,
148
+ windowsHide: true,
149
+ });
150
+ return { ok: !r.error && r.status === 0, out: String(r.stdout || ""), err: String(r.stderr || r.error?.message || "") };
151
+ } finally {
152
+ rmSync(dir, { recursive: true, force: true });
153
+ }
154
+ }
155
+
156
+ /** { userPath, machinePath } from the registry, as stored (%VARS% unexpanded). */
157
+ export function readSavedPaths() {
158
+ const r = runPs(
159
+ "$u = (Get-Item 'HKCU:\\Environment').GetValue('Path', '', 'DoNotExpandEnvironmentNames')\n" +
160
+ "$m = (Get-Item 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment').GetValue('Path', '', 'DoNotExpandEnvironmentNames')\n" +
161
+ "[Console]::Out.Write(\"$u`n$m\")\n",
162
+ );
163
+ const [userPath = "", machinePath = ""] = r.out.split(/\r?\n/);
164
+ return { userPath, machinePath, ok: r.ok };
165
+ }
166
+
167
+ /**
168
+ * Add `dir` to the USER PATH — only ever after the user said yes. Written as REG_EXPAND_SZ so
169
+ * entries like %USERPROFILE%\x keep working (setx and .NET's SetEnvironmentVariable on 5.1 flatten
170
+ * them, and setx cuts PATH at 1024 characters); then the change is announced
171
+ * (WM_SETTINGCHANGE, via a throwaway variable's set + clear) so new terminals see it.
172
+ */
173
+ export function addToUserPath(dir) {
174
+ const q = String(dir).replace(/'/g, "''");
175
+ const r = runPs(
176
+ "$k = 'HKCU:\\Environment'\n" +
177
+ "$p = (Get-Item $k).GetValue('Path', '', 'DoNotExpandEnvironmentNames')\n" +
178
+ `$d = '${q}'\n` +
179
+ "$have = @($p -split ';' | Where-Object { $_.Trim().TrimEnd('\\') -ieq $d.TrimEnd('\\') })\n" +
180
+ "if (-not $have) { Set-ItemProperty $k Path (($p.TrimEnd(';') + ';' + $d).TrimStart(';')) -Type ExpandString }\n" +
181
+ "[Environment]::SetEnvironmentVariable('GU_PATH_REFRESH', '1', 'User')\n" +
182
+ "[Environment]::SetEnvironmentVariable('GU_PATH_REFRESH', $null, 'User')\n",
183
+ );
184
+ return { ok: r.ok, error: r.ok ? "" : r.err.trim() };
185
+ }