@tiens.nguyen/gu-cli 1.0.791 → 1.0.793

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-logs.mjs ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * "clear logs" (#329) — what is taking the disk under ~/.gonext, and a way to give it back.
3
+ *
4
+ * Asked for after this Mac's disk filled three times in one day and took the local MongoDB down
5
+ * with it (#328). A click on the notice row's "clear logs" (or /clear-logs) DELETES NOTHING at
6
+ * first: it measures, shows the logs beside the two things that are actually big — the RAG indexes
7
+ * and the downloaded API versions — with a total, and then asks about each of the three.
8
+ *
9
+ * THREE DIFFERENT KINDS OF "CLEAR", because losing each costs something different:
10
+ *
11
+ * logs nothing. But the worker and the local API hold their log OPEN: a deleted open file frees
12
+ * no space on macOS/Linux until the holder closes it (and the holder keeps writing into
13
+ * the unlinked file), and on Windows the delete fails outright. So the live "<name>.log"
14
+ * is TRUNCATED IN PLACE and only rotations and finished run-logs are deleted — the same
15
+ * rule rotateIfLarge (log-policy.mjs) already lives by.
16
+ * rag minutes of embedding per folder: an index is rebuilt the next time its folder is used.
17
+ * api nothing, PROVIDED the version in use stays. One folder per installed version, none ever
18
+ * removed; deleting the running one would break every open terminal's turn. So "clear
19
+ * api" only ever means the OLDER versions.
20
+ *
21
+ * SELECTED BY AN EXPLICIT LIST, never by a "*.log*" / "*.jsonl" sweep of the home: sessions/archive
22
+ * is conversation history (#278) and rag/ shards are also .jsonl. Anything not named here is not
23
+ * touched, whatever the answers.
24
+ *
25
+ * Everything takes `home` so the tests run against a real temp folder, not this machine's.
26
+ */
27
+ import { readdir, readFile, rm, rmdir, stat, truncate, unlink } from "node:fs/promises";
28
+ import { existsSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { join } from "node:path";
31
+ import { pickUsableVersion, compareVersions } from "./version-check.mjs";
32
+
33
+ /** The logs gu writes at the top of its home. `<name>` is live; `<name>.N` are its rotations. */
34
+ export const LIVE_LOGS = ["worker.log", "local-api.log", "model.log", "embed.log"];
35
+ /** Folders that hold nothing but finished log files. */
36
+ export const LOG_DIRS = ["run-logs", "logs"];
37
+ /** The link appears once the logs pass this — below it the row stays quiet. */
38
+ export const LOGS_NOTICE_BYTES = 50 * 1024 * 1024;
39
+
40
+ const guHome = (home = homedir()) => join(home, ".gonext");
41
+
42
+ /** "740 KB", "15 MB", "4.9 GB" — two significant figures, the way `du -h` reads. */
43
+ export function humanSize(bytes) {
44
+ const n = Math.max(0, Number(bytes) || 0);
45
+ const units = [["GB", 1024 ** 3], ["MB", 1024 ** 2], ["KB", 1024]];
46
+ for (const [unit, size] of units) {
47
+ if (n < size) continue;
48
+ const v = n / size;
49
+ // 9.96 would print as "10.0" with one decimal — let it round up to the integer form instead.
50
+ return `${v < 9.95 ? v.toFixed(1) : Math.round(v)} ${unit}`;
51
+ }
52
+ return `${Math.round(n)} B`;
53
+ }
54
+
55
+ const plural = (n, one, many = one + "s") => `${n} ${n === 1 ? one : many}`;
56
+
57
+ /**
58
+ * What a file takes ON DISK — its blocks, which is what clearing gives back and what `du` reports.
59
+ * The byte length undercounts thousands of small files (an API version read as 85 MB here against
60
+ * du's 120 MB). Windows reports no blocks; the length is the best there is.
61
+ */
62
+ const onDisk = (s) => (s.blocks > 0 ? s.blocks * 512 : s.size);
63
+
64
+ async function fileSize(p) {
65
+ try {
66
+ const s = await stat(p);
67
+ return s.isFile() ? onDisk(s) : -1;
68
+ } catch {
69
+ return -1;
70
+ }
71
+ }
72
+
73
+ /** Bytes under a folder. Children are walked together: an API version is thousands of files. */
74
+ async function dirSize(p) {
75
+ let entries;
76
+ try {
77
+ entries = await readdir(p, { withFileTypes: true });
78
+ } catch {
79
+ return 0;
80
+ }
81
+ const sizes = await Promise.all(
82
+ entries.map(async (e) => {
83
+ const full = join(p, e.name);
84
+ if (e.isDirectory()) return dirSize(full);
85
+ if (e.isSymbolicLink()) return 0; // never followed: a link out of the home is not ours to count
86
+ try {
87
+ return onDisk(await stat(full));
88
+ } catch {
89
+ return 0;
90
+ }
91
+ })
92
+ );
93
+ return sizes.reduce((a, b) => a + b, 0);
94
+ }
95
+
96
+ /**
97
+ * The logs: each live file and its rotations by name, each log folder as a count.
98
+ * → { items: [{ name, bytes, kind: "live" | "rotation" | "dir", files?, paths? }], bytes, files }
99
+ */
100
+ export async function surveyLogs({ home = homedir() } = {}) {
101
+ const root = guHome(home);
102
+ let names = [];
103
+ try {
104
+ names = await readdir(root);
105
+ } catch {
106
+ /* no home yet — nothing to report */
107
+ }
108
+ const items = [];
109
+ for (const live of LIVE_LOGS) {
110
+ const bytes = await fileSize(join(root, live));
111
+ if (bytes >= 0) items.push({ name: live, bytes, kind: "live", path: join(root, live) });
112
+ const rotations = names
113
+ .filter((n) => n.startsWith(live + ".") && /^\d+$/.test(n.slice(live.length + 1)))
114
+ .sort((a, b) => Number(a.slice(live.length + 1)) - Number(b.slice(live.length + 1)));
115
+ for (const r of rotations) {
116
+ const b = await fileSize(join(root, r));
117
+ if (b >= 0) items.push({ name: r, bytes: b, kind: "rotation", path: join(root, r) });
118
+ }
119
+ }
120
+ for (const dir of LOG_DIRS) {
121
+ let files = [];
122
+ try {
123
+ files = (await readdir(join(root, dir), { withFileTypes: true })).filter(
124
+ (e) => e.isFile() && /\.log(\.\d+)?$/.test(e.name)
125
+ );
126
+ } catch {
127
+ continue;
128
+ }
129
+ const paths = files.map((e) => join(root, dir, e.name));
130
+ let bytes = 0;
131
+ for (const p of paths) bytes += Math.max(0, await fileSize(p));
132
+ if (paths.length) items.push({ name: dir, bytes, kind: "dir", files: paths.length, paths });
133
+ }
134
+ return {
135
+ items,
136
+ bytes: items.reduce((n, i) => n + i.bytes, 0),
137
+ files: items.reduce((n, i) => n + (i.kind === "dir" ? i.files : 1), 0),
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Every local index: ~/.gonext/rag/<folder hash>/<source hash>/. The hashes say nothing to a
143
+ * person, so the name comes from the manifest the agent wrote (`sourceUrl`). `missing` = that
144
+ * source was a folder on this machine and is gone — the index can never be used again.
145
+ */
146
+ export async function surveyRag({ home = homedir() } = {}) {
147
+ const root = join(guHome(home), "rag");
148
+ const indexes = [];
149
+ let folders = [];
150
+ try {
151
+ folders = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory());
152
+ } catch {
153
+ return { indexes, bytes: 0 };
154
+ }
155
+ for (const f of folders) {
156
+ let sources = [];
157
+ try {
158
+ sources = (await readdir(join(root, f.name), { withFileTypes: true })).filter((e) => e.isDirectory());
159
+ } catch {
160
+ continue;
161
+ }
162
+ for (const s of sources) {
163
+ const dir = join(root, f.name, s.name);
164
+ let source = "";
165
+ try {
166
+ source = String(JSON.parse(await readFile(join(dir, "manifest.json"), "utf8"))?.sourceUrl ?? "");
167
+ } catch {
168
+ /* no manifest — an interrupted index; still counted, still clearable */
169
+ }
170
+ const isPath = source.startsWith("/") || /^[A-Za-z]:[\\/]/.test(source);
171
+ indexes.push({
172
+ dir,
173
+ parent: join(root, f.name),
174
+ source,
175
+ label: source ? source.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || source : `${s.name.slice(0, 8)}…`,
176
+ missing: isPath && !existsSync(source),
177
+ bytes: await dirSize(dir),
178
+ });
179
+ }
180
+ }
181
+ indexes.sort((a, b) => b.bytes - a.bytes);
182
+ return { indexes, bytes: indexes.reduce((n, i) => n + i.bytes, 0) };
183
+ }
184
+
185
+ /**
186
+ * The installed API versions, and which must stay.
187
+ *
188
+ * KEPT: the version answering on the port right now (`runningVersion`, from /api/health), the one
189
+ * `gu-cli api start` would pick (newest that has its dependencies — #181), and the newest folder
190
+ * even when it is not runnable yet (an upgrade in progress). Everything else is "old".
191
+ */
192
+ export async function surveyApi({ home = homedir(), runningVersion = "" } = {}) {
193
+ const root = join(guHome(home), "api");
194
+ let names = [];
195
+ try {
196
+ names = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
197
+ } catch {
198
+ return { versions: [], bytes: 0, oldBytes: 0, old: [], inUse: "" };
199
+ }
200
+ names.sort((a, b) => compareVersions(a, b));
201
+ const usable = pickUsableVersion(names, (v) => existsSync(join(root, v, "node_modules", "mongodb", "package.json")));
202
+ const running = names.includes(runningVersion) ? runningVersion : "";
203
+ const keep = new Set([running, usable, names[names.length - 1]].filter(Boolean));
204
+ const sizes = await Promise.all(names.map((v) => dirSize(join(root, v))));
205
+ const versions = names.map((version, i) => ({ version, dir: join(root, version), bytes: sizes[i], keep: keep.has(version) }));
206
+ const old = versions.filter((v) => !v.keep);
207
+ return {
208
+ versions,
209
+ old,
210
+ bytes: versions.reduce((n, v) => n + v.bytes, 0),
211
+ oldBytes: old.reduce((n, v) => n + v.bytes, 0),
212
+ inUse: running || usable || "",
213
+ };
214
+ }
215
+
216
+ /** All three, measured together. */
217
+ export async function surveyDisk({ home = homedir(), runningVersion = "" } = {}) {
218
+ const [logs, rag, api] = await Promise.all([surveyLogs({ home }), surveyRag({ home }), surveyApi({ home, runningVersion })]);
219
+ return { logs, rag, api, total: logs.bytes + rag.bytes + api.bytes };
220
+ }
221
+
222
+ /** Lay `parts` out after `lead`, breaking only between parts; later rows start at `indent`. */
223
+ function wrapParts(lead, parts, width, { sep = " · ", indent = lead.length } = {}) {
224
+ const pad = " ".repeat(indent);
225
+ const rows = [];
226
+ let cur = lead;
227
+ let empty = true;
228
+ for (const part of parts) {
229
+ if (!empty && cur.length + sep.length + part.length > width) {
230
+ rows.push(cur);
231
+ cur = pad + part;
232
+ } else {
233
+ cur += (empty ? "" : sep) + part;
234
+ }
235
+ empty = false;
236
+ }
237
+ rows.push(cur.trimEnd());
238
+ // a single part longer than the row is cut, never wrapped — a wrapped row moves everything under it
239
+ return rows.map((r) => (r.length > width ? r.slice(0, Math.max(1, width - 1)) + "…" : r));
240
+ }
241
+
242
+ /**
243
+ * What the click shows before it asks anything: the logs with their files, RAG and API with what
244
+ * they are, and the total. Plain text rows that fit `width` — the caller paints and indents them.
245
+ * A part that is not on this machine (no api/ outside Local mode) has no row.
246
+ */
247
+ export function summaryRows(survey, { width = 76, top = 3 } = {}) {
248
+ const w = Math.max(30, width | 0);
249
+ const { logs, rag, api } = survey;
250
+ const parts = [
251
+ { name: "Logs", bytes: logs.bytes, detail: logs.items.map((i) => i.kind === "dir"
252
+ ? `${i.name} ${plural(i.files, "file")} ${humanSize(i.bytes)}`
253
+ : `${i.name} ${humanSize(i.bytes)}`), none: "none" },
254
+ ];
255
+ if (rag.indexes.length) {
256
+ const shown = rag.indexes.slice(0, top);
257
+ const rest = rag.indexes.slice(top);
258
+ const detail = shown.map((i) => `${i.label} ${humanSize(i.bytes)}`);
259
+ if (rest.length) detail.push(`${rest.length} more ${humanSize(rest.reduce((n, i) => n + i.bytes, 0))}`);
260
+ parts.push({ name: "RAG", bytes: rag.bytes, intro: plural(rag.indexes.length, "folder index", "folder indexes") + ":", detail });
261
+ }
262
+ if (api.versions.length) {
263
+ const detail = [plural(api.versions.length, "version")];
264
+ if (api.inUse) detail.push(`${api.inUse} in use`);
265
+ if (api.old.length) detail.push(`${plural(api.old.length, "older one")} ${humanSize(api.oldBytes)}`);
266
+ parts.push({ name: "API", bytes: api.bytes, detail });
267
+ }
268
+ const nameW = Math.max(5, ...parts.map((p) => p.name.length));
269
+ const sizeW = Math.max(humanSize(survey.total).length, ...parts.map((p) => humanSize(p.bytes).length));
270
+ const lead = (name, bytes) => `${name.padEnd(nameW)} ${humanSize(bytes).padStart(sizeW)} `;
271
+ const rows = [];
272
+ for (const p of parts) {
273
+ const head = lead(p.name, p.bytes);
274
+ // "22 folder indexes:" introduces the list — the list wraps under the first index, the
275
+ // other parts under their own first word
276
+ rows.push(...wrapParts(head + (p.intro ? p.intro + " " : ""), p.detail.length ? p.detail : [p.none ?? ""], w, { indent: head.length }));
277
+ }
278
+ rows.push(lead("Total", survey.total).trimEnd());
279
+ return rows;
280
+ }
281
+
282
+ /**
283
+ * Clear the logs `surveyLogs` found: truncate the live ones, delete rotations and log folders'
284
+ * files. → { freed, truncated, deleted, failed: [name] } — `freed` is what really went, so a file
285
+ * Windows refused to let go of is reported, not counted.
286
+ */
287
+ export async function clearLogs(logs) {
288
+ const out = { freed: 0, truncated: 0, deleted: 0, failed: [] };
289
+ for (const item of logs?.items ?? []) {
290
+ if (item.kind === "live") {
291
+ try {
292
+ const before = Math.max(0, await fileSize(item.path));
293
+ await truncate(item.path, 0);
294
+ out.freed += before;
295
+ out.truncated += 1;
296
+ } catch {
297
+ out.failed.push(item.name);
298
+ }
299
+ continue;
300
+ }
301
+ for (const p of item.kind === "dir" ? item.paths : [item.path]) {
302
+ try {
303
+ const before = Math.max(0, await fileSize(p));
304
+ await unlink(p);
305
+ out.freed += before;
306
+ out.deleted += 1;
307
+ } catch (e) {
308
+ if (e?.code === "ENOENT") continue; // pruned meanwhile — gone is what was wanted
309
+ if (!out.failed.includes(item.name)) out.failed.push(item.name);
310
+ }
311
+ }
312
+ }
313
+ return out;
314
+ }
315
+
316
+ /** Remove the given indexes (surveyRag's rows). An emptied folder-hash directory goes with them. */
317
+ export async function clearRagIndexes(indexes) {
318
+ const out = { freed: 0, removed: 0, failed: [] };
319
+ for (const idx of indexes ?? []) {
320
+ try {
321
+ await rm(idx.dir, { recursive: true, force: true });
322
+ out.freed += idx.bytes;
323
+ out.removed += 1;
324
+ await rmdir(idx.parent).catch(() => {}); // only succeeds when nothing else lives there
325
+ } catch {
326
+ out.failed.push(idx.label);
327
+ }
328
+ }
329
+ return out;
330
+ }
331
+
332
+ /** Remove the OLD API versions (surveyApi's `old`). A row marked keep is refused, whoever asks. */
333
+ export async function clearOldApiVersions(versions) {
334
+ const out = { freed: 0, removed: 0, failed: [] };
335
+ for (const v of versions ?? []) {
336
+ if (v.keep) continue;
337
+ try {
338
+ await rm(v.dir, { recursive: true, force: true });
339
+ out.freed += v.bytes;
340
+ out.removed += 1;
341
+ } catch {
342
+ out.failed.push(v.version);
343
+ }
344
+ }
345
+ return out;
346
+ }
347
+
348
+ /** The notice-row item's text, or "" while the logs are too small to be worth a row. */
349
+ export function clearLogsNoticeText(logBytes, { threshold = LOGS_NOTICE_BYTES } = {}) {
350
+ const n = Number(logBytes) || 0;
351
+ return n >= threshold ? `clear logs · ${humanSize(n)} (click)` : "";
352
+ }
package/clear-state.mjs CHANGED
@@ -30,6 +30,10 @@ export const STATE_ITEMS = [
30
30
  what: "the account link (worker key + API base)", cost: "you sign in again" },
31
31
  { path: "client-expert.json", group: "identity",
32
32
  what: "Local-mode setup progress and the chosen mode", cost: "setup starts over" },
33
+ // With the setup it belongs to (#337): a leftover port file would send a fresh setup to a
34
+ // port chosen for a clash that may no longer exist.
35
+ { path: "local-api.port", group: "identity",
36
+ what: "the port the local API moved to (4100 was taken)", cost: "setup picks again" },
33
37
  { path: "instance", group: "identity",
34
38
  what: "this machine's id", cost: "it gets a new one" },
35
39
  { path: "cli-token.json", group: "identity",
package/cli-token.mjs CHANGED
@@ -328,6 +328,10 @@ export async function readState(dir) {
328
328
  return {
329
329
  token: typeof s.token === "string" ? s.token : "",
330
330
  lastAttemptAt: isIso(s.lastAttemptAt) ? s.lastAttemptAt : null,
331
+ // Why the last automatic fetch failed (absent when it worked) — so the bar can tell "the gu
332
+ // service had a problem" from "this machine is not signed in" (#102).
333
+ // Present only when the last try failed: the shape is unchanged for a state that never did.
334
+ ...(typeof s.lastAttemptWhy === "string" && s.lastAttemptWhy ? { lastAttemptWhy: s.lastAttemptWhy } : {}),
331
335
  };
332
336
  } catch {
333
337
  return { token: "", lastAttemptAt: null };
@@ -339,7 +343,7 @@ export async function writeState(dir, state) {
339
343
  const file = join(dir, STATE_FILE);
340
344
  await mkdir(dirname(file), { recursive: true });
341
345
  const tmp = `${file}.${process.pid}.tmp`;
342
- await writeFile(tmp, JSON.stringify({ token: state.token ?? "", lastAttemptAt: state.lastAttemptAt ?? null }, null, 2) + "\n", {
346
+ await writeFile(tmp, JSON.stringify({ token: state.token ?? "", lastAttemptAt: state.lastAttemptAt ?? null, ...(state.lastAttemptWhy ? { lastAttemptWhy: state.lastAttemptWhy } : {}) }, null, 2) + "\n", {
343
347
  encoding: "utf8",
344
348
  mode: 0o600,
345
349
  });
@@ -450,7 +454,7 @@ export function shortDate(iso) {
450
454
  *
451
455
  * Never the word "licence". Returns null when the feature is off (no public key).
452
456
  */
453
- export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempted = false, site = DEFAULT_SITE }) {
457
+ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempted = false, site = DEFAULT_SITE, why = "" }) {
454
458
  if (!publicKeyB64) return null;
455
459
  const host = site.replace(/^https?:\/\//, "");
456
460
  // Shown without the scheme — shorter on a narrow bar, and still what a person types.
@@ -506,6 +510,12 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
506
510
  return null;
507
511
  } else {
508
512
  // Hosted client, its automatic fetch has failed: the bar is the only place this is said.
513
+ //
514
+ // SAY WHICH FAILURE (#102). "gu isn't signed in · run gu login" was shown on a machine that WAS
515
+ // signed in — its worker key worked and turns ran — because the gu service answered HTTP 502 to
516
+ // the token fetch. `gu login` does not fix a server error; the next automatic try does.
517
+ const trouble = fetchTrouble(why);
518
+ if (trouble) return { kind: "unchecked", urgent: false, text: `plan not checked — ${trouble} · gu will try again` };
509
519
  return { kind: "missing", urgent: false, text: "gu isn't signed in · run gu login" };
510
520
  }
511
521
 
@@ -515,6 +525,19 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
515
525
  : { kind: "stale", urgent: false, text: "gu token is old · run gu login" };
516
526
  }
517
527
 
528
+ /**
529
+ * A failed token fetch that is the SERVICE's or the network's doing, in words — or "" when it is
530
+ * this machine's (a refused key: http-401/403), or unknown. `why` is renewToken's.
531
+ */
532
+ export function fetchTrouble(why) {
533
+ const w = String(why ?? "");
534
+ const http = /^http-(\d{3})$/.exec(w);
535
+ if (http) return Number(http[1]) >= 500 ? `the gu service had an error (HTTP ${http[1]})` : "";
536
+ if (w === "network" || w === "timeout" || w === "no-api") return "the gu service could not be reached";
537
+ if (w === "bad-response") return "the gu service sent an unexpected answer";
538
+ return "";
539
+ }
540
+
518
541
  /** "Free plan" / "Pro plan" — the name a person uses. */
519
542
  const planName = (plan) => (plan === "pro" ? "Pro plan" : plan === "trial" ? "free trial" : "Free plan");
520
543
  /** "1 computer" / "4 computers" — "computer", not "device": a device is also a phone. */