@tiens.nguyen/gu-cli 1.0.730 → 1.0.731

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,219 @@
1
+ /**
2
+ * `gu full-log on|off|status [account]` and `/full-log` in the terminal (task #250) — ONE code path.
3
+ *
4
+ * An administrator turns "Enable Full Log" (#249) on or off for an account from a terminal. The
5
+ * command asks for an ADMIN TOKEN first — minted on the web admin page (Admin → Full Log → Create
6
+ * CLI token), 15 minutes, one use — and sends it to this machine's own API, which (hosted) decides
7
+ * or (Client Expert) forwards to the hosted API. The ordinary gu CLI token cannot do this: every
8
+ * account has one.
9
+ *
10
+ * THE TOKEN IS A SECRET HERE TOO: typed masked, never echoed, never printed, never logged, never
11
+ * saved. It lives in one local variable for one request.
12
+ *
13
+ * `status` for THIS machine's account needs no token (reading your own setting is harmless, and
14
+ * the worker reads it every 5 minutes anyway). Another account's status needs one.
15
+ */
16
+ import { mkdir, writeFile } from "node:fs/promises";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+
20
+ export const USAGE = "gu full-log on|off|status [email or account id]";
21
+ /** The daemon's minute tick sees this and re-reads the policy at once (see gu-cli.mjs startLogPolicy). */
22
+ export const REFRESH_FLAG = join(homedir(), ".gonext", "log-policy.refresh");
23
+ export const TOKEN_PROMPT = "Admin token (Admin → Full Log → Create CLI token), hidden:";
24
+
25
+ /** → { action: "on"|"off"|"status", account? } | { error } */
26
+ export function parseFullLogArgs(words) {
27
+ const w = (words ?? []).map((s) => String(s).trim()).filter(Boolean);
28
+ const action = (w[0] ?? "status").toLowerCase();
29
+ if (!["on", "off", "status"].includes(action)) return { error: `unknown "${w[0]}" — ${USAGE}` };
30
+ if (w.length > 2) return { error: `too many words — ${USAGE}` };
31
+ return w[1] ? { action, account: w[1] } : { action };
32
+ }
33
+
34
+ const onOff = (b) => (b ? "ON" : "OFF");
35
+
36
+ /** Tell a running worker on this machine to re-read its policy now, instead of within 5 minutes. */
37
+ export async function requestLocalRefresh(file = REFRESH_FLAG) {
38
+ try {
39
+ await mkdir(dirname(file), { recursive: true });
40
+ await writeFile(file, new Date().toISOString() + "\n", "utf8");
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ async function readJson(res) {
48
+ return (await res.json().catch(() => null)) ?? {};
49
+ }
50
+
51
+ /**
52
+ * Run it. `askSecret(label)` → the typed token ("" = cancelled). `print(line)` writes one line.
53
+ * → an exit code: 0 done, 1 refused / failed, 2 usage.
54
+ */
55
+ export async function runFullLog({
56
+ action,
57
+ account,
58
+ apiBase,
59
+ workerKey,
60
+ askSecret,
61
+ print = (l) => console.log(l),
62
+ fetchImpl = fetch,
63
+ requestRefresh = requestLocalRefresh,
64
+ }) {
65
+ const base = String(apiBase ?? "").trim().replace(/\/+$/, "");
66
+ const key = String(workerKey ?? "").trim();
67
+ if (!base || !key) {
68
+ print("This computer is not signed in to gu yet — run `gu login` first.");
69
+ return 1;
70
+ }
71
+ const who = account ? account : "this computer's account";
72
+
73
+ // Your own status: no token.
74
+ if (action === "status" && !account) {
75
+ let res;
76
+ try {
77
+ res = await fetchImpl(`${base}/api/worker/log-policy`, {
78
+ headers: { "X-Worker-Key": key },
79
+ signal: AbortSignal.timeout(10_000),
80
+ });
81
+ } catch {
82
+ print(`Could not reach ${base} — try again in a moment.`);
83
+ return 1;
84
+ }
85
+ const body = await readJson(res);
86
+ if (res.status === 404) {
87
+ print("Full Log is not available on this gu service yet — this computer keeps full logs until it is.");
88
+ return 0;
89
+ }
90
+ if (!res.ok || typeof body.fullLog !== "boolean") {
91
+ print(`Could not read the log setting: ${body.error ?? `HTTP ${res.status}`}.`);
92
+ return 1;
93
+ }
94
+ print(
95
+ body.fullLog
96
+ ? "Full Log: ON for this computer's account — its logs include what was asked and answered."
97
+ : "Full Log: OFF for this computer's account — its logs carry no conversation content (the default)."
98
+ );
99
+ print(`To change it (administrators): ${USAGE.replace("on|off|status", "on|off")}`);
100
+ return 0;
101
+ }
102
+
103
+ let token = "";
104
+ try {
105
+ token = String((await askSecret(TOKEN_PROMPT)) ?? "").trim();
106
+ } catch {
107
+ token = "";
108
+ }
109
+ if (!token) {
110
+ print("No token entered — nothing changed.");
111
+ return 1;
112
+ }
113
+
114
+ const body = action === "status" ? { account } : { account, fullLog: action === "on" };
115
+ let res;
116
+ try {
117
+ res = await fetchImpl(`${base}/api/worker/log-policy/admin`, {
118
+ method: "POST",
119
+ headers: { "X-Worker-Key": key, Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
120
+ body: JSON.stringify(body),
121
+ signal: AbortSignal.timeout(20_000),
122
+ });
123
+ } catch {
124
+ print(`Could not reach ${base} — nothing changed.`);
125
+ return 1;
126
+ } finally {
127
+ token = ""; // one request, then gone
128
+ }
129
+ const a = await readJson(res);
130
+ if (!res.ok) {
131
+ if (res.status === 404 && typeof a.error !== "string") {
132
+ print("This computer's gu service does not support `gu full-log` yet — it needs an update. Nothing changed.");
133
+ } else {
134
+ const why = a.error ?? `HTTP ${res.status}`;
135
+ const tail = action === "status" || /nothing (was )?changed/i.test(why) ? "" : " Nothing changed.";
136
+ print(`${res.status >= 500 ? "Failed" : "Refused"}: ${why}${tail}`);
137
+ }
138
+ return 1;
139
+ }
140
+ const name = a.email ? `${a.email} (${a.userId})` : a.self ? `this computer's account (${a.userId})` : String(a.userId ?? who);
141
+ if (action === "status") {
142
+ print(`Full Log: ${onOff(a.fullLog)} for ${name}.`);
143
+ if (a.lastChange) {
144
+ const lc = a.lastChange;
145
+ print(`Last changed ${String(lc.at).replace("T", " ").slice(0, 16)} UTC by ${lc.by}${lc.via === "cli" ? " from the CLI" : ""}.`);
146
+ }
147
+ return 0;
148
+ }
149
+ print(
150
+ a.changed
151
+ ? `Full Log ${onOff(a.fullLog)} for ${name} — it was ${onOff(a.previous)}.`
152
+ : `Full Log was already ${onOff(a.fullLog)} for ${name} — nothing changed (recorded).`
153
+ );
154
+ if (a.self) {
155
+ await requestRefresh();
156
+ print("This computer picks it up within a minute; the account's other computers within 5 minutes.");
157
+ } else {
158
+ print("That account's computers pick it up within 5 minutes.");
159
+ }
160
+ return 0;
161
+ }
162
+
163
+ /**
164
+ * A masked line from a plain terminal (no REPL) — `gu full-log` in a shell. A bullet per
165
+ * character, nothing of the value on screen; Ctrl+C / Ctrl+D cancel (→ ""). Piped stdin (a
166
+ * script: `printf '%s\n' "$TOKEN" | gu full-log on`) reads the first line, echoing nothing.
167
+ */
168
+ export function readHiddenLine(label, { input = process.stdin, output = process.stderr } = {}) {
169
+ return new Promise((resolve) => {
170
+ output.write(label + " ");
171
+ if (!input.isTTY) {
172
+ let buf = "";
173
+ const onData = (d) => {
174
+ buf += d.toString("utf8");
175
+ const nl = buf.indexOf("\n");
176
+ if (nl >= 0) finish(buf.slice(0, nl));
177
+ };
178
+ const onEnd = () => finish(buf);
179
+ const finish = (v) => {
180
+ input.off("data", onData);
181
+ input.off("end", onEnd);
182
+ input.pause();
183
+ output.write("\n");
184
+ resolve(v.replace(/\r$/, "").trim());
185
+ };
186
+ input.on("data", onData);
187
+ input.on("end", onEnd);
188
+ input.resume();
189
+ return;
190
+ }
191
+ let value = "";
192
+ input.setRawMode(true);
193
+ input.resume();
194
+ const done = (v) => {
195
+ input.off("data", onData);
196
+ input.setRawMode(false);
197
+ input.pause();
198
+ output.write("\n");
199
+ resolve(v);
200
+ };
201
+ const onData = (d) => {
202
+ for (const ch of d.toString("utf8")) {
203
+ if (ch === "\r" || ch === "\n") return done(value.trim());
204
+ if (ch === "\u0003" || ch === "\u0004") return done("");
205
+ if (ch === "\u007f" || ch === "\b") {
206
+ if (value) {
207
+ value = value.slice(0, -1);
208
+ output.write("\b \b");
209
+ }
210
+ continue;
211
+ }
212
+ if (ch < " ") continue; // other control bytes: ignored, never stored
213
+ value += ch;
214
+ output.write("•");
215
+ }
216
+ };
217
+ input.on("data", onData);
218
+ });
219
+ }
package/gu-cli.mjs CHANGED
@@ -15,7 +15,7 @@ import { requireSupportedNode } from "./node-version.mjs";
15
15
  // The brand accent, shared with the REPL — see accent.mjs.
16
16
  import { accent } from "./accent.mjs";
17
17
  requireSupportedNode();
18
- import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
18
+ import { mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
19
19
  import { execFile as execFileCallback, spawn } from "node:child_process";
20
20
  import { createHash } from "node:crypto";
21
21
  import { createTaskPool, createFolderQueue } from "./job-pools.mjs";
@@ -76,6 +76,9 @@ gu-cli
76
76
  Usage:
77
77
  gu-cli
78
78
  gu-cli login [--api-base <url>] # browser sign-in → writes ~/.gonext/worker.env
79
+ gu-cli full-log on|off|status [account] # administrators: Enable Full Log for an account (asks for
80
+ # an admin token from the web admin page first); status
81
+ # alone shows this computer's account, no token needed
79
82
  gu-cli token [<token>] # show this computer's gu token, or save one pasted from
80
83
  # the Account page (Client Expert machines)
81
84
  gu-cli set <workerKey> [--api-base <url>]
@@ -220,6 +223,24 @@ if (args[0] === "token") {
220
223
  process.exit(0);
221
224
  }
222
225
 
226
+ if (args[0] === "full-log") {
227
+ // #250. An administrator switches Enable Full Log (#249) from a terminal — after typing an admin
228
+ // token minted on the web admin page. Same code as /full-log in the terminal UI.
229
+ const fl = await import("./full-log-command.mjs");
230
+ const parsed = fl.parseFullLogArgs(args.slice(1));
231
+ if (parsed.error) {
232
+ console.error(parsed.error);
233
+ process.exit(2);
234
+ }
235
+ const code = await fl.runFullLog({
236
+ ...parsed,
237
+ apiBase: process.env.GONEXT_API_BASE,
238
+ workerKey: process.env.GONEXT_WORKER_KEY,
239
+ askSecret: (label) => fl.readHiddenLine(label),
240
+ });
241
+ process.exit(code);
242
+ }
243
+
223
244
  if (args[0] === "login") {
224
245
  // Task #127, Phase 1: browser device-pairing → writes worker.env. `--api-base` overrides
225
246
  // which API to pair against (defaults to the production API).
@@ -5067,10 +5088,13 @@ async function startLogPolicy() {
5067
5088
  };
5068
5089
  await housekeep();
5069
5090
  let ticks = 0;
5091
+ const { REFRESH_FLAG } = await import("./full-log-command.mjs");
5070
5092
  setInterval(() => {
5071
5093
  ticks++;
5072
5094
  void (async () => {
5073
- if (ticks % Math.round(lp.REFRESH_MS / 60_000) === 0) await logPolicy.refresh();
5095
+ // `gu full-log` just changed this machine's account (#250): re-read now, not in 5 minutes.
5096
+ const asked = await unlink(REFRESH_FLAG).then(() => true, () => false);
5097
+ if (asked || ticks % Math.round(lp.REFRESH_MS / 60_000) === 0) await logPolicy.refresh();
5074
5098
  await housekeep();
5075
5099
  })().catch(() => {});
5076
5100
  }, 60_000).unref();
package/gu-repl.mjs CHANGED
@@ -532,7 +532,7 @@ const argv = process.argv.slice(2);
532
532
  // process.argv and exits inside its own subcommand branch, before it ever starts polling.
533
533
  const CLI_SUBCOMMANDS = [
534
534
  "status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
535
- "clear", "reset", "api", "token-usage", "tokens", "token",
535
+ "clear", "reset", "api", "token-usage", "tokens", "token", "full-log",
536
536
  ];
537
537
  // `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
538
538
  // the list, so it fell through to "no subcommand given" and a full agent session opened in that
@@ -806,6 +806,7 @@ const COMMANDS = [
806
806
  { name: "/load-settings", usage: "/load-settings [name|path]", desc: "read that file back on another machine — picks from the settings files in your home folder, asks before it replaces anything" },
807
807
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
808
808
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
809
+ { name: "/full-log", usage: "/full-log [on|off|status] [account]", desc: "administrators: Enable Full Log for an account — asks for an admin token from the web admin page first" },
809
810
  { name: "/help", desc: "list these commands" },
810
811
  { name: "/exit", aliases: ["/quit"], desc: "quit" },
811
812
  ];
@@ -7085,6 +7086,28 @@ async function runS3Command(line) {
7085
7086
  await runTest({});
7086
7087
  }
7087
7088
 
7089
+ /**
7090
+ * /full-log (#250) — the same code as `gu full-log`. The admin token goes through askSecret: masked
7091
+ * in the frozen row, dropped from readline history, never echoed (#191).
7092
+ */
7093
+ async function runFullLogCommand(line) {
7094
+ const fl = await import("./full-log-command.mjs");
7095
+ const parsed = fl.parseFullLogArgs(line.split(/\s+/).slice(1));
7096
+ if (parsed.error) {
7097
+ console.log(red(parsed.error) + "\n");
7098
+ return;
7099
+ }
7100
+ // A cancelled token prompt (Esc / Ctrl+C) reads as "no token entered" inside runFullLog.
7101
+ await fl.runFullLog({
7102
+ ...parsed,
7103
+ apiBase,
7104
+ workerKey,
7105
+ askSecret: (label) => askSecret(` ${label}`),
7106
+ print: (l) => console.log(gutterLines(l)),
7107
+ });
7108
+ console.log("");
7109
+ }
7110
+
7088
7111
  async function runProxyCommand(line) {
7089
7112
  const cmd = await import("./proxy-command.mjs");
7090
7113
  const store = await import("./proxy-store.mjs");
@@ -11065,6 +11088,10 @@ async function main() {
11065
11088
  await saveSession(cwd, history); // remember the pick for this folder
11066
11089
  continue;
11067
11090
  }
11091
+ if (line === "/full-log" || line.startsWith("/full-log ")) {
11092
+ await runFullLogCommand(line);
11093
+ continue;
11094
+ }
11068
11095
  if (line === "/proxy" || line.startsWith("/proxy ")) {
11069
11096
  await runProxyCommand(line);
11070
11097
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.730",
3
+ "version": "1.0.731",
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",
@@ -57,6 +57,7 @@
57
57
  "device-login.mjs",
58
58
  "daemon-control.mjs",
59
59
  "log-policy.mjs",
60
+ "full-log-command.mjs",
60
61
  "poll-errors.mjs",
61
62
  "node-version.mjs",
62
63
  "clear-state.mjs",