@tiens.nguyen/gu-cli 1.0.862 → 1.0.864

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
  *
@@ -275,6 +288,8 @@ export async function promptForToken({
275
288
  why = "",
276
289
  // #209: the block is the computer LIMIT (not a missing/expired plan) — a Pro token is not the way out.
277
290
  overLimit = false,
291
+ // #219: with overLimit, the ONE short yellow line (the long `why` is shown only on request).
292
+ short = "",
278
293
  // #315: a saved token must also be ENTITLED on this computer. `accept(token)` → { ok } | { ok:false, reason }
279
294
  // — the REPL renews it from this computer (to learn its free time) and runs the licence rule.
280
295
  accept = null,
@@ -283,7 +298,10 @@ export async function promptForToken({
283
298
  const dev = device === undefined ? await trialDevice().catch(() => null) : device;
284
299
  const getOne = `For a free token for 1 computer, log in at ${accountUrl} and create one — or buy Pro there for more computers.`;
285
300
  let trial, trialOk, shown, hint;
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
286
303
  const offer = async () => {
304
+ details = [];
287
305
  trial = dev ? await claim({ hostedBase, device: dev }) : { ok: false, why: "no-device" };
288
306
  trialOk = Boolean(trial.ok && verifyCliToken(trial.token, publicKeyB64).ok);
289
307
  shown = trialOk ? shortToken(trial.token) : "";
@@ -305,12 +323,16 @@ export async function promptForToken({
305
323
  ];
306
324
  if (why) {
307
325
  const pricingUrl = `${String(site).replace(/^https?:\/\//, "")}/pricing`;
326
+ if (overLimit) {
327
+ details = [
328
+ why,
329
+ `Remove the computers you no longer use at ${accountUrl}, then start gu again — or Pro allows more computers: ${pricingUrl}`,
330
+ `Think this is wrong? ${SUPPORT_EMAIL}`,
331
+ required ? "/exit or Ctrl+C quits gu." : "Enter to skip for now.",
332
+ ];
333
+ }
308
334
  hint = overLimit
309
- ? [
310
- `Remove the computers you no longer use at ${accountUrl}, then start gu again — or Pro allows more computers: ${pricingUrl}`,
311
- `Think this is wrong? ${SUPPORT_EMAIL}`,
312
- required ? "/exit or Ctrl+C quits gu." : "Enter to skip for now.",
313
- ]
335
+ ? [] // #219: one yellow line only — the rest is behind "?"
314
336
  : [
315
337
  // The reason (the title) already says what happened; a "trial ended on <date>" line beside it is
316
338
  // noise — and wrong for a computer that went straight to a free account and never had one.
@@ -319,9 +341,18 @@ export async function promptForToken({
319
341
  ...(trialOk ? [] : ["Paste a Pro token here, then Enter."]),
320
342
  ];
321
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
+ }
322
353
  };
323
354
  await offer();
324
- log(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");
325
356
  const entitled = async (token) => {
326
357
  if (!accept) return true;
327
358
  const r = await accept(token).catch(() => ({ ok: true })); // unknown = soft, like every other check here
@@ -334,6 +365,14 @@ export async function promptForToken({
334
365
  // A way OUT of a required question (task #17): "/exit" is not a token, and treating it as one
335
366
  // ("That token was not saved…" and the same question again) left no way to leave gu at all.
336
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();
370
+ // #219: the over-limit detail, on request only — "?" or an empty Enter, then the same question again.
371
+ if (details.length && (typed === "?" || (!typed && required))) {
372
+ for (const l of details) log(l, "info");
373
+ if (!required) round--; // "?" does not use up one of the two tries
374
+ continue;
375
+ }
337
376
  if (!typed) {
338
377
  if (!required) return { ok: false, why: "skipped" };
339
378
  if (!trialOk) await offer(); // no default: maybe the service is back, or the trial exists now
@@ -754,6 +793,8 @@ export function enforcementDecision({ token, publicKeyB64, now = new Date(), sit
754
793
  enforced: true,
755
794
  allowed: false,
756
795
  reason: "over-limit",
796
+ // #219: the ONE short line shown first (the long `why` is the detail, shown on request).
797
+ short: `${planName(p.plan)} allows ${computers(p.deviceLimit)}; this account is on ${p.activeDevices}.`,
757
798
  why:
758
799
  `This account has used gu on ${computers(p.activeDevices)} in the last 30 days, and the ` +
759
800
  `${planName(p.plan)} allows ${p.deviceLimit}. Sign out of one, or remove it at ${accountUrl}.`,
@@ -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
+ }