@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.
- package/full-log-command.mjs +219 -0
- package/gu-cli.mjs +26 -2
- package/gu-repl.mjs +28 -1
- package/package.json +2 -1
|
@@ -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
|
-
|
|
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.
|
|
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",
|