@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.
- package/command-allow.mjs +35 -0
- package/gonext_agent_chat.py +80 -2
- package/gu-cli.mjs +11 -0
- package/gu-repl.mjs +48 -12
- package/package.json +3 -1
- package/support-report.mjs +161 -0
|
@@ -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
|
+
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
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(
|
|
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:
|
|
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() +
|
|
10988
|
+
approvalDeadline = Date.now() + COMMAND_APPROVAL_MS;
|
|
10964
10989
|
drawApprovalOptions(false);
|
|
10965
|
-
const t = setTimeout(() => finishApproval(false, true),
|
|
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.
|
|
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
|
+
}
|