@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 +7 -3
- package/cli-token.mjs +47 -6
- package/client-expert-run.mjs +22 -7
- package/env-check.mjs +228 -0
- package/gu-cli.mjs +130 -77
- package/gu-repl.mjs +153 -12
- package/install-path.mjs +11 -7
- package/mongo-native.mjs +15 -0
- package/package.json +4 -1
- package/powershell-alias.mjs +62 -4
- package/support-bundle.mjs +389 -0
- package/support-command.mjs +106 -0
- package/terminal-layout.mjs +41 -1
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
|
|
13
|
-
npm
|
|
14
|
-
|
|
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}.`,
|
package/client-expert-run.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|