@tiens.nguyen/gu-cli 1.0.792 → 1.0.794

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 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. */
@@ -27,9 +27,64 @@ import { readFileSync } from "node:fs";
27
27
  import { homedir } from "node:os";
28
28
  import { join } from "node:path";
29
29
 
30
- /** Where the local API listens. 4100 keeps clear of the hosted API and of Vite's 5173. */
31
- export const LOCAL_API_PORT = Number(process.env.GONEXT_LOCAL_API_PORT ?? "4100") || 4100;
32
- export const LOCAL_API_BASE = `http://127.0.0.1:${LOCAL_API_PORT}`;
30
+ /** Where the local API listens by default. 4100 keeps clear of the hosted API and of Vite's 5173. */
31
+ export const DEFAULT_LOCAL_API_PORT = 4100;
32
+ /** The port setup had to move to, when 4100 belonged to another program (task #337). */
33
+ export const API_PORT_FILE = join(homedir(), ".gonext", "local-api.port");
34
+
35
+ const validPort = (v) => {
36
+ const n = Number.parseInt(String(v ?? "").trim(), 10);
37
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
38
+ };
39
+
40
+ /**
41
+ * WHICH PORT THE LOCAL API USES, and why (task #337).
42
+ *
43
+ * env — GONEXT_LOCAL_API_PORT: someone chose it; gu never moves away from it on its own.
44
+ * saved — ~/.gonext/local-api.port: 4100 was taken by ANOTHER PROGRAM when the API was first
45
+ * started, and gu moved. Every later process — `gu`, `gu-cli api …`, the worker's
46
+ * terminal — reads the same file, so they all follow the move.
47
+ * default — 4100. A machine where 4100 was free has no file and behaves exactly as before.
48
+ *
49
+ * Pure: the file's text is passed in.
50
+ */
51
+ export function resolveApiPort({ env = process.env, saved = "" } = {}) {
52
+ const fromEnv = validPort(env.GONEXT_LOCAL_API_PORT);
53
+ if (fromEnv) return { port: fromEnv, source: "env" };
54
+ const fromFile = validPort(saved);
55
+ if (fromFile) return { port: fromFile, source: "saved" };
56
+ return { port: DEFAULT_LOCAL_API_PORT, source: "default" };
57
+ }
58
+
59
+ function readSavedPort() {
60
+ try {
61
+ return readFileSync(API_PORT_FILE, "utf8");
62
+ } catch {
63
+ return "";
64
+ }
65
+ }
66
+
67
+ /**
68
+ * LIVE BINDINGS, not constants: thirty readers across gu take the port from these two names. When
69
+ * setup moves the API in THIS process they must all see the new port at once — an ES module's
70
+ * exported `let` does exactly that for every importer, and a default parameter reads it at call
71
+ * time. Other processes get it from the file when they start.
72
+ */
73
+ export let LOCAL_API_PORT = resolveApiPort({ saved: readSavedPort() }).port;
74
+ export let LOCAL_API_BASE = `http://127.0.0.1:${LOCAL_API_PORT}`;
75
+
76
+ /** Use `port` from now on in this process (and, with save, in every later one). */
77
+ export async function useApiPort(port, { save = true } = {}) {
78
+ const n = validPort(port);
79
+ if (!n) throw new Error(`not a port: ${port}`);
80
+ LOCAL_API_PORT = n;
81
+ LOCAL_API_BASE = `http://127.0.0.1:${n}`;
82
+ if (save) {
83
+ await mkdir(join(homedir(), ".gonext"), { recursive: true });
84
+ await writeFile(API_PORT_FILE, `${n}\n`, "utf8");
85
+ }
86
+ return n;
87
+ }
33
88
  export const LOCAL_MONGO_URI = process.env.GONEXT_LOCAL_MONGO_URI || "mongodb://127.0.0.1:27017";
34
89
  export const LOCAL_DB_NAME = process.env.GONEXT_LOCAL_DB_NAME || "gu";
35
90
 
@@ -189,7 +244,39 @@ export function npmInstallCommand(platform = process.platform) {
189
244
  };
190
245
  }
191
246
 
192
- export function installApiDeps(apiDir, { onLine } = {}) {
247
+ /**
248
+ * A DROPPED CONNECTION IS NOT A FAILED INSTALL (task #333). The Windows 11 test box fetched
249
+ * single packages in 30 s and then got "npm error network read ECONNRESET" — twice in a row, on
250
+ * the first install, which then stopped with "Run `gu` again". A rerun went through. npm keeps
251
+ * what it already downloaded, so trying again in place is cheap and is what the user would do.
252
+ * Only NETWORK failures are retried: a real error (a bad package, a full disk) fails the same way
253
+ * every time, and three copies of it would only bury it.
254
+ */
255
+ export function isNetworkNpmError(text) {
256
+ return /\b(ECONNRESET|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|ENETUNREACH|EPIPE|ERR_SOCKET_TIMEOUT)\b|socket hang up|network (read|request)/i.test(
257
+ String(text ?? "")
258
+ );
259
+ }
260
+
261
+ /** npm on Windows exits with a NEGATIVE errno that arrives unsigned: 4294963219 is -4077. */
262
+ export function signedExitCode(code) {
263
+ return typeof code === "number" && code > 0x7fffffff ? code - 0x100000000 : code;
264
+ }
265
+
266
+ export const API_DEPS_ATTEMPTS = 3;
267
+
268
+ export async function installApiDeps(apiDir, { onLine, attempts = API_DEPS_ATTEMPTS, once = installApiDepsOnce } = {}) {
269
+ for (let i = 1; ; i++) {
270
+ try {
271
+ return await once(apiDir, { onLine });
272
+ } catch (e) {
273
+ if (i >= attempts || !isNetworkNpmError(`${e?.output ?? ""}\n${e?.message ?? ""}`)) throw e;
274
+ onLine?.(`network error — trying again (${i + 1} of ${attempts}); what was downloaded is kept`);
275
+ }
276
+ }
277
+ }
278
+
279
+ function installApiDepsOnce(apiDir, { onLine } = {}) {
193
280
  const { cmd, args, shell, line } = npmInstallCommand();
194
281
  return new Promise((resolve, reject) => {
195
282
  let child;
@@ -212,7 +299,11 @@ export function installApiDeps(apiDir, { onLine } = {}) {
212
299
  });
213
300
  child.on("error", (e) => reject(new Error(`could not run ${cmd} in ${apiDir}: ${e.message}`)));
214
301
  child.on("exit", (code) =>
215
- code === 0 ? resolve() : reject(new Error(`npm install failed (exit ${code}) ${err.slice(-400)}`))
302
+ code === 0
303
+ ? resolve()
304
+ : // `output` keeps ALL of stderr: the 400-char tail in the message had already lost the
305
+ // "ECONNRESET" line on the real Windows failure, so the retry never saw it.
306
+ reject(Object.assign(new Error(`npm install failed (exit ${signedExitCode(code)}) ${err.slice(-400)}`), { output: err }))
216
307
  );
217
308
  });
218
309
  }
@@ -257,6 +348,150 @@ export async function seedDatabase({
257
348
  }
258
349
  }
259
350
 
351
+ /**
352
+ * WHO IS ON A PORT → "free" | "gu" | "other" (task #337).
353
+ *
354
+ * "gu" is anything that answers /api/health the way this API does — usable or not, this home's
355
+ * or another's: it is our program and moving away from it would start a second one. "other" is a
356
+ * port that accepts a connection and is not that: someone else's server. Only "other" is a
357
+ * reason to move.
358
+ */
359
+ export async function whoHoldsPort(port, { fetchImpl = fetch, connects = tcpConnects } = {}) {
360
+ try {
361
+ const res = await fetchImpl(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(2500) });
362
+ const body = await res.json().catch(() => null);
363
+ if (body && typeof body === "object" && ("dataBackend" in body || "persistence" in body)) return "gu";
364
+ return "other";
365
+ } catch {
366
+ return (await connects(port)) ? "other" : "free";
367
+ }
368
+ }
369
+
370
+ /** Does anything accept a TCP connection on 127.0.0.1:port? */
371
+ async function tcpConnects(port, timeoutMs = 1500) {
372
+ const net = await import("node:net");
373
+ return new Promise((resolve) => {
374
+ const sock = net.connect({ host: "127.0.0.1", port });
375
+ const done = (v) => {
376
+ sock.destroy();
377
+ resolve(v);
378
+ };
379
+ sock.setTimeout(timeoutMs, () => done(false));
380
+ sock.once("connect", () => done(true));
381
+ sock.once("error", () => done(false));
382
+ });
383
+ }
384
+
385
+ /** Can a server be opened on `port` right now? The only honest test of "free". */
386
+ async function canListen(port) {
387
+ const net = await import("node:net");
388
+ return new Promise((resolve) => {
389
+ const srv = net.createServer();
390
+ srv.once("error", () => resolve(false));
391
+ srv.listen(port, () => srv.close(() => resolve(true)));
392
+ });
393
+ }
394
+
395
+ /** How many ports after the taken one are tried before asking the system for any free one. */
396
+ export const API_PORT_TRIES = 10;
397
+
398
+ /** The first port after `taken` that a server can open — else one the system picks. */
399
+ export async function findFreePort(taken, { isFree = canListen, tries = API_PORT_TRIES, anyFree = systemFreePort } = {}) {
400
+ for (let p = taken + 1; p <= taken + tries && p < 65536; p++) {
401
+ if (await isFree(p)) return p;
402
+ }
403
+ return anyFree();
404
+ }
405
+
406
+ async function systemFreePort() {
407
+ const net = await import("node:net");
408
+ return new Promise((resolve, reject) => {
409
+ const srv = net.createServer();
410
+ srv.once("error", reject);
411
+ srv.listen(0, () => {
412
+ const { port } = srv.address();
413
+ srv.close(() => resolve(port));
414
+ });
415
+ });
416
+ }
417
+
418
+ /**
419
+ * What to do about the port before starting the API. Pure.
420
+ * use — it is free, or gu's own API is already there.
421
+ * move — another program has it and nobody insisted on this port.
422
+ * refuse — another program has it and GONEXT_LOCAL_API_PORT asked for exactly this port:
423
+ * an explicit choice is not overridden, it is reported.
424
+ */
425
+ export function portDecision({ holder, source }) {
426
+ if (holder !== "other") return "use";
427
+ return source === "env" ? "refuse" : "move";
428
+ }
429
+
430
+ /**
431
+ * MAKE SURE THE API HAS A PORT IT CAN OPEN, moving off one that belongs to another program.
432
+ *
433
+ * The first install used to end in "The API started but is not usable: not up yet" when 4100
434
+ * was somebody else's — the API could not bind, and nothing said why. Now gu says which program
435
+ * has the port, takes the next free one, REMEMBERS it (API_PORT_FILE), and re-points worker.env
436
+ * when there is one, so the terminal and the worker follow.
437
+ *
438
+ * → { moved, port, from?, holder? }. Never throws: a port problem must not hide the real error.
439
+ */
440
+ export async function settleApiPort({
441
+ log = (l) => console.log(l),
442
+ env = process.env,
443
+ holds = whoHoldsPort,
444
+ nameOf = null,
445
+ freePort = findFreePort,
446
+ } = {}) {
447
+ const from = LOCAL_API_PORT;
448
+ try {
449
+ const source = resolveApiPort({ env, saved: readSavedPort() }).source;
450
+ const holder = await holds(from);
451
+ const action = portDecision({ holder, source });
452
+ if (action === "use") return { moved: false, port: from };
453
+ // The program's name when the system gives it; on Windows it often does not (seen live),
454
+ // and "another program (another program)" is not a sentence.
455
+ const name = (await (nameOf ?? (await import("./mongo-native.mjs")).portHolder)(from).catch(() => null)) || "";
456
+ const who = name || "another program";
457
+ if (action === "refuse") {
458
+ log(` Port ${from} (set by GONEXT_LOCAL_API_PORT) is in use by ${who}.`);
459
+ log(" Stop that program, or choose another port, then run gu again.");
460
+ return { moved: false, port: from, holder: who, refused: true };
461
+ }
462
+ const to = await freePort(from);
463
+ await useApiPort(to);
464
+ log(` Port ${from} is used by another program${name ? ` (${name})` : ""} — gu's API uses ${to}.`);
465
+ // An install that was already working: its terminal and its worker read worker.env. The
466
+ // worker read it ONCE, at start, so it is restarted — or it would poll the old port forever.
467
+ if (await repointWorkerEnv(from, to)) {
468
+ try {
469
+ const ctl = await import("./daemon-control.mjs");
470
+ if ((await ctl.daemonStatus()).running) {
471
+ await ctl.stopDaemon();
472
+ await ctl.startDaemon();
473
+ log(" · restarted the background worker so it uses the new port");
474
+ }
475
+ } catch {
476
+ log(" · restart the background worker to pick up the new port: gu-cli restart");
477
+ }
478
+ }
479
+ return { moved: true, port: to, from, holder: who };
480
+ } catch {
481
+ return { moved: false, port: from };
482
+ }
483
+ }
484
+
485
+ /** worker.env pointed at the local API on the old port → point it at the new one. */
486
+ async function repointWorkerEnv(from, to) {
487
+ const path = join(GONEXT_DIR, "worker.env");
488
+ const text = await readFile(path, "utf8").catch(() => "");
489
+ const re = new RegExp(`^(GONEXT_API_BASE=https?://(?:127\\.0\\.0\\.1|localhost)):${from}\\b`, "m");
490
+ if (!re.test(text)) return false;
491
+ await writeFile(path, text.replace(re, `$1:${to}`));
492
+ return true;
493
+ }
494
+
260
495
  /**
261
496
  * Start the API so it OUTLIVES this terminal — including an SSH session.
262
497
  *
@@ -271,8 +506,16 @@ export async function startLocalApi({
271
506
  port = LOCAL_API_PORT,
272
507
  mongoUri = localMongoUri(),
273
508
  dbName = LOCAL_DB_NAME,
509
+ log = (l) => console.log(l),
274
510
  } = {}) {
275
511
  await mkdir(GONEXT_DIR, { recursive: true });
512
+ // Every start goes through here — setup, `gu-cli api start|restart|upgrade`, doctor, the
513
+ // terminal's own restart — so this is where a port taken by another program is noticed.
514
+ // A caller that named a port keeps it.
515
+ if (port === LOCAL_API_PORT) {
516
+ const settled = await settleApiPort({ log });
517
+ port = settled.port;
518
+ }
276
519
  const res = await spawnSurviving({
277
520
  cmd: process.execPath,
278
521
  args: ["index.js"],
@@ -286,7 +529,7 @@ export async function startLocalApi({
286
529
  // we spawned. Recorded after the server is up, by the caller's waitForApi.
287
530
  const pid = res.pid ?? null;
288
531
  if (pid) await writeFile(API_PID_FILE, String(pid) + "\n").catch(() => {});
289
- return { pid, how: res.how };
532
+ return { pid, how: res.how, port };
290
533
  }
291
534
 
292
535
  /** Record whoever is serving the port, so `api stop` has something to stop. */
@@ -20,11 +20,13 @@ import { homedir } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import { promisify } from "node:util";
22
22
  import { createHash } from "node:crypto";
23
+ import { localModeNodeProblem } from "./node-version.mjs";
23
24
  import {
24
25
  apiDepsInstalled,
25
26
  installApiDeps,
26
27
  seedDatabase,
27
28
  startLocalApi,
29
+ settleApiPort,
28
30
  waitForApi,
29
31
  probeLocalApi,
30
32
  writeWorkerEnv,
@@ -515,7 +517,7 @@ export async function preflightLocalMongo({
515
517
  log("\n " + red("gu: MongoDB is not answering") + dim(` at ${host}:${port}`) +
516
518
  red(mongo.installed || mongo.silentPort || !local ? "." : " — it is not installed on this machine."));
517
519
  log(dim(" Local mode keeps your conversations, models and keys in that database, so"));
518
- log(dim(" nothing can start without it. The API on :4100 is fine — it is waiting on this."));
520
+ log(dim(` nothing can start without it. The API on :${LOCAL_API_PORT} is fine — it is waiting on this.`));
519
521
  if (!local) {
520
522
  log(dim(`\n That database is on another machine (${host}). Start it there, then run gu again.\n`));
521
523
  return { ok: false, fixed: false, installed: false };
@@ -925,6 +927,16 @@ export function markInterruptedOnExit(getState, stepId) {
925
927
  };
926
928
  }
927
929
 
930
+ /** The last few non-empty lines of the API log, clipped to the screen — [] when there is none. */
931
+ export async function apiLogTail(file, { lines = 3, width = 72 } = {}) {
932
+ try {
933
+ const all = (await readFile(file, "utf8")).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
934
+ return all.slice(-lines).map((l) => (l.length > width ? l.slice(0, width - 1) + "…" : l));
935
+ } catch {
936
+ return [];
937
+ }
938
+ }
939
+
928
940
  export async function runClientExpertSetup({
929
941
  ask,
930
942
  // #301/#300: a MASKED line reader (API keys, the MongoDB password). Absent → no key is asked.
@@ -940,6 +952,15 @@ export async function runClientExpertSetup({
940
952
  return { complete: false, stoppedAt: "gate" };
941
953
  }
942
954
 
955
+ // BEFORE ANYTHING IS INSTALLED (task #333): the API needs a newer Node than gu does. Not part
956
+ // of the gate on purpose — a gate "no" makes the first run settle on Cloud mode for good,
957
+ // while the fix here is a Node upgrade, after which `gu` must resume Local setup.
958
+ const nodeProblem = localModeNodeProblem(process.version, machine.platform);
959
+ if (nodeProblem) {
960
+ console.log("\n " + red("✗ ") + nodeProblem + "\n");
961
+ return { complete: false, stoppedAt: "node" };
962
+ }
963
+
943
964
  let state = await loadState();
944
965
  const mongo = await probeMongo();
945
966
  const probe = {
@@ -1165,13 +1186,27 @@ export async function runClientExpertSetup({
1165
1186
  // --- the API itself -------------------------------------------------------------------
1166
1187
  let health = await probeLocalApi(LOCAL_API_PORT);
1167
1188
  if (!health.ok) {
1168
- console.log("\n " + bold(`Starting the local API on port ${LOCAL_API_PORT}`));
1169
- await startLocalApi({ apiDir, mongoUri });
1170
- health = await waitForApi({ port: LOCAL_API_PORT });
1189
+ // THE PORT FIRST (task #337): 4100 may belong to another program on this machine. Settled
1190
+ // before the "Starting…" line so that line names the port the API will really use —
1191
+ // LOCAL_API_PORT and LOCAL_API_BASE are live and change with it.
1192
+ console.log("");
1193
+ const settled = await settleApiPort({ log: (l) => console.log(l) });
1194
+ console.log(" " + bold(`Starting the local API on port ${LOCAL_API_PORT}`));
1195
+ if (!settled.refused) {
1196
+ await startLocalApi({ apiDir, mongoUri });
1197
+ health = await waitForApi({ port: LOCAL_API_PORT });
1198
+ }
1171
1199
  }
1172
1200
  if (!health.ok) {
1173
1201
  // apiIsUsable, not a 200: a wrong DATA_BACKEND answers 200 and stores nothing.
1174
1202
  console.log("\n " + red("✗ ") + `The API started but is not usable: ${health.why}`);
1203
+ // WHAT THE API SAID, not only where it said it (task #333): on Windows the whole log was
1204
+ // one line, "Access is denied.", and the screen showed only a path to go and open.
1205
+ const said = await apiLogTail(API_LOG_FILE);
1206
+ if (said.length) {
1207
+ console.log(dim(" The last thing its log says:"));
1208
+ for (const l of said) console.log(dim(" " + l));
1209
+ }
1175
1210
  console.log(dim(` Its log is ${API_LOG_FILE}`));
1176
1211
  console.log(dim(" Run `gu` again to retry — nothing else has been changed.\n"));
1177
1212
  state = recordStep(state, "api-running", "failed", { reason: health.why });
@@ -834,12 +834,30 @@ export function validateLicenseVerdict({ licenseToken, expertAvailable }, result
834
834
  * validateLicenseToken by default; injectable for tests. Never throws — a validation that errors
835
835
  * is treated as unverifiable, not a crash.
836
836
  */
837
+ /**
838
+ * What to say when a license token could not be CONFIRMED (#102) — it is still used: the licence
839
+ * is soft, and an outage never blocks a paid user. But "couldn't reach the license server" was
840
+ * printed when the server had been reached and answered HTTP 502; say which it was.
841
+ * `detail` is validateLicenseToken's: "http-NNN" | "timeout" | "network" | "bad-response" | …
842
+ */
843
+ export function licenseUnverifiedLine(detail) {
844
+ const http = /^http-(\d{3})$/.exec(String(detail ?? ""));
845
+ if (http) return `The license server answered with an error (HTTP ${http[1]}), so the token was not confirmed — showing the menu anyway.`;
846
+ if (detail === "bad-response") return "The license server sent an unexpected answer, so the token was not confirmed — showing the menu anyway.";
847
+ return "Couldn't reach the license server to confirm the token — showing the menu anyway.";
848
+ }
849
+
837
850
  export async function promptForLicenseToken(ask, initialToken, {
838
851
  validate = validateLicenseToken,
839
852
  validateOpts = {},
840
853
  warn = (msg) => console.log(msg),
841
854
  maxTries = 5,
855
+ // A MASKED reader (#100). A license token is a credential — whoever holds one can renew it —
856
+ // and the plain reader echoed it into the terminal and its scrollback (and into a test log).
857
+ // The first run and the REPL both have one; without it (a caller that has none) it is `ask`.
858
+ askSecret = null,
842
859
  } = {}) {
860
+ const read = typeof askSecret === "function" ? askSecret : ask;
843
861
  let token = String(initialToken ?? "").trim();
844
862
  for (let attempt = 0; attempt < maxTries; attempt += 1) {
845
863
  // Format gate first — a malformed token is warned about WITHOUT a network round-trip.
@@ -851,7 +869,9 @@ export async function promptForLicenseToken(ask, initialToken, {
851
869
  if (result.ok || result.reason === "unverified") return { token, result, declined: false };
852
870
  warn(` That license token was not accepted (${result.detail ?? result.reason}).`);
853
871
  }
854
- const again = String(await ask(" Paste a pro license token to choose a hosted mode, or press Enter to keep this machine private: ") ?? "").trim();
872
+ // Esc in the masked reader resolves with a symbol (SKIP_ALL): that is "no token", like Enter.
873
+ const typed = await read(" Paste a pro license token to choose a hosted mode, or press Enter to keep this machine private: ");
874
+ const again = typeof typed === "string" ? typed.trim() : "";
855
875
  if (!again) return { token: "", result: null, declined: true };
856
876
  token = again;
857
877
  }
@@ -169,3 +169,29 @@ export function truncateDetail(detail, max = 160) {
169
169
  if (oneLine.length <= max) return oneLine;
170
170
  return "…" + oneLine.slice(oneLine.length - (max - 1));
171
171
  }
172
+
173
+ /**
174
+ * Ctrl+C or Ctrl+D while a first-run question is waiting (task #333).
175
+ *
176
+ * `interrupt` (Ctrl+C): when setup has a step in progress it registered a SIGINT listener on the
177
+ * process (markInterruptedOnExit) that records where it stopped and exits — hand over to it.
178
+ * Otherwise say so and exit 130 here. `eof` (Ctrl+D): input is over; nothing more can be asked.
179
+ *
180
+ * Everything it touches is passed in, so the branches are tested without a terminal.
181
+ */
182
+ export function stopFirstRun({ reason, hasProcessListener, emitSigint, log, exit, style = {} }) {
183
+ const yellow = style.yellow ?? ((t) => t);
184
+ const dim = style.dim ?? ((t) => t);
185
+ if (reason === "interrupt") {
186
+ if (hasProcessListener) {
187
+ emitSigint();
188
+ return;
189
+ }
190
+ log("\n\n " + yellow("Stopped.") + dim(" Run `gu` again and setup continues from this step.\n"));
191
+ exit(130); // 128 + SIGINT
192
+ return;
193
+ }
194
+ log("\n\n " + dim("input ended (Ctrl+D) — nothing further will be asked or started."));
195
+ log(" " + dim("Run `gu` again and setup continues from this step.\n"));
196
+ exit(0);
197
+ }