@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 +352 -0
- package/clear-state.mjs +4 -0
- package/cli-token.mjs +25 -2
- package/client-expert-api.mjs +249 -6
- package/client-expert-run.mjs +39 -4
- package/client-expert-setup.mjs +21 -1
- package/first-run-steps.mjs +26 -0
- package/gu-cli.mjs +38 -0
- package/gu-repl.mjs +219 -7
- package/install-path.mjs +23 -1
- package/model-doctor.mjs +33 -4
- package/node-version.mjs +50 -2
- package/package.json +2 -1
- package/platform-tools.mjs +49 -3
- package/preset-apply.mjs +18 -8
- package/presets-transfer.mjs +44 -3
- package/settings-transfer.mjs +14 -1
- package/terminal-layout.mjs +31 -2
package/client-expert-api.mjs
CHANGED
|
@@ -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
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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. */
|
package/client-expert-run.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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 });
|
package/client-expert-setup.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/first-run-steps.mjs
CHANGED
|
@@ -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
|
+
}
|
package/gu-cli.mjs
CHANGED
|
@@ -134,6 +134,8 @@ Usage:
|
|
|
134
134
|
gu-cli workspace revert [runId] # undo an agent run's file edits from its backups
|
|
135
135
|
gu-cli mongo migrate-from-docker # macOS: move a Docker MongoDB to native (Homebrew)
|
|
136
136
|
gu-cli clear [--scratch|--keep-history] [--yes] # back to a fresh machine (asks first)
|
|
137
|
+
gu-cli fix-path # macOS/Linux: put npm's global bin folder on PATH
|
|
138
|
+
# (what the install does, if npm skipped it)
|
|
137
139
|
gu-cli fix-powershell [--check] # Windows: drop PowerShell's built-in \`gu\` alias
|
|
138
140
|
# (it is Get-Unique and shadows this CLI)
|
|
139
141
|
gu-cli rag plan [path] # is RAG worth it here? files/chars/chunks → estimated index time
|
|
@@ -1054,6 +1056,42 @@ if (args[0] === "token-usage" || args[0] === "tokens") {
|
|
|
1054
1056
|
process.exit(0);
|
|
1055
1057
|
}
|
|
1056
1058
|
|
|
1059
|
+
// `gu-cli fix-path` — put npm's global bin folder on PATH (task #333). The same repair the
|
|
1060
|
+
// postinstall does, for when npm did not run it: run through install-path.mjs itself, with the
|
|
1061
|
+
// prefix worked out from where this file is installed, so there is one implementation.
|
|
1062
|
+
if (args[0] === "fix-path") {
|
|
1063
|
+
if (process.platform === "win32") {
|
|
1064
|
+
console.log(" Nothing to fix here — on Windows npm's folder is on PATH through the registry.");
|
|
1065
|
+
console.log(" If `gu` is not found in PowerShell: gu-cli fix-powershell");
|
|
1066
|
+
process.exit(0);
|
|
1067
|
+
}
|
|
1068
|
+
const { globalPrefixOf } = await import("./install-path.mjs");
|
|
1069
|
+
const { fileURLToPath } = await import("node:url");
|
|
1070
|
+
const { dirname, join } = await import("node:path");
|
|
1071
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1072
|
+
const prefix = globalPrefixOf(here);
|
|
1073
|
+
if (!prefix) {
|
|
1074
|
+
console.log(` This gu is not a global npm install (${here}) — there is no PATH to fix.`);
|
|
1075
|
+
process.exit(0);
|
|
1076
|
+
}
|
|
1077
|
+
const binDir = join(prefix, "bin");
|
|
1078
|
+
if ((process.env.PATH || "").split(":").includes(binDir)) {
|
|
1079
|
+
console.log(` Nothing to fix — ${binDir} is already on PATH.`);
|
|
1080
|
+
process.exit(0);
|
|
1081
|
+
}
|
|
1082
|
+
const { spawnSync } = await import("node:child_process");
|
|
1083
|
+
const { CI: _ci, ...env } = process.env;
|
|
1084
|
+
const r = spawnSync(process.execPath, [join(here, "install-path.mjs")], {
|
|
1085
|
+
encoding: "utf8",
|
|
1086
|
+
env: { ...env, npm_config_prefix: prefix, npm_config_global: "true" },
|
|
1087
|
+
});
|
|
1088
|
+
const said = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
|
|
1089
|
+
// It prints only when it wrote something. Silence means the line is already in the profile
|
|
1090
|
+
// and this terminal simply predates it.
|
|
1091
|
+
console.log(said || ` Your shell profile already adds ${binDir} to PATH — open a new terminal.`);
|
|
1092
|
+
process.exit(r.status ?? 0);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1057
1095
|
// `gu-cli fix-powershell` — drop PowerShell's built-in `gu` alias so the command works there.
|
|
1058
1096
|
// ASKS FIRST and never runs as part of an install: a PowerShell profile is the user's file, and
|
|
1059
1097
|
// a CLI that edits it unannounced is one that surprises people. --check only reports.
|