@tiens.nguyen/gu-cli 1.0.705 → 1.0.706
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/clear-state.mjs +2 -0
- package/cli-token.mjs +285 -0
- package/gonext_agent_chat.py +34 -0
- package/gu-cli.mjs +44 -0
- package/gu-repl.mjs +112 -6
- package/package.json +2 -1
package/clear-state.mjs
CHANGED
|
@@ -32,6 +32,8 @@ export const STATE_ITEMS = [
|
|
|
32
32
|
what: "Local-mode setup progress and the chosen mode", cost: "setup starts over" },
|
|
33
33
|
{ path: "instance", group: "identity",
|
|
34
34
|
what: "this machine's id", cost: "it gets a new one" },
|
|
35
|
+
{ path: "cli-token.json", group: "identity",
|
|
36
|
+
what: "the gu token (plan + computers)", cost: "a Client Expert machine needs it pasted again" },
|
|
35
37
|
{ path: "prefs.json", group: "identity", what: "CLI preferences", cost: "defaults return" },
|
|
36
38
|
{ path: "wizard-state.json", group: "identity",
|
|
37
39
|
what: "installer progress", cost: "the wizard starts over" },
|
package/cli-token.mjs
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CLI TOKEN, CLIENT SIDE (#232) — keep it fresh, and say one line when something needs attention.
|
|
3
|
+
*
|
|
4
|
+
* A SOFT LICENCE, by the owner's choice: nothing here ever blocks a session, refuses a command or
|
|
5
|
+
* delays a prompt. The worst it does is one line in the bottom bar. Someone who patches it out is
|
|
6
|
+
* acceptable.
|
|
7
|
+
*
|
|
8
|
+
* The token is signed by the hosted API (api/src/billing/cliToken.ts) and checked HERE, offline,
|
|
9
|
+
* with the public key only. The rules in verifyCliToken must match the API's; the test file checks
|
|
10
|
+
* a token the real API code signed.
|
|
11
|
+
*
|
|
12
|
+
* RENEWAL, agreed with the owner:
|
|
13
|
+
* · Client mode (hosted API): at start, then at most once a DAY — with the worker key when there is
|
|
14
|
+
* no token yet, with the token after that.
|
|
15
|
+
* · Client Expert (local-only): at most once a WEEK, and always against the HOSTED API. The local
|
|
16
|
+
* API cannot sign tokens or see payments, and a local-only machine should make the fewest
|
|
17
|
+
* outside calls that still work.
|
|
18
|
+
* · Either mode renews at start while a warning is showing, so someone who just paid is not told
|
|
19
|
+
* "plan ended" for a week.
|
|
20
|
+
* · Every failure is silent: the current token stays and nothing new is shown.
|
|
21
|
+
*
|
|
22
|
+
* THE KEY. EMBEDDED_PUBLIC_KEY is the public half of the production pair, generated ONCE on
|
|
23
|
+
* 2026-09-17 (api: npm run cli-token:keygen). The private half lives only in the API's api.env and the
|
|
24
|
+
* owner's password manager. Generating another pair makes every installed CLI reject every new token
|
|
25
|
+
* — see the note in api/scripts/cli-token-keygen.mts before ever doing it.
|
|
26
|
+
* With an empty key (GU_CLI_TOKEN_PUBLIC_KEY unset and none embedded) the feature shows nothing and
|
|
27
|
+
* sends nothing.
|
|
28
|
+
*/
|
|
29
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
30
|
+
import { execFile } from "node:child_process";
|
|
31
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
32
|
+
import { hostname as osHostname } from "node:os";
|
|
33
|
+
import { dirname, join } from "node:path";
|
|
34
|
+
|
|
35
|
+
/** The public half of the API's CLI_TOKEN_PRIVATE_KEY, base64 SPKI DER (Ed25519). Generated 2026-09-17. */
|
|
36
|
+
export const EMBEDDED_PUBLIC_KEY = "MCowBQYDK2VwAyEA2xkKnY+xWGoS4Nvc7DEHCCurdQQYZGl/x7kPp+gDmvY=";
|
|
37
|
+
|
|
38
|
+
export const TOKEN_PREFIX = "gu1";
|
|
39
|
+
export const STALE_AFTER_DAYS = 30;
|
|
40
|
+
export const RENEW_EVERY_MS = { client: 86_400_000, expert: 7 * 86_400_000 };
|
|
41
|
+
/** Where the notices send people. Overridable for staging. */
|
|
42
|
+
export const DEFAULT_SITE = "https://gu.gomarsic.cc";
|
|
43
|
+
|
|
44
|
+
export function publicKey(env = process.env) {
|
|
45
|
+
return String(env.GU_CLI_TOKEN_PUBLIC_KEY ?? "").trim() || EMBEDDED_PUBLIC_KEY;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function siteUrl(env = process.env) {
|
|
49
|
+
return (String(env.GU_SITE_URL ?? "").trim() || DEFAULT_SITE).replace(/\/+$/, "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isIso = (v) => typeof v === "string" && v.length > 0 && !Number.isNaN(Date.parse(v));
|
|
53
|
+
|
|
54
|
+
/** Same rules as api/src/billing/cliToken.ts verifyCliToken. Never throws. */
|
|
55
|
+
export function verifyCliToken(token, publicKeyB64) {
|
|
56
|
+
if (typeof token !== "string") return { ok: false, why: "malformed" };
|
|
57
|
+
const parts = token.trim().split(".");
|
|
58
|
+
if (parts.length !== 3 || !parts[1] || !parts[2]) return { ok: false, why: "malformed" };
|
|
59
|
+
if (parts[0] !== TOKEN_PREFIX) return { ok: false, why: "unknown-version" };
|
|
60
|
+
let key;
|
|
61
|
+
try {
|
|
62
|
+
key = createPublicKey({ key: Buffer.from(String(publicKeyB64), "base64"), format: "der", type: "spki" });
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false, why: "bad-signature" };
|
|
65
|
+
}
|
|
66
|
+
let good = false;
|
|
67
|
+
try {
|
|
68
|
+
good = verify(null, Buffer.from(`${parts[0]}.${parts[1]}`, "utf8"), key, Buffer.from(parts[2], "base64url"));
|
|
69
|
+
} catch {
|
|
70
|
+
good = false;
|
|
71
|
+
}
|
|
72
|
+
if (!good) return { ok: false, why: "bad-signature" };
|
|
73
|
+
let p;
|
|
74
|
+
try {
|
|
75
|
+
p = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
76
|
+
} catch {
|
|
77
|
+
return { ok: false, why: "bad-payload" };
|
|
78
|
+
}
|
|
79
|
+
if (
|
|
80
|
+
!p || typeof p !== "object" ||
|
|
81
|
+
p.v !== 1 ||
|
|
82
|
+
typeof p.sub !== "string" || !p.sub ||
|
|
83
|
+
(p.plan !== "pro" && p.plan !== "free") ||
|
|
84
|
+
!(p.paidUntil === null || isIso(p.paidUntil)) ||
|
|
85
|
+
!isIso(p.issuedAt) ||
|
|
86
|
+
(p.issuedBy !== "refresh" && p.issuedBy !== "account" && p.issuedBy !== "admin") ||
|
|
87
|
+
!(p.device === null || (typeof p.device === "string" && /^[0-9a-f]{64}$/.test(p.device))) ||
|
|
88
|
+
!(p.activeDevices === null || (Number.isInteger(p.activeDevices) && p.activeDevices >= 0)) ||
|
|
89
|
+
!(Number.isInteger(p.deviceLimit) && p.deviceLimit > 0)
|
|
90
|
+
) {
|
|
91
|
+
return { ok: false, why: "bad-payload" };
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, payload: p };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// THIS COMPUTER
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
const run = (cmd, args) =>
|
|
101
|
+
new Promise((resolve) => {
|
|
102
|
+
execFile(cmd, args, { timeout: 3000, windowsHide: true }, (err, stdout) => resolve(err ? "" : String(stdout)));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The operating system's own id for this machine, or "".
|
|
107
|
+
*
|
|
108
|
+
* NOT ~/.gonext/instance: copying the folder to another computer carries that file along, and
|
|
109
|
+
* `gu clear` makes a new one, which would count one laptop several times. The OS id survives both.
|
|
110
|
+
*/
|
|
111
|
+
export async function osMachineId({ platform = process.platform, read = (p) => readFile(p, "utf8"), exec = run } = {}) {
|
|
112
|
+
try {
|
|
113
|
+
if (platform === "darwin") {
|
|
114
|
+
const out = await exec("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
|
|
115
|
+
return out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1]?.trim() ?? "";
|
|
116
|
+
}
|
|
117
|
+
if (platform === "win32") {
|
|
118
|
+
const out = await exec("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]);
|
|
119
|
+
return out.match(/MachineGuid\s+REG_SZ\s+(\S+)/i)?.[1]?.trim() ?? "";
|
|
120
|
+
}
|
|
121
|
+
for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
122
|
+
const id = (await read(p).catch(() => "")).trim();
|
|
123
|
+
if (id) return id;
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
/* fall through to "" */
|
|
127
|
+
}
|
|
128
|
+
return "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What renewals send about this computer. The raw OS id never leaves the machine — only a salted hash. */
|
|
132
|
+
export async function thisDevice({ platform = process.platform, hostName = osHostname(), machineId, instanceId } = {}) {
|
|
133
|
+
const raw = machineId ?? (await osMachineId({ platform }));
|
|
134
|
+
const source = raw || (instanceId ? `instance:${instanceId}` : "");
|
|
135
|
+
if (!source) return null;
|
|
136
|
+
return {
|
|
137
|
+
id: createHash("sha256").update(`gu-device-v1:${source}`).digest("hex"),
|
|
138
|
+
hostName: String(hostName ?? "").slice(0, 64),
|
|
139
|
+
os: platform === "darwin" || platform === "linux" || platform === "win32" ? platform : "other",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// STATE: ~/.gonext/cli-token.json
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
export const STATE_FILE = "cli-token.json";
|
|
148
|
+
|
|
149
|
+
export async function readState(dir) {
|
|
150
|
+
try {
|
|
151
|
+
const s = JSON.parse(await readFile(join(dir, STATE_FILE), "utf8"));
|
|
152
|
+
return {
|
|
153
|
+
token: typeof s.token === "string" ? s.token : "",
|
|
154
|
+
lastAttemptAt: isIso(s.lastAttemptAt) ? s.lastAttemptAt : null,
|
|
155
|
+
};
|
|
156
|
+
} catch {
|
|
157
|
+
return { token: "", lastAttemptAt: null };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Atomic and owner-only: a token is not a password, but it is someone's, and renews for them. */
|
|
162
|
+
export async function writeState(dir, state) {
|
|
163
|
+
const file = join(dir, STATE_FILE);
|
|
164
|
+
await mkdir(dirname(file), { recursive: true });
|
|
165
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
166
|
+
await writeFile(tmp, JSON.stringify({ token: state.token ?? "", lastAttemptAt: state.lastAttemptAt ?? null }, null, 2) + "\n", {
|
|
167
|
+
encoding: "utf8",
|
|
168
|
+
mode: 0o600,
|
|
169
|
+
});
|
|
170
|
+
await rename(tmp, file);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// WHEN TO RENEW, AND HOW
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/** mode: "client" | "expert". warningShowing: a notice is up right now. */
|
|
178
|
+
export function shouldRenew({ state, mode, now = new Date(), warningShowing = false }) {
|
|
179
|
+
if (warningShowing) return true;
|
|
180
|
+
const last = Date.parse(state?.lastAttemptAt ?? "");
|
|
181
|
+
if (Number.isNaN(last)) return true;
|
|
182
|
+
return now.getTime() - last >= (RENEW_EVERY_MS[mode] ?? RENEW_EVERY_MS.client);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One renewal request. Returns { ok: true, token } or { ok: false, why } — never throws.
|
|
187
|
+
*
|
|
188
|
+
* With a token: POST /api/billing/cli-token/renew, bearer = token. Without one, in Client mode only:
|
|
189
|
+
* POST /api/billing/cli-token with the worker key. A Client Expert worker key belongs to the LOCAL
|
|
190
|
+
* API and means nothing to the hosted one, so it is never sent there.
|
|
191
|
+
*/
|
|
192
|
+
export async function renewToken({ mode, apiBase, hostedBase, token, workerKey, device, fetchImpl = fetch, timeoutMs = 8000 }) {
|
|
193
|
+
const base = String(mode === "expert" ? hostedBase : apiBase || hostedBase).replace(/\/+$/, "");
|
|
194
|
+
const body = JSON.stringify(device ? { device } : {});
|
|
195
|
+
let url;
|
|
196
|
+
const headers = { "content-type": "application/json" };
|
|
197
|
+
if (token) {
|
|
198
|
+
url = `${base}/api/billing/cli-token/renew`;
|
|
199
|
+
headers.authorization = `Bearer ${token}`;
|
|
200
|
+
} else if (mode === "client" && workerKey) {
|
|
201
|
+
url = `${base}/api/billing/cli-token`;
|
|
202
|
+
headers["x-worker-key"] = workerKey;
|
|
203
|
+
} else {
|
|
204
|
+
return { ok: false, why: "no-credential" };
|
|
205
|
+
}
|
|
206
|
+
if (!base) return { ok: false, why: "no-api" };
|
|
207
|
+
try {
|
|
208
|
+
const res = await fetchImpl(url, { method: "POST", headers, body, signal: AbortSignal.timeout(timeoutMs) });
|
|
209
|
+
if (!res.ok) return { ok: false, why: `http-${res.status}` };
|
|
210
|
+
const data = await res.json().catch(() => null);
|
|
211
|
+
return typeof data?.token === "string" ? { ok: true, token: data.token } : { ok: false, why: "bad-response" };
|
|
212
|
+
} catch (e) {
|
|
213
|
+
return { ok: false, why: e?.name === "TimeoutError" ? "timeout" : "network" };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// WHAT THE BAR SAYS
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
222
|
+
/** "12 Oct 2026" — spelled out here rather than via toLocaleDateString, whose month names vary by ICU version. */
|
|
223
|
+
export function shortDate(iso) {
|
|
224
|
+
const d = new Date(iso);
|
|
225
|
+
return Number.isNaN(d.getTime()) ? "" : `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The one line for the bottom bar, or null. Highest priority first:
|
|
230
|
+
*
|
|
231
|
+
* 1. the plan ended — money, and the fix is on the site
|
|
232
|
+
* 2. more computers than the plan covers
|
|
233
|
+
* 3. no usable token, or one over 30 days old — only AFTER a renewal was tried in Client mode,
|
|
234
|
+
* where one is normally fetched automatically; straight away in Client Expert, where nothing
|
|
235
|
+
* fetches one until a token is pasted
|
|
236
|
+
*
|
|
237
|
+
* Never the word "licence". Returns null when the feature is off (no public key).
|
|
238
|
+
*/
|
|
239
|
+
export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempted = false, site = DEFAULT_SITE }) {
|
|
240
|
+
if (!publicKeyB64) return null;
|
|
241
|
+
const host = site.replace(/^https?:\/\//, "");
|
|
242
|
+
// Shown without the scheme — shorter on a narrow bar, and still what a person types.
|
|
243
|
+
const pricingUrl = `${host}/pricing`;
|
|
244
|
+
const accountUrl = `${host}/account`;
|
|
245
|
+
const check = token ? verifyCliToken(token, publicKeyB64) : { ok: false, why: "missing" };
|
|
246
|
+
|
|
247
|
+
if (check.ok) {
|
|
248
|
+
const p = check.payload;
|
|
249
|
+
const ended = p.paidUntil && Date.parse(p.paidUntil) <= now.getTime();
|
|
250
|
+
if (ended) return { kind: "plan-ended", text: `Your gu Pro plan ended on ${shortDate(p.paidUntil)} — renew at ${pricingUrl}` };
|
|
251
|
+
if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
|
|
252
|
+
return {
|
|
253
|
+
kind: "over-limit",
|
|
254
|
+
text: `gu is active on ${p.activeDevices} computers — ${p.plan === "pro" ? "Pro" : "Free"} covers ${p.deviceLimit}`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const age = now.getTime() - Date.parse(p.issuedAt);
|
|
258
|
+
if (age <= STALE_AFTER_DAYS * 86_400_000) return null;
|
|
259
|
+
} else if (mode === "client" && !attempted) {
|
|
260
|
+
return null; // Client mode fetches one by itself; do not complain before it has tried.
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return mode === "expert"
|
|
264
|
+
? { kind: "stale", text: `Get a fresh gu token at ${accountUrl} to keep getting updates` }
|
|
265
|
+
: { kind: "stale", text: "Sign in to keep getting gu updates — run gu login" };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** What `gu token` prints about a valid token — plain lines, no colour, for the command to style. */
|
|
269
|
+
export function tokenSummary(payload, now = new Date()) {
|
|
270
|
+
const lines = [];
|
|
271
|
+
if (payload.plan === "pro" && payload.paidUntil && Date.parse(payload.paidUntil) > now.getTime()) {
|
|
272
|
+
lines.push(`Pro until ${shortDate(payload.paidUntil)}.`);
|
|
273
|
+
} else if (payload.paidUntil) {
|
|
274
|
+
lines.push(`Free — the Pro plan ended on ${shortDate(payload.paidUntil)}.`);
|
|
275
|
+
} else {
|
|
276
|
+
lines.push("Free.");
|
|
277
|
+
}
|
|
278
|
+
if (Number.isInteger(payload.activeDevices)) {
|
|
279
|
+
const n = payload.activeDevices;
|
|
280
|
+
lines.push(`Active on ${n} ${n === 1 ? "computer" : "computers"} in the last 30 days — ${payload.plan === "pro" ? "Pro" : "Free"} covers ${payload.deviceLimit}.`);
|
|
281
|
+
}
|
|
282
|
+
lines.push(`Issued ${shortDate(payload.issuedAt)}.`);
|
|
283
|
+
return lines;
|
|
284
|
+
}
|
|
285
|
+
|
package/gonext_agent_chat.py
CHANGED
|
@@ -6023,6 +6023,35 @@ def _shell_syntax_error(command, argv):
|
|
|
6023
6023
|
# commands are confirmed. False = confirm every command. Module-level default so the tool body
|
|
6024
6024
|
# reads a real boolean even on a path where run() never set it.
|
|
6025
6025
|
_accept_edits = True
|
|
6026
|
+
|
|
6027
|
+
|
|
6028
|
+
def _live_accept_edits(api_base, worker_key, job_id, current, _fetch=None):
|
|
6029
|
+
"""The accept-edits switch AS IT IS NOW (#47), not as it was when the turn was queued.
|
|
6030
|
+
|
|
6031
|
+
Shift+Tab during a turn posts the new value to the job; reading it here, right before a
|
|
6032
|
+
command is gated, is what lets it apply to the turn already running. Any failure keeps
|
|
6033
|
+
`current` — a network blip must not silently flip a safety setting in either direction.
|
|
6034
|
+
One GET per command: small next to running the command, and only when a terminal is there
|
|
6035
|
+
to ask (the caller checks)."""
|
|
6036
|
+
base = (api_base or "").rstrip("/")
|
|
6037
|
+
if not base or not worker_key or not job_id:
|
|
6038
|
+
return current
|
|
6039
|
+
try:
|
|
6040
|
+
if _fetch is None:
|
|
6041
|
+
import urllib.request as _u
|
|
6042
|
+
req = _u.Request(f"{base}/api/worker/jobs/{job_id}",
|
|
6043
|
+
headers={"X-Worker-Key": worker_key}, method="GET")
|
|
6044
|
+
with _u.urlopen(req, timeout=5, context=_ssl_context()) as resp:
|
|
6045
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
6046
|
+
else:
|
|
6047
|
+
data = _fetch(f"{base}/api/worker/jobs/{job_id}")
|
|
6048
|
+
except Exception as e: # noqa: BLE001
|
|
6049
|
+
_log(f"accept-edits read failed: {e} — keeping {'on' if current else 'off'}")
|
|
6050
|
+
return current
|
|
6051
|
+
value = data.get("acceptEdits") if isinstance(data, dict) else None
|
|
6052
|
+
return value if isinstance(value, bool) else current
|
|
6053
|
+
|
|
6054
|
+
|
|
6026
6055
|
_WS_RUN_ALLOWLIST = set() # opt-in lockdown from cfg; empty = allow all
|
|
6027
6056
|
_WS_RUN_DENYLIST = set() # extra user blocks from cfg
|
|
6028
6057
|
|
|
@@ -11601,6 +11630,11 @@ def run_agent_chat(cfg):
|
|
|
11601
11630
|
# client can approve interactively (terminal), PAUSE and ask the user;
|
|
11602
11631
|
# otherwise (web) keep hard-blocking the privilege/denylist cases.
|
|
11603
11632
|
_hard_reason, _ask_reason = _ws_command_risk(command, argv, _WS_RUN_DENYLIST)
|
|
11633
|
+
# Shift+Tab may have switched it since the turn started (#47).
|
|
11634
|
+
global _accept_edits
|
|
11635
|
+
if _interactive_approval and _job_id:
|
|
11636
|
+
_accept_edits = _live_accept_edits(worker_api_base, worker_api_key,
|
|
11637
|
+
_job_id, _accept_edits)
|
|
11604
11638
|
# ACCEPT EDITS OFF ⇒ ASK BEFORE RUNNING ANYTHING (task #179). The risk gate is
|
|
11605
11639
|
# the FLOOR, not the whole policy: sudo and rm -rf ask either way, and turning
|
|
11606
11640
|
# the toggle off raises the bar to every command. Off is the cautious setting,
|
package/gu-cli.mjs
CHANGED
|
@@ -76,6 +76,8 @@ 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 token [<token>] # show this computer's gu token, or save one pasted from
|
|
80
|
+
# the Account page (Client Expert machines)
|
|
79
81
|
gu-cli set <workerKey> [--api-base <url>]
|
|
80
82
|
gu-cli start | stop | status | restart # run the polling daemon in the background
|
|
81
83
|
gu-cli status # daemon + a table of the running model servers
|
|
@@ -181,6 +183,48 @@ if (args[0] === "set") {
|
|
|
181
183
|
await setConfig();
|
|
182
184
|
process.exit(0);
|
|
183
185
|
}
|
|
186
|
+
if (args[0] === "token") {
|
|
187
|
+
// #232. `gu token <token>` saves a token pasted from the web Account page — the only way a Client
|
|
188
|
+
// Expert machine, which has no worker key, gets its first one. `gu token` alone shows what the
|
|
189
|
+
// saved one says. Checked OFFLINE against the public key before saving, so a typo or a token from
|
|
190
|
+
// another server is refused here rather than turning into a warning later.
|
|
191
|
+
// NOT `gu tokens`: that is token USAGE, and stays exactly as it was.
|
|
192
|
+
const ct = await import("./cli-token.mjs");
|
|
193
|
+
const dir = join(homedir(), ".gonext");
|
|
194
|
+
const pk = ct.publicKey();
|
|
195
|
+
const pasted = String(args[1] ?? "").trim();
|
|
196
|
+
if (!pk) {
|
|
197
|
+
console.error("This version of gu cannot check tokens yet — nothing to do, gu works without one.");
|
|
198
|
+
process.exit(pasted ? 1 : 0);
|
|
199
|
+
}
|
|
200
|
+
if (pasted) {
|
|
201
|
+
const check = ct.verifyCliToken(pasted, pk);
|
|
202
|
+
if (!check.ok) {
|
|
203
|
+
const why =
|
|
204
|
+
check.why === "bad-signature" ? "it was not issued by gu (or was copied incompletely)"
|
|
205
|
+
: check.why === "unknown-version" ? "it is from a newer gu — update this one first"
|
|
206
|
+
: "it is not a whole token — copy it again from the Account page";
|
|
207
|
+
console.error(`That token was not saved: ${why}.`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
// lastAttemptAt null: the next gu start renews at once, which records THIS computer.
|
|
211
|
+
await ct.writeState(dir, { token: pasted, lastAttemptAt: null });
|
|
212
|
+
console.log(accent("Saved.") + " It renews itself from now on.");
|
|
213
|
+
for (const l of ct.tokenSummary(check.payload)) console.log(" " + l);
|
|
214
|
+
process.exit(0);
|
|
215
|
+
}
|
|
216
|
+
const state = await ct.readState(dir);
|
|
217
|
+
const check = state.token ? ct.verifyCliToken(state.token, pk) : { ok: false, why: "missing" };
|
|
218
|
+
if (!check.ok) {
|
|
219
|
+
console.log(state.token ? "The saved token is not valid here." : "No gu token on this computer.");
|
|
220
|
+
console.log(` Get one at ${ct.siteUrl()}/account, then: gu token <paste>`);
|
|
221
|
+
process.exit(0);
|
|
222
|
+
}
|
|
223
|
+
for (const l of ct.tokenSummary(check.payload)) console.log(l);
|
|
224
|
+
if (state.lastAttemptAt) console.log(`Last renewal attempt ${ct.shortDate(state.lastAttemptAt)}.`);
|
|
225
|
+
process.exit(0);
|
|
226
|
+
}
|
|
227
|
+
|
|
184
228
|
if (args[0] === "login") {
|
|
185
229
|
// Task #127, Phase 1: browser device-pairing → writes worker.env. `--api-base` overrides
|
|
186
230
|
// which API to pair against (defaults to the production API).
|
package/gu-repl.mjs
CHANGED
|
@@ -522,7 +522,7 @@ const argv = process.argv.slice(2);
|
|
|
522
522
|
// process.argv and exits inside its own subcommand branch, before it ever starts polling.
|
|
523
523
|
const CLI_SUBCOMMANDS = [
|
|
524
524
|
"status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
|
|
525
|
-
"clear", "reset", "api", "token-usage", "tokens",
|
|
525
|
+
"clear", "reset", "api", "token-usage", "tokens", "token",
|
|
526
526
|
];
|
|
527
527
|
// `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
|
|
528
528
|
// the list, so it fell through to "no subcommand given" and a full agent session opened in that
|
|
@@ -1848,6 +1848,69 @@ async function refreshAvailableUpdates() {
|
|
|
1848
1848
|
* output and corrupt a step's story. When that happens the notice waits: the main loop calls
|
|
1849
1849
|
* this again at the next prompt, and the bar has been showing the control the whole time.
|
|
1850
1850
|
*/
|
|
1851
|
+
/**
|
|
1852
|
+
* THE gu TOKEN (#232) — renewed quietly, and at most one line on the separator row.
|
|
1853
|
+
*
|
|
1854
|
+
* A SOFT LICENCE: nothing here blocks, prompts or delays. The schedule lives in cli-token.mjs
|
|
1855
|
+
* (Client: a day; Client Expert, local-only: a week — the owner's call) so this runs on a cheap
|
|
1856
|
+
* hourly tick that usually just re-reads a file and decides nothing is due.
|
|
1857
|
+
*
|
|
1858
|
+
* `startupRenewDone` is what stops "renew while a warning is up" from becoming "renew every hour
|
|
1859
|
+
* while a warning is up": an account over its computer limit stays over it, and would otherwise
|
|
1860
|
+
* call the hosted API all day. It gets ONE early renewal per gu start — enough for someone who just
|
|
1861
|
+
* paid to see the warning go.
|
|
1862
|
+
*/
|
|
1863
|
+
let tokenNotice = null;
|
|
1864
|
+
let tokenBusy = false;
|
|
1865
|
+
let startupRenewDone = false;
|
|
1866
|
+
async function refreshCliToken() {
|
|
1867
|
+
if (tokenBusy) return;
|
|
1868
|
+
tokenBusy = true;
|
|
1869
|
+
try {
|
|
1870
|
+
const ct = await import("./cli-token.mjs");
|
|
1871
|
+
const pk = ct.publicKey();
|
|
1872
|
+
if (!pk) {
|
|
1873
|
+
tokenNotice = null; // no key (none embedded, GU_CLI_TOKEN_PUBLIC_KEY empty): nothing to check against
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
const dir = join(homedir(), ".gonext");
|
|
1877
|
+
const mode = isClientExpert() ? "expert" : "client";
|
|
1878
|
+
const site = ct.siteUrl();
|
|
1879
|
+
let state = await ct.readState(dir);
|
|
1880
|
+
const now = new Date();
|
|
1881
|
+
const noticeNow = () =>
|
|
1882
|
+
ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site });
|
|
1883
|
+
tokenNotice = noticeNow();
|
|
1884
|
+
|
|
1885
|
+
const early = Boolean(tokenNotice) && !startupRenewDone;
|
|
1886
|
+
startupRenewDone = true;
|
|
1887
|
+
if (ct.shouldRenew({ state, mode, now, warningShowing: early })) {
|
|
1888
|
+
const { DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
1889
|
+
const device = await ct.thisDevice({ instanceId: myInstanceId || "" }).catch(() => null);
|
|
1890
|
+
const r = await ct.renewToken({
|
|
1891
|
+
mode,
|
|
1892
|
+
apiBase,
|
|
1893
|
+
hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
|
|
1894
|
+
token: state.token,
|
|
1895
|
+
workerKey,
|
|
1896
|
+
device,
|
|
1897
|
+
});
|
|
1898
|
+
// A reply is only kept if it checks out here — a proxy's HTML page or another server's token
|
|
1899
|
+
// must not replace a good one.
|
|
1900
|
+
const fresh = r.ok && ct.verifyCliToken(r.token, pk).ok ? r.token : state.token;
|
|
1901
|
+
state = { token: fresh, lastAttemptAt: now.toISOString() };
|
|
1902
|
+
await ct.writeState(dir, state).catch(() => {});
|
|
1903
|
+
tokenNotice = noticeNow();
|
|
1904
|
+
}
|
|
1905
|
+
} catch {
|
|
1906
|
+
/* never a reason to disturb a session */
|
|
1907
|
+
} finally {
|
|
1908
|
+
tokenBusy = false;
|
|
1909
|
+
if (barPinned()) paintBar();
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
const TOKEN_TICK_MS = 60 * 60 * 1000;
|
|
1913
|
+
|
|
1851
1914
|
let cliNoticeShown = false;
|
|
1852
1915
|
function announceCliUpdate() {
|
|
1853
1916
|
if (cliNoticeShown || !availableUpdates.cli) return;
|
|
@@ -2148,8 +2211,11 @@ function frozenZone() {
|
|
|
2148
2211
|
// separator exists to lift a CONTROL off the typed line, so with no control there is nothing
|
|
2149
2212
|
// to lift. The same three conditions the rows[sep] writes below test — kept adjacent so they
|
|
2150
2213
|
// cannot drift into disagreeing about whether a control is showing.
|
|
2214
|
+
// The token notice counts only when it FITS — the same test its write below uses. With
|
|
2215
|
+
// Boolean(tokenNotice) alone, a narrow terminal lifted a blank row for a line it never drew.
|
|
2216
|
+
const tokenFits = Boolean(tokenNotice) && visibleLen(tokenNotice.text) + 4 <= width - 1;
|
|
2151
2217
|
const headControl =
|
|
2152
|
-
scrolledBack() || availableUpdates.any || Boolean(agentsIndicator(otherAgents));
|
|
2218
|
+
scrolledBack() || availableUpdates.any || tokenFits || Boolean(agentsIndicator(otherAgents));
|
|
2153
2219
|
const zone = inputZoneRows({
|
|
2154
2220
|
headControl,
|
|
2155
2221
|
width,
|
|
@@ -2213,6 +2279,14 @@ function frozenZone() {
|
|
|
2213
2279
|
rows[sep] = clipVisible(dim(up.text.slice(0, up.from)) + accent(up.text.slice(up.from)), width - 1);
|
|
2214
2280
|
updateRowTarget = { from: up.from, to: up.to };
|
|
2215
2281
|
}
|
|
2282
|
+
} else if (tokenFits) {
|
|
2283
|
+
// The gu token's one line (#232), right-aligned like the controls around it. BELOW the update
|
|
2284
|
+
// control, because an update is actionable with a click and this is a standing fact; ABOVE the
|
|
2285
|
+
// agents count, because it needs the user and that does not. Not clickable. Too narrow to say
|
|
2286
|
+
// it whole → the condition above lets the agents count (or nothing) have the row instead.
|
|
2287
|
+
const len = visibleLen(tokenNotice.text);
|
|
2288
|
+
const from = width - 1 - len;
|
|
2289
|
+
rows[sep] = clipVisible(dim("\u2500".repeat(Math.max(0, from - 1)) + " ") + yellow(tokenNotice.text), width - 1);
|
|
2216
2290
|
} else if (agentsIndicator(otherAgents)) {
|
|
2217
2291
|
// …and when there is no update to offer, the same row carries the agents count (task #195).
|
|
2218
2292
|
// THREE THINGS CANNOT SHARE ONE ROW, so they are ranked by how time-critical they are:
|
|
@@ -3114,6 +3188,34 @@ function eraseFooter() {
|
|
|
3114
3188
|
}
|
|
3115
3189
|
// Shift+Tab redraw: the bar is below the caret, so a plain redraw in place is enough.
|
|
3116
3190
|
const redrawModeLine = () => drawFooter();
|
|
3191
|
+
// Shift+Tab (#47). At the prompt it sets the mode for the next message. During a turn it ALSO
|
|
3192
|
+
// tells the running agent, which re-reads it before each command — the flag the turn was queued
|
|
3193
|
+
// with is only its starting value. If the API cannot take it (an older one without the route),
|
|
3194
|
+
// say once that it applies from the next message, rather than let the bar claim something the
|
|
3195
|
+
// running agent never heard.
|
|
3196
|
+
let editModeLiveWarnedJob = null;
|
|
3197
|
+
function switchEditMode() {
|
|
3198
|
+
editMode = editMode === "accept" ? "manual" : "accept";
|
|
3199
|
+
redrawModeLine();
|
|
3200
|
+
const jobId = currentJobId;
|
|
3201
|
+
if (!jobId || !apiBase || !workerKey) return;
|
|
3202
|
+
fetch(`${apiBase}/api/worker/jobs/${encodeURIComponent(jobId)}/accept-edits`, {
|
|
3203
|
+
method: "POST",
|
|
3204
|
+
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
3205
|
+
body: JSON.stringify({ acceptEdits: editMode === "accept" }),
|
|
3206
|
+
signal: AbortSignal.timeout(10_000),
|
|
3207
|
+
})
|
|
3208
|
+
.then((r) => {
|
|
3209
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
3210
|
+
})
|
|
3211
|
+
.catch(() => {
|
|
3212
|
+
if (editModeLiveWarnedJob === jobId || currentJobId !== jobId) return;
|
|
3213
|
+
editModeLiveWarnedJob = jobId;
|
|
3214
|
+
process.stdout.write(
|
|
3215
|
+
"\n" + GUTTER + dim("accept edits: the running agent did not get the change — it applies from your next message") + "\n"
|
|
3216
|
+
);
|
|
3217
|
+
});
|
|
3218
|
+
}
|
|
3117
3219
|
|
|
3118
3220
|
// ---------- the gap between Enter and the agent (task #140) -----------------------------
|
|
3119
3221
|
// Enter tears the bar down at once (it must: the echoed newline would otherwise land on the
|
|
@@ -3471,6 +3573,10 @@ if (process.stdin.isTTY) {
|
|
|
3471
3573
|
// hover-click, so the fuller thought stays reachable with the mouse capture off (the
|
|
3472
3574
|
// capture is what would eat the scroll wheel; see hoverMouse).
|
|
3473
3575
|
if (key && key.ctrl && key.name === "t" && toggleActiveThought) toggleActiveThought();
|
|
3576
|
+
// Shift+Tab WORKS DURING A TURN (#47). This branch used to swallow every key but Ctrl+T,
|
|
3577
|
+
// so the switch did nothing while the agent was thinking — which is exactly when a user
|
|
3578
|
+
// watching it reach for commands wants to change it.
|
|
3579
|
+
if (key && key.name === "tab" && key.shift) switchEditMode();
|
|
3474
3580
|
rl.line = "";
|
|
3475
3581
|
rl.cursor = 0;
|
|
3476
3582
|
rl.historyIndex = -1;
|
|
@@ -3501,10 +3607,7 @@ if (process.stdin.isTTY) {
|
|
|
3501
3607
|
// Shift+Tab cycles the edit mode shown in the bottom bar (display-only). Skip while the
|
|
3502
3608
|
// slash overlay owns the screen so we don't fight its redraw.
|
|
3503
3609
|
if (key && key.name === "tab" && key.shift) {
|
|
3504
|
-
if (!slashOverlayActive)
|
|
3505
|
-
editMode = editMode === "accept" ? "manual" : "accept";
|
|
3506
|
-
redrawModeLine();
|
|
3507
|
-
}
|
|
3610
|
+
if (!slashOverlayActive) switchEditMode();
|
|
3508
3611
|
return;
|
|
3509
3612
|
}
|
|
3510
3613
|
// Enter is handled by rl.on("line") — don't redraw the hint on the way out.
|
|
@@ -10091,6 +10194,9 @@ async function main() {
|
|
|
10091
10194
|
// reason gu refuses to exit.
|
|
10092
10195
|
void refreshAvailableUpdates();
|
|
10093
10196
|
setInterval(() => void refreshAvailableUpdates(), UPDATE_CHECK_MS).unref();
|
|
10197
|
+
// The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
|
|
10198
|
+
void refreshCliToken();
|
|
10199
|
+
setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
|
|
10094
10200
|
// …and the other-agents count (task #195). Slower than the turn's own poll and skipped while
|
|
10095
10201
|
// a turn runs; unref'd so a pending timer is never the reason gu refuses to exit.
|
|
10096
10202
|
void refreshOtherAgents();
|
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.706",
|
|
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",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"terminal-copy.mjs",
|
|
52
52
|
"version-check.mjs",
|
|
53
53
|
"rag-selftest.mjs",
|
|
54
|
+
"cli-token.mjs",
|
|
54
55
|
"device-login.mjs",
|
|
55
56
|
"daemon-control.mjs",
|
|
56
57
|
"poll-errors.mjs",
|