@tiens.nguyen/gu-cli 1.0.858 → 1.0.860

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.
@@ -0,0 +1,35 @@
1
+ // "Yes, always in this folder" for the destructive-command question (GH #215).
2
+ // The agent reads the same file (gonext_agent_chat._cmd_allowed_in) before it asks. Only the SOFT
3
+ // question ("a destructive command ...") can be skipped by it - sudo, the denylist and deletes
4
+ // outside the project are never covered by it.
5
+ import { readFileSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
6
+ import { dirname, join, sep } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ export const commandAllowPath = () => process.env.GU_COMMAND_ALLOW || join(homedir(), ".gonext", "command-allow.json");
10
+ const real = (p) => {
11
+ try {
12
+ return realpathSync(p);
13
+ } catch {
14
+ return p;
15
+ }
16
+ };
17
+ export function commandAllowList() {
18
+ try {
19
+ const f = JSON.parse(readFileSync(commandAllowPath(), "utf8")).folders;
20
+ return Array.isArray(f) ? f : [];
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+ /** Is `folder` on the list, or inside a folder that is (a project covers its subfolders)? */
26
+ export function commandAllowedIn(folder) {
27
+ const f = real(folder);
28
+ return commandAllowList().some((r) => f === r || f.startsWith(r.replace(/[\\/]+$/, "") + sep));
29
+ }
30
+ export function commandAllowIn(folder, allow = true) {
31
+ const f = real(folder);
32
+ const folders = [...commandAllowList().filter((x) => x !== f), ...(allow ? [f] : [])];
33
+ mkdirSync(dirname(commandAllowPath()), { recursive: true });
34
+ writeFileSync(commandAllowPath(), JSON.stringify({ folders }, null, 2));
35
+ }
@@ -7971,8 +7971,19 @@ def _ws_shell_command_risk(scan, denyset, wd="", allowset=None, roots=None):
7971
7971
  return (None, None, f"it runs text as code ({a[0]})")
7972
7972
  if "$" in a[0] or "`" in a[0]:
7973
7973
  return (None, None, f"the program to run is only known at run time ({a[0][:40]})")
7974
+ # `cd sub && rm -rf x`: judge a delete from the folder the shell will be in when it runs.
7975
+ cwds, cur = {}, wd
7976
+ for c in top:
7977
+ a = c["argv"]
7978
+ if a and a[0] == "cd" and len(a) >= 2 and c["sep"] not in ("|", "|&"):
7979
+ cur = os.path.normpath(os.path.join(cur or os.getcwd(),
7980
+ os.path.expanduser(_shell_path_to_native(a[1]))))
7981
+ cwds[id(c)] = cur
7974
7982
  for c in every:
7975
- if _WS_DESTRUCTIVE_RE.search(" ".join(c["argv"])) and not _agent_created_owns_delete(c["argv"], wd):
7983
+ here = cwds.get(id(c), wd)
7984
+ if (_WS_DESTRUCTIVE_RE.search(" ".join(c["argv"]))
7985
+ and not _agent_created_owns_delete(c["argv"], wd)
7986
+ and not _ws_delete_inside_only(c["argv"], here, roots)):
7976
7987
  return (None, None, "a destructive command (deletes files / wipes disk / powers off)")
7977
7988
  return (None, None, None)
7978
7989
 
@@ -8905,6 +8916,42 @@ def _ws_is_inside(path, roots):
8905
8916
  return any(path == r or path.startswith(r.rstrip(os.sep) + os.sep) for r in roots)
8906
8917
 
8907
8918
 
8919
+ def _ws_delete_inside_only(argv, wd, roots=None):
8920
+ """True when `argv` is an rm/rmdir/unlink whose EVERY target is inside a registered project
8921
+ (the user's own build output, node_modules, scratch files...) - deleting those is the normal
8922
+ work of a project, so the destructive question is not asked. Still asks for: the project root
8923
+ itself, its .git folder, a path only known at run time ($VAR, `cmd`), anything outside."""
8924
+ if not argv:
8925
+ return False
8926
+ runner = os.path.basename(argv[0]).lower()
8927
+ if runner.endswith(".exe"):
8928
+ runner = runner[:-4]
8929
+ if runner not in ("rm", "rmdir", "unlink"):
8930
+ return False
8931
+ targets = _ws_delete_targets(argv)
8932
+ if not targets:
8933
+ return False
8934
+ ws = [os.path.realpath(r["path"]) for r in (_WS_ROOTS if roots is None else roots) if r.get("path")]
8935
+ if not ws:
8936
+ return False
8937
+ for t in targets:
8938
+ if "$" in t or "`" in t:
8939
+ return False
8940
+ if any(ch in t for ch in "*?[") and os.path.basename(t).startswith("."):
8941
+ return False # `.*` would also match .git
8942
+ p = os.path.expanduser(_shell_path_to_native(t))
8943
+ try:
8944
+ p = os.path.realpath(p if os.path.isabs(p) else os.path.join(wd or os.getcwd(), p))
8945
+ except OSError:
8946
+ return False
8947
+ home = next((r for r in ws if _ws_is_inside(p, [r])), None)
8948
+ if home is None or p == home:
8949
+ return False
8950
+ if os.path.relpath(p, home).split(os.sep)[0] == ".git":
8951
+ return False
8952
+ return True
8953
+
8954
+
8908
8955
  def _ws_delete_outside_reason(argv, wd, roots=None):
8909
8956
  """Why this delete needs the user's yes, or None when every target is inside a workspace or
8910
8957
  the temp folder. Paths are resolved from `wd`, with ~ expanded and symlinks followed."""
@@ -9015,6 +9062,29 @@ def _with_attribution(argv, settings):
9015
9062
  return argv, ""
9016
9063
 
9017
9064
 
9065
+ # GH #215: "Yes, always in this folder" for the SOFT destructive question. ~/.gonext/command-allow.json
9066
+ # ({"folders": [...]}) is written by the terminal (command-allow.mjs). It never covers a hard reason
9067
+ # (sudo, denylist, delete outside the project) - those keep asking.
9068
+ _CMD_ALLOW_ASK = "a destructive command"
9069
+
9070
+
9071
+ def _cmd_allow_path():
9072
+ return os.environ.get("GU_COMMAND_ALLOW") or os.path.join(os.path.expanduser("~"), ".gonext", "command-allow.json")
9073
+
9074
+
9075
+ def _cmd_allowed_in(folder):
9076
+ """Is `folder` on the always-allow list, or inside a folder that is?"""
9077
+ if not folder:
9078
+ return False
9079
+ try:
9080
+ with open(_cmd_allow_path(), encoding="utf-8") as f:
9081
+ folders = json.load(f).get("folders") or []
9082
+ real = os.path.realpath(folder)
9083
+ return any(isinstance(r, str) and _ws_is_inside(real, [r]) for r in folders)
9084
+ except (OSError, ValueError, AttributeError):
9085
+ return False
9086
+
9087
+
9018
9088
  def _ws_command_risk(command, argv, denyset, wd=""):
9019
9089
  """Classify a command for the run policy. Returns (hard_reason, ask_reason), either
9020
9090
  None when N/A:
@@ -9037,7 +9107,8 @@ def _ws_command_risk(command, argv, denyset, wd=""):
9037
9107
  outside = _ws_delete_outside_reason(argv, wd)
9038
9108
  if outside:
9039
9109
  return (outside, outside)
9040
- if _WS_DESTRUCTIVE_RE.search(cmd) and not _agent_created_owns_delete(argv, wd):
9110
+ if (_WS_DESTRUCTIVE_RE.search(cmd) and not _agent_created_owns_delete(argv, wd)
9111
+ and not _ws_delete_inside_only(argv, wd)):
9041
9112
  return (None, "a destructive command (deletes files / wipes disk / powers off)")
9042
9113
  return (None, None)
9043
9114
 
@@ -15709,6 +15780,13 @@ def run_agent_chat(cfg):
15709
15780
  f"Needs {_priv} → cannot run here ({_ws_access_line(_facts)})"})
15710
15781
  _last_obs["text"] = msg
15711
15782
  return msg
15783
+ # GH #215: the user said "Yes, always in this folder" to the destructive question.
15784
+ if (_ask_reason and not _hard_reason and _ask_reason.startswith(_CMD_ALLOW_ASK)
15785
+ and _cmd_allowed_in(wd or _WS_ACTIVE or _default_ws_root())):
15786
+ # accept-edits OFF still confirms every command, so only the destructive
15787
+ # question is dropped, not that one.
15788
+ _ask_reason = None if _accept_edits else (
15789
+ "accept edits is off, so every command is confirmed first")
15712
15790
  if _ask_reason:
15713
15791
  if _priv and _facts:
15714
15792
  _ask_reason += f" — {_facts['why']}"
package/gu-cli.mjs CHANGED
@@ -139,6 +139,7 @@ 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
143
  gu-cli fix-path [--check] # put npm's global folder on PATH (Windows: check, then ask)
143
144
  # (what the install does, if npm skipped it)
144
145
  gu-cli fix-powershell [--check] # Windows: drop PowerShell's built-in \`gu\` alias
@@ -1235,6 +1236,16 @@ if (args[0] === "fix-powershell") {
1235
1236
  process.exit(0);
1236
1237
  }
1237
1238
 
1239
+ if (args[0] === "support") {
1240
+ // One redacted report for the support team (see support-report.mjs). Changes nothing but its own file.
1241
+ const sr = await import("./support-report.mjs");
1242
+ const { lines, file } = await sr.writeSupportReport({ version: WORKER_VERSION });
1243
+ for (const l of lines) console.log(l);
1244
+ console.log("");
1245
+ console.log(` Saved to ${file} — send that file (or this text) to the support team. Secrets are masked.`);
1246
+ process.exit(0);
1247
+ }
1248
+
1238
1249
  if (args[0] === "clear" || args[0] === "reset") {
1239
1250
  const cs = await import("./clear-state.mjs");
1240
1251
  const scratchOnly = args.includes("--scratch");
package/gu-repl.mjs CHANGED
@@ -140,6 +140,7 @@ import {
140
140
  import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
141
141
  import { isScaffoldStep, isGroupableStep, stepGroupMark, stepGroupRows, relativizePaths } from "./step-group.mjs";
142
142
  import { clearLogsNoticeText } from "./clear-logs.mjs";
143
+ import { commandAllowIn, commandAllowedIn } from "./command-allow.mjs";
143
144
  import { allowIn, allowedIn, bridgeCommand, browserReport, bridgeStatus, extensionDir, findDevInstalls, helpInstall, installExtensionFiles, installSteps, localLeftovers, openExtensionsPage, removeLocalFiles, STORE_URL, stopBridge } from "./browser-command.mjs";
144
145
  import { commandLogFile, killShell, logTail, markAnnounced, readShells, recapQuestion, runningShells, shellsToRecap, statusText as shellStatusText, renderShellDetails, shellPanelRows, shellPickerLabel, shellState, shellsNotice } from "./shell-details.mjs";
145
146
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
@@ -586,7 +587,7 @@ const argv = process.argv.slice(2);
586
587
  // process.argv and exits inside its own subcommand branch, before it ever starts polling.
587
588
  const CLI_SUBCOMMANDS = [
588
589
  "status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
589
- "clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp", "hooks",
590
+ "clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp", "hooks", "support",
590
591
  ];
591
592
  // `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
592
593
  // the list, so it fell through to "no subcommand given" and a full agent session opened in that
@@ -3694,7 +3695,7 @@ const paintThoughtRows = (block, hovered, collapsed = block.collapsed) => {
3694
3695
  if (block.kind === "command") {
3695
3696
  // #204: the Allow prompt's command — wrapped under its own indent, long ones collapsed.
3696
3697
  const paint = (row) => (hovered ? hoverThought(row) : dim(row));
3697
- return commandBlockRows(block.text, { width: commandWidth(), collapsed, maxRows: COMMAND_COLLAPSED_ROWS }).map(paint);
3698
+ return commandBlockRows(block.text, { width: commandWidth(), indent: GUTTER, collapsed, maxRows: COMMAND_COLLAPSED_ROWS }).map(paint);
3698
3699
  }
3699
3700
  if (block.kind === "steps") {
3700
3701
  // Activity (look-ups, finished commands): dim, like a ✓ row; bright while hovered.
@@ -10699,6 +10700,9 @@ let approvalOpenedOver = null;
10699
10700
  let approvalDeadline = 0;
10700
10701
  let listDeadline = 0;
10701
10702
  let listTimeoutIndex = -1;
10703
+ /** How long a command approval ("Allow running this command?") waits before it answers No by itself.
10704
+ * GH #214: was 170 s; under 30 s so an absent user does not stall the turn. The python side waits 180 s. */
10705
+ const COMMAND_APPROVAL_MS = 25000;
10702
10706
  /** "2:49" above a minute, "45s" below; never negative. */
10703
10707
  function countdownLabel(msLeft) {
10704
10708
  const s = Math.max(0, Math.ceil(msLeft / 1000));
@@ -10733,7 +10737,7 @@ function printApprovalCommand(shown, { toggle = true } = {}) {
10733
10737
  const canToggle = toggle && barPinned();
10734
10738
  const block = { kind: "command", from: 0, to: 0, text: shown, collapsed: canToggle };
10735
10739
  const painted = paintThoughtRows(block, false);
10736
- process.stdout.write("\n" + yellow("⚠ Allow running this command?") + "\n" + painted.join("\n") + "\n");
10740
+ process.stdout.write("\n" + markLine(yellow("⚠")) + yellow("Allow running this command?") + "\n" + painted.join("\n") + "\n");
10737
10741
  if (!canToggle || !blockCanToggle(block)) return null;
10738
10742
  block.to = transcript.dropped() + transcript.length() - 2;
10739
10743
  block.from = block.to - painted.length + 1;
@@ -10774,7 +10778,7 @@ function onApprovalKey(key) {
10774
10778
  // Ctrl+C is delivered via readline "SIGINT" (onInterrupt), which also denies — see there.
10775
10779
  }
10776
10780
  // Show the picker and resolve to the user's choice. Non-TTY (piped) can't pick → deny
10777
- // (safe). Auto-denies after ~170s so an absent user never hangs the turn (and stays
10781
+ // (safe). A command approval auto-denies after COMMAND_APPROVAL_MS (25s, GH #214) so an absent user never hangs the turn (well
10778
10782
  // under the python side's 180s wait); Enter is instant since Yes is preselected.
10779
10783
  // The command carries a "__MAXSTEP__::<message>" sentinel when it's the step-budget
10780
10784
  // continue prompt (task #80) rather than a risky-command approval. That case shows a
@@ -10870,7 +10874,7 @@ async function approvalPrompt(command) {
10870
10874
  if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
10871
10875
  process.stdout.write(
10872
10876
  "\n" + yellow("🌐 Allow gu to use a web browser?") + dim(" (no answer in ~3 min = Yes, this time)") + "\n" +
10873
- indentBlock(`It wants to: ${what || "open a page"}`, { paint: dim }) + "\n" +
10877
+ indentBlock(`It wants to: ${what || "open a page"}`, { indent: GUTTER, paint: dim }) + "\n" +
10874
10878
  dim(" In your Chrome if the gu extension is there, else in gu's own Chrome window.") + "\n"
10875
10879
  );
10876
10880
  // "Yes, this time" is THE default (#380, GitHub #167): highlighted, so Enter allows it once, and
@@ -10891,8 +10895,8 @@ async function approvalPrompt(command) {
10891
10895
  const [server = "", tool = "", preview = ""] = command.slice(MCPTOOL_PREFIX.length).split("\t");
10892
10896
  if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
10893
10897
  process.stdout.write(
10894
- "\n" + yellow(`⚙ Allow the MCP tool ${server}/${tool}?`) + dim(" (no answer in ~3 min = No)") + "\n" +
10895
- (preview ? indentBlock(preview, { paint: dim }) + "\n" : "")
10898
+ "\n" + markLine(yellow("⚙")) + yellow(`Allow the MCP tool ${server}/${tool}?`) + dim(" (no answer in ~3 min = No)") + "\n" +
10899
+ (preview ? indentBlock(preview, { indent: GUTTER, paint: dim }) + "\n" : "")
10896
10900
  );
10897
10901
  const idx = await pickFromList(
10898
10902
  ["Yes", `Yes, and always allow ${server}/${tool}`, `Yes, and always allow every ${server} tool`, "No"],
@@ -10937,11 +10941,11 @@ async function approvalPrompt(command) {
10937
10941
  // "yes" must never cover those.
10938
10942
  if (process.stdin.isTTY && editMode === "manual" && why.startsWith("accept edits is off")) {
10939
10943
  printApprovalCommand(shown, { toggle: false }); // a list picker follows — shown whole, wrapped (#204)
10940
- process.stdout.write(indentBlock(why, { paint: dim }) + "\n");
10944
+ process.stdout.write(indentBlock(why, { indent: GUTTER, paint: dim }) + "\n");
10941
10945
  const idx = await pickFromList(
10942
10946
  ["Yes", "Yes for all later ones (turns accept edits on)", "No"],
10943
10947
  0,
10944
- { timeoutMs: 170000, timeoutIndex: 2 }
10948
+ { timeoutMs: COMMAND_APPROVAL_MS, timeoutIndex: 2 }
10945
10949
  );
10946
10950
  if (idx === 1 && editMode === "manual") {
10947
10951
  switchEditMode();
@@ -10949,6 +10953,27 @@ async function approvalPrompt(command) {
10949
10953
  }
10950
10954
  return { allow: idx === 0 || idx === 1, dontAsk: false };
10951
10955
  }
10956
+ // GH #215 — "Yes, always in this folder". ONLY for the soft "a destructive command" question
10957
+ // (the reason text comes from _ws_command_risk in gonext_agent_chat.py). sudo, the denylist and
10958
+ // deletes outside the project give a different reason, so they keep the plain Yes/No below.
10959
+ if (process.stdin.isTTY && why.startsWith("a destructive command")) {
10960
+ printApprovalCommand(shown, { toggle: false }); // a list picker follows — shown whole, wrapped
10961
+ process.stdout.write(indentBlock(why, { indent: GUTTER, paint: dim }) + "\n");
10962
+ const idx = await pickFromList(
10963
+ ["Yes", "Yes, always in this folder", "No"],
10964
+ 0,
10965
+ { timeoutMs: COMMAND_APPROVAL_MS, timeoutIndex: 2 }
10966
+ );
10967
+ if (idx === 1) {
10968
+ try {
10969
+ commandAllowIn(resolve(process.cwd()), true);
10970
+ process.stdout.write(dim(" (won't ask about destructive commands in this folder again — /command forget undoes it)\n"));
10971
+ } catch (e) {
10972
+ process.stdout.write(yellow(` could not save that (${e?.message || e}) — allowed this once\n`));
10973
+ }
10974
+ }
10975
+ return { allow: idx === 0 || idx === 1, dontAsk: false };
10976
+ }
10952
10977
  const allow = await new Promise((resolve) => {
10953
10978
  if (!process.stdin.isTTY) {
10954
10979
  process.stdout.write("\n" + GUTTER + dim(`(approval needed for: ${shown}${why ? ` — ${why}` : ""} — no interactive terminal, skipping)`) + "\n");
@@ -10956,13 +10981,13 @@ async function approvalPrompt(command) {
10956
10981
  return;
10957
10982
  }
10958
10983
  approvalCommandBlock = printApprovalCommand(shown);
10959
- if (why) process.stdout.write(indentBlock(why, { paint: dim }) + "\n");
10984
+ if (why) process.stdout.write(indentBlock(why, { indent: GUTTER, paint: dim }) + "\n");
10960
10985
  approvalSel = 0;
10961
10986
  approvalResolve = resolve;
10962
10987
  approvalActive = true;
10963
- approvalDeadline = Date.now() + 170000;
10988
+ approvalDeadline = Date.now() + COMMAND_APPROVAL_MS;
10964
10989
  drawApprovalOptions(false);
10965
- const t = setTimeout(() => finishApproval(false, true), 170000);
10990
+ const t = setTimeout(() => finishApproval(false, true), COMMAND_APPROVAL_MS);
10966
10991
  // #135: redraw the two option rows once a second so the countdown on "No" ticks down.
10967
10992
  const tick = setInterval(() => {
10968
10993
  if (approvalActive) drawApprovalOptions(true);
@@ -14564,6 +14589,17 @@ async function main() {
14564
14589
  paintBar();
14565
14590
  continue;
14566
14591
  }
14592
+ if (line === "/command forget" || line === "/command status") {
14593
+ const here = resolve(process.cwd());
14594
+ if (line === "/command forget") commandAllowIn(here, false);
14595
+ console.log("\n" + GUTTER + (line === "/command forget"
14596
+ ? accent("✓ gu asks again before a destructive command in this folder.")
14597
+ : (commandAllowedIn(here)
14598
+ ? accent("Destructive commands run without asking in this folder.") + dim(" (/command forget undoes it)")
14599
+ : dim("gu asks before a destructive command in this folder."))) + "\n");
14600
+ paintBar();
14601
+ continue;
14602
+ }
14567
14603
  if (line === "/model") {
14568
14604
  await chooseModel();
14569
14605
  await refreshNoModelAtAll(); // #188: a coding model may have just been set → drop its row
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.858",
3
+ "version": "1.0.860",
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",
@@ -88,6 +88,7 @@
88
88
  "poll-errors.mjs",
89
89
  "node-version.mjs",
90
90
  "clear-state.mjs",
91
+ "support-report.mjs",
91
92
  "clear-logs.mjs",
92
93
  "platform-tools.mjs",
93
94
  "job-pools.mjs",
@@ -130,6 +131,7 @@
130
131
  "proc-identity.mjs",
131
132
  "win-path.mjs",
132
133
  "browser-command.mjs",
134
+ "command-allow.mjs",
133
135
  "browser-bridge.mjs",
134
136
  "browser-chrome.mjs",
135
137
  "browser-extension/manifest.json",
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `gu support` — one redacted report a customer can send to the support team.
3
+ *
4
+ * Written after a fresh Ubuntu install got no free token and no clue why (#17): the cause (an empty
5
+ * /etc/machine-id, so no trial request was ever made) was invisible from the customer's side. This
6
+ * collects what support would otherwise ask for one question at a time: version and OS, the machine
7
+ * id (present or not, NEVER its value), whether the gu service answers, the token state, the local
8
+ * API and MongoDB, and the tail of the logs.
9
+ *
10
+ * NOTHING SECRET LEAVES: tokens, worker keys, passwords in URLs and key=value secrets are masked
11
+ * before anything is printed or written. It does not claim a trial or change any file but its own report.
12
+ * Everything with a side effect on the outside is injectable so the tests run offline.
13
+ */
14
+ import { readFile, readdir, stat, writeFile, mkdir } from "node:fs/promises";
15
+ import { homedir, platform as osPlatform, release, arch } from "node:os";
16
+ import { join } from "node:path";
17
+ import { redactSecrets } from "./proxy-config.mjs";
18
+
19
+ export const SUPPORT_REPORT_FILE = "support-report.txt";
20
+ export const SUPPORT_LOGS = ["worker.log", "local-api.log", "model.log", "embed.log"];
21
+
22
+ /** Mask tokens, keys, passwords and URL credentials in any text. */
23
+ export function redactReport(text) {
24
+ return redactSecrets(String(text ?? ""))
25
+ .replace(/\bgu1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "gu1.***")
26
+ .replace(/\b(sk|pk|ghp|gho|xox[abp])[-_][A-Za-z0-9_-]{12,}/g, "$1-***")
27
+ .replace(/(["']?[A-Za-z_]*(?:key|token|secret|password|passwd)[A-Za-z_]*["']?\s*[:=]\s*["']?)([^\s"',}]{6,})/gi, "$1***")
28
+ .replace(/(Authorization:\s*Bearer\s+)\S+/gi, "$1***");
29
+ }
30
+
31
+ const lastLines = (text, n) => String(text ?? "").split(/\r?\n/).filter((l, i, a) => l || i < a.length - 1).slice(-n);
32
+
33
+ async function fileInfo(path) {
34
+ try {
35
+ const s = await stat(path);
36
+ return { size: s.size, mtime: s.mtime.toISOString() };
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /** → the report as an array of lines (already redacted). Never throws. */
43
+ export async function buildSupportReport({
44
+ home = homedir(),
45
+ version = "unknown",
46
+ platform = osPlatform(),
47
+ env = process.env,
48
+ now = new Date(),
49
+ logLines = 40,
50
+ fetchImpl = fetch,
51
+ token = null, // the cli-token module, injectable
52
+ api = null, // the client-expert-api module, injectable
53
+ run = null, // the client-expert-run module, injectable
54
+ } = {}) {
55
+ const dir = join(home, ".gonext");
56
+ const out = [];
57
+ const section = (t) => out.push("", `== ${t} ==`);
58
+ const line = (l) => out.push(l);
59
+ const safe = async (label, fn) => {
60
+ try {
61
+ await fn();
62
+ } catch (e) {
63
+ line(`${label}: could not check (${e?.message || e})`);
64
+ }
65
+ };
66
+
67
+ line(`gu support report — ${now.toISOString()}`);
68
+ line("Secrets are masked. Send this file to the support team.");
69
+
70
+ section("This computer");
71
+ line(`gu ${version} node ${process.version}`);
72
+ line(`os ${platform} ${release()} ${arch()}`);
73
+
74
+ const ct = token ?? (await import("./cli-token.mjs"));
75
+
76
+ section("Machine id (the free trial needs one)");
77
+ await safe("machine id", async () => {
78
+ const id = await ct.osMachineId({ platform });
79
+ if (id) line(`OK — the operating system gives this computer an id (${id.length} characters; not shown).`);
80
+ else {
81
+ line("MISSING — no operating-system machine id, so gu makes NO free-trial request.");
82
+ if (platform === "linux") line(" /etc/machine-id is empty or absent. Fix: sudo systemd-machine-id-setup (then run gu again)");
83
+ }
84
+ });
85
+
86
+ section("gu service (free token, account)");
87
+ const site = ct.siteUrl(env);
88
+ await safe("site", async () => {
89
+ try {
90
+ const res = await fetchImpl(site, { method: "GET", signal: AbortSignal.timeout(8000) });
91
+ line(`${site} → HTTP ${res.status}`);
92
+ } catch (err) {
93
+ line(`${site} → cannot be reached: ${ct.netErrorDetail(err, 8000)}`);
94
+ }
95
+ });
96
+ const workerEnv = await readFile(join(dir, "worker.env"), "utf8").catch(() => null);
97
+ if (workerEnv === null) line("worker.env: absent (not signed in / Local mode)");
98
+ else {
99
+ const keys = workerEnv.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith("#") && l.includes("=")).map((l) => l.split("=")[0].trim());
100
+ line(`worker.env: present, settings: ${keys.join(", ") || "(none)"}`);
101
+ const base = /^GONEXT_API_BASE=(.*)$/m.exec(workerEnv)?.[1]?.trim();
102
+ if (base) line(`API base: ${base}`);
103
+ }
104
+
105
+ section("Token");
106
+ await safe("token", async () => {
107
+ const st = await ct.readState(dir);
108
+ if (!st.token) line("No token saved.");
109
+ else {
110
+ const pk = ct.publicKey(env);
111
+ const check = ct.verifyCliToken(st.token, pk);
112
+ line(check.ok ? "A token is saved and valid." : `A token is saved but not valid here: ${check.why}`);
113
+ }
114
+ if (st.lastAttemptAt) line(`last automatic fetch: ${st.lastAttemptAt}${st.lastAttemptWhy ? ` — failed: ${st.lastAttemptWhy}` : ""}`);
115
+ });
116
+
117
+ section("Local API and MongoDB");
118
+ await safe("local api", async () => {
119
+ const ce = api ?? (await import("./client-expert-api.mjs"));
120
+ const probe = await ce.probeLocalApi();
121
+ line(probe.ok ? `API up on ${ce.LOCAL_API_BASE}${probe.body?.version ? ` (running ${probe.body.version})` : ""}` : `API not usable: ${probe.why}`);
122
+ const uri = ce.localMongoUri({ env, home });
123
+ line(`MongoDB uri: ${redactSecrets(uri)}`);
124
+ try {
125
+ const r = run ?? (await import("./client-expert-run.mjs"));
126
+ const down = await r.localMongoDown(uri);
127
+ line(down ? `MongoDB does NOT answer at ${down}` : "MongoDB answers.");
128
+ } catch (e) {
129
+ line(`MongoDB: could not check (${e?.message || e})`);
130
+ }
131
+ });
132
+
133
+ section("Files in ~/.gonext");
134
+ await safe("files", async () => {
135
+ const names = (await readdir(dir).catch(() => [])).sort();
136
+ if (!names.length) line("(folder is empty or absent — a totally fresh install)");
137
+ for (const n of names) {
138
+ const info = await fileInfo(join(dir, n));
139
+ if (info) line(`${n} ${info.size} bytes ${info.mtime}`);
140
+ }
141
+ });
142
+
143
+ for (const name of SUPPORT_LOGS) {
144
+ const text = await readFile(join(dir, name), "utf8").catch(() => null);
145
+ if (text === null) continue;
146
+ section(`${name} (last ${logLines} lines)`);
147
+ for (const l of lastLines(text, logLines)) line(l);
148
+ }
149
+
150
+ return out.map(redactReport);
151
+ }
152
+
153
+ /** Build, write ~/.gonext/support-report.txt (owner-only) and return { lines, file }. */
154
+ export async function writeSupportReport(opts = {}) {
155
+ const home = opts.home ?? homedir();
156
+ const lines = await buildSupportReport(opts);
157
+ const file = join(home, ".gonext", SUPPORT_REPORT_FILE);
158
+ await mkdir(join(home, ".gonext"), { recursive: true });
159
+ await writeFile(file, lines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
160
+ return { lines, file };
161
+ }