@tiens.nguyen/gu-cli 1.0.863 → 1.0.864

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,389 @@
1
+ /**
2
+ * `gu support` / `/support` (#220) — send the support team a request WITH gu's logs attached.
3
+ *
4
+ * Asked for because a customer whose install failed had no idea who to contact, or what to send.
5
+ * support-report.mjs already wrote a masked health report; this adds the rest, the way Claude
6
+ * Code's /bug does it: collect, SHOW what will go, ask, send, and give back a reference.
7
+ *
8
+ * 1. collectSupportFiles — the report plus gu's own logs, each one MASKED (support-report's
9
+ * redactReport) and cut to its END (the end is where the failure is). Chosen by an explicit
10
+ * list, never by a sweep: conversations (sessions/), keys (worker.env) and RAG data are not
11
+ * logs and never go. Also the Windows installer's log and the newest npm debug logs that
12
+ * mention gu, because an install that failed has no gu logs at all.
13
+ * 2. zipFiles — one .zip in ~/.gonext/support/, owner-only. Pure JS: no `zip`/`tar` on PATH needed.
14
+ * 3. sendSupportRequest — POST /api/support/requests (the same queue the web Support page fills)
15
+ * → a GU-XXXXXX reference and a one-time upload key, then the zip to …/:ref/logs.
16
+ * 4. When sending fails, the zip stays and supportFallbackLines says where to email it.
17
+ *
18
+ * Nothing here asks a question: gu-cli.mjs and gu-repl.mjs do that, each in its own way.
19
+ * Everything takes `home` / `fetchImpl` so the tests run on a temp folder and offline.
20
+ */
21
+ import { open, readdir, stat, mkdir, writeFile, unlink } from "node:fs/promises";
22
+ import { homedir, tmpdir, platform as osPlatform, release, arch } from "node:os";
23
+ import { join, basename } from "node:path";
24
+ import { deflateRawSync } from "node:zlib";
25
+ import { redactReport, buildSupportReport } from "./support-report.mjs";
26
+ import { LIVE_LOGS, LOG_DIRS, humanSize } from "./clear-logs.mjs";
27
+ import { netErrorDetail } from "./cli-token.mjs";
28
+
29
+ /** Where people are told to write. Same address as the web Support page (web-v2 supportText.mjs). */
30
+ export const SUPPORT_EMAIL = "tien@getmarsic.cc";
31
+ /** The hosted API — the one device login and the free token use (device-login.mjs DEFAULT_API_BASE). */
32
+ export const SUPPORT_API_BASE = "https://chat-api-v2.gucli.com";
33
+ export const SUPPORT_DIR = "support";
34
+ /** Old bundles kept in ~/.gonext/support — they are ours to remove, and each holds logs. */
35
+ export const KEEP_BUNDLES = 5;
36
+
37
+ export const LIMITS = {
38
+ perFile: 512 * 1024, // the END of each log
39
+ total: 6 * 1024 * 1024, // all text, before compression
40
+ zip: 1000 * 1024, // what the API takes (api/src/routes/support.ts LOGS_MAX_BYTES)
41
+ dirFiles: 10, // newest files from run-logs/ and logs/
42
+ npmLogs: 3,
43
+ };
44
+
45
+ export function supportApiBase(env = process.env) {
46
+ const v = String(env.GU_SUPPORT_API_BASE ?? "").trim() || String(env.GU_TOKEN_API_BASE ?? "").trim() || SUPPORT_API_BASE;
47
+ return v.replace(/\/+$/, "");
48
+ }
49
+
50
+ async function mtimeOf(p) {
51
+ try {
52
+ const s = await stat(p);
53
+ return s.isFile() ? { size: s.size, mtime: s.mtimeMs } : null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** The last `max` bytes of a file, from the first whole line. Never loads a 2 GB log. */
60
+ async function readTail(path, max) {
61
+ const fh = await open(path, "r");
62
+ try {
63
+ const { size } = await fh.stat();
64
+ const start = Math.max(0, size - max);
65
+ const buf = Buffer.alloc(size - start);
66
+ await fh.read(buf, 0, buf.length, start);
67
+ let text = buf.toString("utf8");
68
+ if (start > 0) {
69
+ const nl = text.indexOf("\n");
70
+ text = nl >= 0 ? text.slice(nl + 1) : text;
71
+ text = `[gu support: the first ${humanSize(start)} of this file are left out — only its end is included]\n` + text;
72
+ }
73
+ return { text, size, cut: start > 0 };
74
+ } finally {
75
+ await fh.close();
76
+ }
77
+ }
78
+
79
+ /** Newest first; at most `n` of the files in `dir` whose names pass `keep`. */
80
+ async function newestFiles(dir, n, keep) {
81
+ let names = [];
82
+ try {
83
+ names = (await readdir(dir, { withFileTypes: true })).filter((e) => e.isFile() && keep(e.name)).map((e) => e.name);
84
+ } catch {
85
+ return [];
86
+ }
87
+ const withTime = [];
88
+ for (const name of names) {
89
+ const info = await mtimeOf(join(dir, name));
90
+ if (info) withTime.push({ path: join(dir, name), name, ...info });
91
+ }
92
+ return withTime.sort((a, b) => b.mtime - a.mtime).slice(0, n);
93
+ }
94
+
95
+ /** Where npm keeps its debug logs on this machine (the first that exists wins per path). */
96
+ export function npmLogDirs({ home = homedir(), env = process.env } = {}) {
97
+ const dirs = [];
98
+ if (env.npm_config_cache) dirs.push(join(env.npm_config_cache, "_logs"));
99
+ dirs.push(join(home, ".npm", "_logs"));
100
+ if (env.LOCALAPPDATA) dirs.push(join(env.LOCALAPPDATA, "npm-cache", "_logs"));
101
+ return [...new Set(dirs)];
102
+ }
103
+
104
+ /**
105
+ * What would be sent, in order of use to support: the live logs, the installer's log, npm's logs
106
+ * of a gu install, the newest run logs, then rotations. → [{ name, path }] (name = path in the zip).
107
+ */
108
+ export async function listSupportSources({ home = homedir(), env = process.env, tmp = tmpdir(), limits = LIMITS } = {}) {
109
+ const root = join(home, ".gonext");
110
+ const out = [];
111
+ let names = [];
112
+ try {
113
+ names = await readdir(root);
114
+ } catch {
115
+ /* a fresh machine: no gu home yet */
116
+ }
117
+ for (const live of LIVE_LOGS) if (names.includes(live)) out.push({ name: `logs/${live}`, path: join(root, live) });
118
+ const installLog = join(tmp, "gu-install.log"); // install.ps1 writes npm's output here
119
+ if (await mtimeOf(installLog)) out.push({ name: "install/gu-install.log", path: installLog });
120
+ const seen = new Set();
121
+ let npmCount = 0;
122
+ for (const dir of npmLogDirs({ home, env })) {
123
+ for (const f of await newestFiles(dir, 20, (n) => /-debug(-\d+)?\.log$/.test(n))) {
124
+ if (npmCount >= limits.npmLogs || seen.has(f.name)) continue;
125
+ // Only npm runs that were about gu: someone's other projects' logs are none of ours.
126
+ const head = await readTail(f.path, 256 * 1024).then((t) => t.text).catch(() => "");
127
+ if (!/gu-cli|@tiens\.nguyen/.test(head)) continue;
128
+ seen.add(f.name);
129
+ npmCount++;
130
+ out.push({ name: `npm/${f.name}`, path: f.path });
131
+ }
132
+ }
133
+ for (const dir of LOG_DIRS) {
134
+ for (const f of await newestFiles(join(root, dir), limits.dirFiles, (n) => /\.log(\.\d+)?$/.test(n))) {
135
+ out.push({ name: `logs/${dir}/${f.name}`, path: f.path });
136
+ }
137
+ }
138
+ for (const live of LIVE_LOGS) {
139
+ const rotations = names
140
+ .filter((n) => n.startsWith(live + ".") && /^\d+$/.test(n.slice(live.length + 1)))
141
+ .sort((a, b) => Number(a.slice(live.length + 1)) - Number(b.slice(live.length + 1)));
142
+ for (const r of rotations) out.push({ name: `logs/${r}`, path: join(root, r) });
143
+ }
144
+ return out;
145
+ }
146
+
147
+ /**
148
+ * Read and mask every source, within the limits. → { files: [{ name, text, size, cut }], skipped }
149
+ * `skipped` = sources left out because the total was reached.
150
+ */
151
+ export async function collectSupportFiles({ sources, limits = LIMITS } = {}) {
152
+ const files = [];
153
+ const skipped = [];
154
+ let total = 0;
155
+ for (const s of sources) {
156
+ if (total >= limits.total) {
157
+ skipped.push(s.name);
158
+ continue;
159
+ }
160
+ try {
161
+ const t = await readTail(s.path, Math.min(limits.perFile, limits.total - total));
162
+ const text = redactReport(t.text);
163
+ total += Buffer.byteLength(text);
164
+ files.push({ name: s.name, text, size: t.size, cut: t.cut });
165
+ } catch {
166
+ /* gone or unreadable between the listing and the read — leave it out */
167
+ }
168
+ }
169
+ return { files, skipped };
170
+ }
171
+
172
+ // ---------------------------------------------------------------- zip ----------------------
173
+ const CRC_TABLE = (() => {
174
+ const t = new Uint32Array(256);
175
+ for (let n = 0; n < 256; n++) {
176
+ let c = n;
177
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
178
+ t[n] = c >>> 0;
179
+ }
180
+ return t;
181
+ })();
182
+ export function crc32(buf) {
183
+ let c = 0xffffffff;
184
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
185
+ return (c ^ 0xffffffff) >>> 0;
186
+ }
187
+
188
+ function dosTime(d) {
189
+ const time = (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2);
190
+ const date = ((Math.max(1980, d.getFullYear()) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
191
+ return { time, date };
192
+ }
193
+
194
+ /** entries [{ name, data: Buffer|string }] → a .zip Buffer (deflate). Opens in Finder, Explorer, unzip. */
195
+ export function zipFiles(entries, now = new Date()) {
196
+ const { time, date } = dosTime(now);
197
+ const locals = [];
198
+ const centrals = [];
199
+ let offset = 0;
200
+ for (const e of entries) {
201
+ const data = Buffer.isBuffer(e.data) ? e.data : Buffer.from(String(e.data), "utf8");
202
+ const packed = deflateRawSync(data);
203
+ const name = Buffer.from(e.name, "utf8");
204
+ const crc = crc32(data);
205
+ const local = Buffer.alloc(30);
206
+ local.writeUInt32LE(0x04034b50, 0);
207
+ local.writeUInt16LE(20, 4); // version needed
208
+ local.writeUInt16LE(0x0800, 6); // UTF-8 names
209
+ local.writeUInt16LE(8, 8); // deflate
210
+ local.writeUInt16LE(time, 10);
211
+ local.writeUInt16LE(date, 12);
212
+ local.writeUInt32LE(crc, 14);
213
+ local.writeUInt32LE(packed.length, 18);
214
+ local.writeUInt32LE(data.length, 22);
215
+ local.writeUInt16LE(name.length, 26);
216
+ local.writeUInt16LE(0, 28);
217
+ const central = Buffer.alloc(46);
218
+ central.writeUInt32LE(0x02014b50, 0);
219
+ central.writeUInt16LE(20, 4);
220
+ central.writeUInt16LE(20, 6);
221
+ central.writeUInt16LE(0x0800, 8);
222
+ central.writeUInt16LE(8, 10);
223
+ central.writeUInt16LE(time, 12);
224
+ central.writeUInt16LE(date, 14);
225
+ central.writeUInt32LE(crc, 16);
226
+ central.writeUInt32LE(packed.length, 20);
227
+ central.writeUInt32LE(data.length, 24);
228
+ central.writeUInt16LE(name.length, 28);
229
+ central.writeUInt32LE(offset, 42);
230
+ locals.push(local, name, packed);
231
+ centrals.push(central, name);
232
+ offset += local.length + name.length + packed.length;
233
+ }
234
+ const cd = Buffer.concat(centrals);
235
+ const end = Buffer.alloc(22);
236
+ end.writeUInt32LE(0x06054b50, 0);
237
+ end.writeUInt16LE(entries.length, 8);
238
+ end.writeUInt16LE(entries.length, 10);
239
+ end.writeUInt32LE(cd.length, 12);
240
+ end.writeUInt32LE(offset, 16);
241
+ return Buffer.concat([...locals, cd, end]);
242
+ }
243
+
244
+ const stamp = (d) => d.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
245
+
246
+ /**
247
+ * Build the bundle: the health report + the masked logs, zipped, saved owner-only in
248
+ * ~/.gonext/support/. Shrinks the per-file cut until the zip fits what the API takes.
249
+ * → { file, bytes, zip, files: [{ name, size, cut }], skipped, reportLines }
250
+ */
251
+ export async function prepareSupportBundle(opts = {}) {
252
+ const home = opts.home ?? homedir();
253
+ const now = opts.now ?? new Date();
254
+ const limits = { ...LIMITS, ...(opts.limits ?? {}) };
255
+ const reportLines = opts.reportLines ?? (await buildSupportReport({ ...opts, home, now }));
256
+ const sources = opts.sources ?? (await listSupportSources({ home, env: opts.env, tmp: opts.tmp, limits }));
257
+ let perFile = limits.perFile;
258
+ let built;
259
+ for (let round = 0; round < 6; round++) {
260
+ const { files, skipped } = await collectSupportFiles({ sources, limits: { ...limits, perFile } });
261
+ const readme = [
262
+ "gu support bundle",
263
+ `made ${now.toISOString()} on ${opts.platform ?? osPlatform()} ${release()} ${arch()}`,
264
+ "Every file is masked (tokens, keys, passwords) and cut to its end. Conversations are not included.",
265
+ "",
266
+ ...files.map((f) => `${f.name} (${humanSize(f.size)}${f.cut ? ", end only" : ""})`),
267
+ ...(skipped.length ? ["", "left out (size limit): " + skipped.join(", ")] : []),
268
+ ].join("\n");
269
+ const zip = zipFiles([
270
+ { name: "README.txt", data: readme + "\n" },
271
+ { name: "report.txt", data: reportLines.join("\n") + "\n" },
272
+ ...files.map((f) => ({ name: f.name, data: f.text })),
273
+ ], now);
274
+ built = { zip, files: files.map(({ name, size, cut }) => ({ name, size, cut })), skipped };
275
+ if (zip.length <= limits.zip || perFile < 16 * 1024) break;
276
+ perFile = Math.floor(perFile / 2);
277
+ }
278
+ const dir = join(home, ".gonext", SUPPORT_DIR);
279
+ await mkdir(dir, { recursive: true });
280
+ const file = join(dir, `gu-support-${stamp(now)}.zip`);
281
+ await writeFile(file, built.zip, { mode: 0o600 });
282
+ await pruneBundles(dir, KEEP_BUNDLES).catch(() => {});
283
+ return { file, bytes: built.zip.length, ...built, reportLines };
284
+ }
285
+
286
+ /** Keep the newest `keep` gu-support-*.zip; nothing else in the folder is touched. */
287
+ export async function pruneBundles(dir, keep = KEEP_BUNDLES) {
288
+ const old = (await newestFiles(dir, 1000, (n) => /^gu-support-[\d-]+\.zip$/.test(n))).slice(keep);
289
+ for (const f of old) await unlink(f.path).catch(() => {});
290
+ return old.length;
291
+ }
292
+
293
+ /** One row per file for the "this is what will be sent" list. */
294
+ export function bundleSummaryLines(bundle) {
295
+ const rows = [`report.txt (version, OS, service checks)`];
296
+ for (const f of bundle.files) rows.push(`${f.name} ${humanSize(f.size)}${f.cut ? " · end only" : ""}`);
297
+ if (bundle.skipped.length) rows.push(`(${bundle.skipped.length} older log${bundle.skipped.length === 1 ? "" : "s"} left out to keep it small)`);
298
+ return rows;
299
+ }
300
+
301
+ /** "darwin 25.6.0 arm64" — what the request row shows as OS. */
302
+ export const osLine = (platform = osPlatform()) => `${platform} ${release()} ${arch()}`;
303
+
304
+ /**
305
+ * Send the request, then the zip. → { ok: true, ref, uploaded, uploadWhy } | { ok: false, why }
306
+ * The request is what matters: a failed upload still leaves a reference the support team can see.
307
+ */
308
+ export async function sendSupportRequest({
309
+ base = supportApiBase(),
310
+ message,
311
+ email,
312
+ version = "",
313
+ os = osLine(),
314
+ zip = null,
315
+ fetchImpl = fetch,
316
+ timeoutMs = 20_000,
317
+ } = {}) {
318
+ let res;
319
+ try {
320
+ res = await fetchImpl(`${base}/api/support/requests`, {
321
+ method: "POST",
322
+ headers: { "content-type": "application/json" },
323
+ body: JSON.stringify({ topic: "gu-cli", source: "cli", message, email, guVersion: version, os, browser: "", page: "gu support", attach: Boolean(zip) }),
324
+ signal: AbortSignal.timeout(timeoutMs),
325
+ });
326
+ } catch (e) {
327
+ return { ok: false, why: `cannot reach ${base} (${netErrorDetail(e, timeoutMs)})` };
328
+ }
329
+ const body = await res.json().catch(() => ({}));
330
+ if (!res.ok || !body?.ref) return { ok: false, why: body?.error || `the server answered HTTP ${res.status}` };
331
+ const ref = String(body.ref);
332
+ if (!zip) return { ok: true, ref, uploaded: false, uploadWhy: "" };
333
+ if (!body.uploadKey) return { ok: true, ref, uploaded: false, uploadWhy: "this server does not take attachments yet" };
334
+ try {
335
+ const up = await fetchImpl(`${base}/api/support/requests/${encodeURIComponent(ref)}/logs`, {
336
+ method: "POST",
337
+ headers: { "content-type": "application/zip", "x-upload-key": String(body.uploadKey) },
338
+ body: zip,
339
+ signal: AbortSignal.timeout(timeoutMs * 3),
340
+ });
341
+ if (up.ok) return { ok: true, ref, uploaded: true, uploadWhy: "" };
342
+ const j = await up.json().catch(() => ({}));
343
+ return { ok: true, ref, uploaded: false, uploadWhy: j?.error || `HTTP ${up.status}` };
344
+ } catch (e) {
345
+ return { ok: true, ref, uploaded: false, uploadWhy: e?.name === "TimeoutError" ? "the upload timed out" : String(e?.message || e) };
346
+ }
347
+ }
348
+
349
+ /** The subject support replies keep (api replyEmail: "Re: [gu support GU-…] gu CLI / terminal"). */
350
+ export const supportSubject = (ref = "") => `[gu support${ref ? " " + ref : ""}] gu CLI / terminal`;
351
+
352
+ export function mailtoLink({ ref = "", message = "", file = "" } = {}) {
353
+ const body = [message || "(what went wrong)", "", file ? `Logs: please attach ${basename(file)} (saved at ${file}).` : ""].join("\n");
354
+ return `mailto:${SUPPORT_EMAIL}?subject=${encodeURIComponent(supportSubject(ref))}&body=${encodeURIComponent(body)}`;
355
+ }
356
+
357
+ /** When the request could not be sent, or the logs did not go with it: how to get them there. */
358
+ export function supportFallbackLines({ ref = "", file = "", why = "" } = {}) {
359
+ const lines = [];
360
+ if (why) lines.push(`Not sent: ${why}.`);
361
+ lines.push(`Email ${SUPPORT_EMAIL}, subject "${supportSubject(ref)}"`);
362
+ if (file) lines.push(`and attach ${file}`);
363
+ return lines;
364
+ }
365
+
366
+ /** Reads the account email from the saved token, if any — the default answer to "reply to". */
367
+ export async function defaultReplyEmail({ home = homedir(), env = process.env, token = null } = {}) {
368
+ try {
369
+ const ct = token ?? (await import("./cli-token.mjs"));
370
+ const st = await ct.readState(join(home, ".gonext"));
371
+ if (!st?.token) return "";
372
+ const check = ct.verifyCliToken(st.token, ct.publicKey(env));
373
+ return check.ok && ct.isEmail(check.payload?.email) ? String(check.payload.email).trim() : "";
374
+ } catch {
375
+ return "";
376
+ }
377
+ }
378
+
379
+ export const isEmail = (v) => /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(String(v ?? "").trim());
380
+
381
+ /** The API wants at least 10 characters; an empty answer still gets a useful message. */
382
+ export function supportMessage(typed) {
383
+ const t = String(typed ?? "").trim();
384
+ if (t.length >= 10) return t.slice(0, 4000);
385
+ return t ? `gu CLI problem: ${t}` : "gu CLI problem (no description given) — logs attached.";
386
+ }
387
+
388
+
389
+ export const bundleSize = (b) => humanSize(b.bytes);
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `gu support` in a plain shell (#220) — the same flow as /support in the gu terminal, for when
3
+ * the terminal itself will not start (a failed install, a crash on launch).
4
+ *
5
+ * gu support make the bundle, show what is in it, ask, send
6
+ * gu support -m "…" --email a@b.c --yes send without questions (scripts, a support call)
7
+ * gu support --no-send only make the bundle and say where to email it
8
+ * gu support --report print the old health report (support-report.mjs) and stop
9
+ *
10
+ * No terminal to ask on and no --yes = --no-send: never send what nobody agreed to.
11
+ */
12
+ import * as sb from "./support-bundle.mjs";
13
+
14
+ export function parseSupportArgs(args = []) {
15
+ const o = { report: false, send: null, yes: false, message: "", email: "" };
16
+ for (let i = 0; i < args.length; i++) {
17
+ const a = args[i];
18
+ if (a === "--report") o.report = true;
19
+ else if (a === "--no-send") o.send = false;
20
+ else if (a === "--send") o.send = true;
21
+ else if (a === "-y" || a === "--yes") o.yes = true;
22
+ else if ((a === "-m" || a === "--message") && i + 1 < args.length) o.message = args[++i];
23
+ else if (a.startsWith("--message=")) o.message = a.slice(10);
24
+ else if (a === "--email" && i + 1 < args.length) o.email = args[++i];
25
+ else if (a.startsWith("--email=")) o.email = a.slice(8);
26
+ }
27
+ return o;
28
+ }
29
+
30
+ /**
31
+ * → exit code. `ask(question) → answer` is the only input; `print(line)` the only output.
32
+ * `interactive` false = there is no one to ask.
33
+ */
34
+ export async function runSupportCommand({
35
+ args = [],
36
+ version = "",
37
+ ask = async () => "",
38
+ print = (l) => console.log(l),
39
+ interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
40
+ home,
41
+ env = process.env,
42
+ fetchImpl = fetch,
43
+ bundleOpts = {},
44
+ } = {}) {
45
+ const o = parseSupportArgs(args);
46
+ if (o.report) {
47
+ const { writeSupportReport } = await import("./support-report.mjs");
48
+ const { lines, file } = await writeSupportReport({ version, home, env, fetchImpl, ...bundleOpts });
49
+ for (const l of lines) print(l);
50
+ print("");
51
+ print(` Saved to ${file}. Send it with: gu support`);
52
+ return 0;
53
+ }
54
+
55
+ print(" Collecting gu's logs (secrets are masked)…");
56
+ const bundle = await sb.prepareSupportBundle({ version, home, env, fetchImpl, ...bundleOpts });
57
+ print("");
58
+ print(` Support bundle: ${bundle.file} (${sb.bundleSize(bundle)})`);
59
+ for (const row of sb.bundleSummaryLines(bundle)) print(` ${row}`);
60
+ print("");
61
+
62
+ const canAsk = interactive && !o.yes;
63
+ const willSend = o.send === false ? false : o.yes || o.send === true || canAsk;
64
+ if (!willSend) {
65
+ for (const l of sb.supportFallbackLines({ file: bundle.file })) print(" " + l);
66
+ if (!interactive && o.send !== false) print(" (or run `gu support --yes -m \"what went wrong\" --email you@example.com` to send it from here)");
67
+ return 0;
68
+ }
69
+
70
+ let message = o.message;
71
+ let email = o.email || (await sb.defaultReplyEmail({ home, env }));
72
+ if (canAsk) {
73
+ if (!message) message = await ask(" What went wrong? (one or two sentences, Enter to skip): ");
74
+ const typed = (await ask(` Email for the answer${email ? ` [${email}]` : ""}: `)).trim();
75
+ if (typed) email = typed;
76
+ }
77
+ if (!sb.isEmail(email)) {
78
+ print(" No reply email — the support team could not answer you.");
79
+ for (const l of sb.supportFallbackLines({ file: bundle.file })) print(" " + l);
80
+ return 1;
81
+ }
82
+ if (canAsk) {
83
+ const go = (await ask(` Send this to the gu support team (${sb.SUPPORT_EMAIL})? [Y/n] `)).trim();
84
+ if (!/^(|y|yes)$/i.test(go)) {
85
+ print(" Not sent. The bundle stays where it is:");
86
+ for (const l of sb.supportFallbackLines({ file: bundle.file })) print(" " + l);
87
+ return 0;
88
+ }
89
+ }
90
+
91
+ print(" Sending…");
92
+ const r = await sb.sendSupportRequest({ base: sb.supportApiBase(env), message: sb.supportMessage(message), email, version, zip: bundle.zip, fetchImpl });
93
+ print("");
94
+ if (!r.ok) {
95
+ for (const l of sb.supportFallbackLines({ file: bundle.file, why: r.why })) print(" " + l);
96
+ return 1;
97
+ }
98
+ print(` ✓ Sent. Your reference is ${r.ref} — the answer goes to ${email}.`);
99
+ if (!r.uploaded) {
100
+ print(` The logs did not go with it (${r.uploadWhy}).`);
101
+ for (const l of sb.supportFallbackLines({ ref: r.ref, file: bundle.file })) print(" " + l);
102
+ return 0;
103
+ }
104
+ print(` Logs attached. To add anything, email ${sb.SUPPORT_EMAIL} with ${r.ref} in the subject.`);
105
+ return 0;
106
+ }
@@ -591,6 +591,44 @@ export function pasteHint({ platform = process.platform, images = false } = {})
591
591
  /** At least this many spaces between the mode text and the hint, or the hint is left out. */
592
592
  const HINT_MIN_GAP = 3;
593
593
 
594
+ /** The footer's always-there help link (#220): clicking it runs /support. Plain text; painted underlined. */
595
+ export const SUPPORT_LINK_TEXT = "? support";
596
+ /** Spaces kept between a row's own text and the link, or the link goes to the next row / is left out. */
597
+ const LINK_MIN_GAP = 3;
598
+
599
+ /**
600
+ * Put a link at the RIGHT end of the first footer row that has room for it — the identity row, else
601
+ * the token row. Never at the cost of that row's own text: the link is dropped whole instead
602
+ * (every footer row stays one physical row, #109/#132/#138).
603
+ * → { row: "identity" | "token", from, to } (0-based columns, `to` exclusive) or null.
604
+ */
605
+ export function footerLinkPlacement({ width, gutter = " ", identity = "", token = "", link = SUPPORT_LINK_TEXT }) {
606
+ if (!link) return null;
607
+ const w = Math.max(20, width || 80);
608
+ for (const [row, text] of [["identity", identity], ["token", token]]) {
609
+ if (row === "identity" && !text) continue;
610
+ const used = visibleLen(gutter + text);
611
+ const from = w - 1 - visibleLen(link) - 1; // one column of air before the edge
612
+ if (used + LINK_MIN_GAP <= from) return { row, from, to: from + visibleLen(link) };
613
+ }
614
+ return null;
615
+ }
616
+
617
+ /** buildFooterRows, plus where the help link landed: → { rows, link: { rowIndex, from, to } | null }. */
618
+ export function buildFooter({ link = "", linkPaint = (x) => x, ...args }) {
619
+ const place = footerLinkPlacement({ width: args.width, gutter: args.gutter, identity: args.identity, token: args.token, link });
620
+ const rows = buildFooterRows(args);
621
+ if (!place) return { rows, link: null };
622
+ // The identity row sits after the rule (and the rag row, when there is one).
623
+ const rowIndex = place.row === "identity" ? 1 + (args.rag ? 1 : 0) : 1 + (args.rag ? 1 : 0) + (args.identity ? 1 : 0);
624
+ const w = Math.max(20, args.width || 80);
625
+ const gutter = args.gutter ?? " ";
626
+ const left = clipVisible(gutter + (place.row === "identity" ? args.identity : args.token), place.from);
627
+ rows[rowIndex] = left + " ".repeat(Math.max(1, place.from - visibleLen(left))) + linkPaint(link);
628
+ void w;
629
+ return { rows, link: { rowIndex, from: place.from, to: place.to } };
630
+ }
631
+
594
632
  export function buildFooterRows({
595
633
  width,
596
634
  gutter = " ",
@@ -2236,7 +2274,9 @@ export function scaffoldStartupNotice({ local = null, refresh = null, private: p
2236
2274
  const url = (/(https?:\/\/[^\s)]+\/account)/.exec(why)?.[1] ?? "https://gu.getmarsic.cc/account").replace(/^https?:\/\//, "");
2237
2275
  return {
2238
2276
  kind: "link",
2239
- text: "This computer isn't linked to your gu account yet.",
2277
+ // ONE line (#222): gu asks for the token at the first message anyway, and that question has
2278
+ // the steps behind "?". `steps` stays for a caller that wants them (`gu token`'s help).
2279
+ text: "This computer isn't linked to your gu account yet — gu asks for a token.",
2240
2280
  steps: [
2241
2281
  `1. Copy your gu CLI token from ${url}`,
2242
2282
  "2. Run: gu token <your token> (or just ask something — gu will ask for it)",