@tiens.nguyen/gu-cli 1.0.863 → 1.0.865

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
@@ -9,9 +9,13 @@ gu # open the REPL in the current folder
9
9
  ```
10
10
 
11
11
  The package installs three commands: `gu` (the terminal REPL), `gu-cli` and `gu-local-worker`
12
- (the same worker CLI). Requires Node.js 18 or newer. The install runs `fix-path` for you; if
13
- npm's global bin folder is not on your PATH afterwards, run `npx @tiens.nguyen/gu-cli fix-path`
14
- (works even when `gu-cli` itself is not found, e.g. when npm skipped the install script).
12
+ (the same worker CLI). Requires Node.js 18 or newer. The install tries to put npm's global bin
13
+ folder on your PATH, but npm 11 skips install scripts it was not told to allow (it prints
14
+ "install scripts not yet covered by allowScripts"). If `gu` is "command not found" afterwards, run
15
+ `npx @tiens.nguyen/gu-cli fix-path` (works even when `gu-cli` itself is not found). `gu-cli fix-path`
16
+ also finds what an old install left behind - broken `gu` links, a second gu from another npm, a
17
+ shell-profile PATH line pinned to a versioned Homebrew folder - and asks before fixing each
18
+ (`--check` only reports). `gu support` sends the support team a request with gu's logs attached.
15
19
  On Windows, PowerShell's built-in `gu` alias (Get-Unique) shadows this CLI — run
16
20
  `gu-cli fix-powershell` to drop it.
17
21
 
package/cli-token.mjs CHANGED
@@ -238,6 +238,19 @@ export function shortToken(token) {
238
238
  return t.length <= 24 ? t : `${t.slice(0, 16)}…${t.slice(-6)}`;
239
239
  }
240
240
 
241
+ /**
242
+ * The ONE short yellow line for "there is no token to offer" (#222). The caller's `why` (a licence
243
+ * rule's full sentence) is cut to its first sentence; with none, what the trial lookup said.
244
+ */
245
+ export function noTokenShort({ why = "", trial = {}, dev = null } = {}) {
246
+ const first = (t) => String(t).trim().split(/(?<=[.!?])\s+/)[0].trim();
247
+ if (why) return first(why);
248
+ if (trial.why === "used" && trial.endedAt) return `Free trial ended ${shortDate(trial.endedAt)} — a gu token is needed.`;
249
+ if (trial.why === "unreachable") return "Can't reach the gu service right now.";
250
+ if (/^http-\d+$/.test(String(trial.why ?? "")) || trial.why === "bad-response") return "The gu service gave no free trial right now.";
251
+ return "This computer needs a gu token to start the agent.";
252
+ }
253
+
241
254
  /**
242
255
  * THE TOKEN QUESTION, WITH THE TRIAL AS ITS DEFAULT (#305/#76, placed by #314).
243
256
  *
@@ -285,8 +298,10 @@ export async function promptForToken({
285
298
  const dev = device === undefined ? await trialDevice().catch(() => null) : device;
286
299
  const getOne = `For a free token for 1 computer, log in at ${accountUrl} and create one — or buy Pro there for more computers.`;
287
300
  let trial, trialOk, shown, hint;
288
- let details = []; // #219: the over-limit detail, printed only when the person asks ("?" or an empty Enter)
301
+ let details = []; // #219/#222: the detail, printed only when the person asks ("?" or an empty Enter)
302
+ let compactShort = ""; // #222: the ONE yellow line when there is no default token to offer
289
303
  const offer = async () => {
304
+ details = [];
290
305
  trial = dev ? await claim({ hostedBase, device: dev }) : { ok: false, why: "no-device" };
291
306
  trialOk = Boolean(trial.ok && verifyCliToken(trial.token, publicKeyB64).ok);
292
307
  shown = trialOk ? shortToken(trial.token) : "";
@@ -326,9 +341,18 @@ export async function promptForToken({
326
341
  ...(trialOk ? [] : ["Paste a Pro token here, then Enter."]),
327
342
  ];
328
343
  }
344
+ // #222: NO DEFAULT TOKEN to offer (trial ended, nothing yet, service unreachable): the same
345
+ // advice used to be said three times — the banner, a title, and 3-4 yellow lines under the
346
+ // prompt. Now ONE yellow line; everything else is behind "?" (or an empty Enter).
347
+ compactShort = "";
348
+ if (!trialOk && !overLimit) {
349
+ compactShort = noTokenShort({ why, trial, dev });
350
+ details = [...(why ? [why] : []), ...hint.filter((l) => !why || l !== why)];
351
+ hint = [];
352
+ }
329
353
  };
330
354
  await offer();
331
- log(overLimit && why ? `${short || why} Type ? for details.` : why || "This computer needs a gu token before the agent can start — one time only.", "title");
355
+ log(overLimit && why ? `${short || why} Type ? for details.` : compactShort ? `${compactShort} Type ? for details.` : why || "This computer needs a gu token before the agent can start — one time only.", "title");
332
356
  const entitled = async (token) => {
333
357
  if (!accept) return true;
334
358
  const r = await accept(token).catch(() => ({ ok: true })); // unknown = soft, like every other check here
@@ -341,6 +365,8 @@ export async function promptForToken({
341
365
  // A way OUT of a required question (task #17): "/exit" is not a token, and treating it as one
342
366
  // ("That token was not saved…" and the same question again) left no way to leave gu at all.
343
367
  if (/^\/(exit|quit)$/i.test(typed)) return { ok: false, why: "quit" };
368
+ // An empty Enter with no default is also "try again": the service may be back, the trial may exist now (#222).
369
+ if (!typed && required && !trialOk && !overLimit) await offer();
344
370
  // #219: the over-limit detail, on request only — "?" or an empty Enter, then the same question again.
345
371
  if (details.length && (typed === "?" || (!typed && required))) {
346
372
  for (const l of details) log(l, "info");
@@ -324,15 +324,18 @@ export async function ensureNativeMongoMac(ask, mongo, { log = (l) => console.lo
324
324
  // a yes to something unseen. These are exactly the lines a yes runs.
325
325
  log(dim(installing ? "\n It is not installed here. This installs it (Homebrew):\n" : "\n It is installed but not running. This starts it:\n"));
326
326
  for (const s of steps) log(` brew ${s.join(" ")}`);
327
+ // How long and how big, BEFORE the yes (#221): the install took minutes and pulled a second Node
328
+ // and a dozen libraries, with nothing said about it up front.
329
+ if (installing) log(dim("\n This downloads a few hundred MB and takes a few minutes — Homebrew may also\n add or update some of its own libraries (a Node, OpenSSL…) that MongoDB's tools need."));
327
330
  log("");
328
331
  const yes = await readYesNo(ask, installing ? " Install MongoDB now? [Y/n]: " : " Start MongoDB now? [Y/n]: ", true, log);
329
332
  if (!yes) {
330
333
  log(dim(`\n Not ${installing ? "installed" : "started"} — run the commands above yourself.`));
331
334
  return { ok: false, explained: true };
332
335
  }
333
- log("\n " + bold(installing ? "Installing MongoDB Community" : "Starting MongoDB") + dim(" Homebrew, a minute or two"));
336
+ log("\n " + bold(installing ? "Installing MongoDB Community" : "Starting MongoDB") + dim(installing ? " Homebrew, a few minutes" : " Homebrew"));
334
337
  const done = await mn.runBrewSteps(brew, steps, {
335
- onLine: (l) => l.length < 200 && log(dim(" " + l)),
338
+ onLine: (l) => l.length < 200 && mn.brewLineWorthShowing(l) && log(dim(" " + l)),
336
339
  });
337
340
  if (!done) {
338
341
  log("\n " + red("✗ ") + "Homebrew did not finish — the lines above say why.");
@@ -771,6 +774,19 @@ export async function readYesNo(ask, prompt, defaultYes, say = (l) => console.lo
771
774
  return defaultYes;
772
775
  }
773
776
 
777
+ /**
778
+ * Does setup still need to ASK whose MongoDB this is? (#221) → "custom" | "default" | "forced" | "ask".
779
+ * A resumed run reuses what an earlier run was told: custom credentials (kept as a uri), or a yes
780
+ * to the default empty pair (kept as mongoCredsConfirmed — it used to be forgotten, so an
781
+ * interrupted setup asked the same question again). A uri forced through the environment is deliberate.
782
+ */
783
+ export function mongoCredsDecision(state = {}, env = process.env) {
784
+ if (typeof state.localMongoUri === "string" && state.localMongoUri) return "custom";
785
+ if (state.mongoCredsConfirmed === true) return "default";
786
+ if (env.GONEXT_LOCAL_MONGO_URI) return "forced";
787
+ return "ask";
788
+ }
789
+
774
790
  export async function askMongoCredentials(ask, askSecret = null) {
775
791
  const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
776
792
  console.log(dim(` MongoDB is answering at ${host}:${port}.`));
@@ -1167,12 +1183,13 @@ export async function runClientExpertSetup({
1167
1183
  // A uri already in state — a resumed run — is reused silently, like every other answered
1168
1184
  // question, and a URI forced via GONEXT_LOCAL_MONGO_URI is taken as deliberate.
1169
1185
  let mongoUri = LOCAL_MONGO_URI;
1170
- if (typeof state.localMongoUri === "string" && state.localMongoUri) {
1186
+ const credsDecision = mongoCredsDecision(state);
1187
+ if (credsDecision === "custom") {
1171
1188
  mongoUri = state.localMongoUri;
1172
1189
  if (mongoUri !== LOCAL_MONGO_URI) {
1173
1190
  console.log(dim(" using the MongoDB credentials from the previous run"));
1174
1191
  }
1175
- } else if (!process.env.GONEXT_LOCAL_MONGO_URI) {
1192
+ } else if (credsDecision === "ask") {
1176
1193
  const credsChoice = await askMongoCredentials(ask, askSecret);
1177
1194
  if (credsChoice.exit) {
1178
1195
  console.log("\n " + dim("No working MongoDB credentials — setup stops here."));
@@ -1184,9 +1201,7 @@ export async function runClientExpertSetup({
1184
1201
  return { complete: false, stoppedAt: "mongo" };
1185
1202
  }
1186
1203
  mongoUri = credsChoice.uri;
1187
- if (credsChoice.custom) {
1188
- state = { ...state, localMongoUri: mongoUri };
1189
- }
1204
+ state = credsChoice.custom ? { ...state, localMongoUri: mongoUri } : { ...state, mongoCredsConfirmed: true };
1190
1205
  }
1191
1206
  state = recordStep(state, "mongo", "done");
1192
1207
  await saveState(state);
package/env-check.mjs ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * What is wrong with gu's footprint on THIS machine's PATH (#221) — found on a Mac that was
3
+ * "uninstalled" and reinstalled:
4
+ *
5
+ * · a second gu from ANOTHER npm (Homebrew's, beside nvm's) survived the uninstall and kept
6
+ * answering `which gu` — two copies, two versions, no hint which one runs;
7
+ * · `gu`/`gu-cli` links whose package was removed (broken) are left in a bin folder;
8
+ * · the shell profile's PATH block was written with a VERSIONED Homebrew path
9
+ * (…/Cellar/node/26.10.0_1/bin), which stops existing at the next `brew upgrade node`.
10
+ *
11
+ * Pure of prompts: this file looks and fixes ONE thing at a time; `gu-cli fix-path` asks. Everything
12
+ * takes `pathDirs` / `home`, so the tests run on a temp folder.
13
+ */
14
+ import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync, rmSync, writeFileSync } from "node:fs";
15
+ import { dirname, isAbsolute, join, resolve } from "node:path";
16
+
17
+ export const GU_BIN_NAMES = ["gu", "gu-cli"];
18
+ export const RC_BEGIN = "# >>> gu-cli PATH >>>";
19
+ export const RC_END = "# <<< gu-cli PATH <<<";
20
+ const PACKAGE_MARK = "@tiens.nguyen/gu-cli";
21
+
22
+ /**
23
+ * A Homebrew Cellar path names ONE version of node and disappears when it is upgraded; the same
24
+ * folder is reachable as <brew prefix>/bin for good. Anything else is returned as it was.
25
+ * /opt/homebrew/Cellar/node/26.10.0_1/bin → /opt/homebrew/bin
26
+ */
27
+ export function stableBinDir(binDir) {
28
+ const m = /^(.*)\/Cellar\/[^/]+\/[^/]+\/bin\/?$/.exec(String(binDir ?? ""));
29
+ return m ? join(m[1] || "/", "bin") : String(binDir ?? "");
30
+ }
31
+
32
+ const isGuTarget = (t) => String(t).includes(PACKAGE_MARK) || /gu-cli[\\/](gu-repl|gu-cli)\.mjs$/.test(String(t));
33
+
34
+ /**
35
+ * Every gu / gu-cli launcher in the PATH folders (POSIX symlinks, what npm makes).
36
+ * → [{ dir, name, path, target, broken, pkgDir }] (`pkgDir` = the package folder it points into, when it works)
37
+ */
38
+ export function scanGuLinks({ pathDirs = [] } = {}) {
39
+ const out = [];
40
+ const seen = new Set();
41
+ for (const dir of pathDirs) {
42
+ if (!dir || seen.has(dir)) continue;
43
+ seen.add(dir);
44
+ for (const name of GU_BIN_NAMES) {
45
+ const path = join(dir, name);
46
+ let st;
47
+ try {
48
+ st = lstatSync(path);
49
+ } catch {
50
+ continue;
51
+ }
52
+ if (!st.isSymbolicLink()) continue;
53
+ let target = "";
54
+ try {
55
+ target = readlinkSync(path);
56
+ } catch {
57
+ continue;
58
+ }
59
+ const abs = isAbsolute(target) ? target : resolve(dir, target);
60
+ if (!isGuTarget(abs)) continue; // somebody else's `gu` is none of our business
61
+ let real = "";
62
+ try {
63
+ real = realpathSync(path);
64
+ } catch {
65
+ /* broken */
66
+ }
67
+ out.push({ dir, name, path, target: abs, broken: !real, pkgDir: real ? dirname(real) : "" });
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** The npm prefix a package folder belongs to: <prefix>/lib/node_modules/@tiens.nguyen/gu-cli → <prefix>. */
74
+ export function prefixOfPackageDir(pkgDir) {
75
+ const m = /^(.*)\/lib\/node_modules\/@tiens\.nguyen\/gu-cli$/.exec(String(pkgDir ?? ""));
76
+ return m ? m[1] || "/" : "";
77
+ }
78
+
79
+ /**
80
+ * Working gu installs other than `here` (the one running this).
81
+ * → [{ pkgDir, prefix, links: [path] }]
82
+ */
83
+ export function otherGuCopies(links, here = "") {
84
+ const mine = here ? safeReal(here) : "";
85
+ const byPkg = new Map();
86
+ for (const l of links) {
87
+ if (l.broken || !l.pkgDir) continue;
88
+ if (mine && safeReal(l.pkgDir) === mine) continue;
89
+ const e = byPkg.get(l.pkgDir) ?? { pkgDir: l.pkgDir, prefix: prefixOfPackageDir(l.pkgDir), links: [] };
90
+ e.links.push(l.path);
91
+ byPkg.set(l.pkgDir, e);
92
+ }
93
+ return [...byPkg.values()];
94
+ }
95
+
96
+ function safeReal(p) {
97
+ try {
98
+ return realpathSync(p);
99
+ } catch {
100
+ return resolve(p);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * The gu block in a shell profile, read. → { has, binDir, versioned, missing }
106
+ * versioned = the path names a Homebrew Cellar version (breaks on upgrade)
107
+ * missing = the folder it adds is not there any more
108
+ */
109
+ export function readRcBlock(text, { exists = existsSync } = {}) {
110
+ const s = String(text ?? "");
111
+ const a = s.indexOf(RC_BEGIN);
112
+ const b = s.indexOf(RC_END);
113
+ if (a < 0 || b < a) return { has: false, binDir: "", versioned: false, missing: false };
114
+ const m = /export PATH="([^"$]+?):\$PATH"/.exec(s.slice(a, b));
115
+ const binDir = m ? m[1] : "";
116
+ return { has: true, binDir, versioned: Boolean(binDir) && stableBinDir(binDir) !== binDir, missing: Boolean(binDir) && !exists(binDir) };
117
+ }
118
+
119
+ /** The block, rewritten to add `binDir` instead (everything outside the markers untouched). */
120
+ export function rewriteRcBlock(text, binDir) {
121
+ const s = String(text ?? "");
122
+ const a = s.indexOf(RC_BEGIN);
123
+ const b = s.indexOf(RC_END);
124
+ if (a < 0 || b < a) return s;
125
+ const end = b + RC_END.length;
126
+ const block = `${RC_BEGIN}\n# npm's global bin dir was not on PATH; added by @tiens.nguyen/gu-cli.\nexport PATH="${binDir}:$PATH"\n${RC_END}`;
127
+ return s.slice(0, a) + block + s.slice(end);
128
+ }
129
+
130
+ export const RC_FILES = [".zshrc", ".bashrc", ".bash_profile", ".profile"];
131
+
132
+ /**
133
+ * Everything found, as findings the caller can print and (when `fix`) apply:
134
+ * { id, text, fix?: { kind: "remove-link" | "rewrite-rc" | "uninstall-copy", … , label } }
135
+ */
136
+ export function checkEnvironment({ pathDirs = [], home = "", here = "", exists = existsSync } = {}) {
137
+ const findings = [];
138
+ const links = scanGuLinks({ pathDirs });
139
+ for (const l of links.filter((x) => x.broken)) {
140
+ findings.push({
141
+ id: "broken-link",
142
+ text: `${l.path} points to a gu that is no longer installed.`,
143
+ fix: { kind: "remove-link", path: l.path, label: `remove ${l.path}` },
144
+ });
145
+ }
146
+ for (const c of otherGuCopies(links, here)) {
147
+ let version = "?";
148
+ try {
149
+ version = JSON.parse(readFileSync(join(c.pkgDir, "package.json"), "utf8")).version;
150
+ } catch {
151
+ /* shown as ? */
152
+ }
153
+ findings.push({
154
+ id: "second-copy",
155
+ text: `A second gu (${version}) is installed at ${c.pkgDir} — which one runs depends on PATH order.`,
156
+ fix: c.prefix ? { kind: "uninstall-copy", prefix: c.prefix, label: `npm uninstall -g ${PACKAGE_MARK} --prefix ${c.prefix}` } : undefined,
157
+ });
158
+ }
159
+ for (const f of RC_FILES) {
160
+ const file = join(home, f);
161
+ let text = "";
162
+ try {
163
+ text = readFileSync(file, "utf8");
164
+ } catch {
165
+ continue;
166
+ }
167
+ const rc = readRcBlock(text, { exists });
168
+ if (!rc.has) continue;
169
+ if (rc.versioned) {
170
+ const stable = stableBinDir(rc.binDir);
171
+ findings.push({
172
+ id: "versioned-path",
173
+ text: `${file} adds ${rc.binDir}, a Homebrew version folder that goes away on \`brew upgrade\`${rc.missing ? " (it already has — gu is not on PATH from there)" : ""}.`,
174
+ fix: { kind: "rewrite-rc", file, binDir: stable, label: `change it to ${stable}` },
175
+ });
176
+ } else if (rc.missing) {
177
+ findings.push({ id: "missing-path", text: `${file} adds ${rc.binDir}, which does not exist.` });
178
+ }
179
+ }
180
+ return findings;
181
+ }
182
+
183
+ /** Apply one finding's fix. → { ok, why } — never throws. */
184
+ export function applyFix(fix, { run = null } = {}) {
185
+ try {
186
+ if (fix.kind === "remove-link") {
187
+ rmSync(fix.path, { force: true });
188
+ return { ok: !lstatOk(fix.path), why: "" };
189
+ }
190
+ if (fix.kind === "rewrite-rc") {
191
+ const text = readFileSync(fix.file, "utf8");
192
+ writeFileSync(fix.file, rewriteRcBlock(text, fix.binDir));
193
+ return { ok: true, why: "" };
194
+ }
195
+ if (fix.kind === "uninstall-copy") {
196
+ if (!run) return { ok: false, why: "no runner" };
197
+ const r = run("npm", ["uninstall", "-g", PACKAGE_MARK, "--prefix", fix.prefix]);
198
+ return { ok: r.status === 0, why: r.status === 0 ? "" : String(r.stderr || r.error || `exit ${r.status}`).trim().slice(0, 200) };
199
+ }
200
+ } catch (e) {
201
+ return { ok: false, why: e?.message || String(e) };
202
+ }
203
+ return { ok: false, why: "unknown fix" };
204
+ }
205
+
206
+ function lstatOk(p) {
207
+ try {
208
+ lstatSync(p);
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * One line for gu's start when gu runs but its own npm folder is NOT on PATH (#221). npm 11 no
217
+ * longer runs install scripts it was not told to allow, so the postinstall that adds the folder
218
+ * may never have run: gu was started by its full path (or npx) and the next terminal will say
219
+ * "gu: command not found". The line gives the command, by full path since `gu-cli` is not found either.
220
+ * → "" when there is nothing to say (on PATH, not a global install, Windows, or CI).
221
+ */
222
+ export function pathNoticeLine({ prefix = "", pathEnv = "", platform = process.platform, ci = false } = {}) {
223
+ if (!prefix || platform === "win32" || ci) return "";
224
+ const binDir = join(prefix, "bin");
225
+ const dirs = String(pathEnv).split(":");
226
+ if (dirs.includes(binDir) || dirs.includes(stableBinDir(binDir))) return "";
227
+ return `${binDir} is not on your PATH, so a new terminal will not find \`gu\`. Fix it: ${join(binDir, "gu-cli")} fix-path`;
228
+ }
package/gu-cli.mjs CHANGED
@@ -139,7 +139,8 @@ Usage:
139
139
  gu-cli workspace revert [runId] # undo an agent run's file edits from its backups
140
140
  gu-cli mongo migrate-from-docker # macOS: move a Docker MongoDB to native (Homebrew)
141
141
  gu-cli clear [--scratch|--keep-history] [--yes] # back to a fresh machine (asks first)
142
- gu-cli support # write a masked health report for the support team
142
+ gu-cli support # send the support team a request with gu's logs (masked) attached
143
+ gu-cli support --no-send # only make the logs zip (~/.gonext/support/) to email yourself
143
144
  gu-cli fix-path [--check] # put npm's global folder on PATH (Windows: check, then ask)
144
145
  # (what the install does, if npm skipped it)
145
146
  gu-cli fix-powershell [--check] # Windows: drop PowerShell's built-in \`gu\` alias
@@ -1111,14 +1112,45 @@ if (args[0] === "fix-path") {
1111
1112
  // #94: under `npx @tiens.nguyen/gu-cli fix-path` this file runs from npx's cache; fixPathPrefix
1112
1113
  // then falls back to npm's global prefix when gu is installed there.
1113
1114
  const prefix = fixPathPrefix({ here });
1115
+ // #221: besides the PATH line, look for what an old or second install left behind — broken
1116
+ // `gu` links, a second copy from another npm, a profile line pinned to a versioned Homebrew
1117
+ // folder. Each is explained and asked about; `--check` only reports.
1118
+ const scanEnvironment = async () => {
1119
+ const { checkEnvironment, applyFix } = await import("./env-check.mjs");
1120
+ const { homedir } = await import("node:os");
1121
+ const findings = checkEnvironment({ pathDirs: (process.env.PATH || "").split(":"), home: homedir(), here });
1122
+ if (!findings.length) return;
1123
+ console.log("\n Also found:");
1124
+ const { spawnSync } = await import("node:child_process");
1125
+ let rl = null;
1126
+ const check = args.includes("--check") || !process.stdin.isTTY;
1127
+ for (const f of findings) {
1128
+ console.log(` • ${f.text}`);
1129
+ if (!f.fix || check) {
1130
+ if (f.fix) console.log(` Fix: ${f.fix.label} (run \`gu-cli fix-path\` in a terminal to be asked)`);
1131
+ continue;
1132
+ }
1133
+ rl ??= (await import("node:readline/promises")).createInterface({ input: process.stdin, output: process.stdout });
1134
+ // Removing another gu install is the one that costs something: default No.
1135
+ const dflt = f.fix.kind === "uninstall-copy" ? "N" : "Y";
1136
+ const a = (await rl.question(` ${f.fix.label}? [${dflt === "Y" ? "Y/n" : "y/N"}] `)).trim().toLowerCase();
1137
+ if (!(a === "y" || a === "yes" || (a === "" && dflt === "Y"))) continue;
1138
+ const r = applyFix(f.fix, { run: (cmd, a2) => spawnSync(cmd, a2, { encoding: "utf8" }) });
1139
+ console.log(r.ok ? " Done." : ` Could not: ${r.why}`);
1140
+ }
1141
+ rl?.close();
1142
+ };
1114
1143
  if (!prefix) {
1115
1144
  console.log(` gu is not installed globally with npm (${here}) — there is no PATH to fix.`);
1116
1145
  console.log(" Install it with: npm install -g @tiens.nguyen/gu-cli");
1146
+ await scanEnvironment();
1117
1147
  process.exit(0);
1118
1148
  }
1149
+ const { stableBinDir } = await import("./env-check.mjs");
1119
1150
  const binDir = join(prefix, "bin");
1120
- if ((process.env.PATH || "").split(":").includes(binDir)) {
1151
+ if ((process.env.PATH || "").split(":").some((d) => d === binDir || d === stableBinDir(binDir))) {
1121
1152
  console.log(` Nothing to fix — ${binDir} is already on PATH.`);
1153
+ await scanEnvironment();
1122
1154
  process.exit(0);
1123
1155
  }
1124
1156
  const { spawnSync } = await import("node:child_process");
@@ -1143,9 +1175,10 @@ if (args[0] === "fix-path") {
1143
1175
  if (has) console.log(` Your shell profile already adds ${binDir} to PATH — open a new terminal.`);
1144
1176
  else {
1145
1177
  console.log(` ${binDir} is not on your PATH and was not added to a shell profile. Add this line to it:`);
1146
- console.log(` export PATH="${binDir}:$PATH"`);
1178
+ console.log(` export PATH="${stableBinDir(binDir)}:$PATH"`);
1147
1179
  }
1148
1180
  }
1181
+ await scanEnvironment();
1149
1182
  process.exit(r.status ?? 0);
1150
1183
  }
1151
1184
 
@@ -1253,13 +1286,17 @@ if (args[0] === "fix-powershell") {
1253
1286
  }
1254
1287
 
1255
1288
  if (args[0] === "support") {
1256
- // One redacted report for the support team (see support-report.mjs). Changes nothing but its own file.
1257
- const sr = await import("./support-report.mjs");
1258
- const { lines, file } = await sr.writeSupportReport({ version: WORKER_VERSION });
1259
- for (const l of lines) console.log(l);
1260
- console.log("");
1261
- console.log(` Saved to ${file} — send that file (or this text) to the support team. Secrets are masked.`);
1262
- process.exit(0);
1289
+ // #220: the masked logs + health report as one zip, shown, then sent to the support team with a
1290
+ // reference back (support-command.mjs). `--report` is the old text-only report.
1291
+ const { runSupportCommand } = await import("./support-command.mjs");
1292
+ let rl = null;
1293
+ const ask = async (q) => {
1294
+ rl ??= (await import("node:readline/promises")).createInterface({ input: process.stdin, output: process.stdout });
1295
+ return rl.question(q).catch(() => "");
1296
+ };
1297
+ const code = await runSupportCommand({ args: args.slice(1), version: WORKER_VERSION, ask });
1298
+ rl?.close();
1299
+ process.exit(code);
1263
1300
  }
1264
1301
 
1265
1302
  if (args[0] === "clear" || args[0] === "reset") {