@tiens.nguyen/gu-cli 1.0.792 → 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-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 +67 -6
- package/install-path.mjs +23 -1
- package/model-doctor.mjs +33 -4
- package/node-version.mjs +50 -2
- package/package.json +1 -1
- package/platform-tools.mjs +49 -3
- package/terminal-layout.mjs +31 -2
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. */
|
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.
|
package/gu-repl.mjs
CHANGED
|
@@ -110,6 +110,8 @@ import {
|
|
|
110
110
|
workspaceScopeWarning,
|
|
111
111
|
isLocalApiBase,
|
|
112
112
|
modeRow,
|
|
113
|
+
nothingToAnswerWith,
|
|
114
|
+
NO_MODEL_LINES,
|
|
113
115
|
resizeRepaintTarget,
|
|
114
116
|
workspaceSyncQuestion,
|
|
115
117
|
workspaceSyncLabel,
|
|
@@ -665,6 +667,26 @@ if (!workerKey || !apiBase) {
|
|
|
665
667
|
input: process.stdin,
|
|
666
668
|
output: process.stdout,
|
|
667
669
|
});
|
|
670
|
+
// Ctrl+C AND Ctrl+D MUST END THE RUN CLEANLY (task #333). readline swallows Ctrl+C in raw
|
|
671
|
+
// mode: with no listener it only pauses, the pending question never settles, and Node
|
|
672
|
+
// exits with "Warning: Detected unsettled top-level await" and a stack line — at the
|
|
673
|
+
// models menu of a first install. It also meant the "stopped during <step>" record
|
|
674
|
+
// (markInterruptedOnExit listens on the PROCESS) never fired for a Ctrl+C typed here.
|
|
675
|
+
let rl0Done = false;
|
|
676
|
+
const { stopFirstRun } = await import("./first-run-steps.mjs");
|
|
677
|
+
const endFirstRun = (reason) =>
|
|
678
|
+
stopFirstRun({
|
|
679
|
+
reason,
|
|
680
|
+
hasProcessListener: process.listenerCount("SIGINT") > 0,
|
|
681
|
+
emitSigint: () => process.emit("SIGINT"),
|
|
682
|
+
log: (t) => console.log(t),
|
|
683
|
+
exit: (code) => process.exit(code),
|
|
684
|
+
style: { yellow, dim },
|
|
685
|
+
});
|
|
686
|
+
rl0.on("SIGINT", () => endFirstRun("interrupt"));
|
|
687
|
+
rl0.on("close", () => {
|
|
688
|
+
if (!rl0Done) endFirstRun("eof");
|
|
689
|
+
});
|
|
668
690
|
const ask0 = (q) =>
|
|
669
691
|
new Promise((res) => rl0.question(accent(q), (a) => res(String(a || ""))));
|
|
670
692
|
let typed = "";
|
|
@@ -682,7 +704,7 @@ if (!workerKey || !apiBase) {
|
|
|
682
704
|
const {
|
|
683
705
|
probeMachine, askMode, runClientExpertSetup, loadState, saveState,
|
|
684
706
|
} = await import("./client-expert-run.mjs");
|
|
685
|
-
const { clientExpertAvailable, parseLicenseFlag, resolveFirstRunMode, promptForLicenseToken } =
|
|
707
|
+
const { clientExpertAvailable, parseLicenseFlag, resolveFirstRunMode, licenseUnverifiedLine, promptForLicenseToken } =
|
|
686
708
|
await import("./client-expert-setup.mjs");
|
|
687
709
|
const machine0 = await probeMachine();
|
|
688
710
|
const gate0 = clientExpertAvailable({ workerEnvExists: false, ...machine0 });
|
|
@@ -701,7 +723,9 @@ if (!workerKey || !apiBase) {
|
|
|
701
723
|
// (task #13). Format is checked first (no network), then offline signature, then the
|
|
702
724
|
// live API. Empty Enter declines → the private default. Unreachable API still opens
|
|
703
725
|
// the menu (soft licence: an outage never blocks a paid user).
|
|
726
|
+
const { setupPrompts: tokenPrompts } = await import("./setup-prompts.mjs");
|
|
704
727
|
const pick = await promptForLicenseToken(ask0, licenseToken0, {
|
|
728
|
+
askSecret: tokenPrompts(rl0, { style: accent }).askSecret,
|
|
705
729
|
validateOpts: {
|
|
706
730
|
hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
|
|
707
731
|
},
|
|
@@ -710,7 +734,7 @@ if (!workerKey || !apiBase) {
|
|
|
710
734
|
if (pick.declined || !pick.token) {
|
|
711
735
|
plan0 = resolveFirstRunMode({ licenseToken: "", expertAvailable: gate0.available });
|
|
712
736
|
} else if (pick.result && pick.result.reason === "unverified") {
|
|
713
|
-
console.log(dim("\n
|
|
737
|
+
console.log(dim("\n " + licenseUnverifiedLine(pick.result.detail)));
|
|
714
738
|
}
|
|
715
739
|
}
|
|
716
740
|
let mode0;
|
|
@@ -782,6 +806,7 @@ if (!workerKey || !apiBase) {
|
|
|
782
806
|
typed = (await ask0(" worker key (or Enter to sign in): ")).trim();
|
|
783
807
|
}
|
|
784
808
|
} finally {
|
|
809
|
+
rl0Done = true;
|
|
785
810
|
rl0.close();
|
|
786
811
|
}
|
|
787
812
|
if (stopAfterSetup) {
|
|
@@ -1599,6 +1624,9 @@ let imageNoticeTimer = null;
|
|
|
1599
1624
|
// its own model is unset, and choosing that model (/agent-model, /embed-model, /setup, or the
|
|
1600
1625
|
// API seeding one) clears only that one.
|
|
1601
1626
|
let agentMissing = false; // no agent model chosen → agent falls back to the coding model
|
|
1627
|
+
// NO MODEL AT ALL (#99): neither an agent model nor a coder — there is no server to send a
|
|
1628
|
+
// question to. Seeded from the startup payload; re-checked before a question is refused.
|
|
1629
|
+
let noModelAtAll = false;
|
|
1602
1630
|
let embedMissing = false; // no embed model chosen → RAG is off until /embed-model picks one
|
|
1603
1631
|
// One footer row; the two halves are independent so a model that IS set is never named.
|
|
1604
1632
|
// ONE SHORT ROW EACH (#317): joined, they ran past 80 columns and were cut mid-word.
|
|
@@ -2277,7 +2305,7 @@ async function refreshCliToken() {
|
|
|
2277
2305
|
let state = await ct.readState(dir);
|
|
2278
2306
|
const now = new Date();
|
|
2279
2307
|
const noticeNow = () =>
|
|
2280
|
-
ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site });
|
|
2308
|
+
ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site, why: state.lastAttemptWhy ?? "" });
|
|
2281
2309
|
tokenNotice = noticeNow();
|
|
2282
2310
|
|
|
2283
2311
|
const early = Boolean(tokenNotice) && !startupRenewDone;
|
|
@@ -2296,7 +2324,8 @@ async function refreshCliToken() {
|
|
|
2296
2324
|
// A reply is only kept if it checks out here — a proxy's HTML page or another server's token
|
|
2297
2325
|
// must not replace a good one.
|
|
2298
2326
|
const fresh = r.ok && ct.verifyCliToken(r.token, pk).ok ? r.token : state.token;
|
|
2299
|
-
|
|
2327
|
+
// WHY it failed is kept with the attempt, for the bar (#102).
|
|
2328
|
+
state = { token: fresh, lastAttemptAt: now.toISOString(), lastAttemptWhy: r.ok ? "" : String(r.why ?? "") };
|
|
2300
2329
|
await ct.writeState(dir, state).catch(() => {});
|
|
2301
2330
|
tokenNotice = noticeNow();
|
|
2302
2331
|
}
|
|
@@ -10453,8 +10482,13 @@ async function tryStartLocalApi() {
|
|
|
10453
10482
|
);
|
|
10454
10483
|
const version = versions.pop();
|
|
10455
10484
|
if (!version) return false; // nothing installed — setup never got that far
|
|
10485
|
+
const portBefore = ce.LOCAL_API_PORT;
|
|
10456
10486
|
await ce.startLocalApi({ apiDir: join(base, version) });
|
|
10457
|
-
|
|
10487
|
+
const up = (await ce.waitForApi({ timeoutMs: 30_000 })).ok;
|
|
10488
|
+
// The port can move here (#337: the saved one now belongs to another program). worker.env
|
|
10489
|
+
// was re-pointed; THIS terminal still holds the old address until it reads the file again.
|
|
10490
|
+
if (ce.LOCAL_API_PORT !== portBefore) reloadWorkerEnv();
|
|
10491
|
+
return up;
|
|
10458
10492
|
} catch {
|
|
10459
10493
|
return false;
|
|
10460
10494
|
}
|
|
@@ -10786,6 +10820,22 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
10786
10820
|
// from the gu service with it. Asked for HERE, before anything is sent, with the steps — rather
|
|
10787
10821
|
// than letting the turn fail and leaving the user to decode why.
|
|
10788
10822
|
if (!skipAsk && !(await ensureTokenForTurn())) return;
|
|
10823
|
+
// NOTHING TO ANSWER WITH (#99). On an account with no models at all the question used to be
|
|
10824
|
+
// sent, sat at "queued — waiting for the worker…", and ended in "✗ Job not found." — the job
|
|
10825
|
+
// had no model server in it, so nothing could read or claim it. Say what is missing instead.
|
|
10826
|
+
// Asked again first: /model or /agent-model may have set one since gu started.
|
|
10827
|
+
if (!skipAsk && noModelAtAll) {
|
|
10828
|
+
try {
|
|
10829
|
+
noModelAtAll = nothingToAnswerWith((await fetchAgentPayload([{ role: "user", content: "." }]))?.payload);
|
|
10830
|
+
} catch {
|
|
10831
|
+
/* cannot ask → keep what startup saw */
|
|
10832
|
+
}
|
|
10833
|
+
if (noModelAtAll) {
|
|
10834
|
+
for (const l of NO_MODEL_LINES) console.log(GUTTER + (l === NO_MODEL_LINES[0] ? red("✗ ") + l : dim(" " + l)));
|
|
10835
|
+
console.log("");
|
|
10836
|
+
return;
|
|
10837
|
+
}
|
|
10838
|
+
}
|
|
10789
10839
|
inputVisible = false; // the frozen zone shows an empty prompt for the duration (#141)
|
|
10790
10840
|
// Something on screen from the FIRST moment (task #140): Enter has just wiped the bar, and
|
|
10791
10841
|
// the next thing to draw itself is the turn's ticker — which stays silent until the daemon
|
|
@@ -12274,6 +12324,7 @@ async function main() {
|
|
|
12274
12324
|
// private (task #13); empty Enter declines to the private default.
|
|
12275
12325
|
const { DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
12276
12326
|
const pick1 = await promptForLicenseToken(ask, licenseToken1, {
|
|
12327
|
+
askSecret,
|
|
12277
12328
|
validateOpts: {
|
|
12278
12329
|
hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
|
|
12279
12330
|
},
|
|
@@ -12503,6 +12554,7 @@ async function main() {
|
|
|
12503
12554
|
// the local setup answers — OR them in, never clear a local "missing" on a maybe, because
|
|
12504
12555
|
// private mode's local API can itself be unconfigured.
|
|
12505
12556
|
if (p.agentMissing === true) agentMissing = true;
|
|
12557
|
+
noModelAtAll = nothingToAnswerWith(p);
|
|
12506
12558
|
if (p.ragEnabled !== true) embedMissing = true;
|
|
12507
12559
|
// A RESTORED PICK MUST STILL EXIST. The stored id names a model on the box this folder was
|
|
12508
12560
|
// last pointed at — and the box can change (`/model` → change the Ollama server), a model
|
|
@@ -12596,7 +12648,14 @@ async function main() {
|
|
|
12596
12648
|
// Task #136/#154: mode changes WHERE things run — too important to leave implicit.
|
|
12597
12649
|
// The precedence lives in modeRow() with its tests; see the note there.
|
|
12598
12650
|
...(() => {
|
|
12599
|
-
const m = modeRow({
|
|
12651
|
+
const m = modeRow({
|
|
12652
|
+
clientExpert: isClientExpert(),
|
|
12653
|
+
clientMode: clientModeBanner,
|
|
12654
|
+
modelUrls: [p.agentBaseURL],
|
|
12655
|
+
// The coder IN USE in this folder, not the account default (the raw payload field
|
|
12656
|
+
// is the default only — key-choice-regression.test.mjs).
|
|
12657
|
+
coderWhere: activeCoderWhere,
|
|
12658
|
+
});
|
|
12600
12659
|
if (!m) return [];
|
|
12601
12660
|
const name = m.label === "client expert" ? accent(m.label + " ") : m.label + " ";
|
|
12602
12661
|
return [lbl("mode") + name + dim(m.detail)];
|
|
@@ -12693,6 +12752,8 @@ async function main() {
|
|
|
12693
12752
|
confirm: (q, defYes) => askYesNo(q, defYes),
|
|
12694
12753
|
// A client installs ONLY the framework — never mlx-lm/hf.
|
|
12695
12754
|
modelHost: clientModeBanner ? false : undefined,
|
|
12755
|
+
// The yes two lines up IS the answer — do not ask the same thing again (#333).
|
|
12756
|
+
agreed: ["agentLibs"],
|
|
12696
12757
|
});
|
|
12697
12758
|
// The worker reads worker.env ONCE at startup, so a daemon that is already
|
|
12698
12759
|
// running still has the OLD python — the packages would look "not installed"
|
package/install-path.mjs
CHANGED
|
@@ -56,11 +56,33 @@ if (isEntryPoint) {
|
|
|
56
56
|
main();
|
|
57
57
|
} catch (e) {
|
|
58
58
|
// A postinstall that throws uninstalls the package. Report, swallow, succeed.
|
|
59
|
-
console.log(`gu-cli: PATH setup skipped (${e.message}) —
|
|
59
|
+
console.log(`gu-cli: PATH setup skipped (${e.message}) — run \`gu-cli fix-path\` if \`gu\` is not found.`);
|
|
60
60
|
}
|
|
61
61
|
process.exit(0);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The npm global prefix this package is installed under, from where its own files are — or "".
|
|
66
|
+
*
|
|
67
|
+
* For `gu-cli fix-path` (task #333): the same PATH repair as this postinstall, WITHOUT npm
|
|
68
|
+
* running it. npm 11 already prints "install scripts not yet covered by allowScripts" for this
|
|
69
|
+
* package on every install; it still runs the script today (checked on 11.19.1), but the day it
|
|
70
|
+
* stops, a custom-prefix user is back to "gu: command not found" with nothing to run. A global
|
|
71
|
+
* install lives at <prefix>/lib/node_modules/@tiens.nguyen/gu-cli; anything else (a checkout,
|
|
72
|
+
* `npm link`, a project's node_modules) is not one, and gets no PATH edit.
|
|
73
|
+
*/
|
|
74
|
+
export function globalPrefixOf(packageDir) {
|
|
75
|
+
const parts = resolve(String(packageDir ?? "")).split("/");
|
|
76
|
+
const n = parts.length;
|
|
77
|
+
if (n < 5 || parts[n - 3] !== "node_modules" || parts[n - 4] !== "lib") return "";
|
|
78
|
+
if (!parts[n - 2].startsWith("@")) return "";
|
|
79
|
+
// A project-local install is <project>/node_modules/… — never <x>/lib/node_modules, but a
|
|
80
|
+
// nested one could be; a prefix inside another node_modules tree is not a global prefix.
|
|
81
|
+
const prefix = parts.slice(0, n - 4);
|
|
82
|
+
if (prefix.includes("node_modules")) return "";
|
|
83
|
+
return prefix.join("/") || "/";
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
function main() {
|
|
65
87
|
if (platform() === "win32") return; // registry PATH + PowerShell alias: not here
|
|
66
88
|
if (process.env.CI) return; // no user shell to help in CI
|
package/model-doctor.mjs
CHANGED
|
@@ -130,6 +130,17 @@ export function isExternallyManaged(output) {
|
|
|
130
130
|
return /externally[\s-]?managed/i.test(String(output ?? ""));
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Is there no pip on this python at all? (task #333)
|
|
135
|
+
*
|
|
136
|
+
* A stock Ubuntu 24.04 ships python3 WITHOUT pip (python3-pip is a separate package), so the
|
|
137
|
+
* first install said "/usr/bin/python3: No module named pip" and gave up — although
|
|
138
|
+
* python3-venv was installed and a venv brings its own pip. Same remedy as PEP 668: a venv.
|
|
139
|
+
*/
|
|
140
|
+
export function isPipMissing(output) {
|
|
141
|
+
return /No module named pip\b/i.test(String(output ?? ""));
|
|
142
|
+
}
|
|
143
|
+
|
|
133
144
|
/** Run pip once. Resolves { code, err } — never rejects, so the caller owns the policy. */
|
|
134
145
|
function runPip(python, pkgs, extraArgs = []) {
|
|
135
146
|
return new Promise((resolve) => {
|
|
@@ -190,11 +201,16 @@ async function pipInstall(pkgs, log) {
|
|
|
190
201
|
log(` installing ${pkgs.join(" ")} … (pip, this can take a minute)`);
|
|
191
202
|
let r = await runPip(python3Bin(), pkgs);
|
|
192
203
|
if (r.code === 0) return;
|
|
193
|
-
|
|
204
|
+
const noPip = isPipMissing(r.err);
|
|
205
|
+
if (!noPip && !isExternallyManaged(r.err)) {
|
|
194
206
|
throw new Error(`pip install ${pkgs.join(" ")} exited ${r.code}`);
|
|
195
207
|
}
|
|
196
208
|
|
|
197
|
-
log(
|
|
209
|
+
log(
|
|
210
|
+
noPip
|
|
211
|
+
? " this python has no pip — using a private virtualenv, which brings its own."
|
|
212
|
+
: " this python is managed by your OS (PEP 668) — it will not accept packages directly."
|
|
213
|
+
);
|
|
198
214
|
const venvPy = await ensureVenv(log);
|
|
199
215
|
if (venvPy) {
|
|
200
216
|
r = await runPip(venvPy, pkgs);
|
|
@@ -208,6 +224,13 @@ async function pipInstall(pkgs, log) {
|
|
|
208
224
|
}
|
|
209
225
|
}
|
|
210
226
|
|
|
227
|
+
// Without pip AND without venv nothing here can install anything; --break-system-packages
|
|
228
|
+
// would only fail again with the same "No module named pip". Say what to install instead.
|
|
229
|
+
if (noPip) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
"this python has no pip and no venv module — on Debian/Ubuntu: sudo apt install -y python3-venv, then gu doctor"
|
|
232
|
+
);
|
|
233
|
+
}
|
|
211
234
|
log(" falling back to --break-system-packages (no virtualenv available)");
|
|
212
235
|
r = await runPip(python3Bin(), pkgs, ["--break-system-packages"]);
|
|
213
236
|
if (r.code === 0) return;
|
|
@@ -1246,7 +1269,13 @@ export async function ensureClaudeSdk({ log, confirm }) {
|
|
|
1246
1269
|
return (await pyHasModule("anthropic")) ? "installed" : "failed";
|
|
1247
1270
|
}
|
|
1248
1271
|
|
|
1249
|
-
|
|
1272
|
+
/**
|
|
1273
|
+
* `agreed` names packages the caller has ALREADY asked about (task #333): `gu` asks "Install the
|
|
1274
|
+
* agent framework now?" and then called this, which asked "Install the agent framework
|
|
1275
|
+
* (smolagents + openai) — required to run the agent?" — the same question twice in a row on
|
|
1276
|
+
* every first install. A yes that was just given is not asked for again.
|
|
1277
|
+
*/
|
|
1278
|
+
export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxModels(), agreed = [] }) {
|
|
1250
1279
|
const out = {};
|
|
1251
1280
|
log("");
|
|
1252
1281
|
log(" Checking the agent's dependencies…");
|
|
@@ -1264,7 +1293,7 @@ export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxMode
|
|
|
1264
1293
|
out[key] = "present";
|
|
1265
1294
|
return;
|
|
1266
1295
|
}
|
|
1267
|
-
if (!(await confirm(question, true))) {
|
|
1296
|
+
if (!agreed.includes(key) && !(await confirm(question, true))) {
|
|
1268
1297
|
out[key] = "skipped";
|
|
1269
1298
|
log(` Skipped ${pkgs.join(" ")} — install later: ${python3Bin()} -m pip install -U ${pkgs.join(" ")}`);
|
|
1270
1299
|
return;
|
package/node-version.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export const MIN_NODE_MAJOR = 18;
|
|
|
17
17
|
/** The message for a too-old runtime, or "" when this one is fine. Pure, so it is testable. */
|
|
18
18
|
export function nodeVersionProblem(version = process.version) {
|
|
19
19
|
const major = Number.parseInt(String(version).replace(/^v/, "").split(".")[0], 10);
|
|
20
|
-
if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR) return "";
|
|
20
|
+
if (!Number.isFinite(major) || major >= MIN_NODE_MAJOR) {return "";}
|
|
21
21
|
return (
|
|
22
22
|
`gu needs Node ${MIN_NODE_MAJOR} or newer — this is ${version}.\n` +
|
|
23
23
|
` Node ${major} has no global fetch, which the setup and update paths rely on.\n` +
|
|
@@ -30,10 +30,58 @@ export function nodeVersionProblem(version = process.version) {
|
|
|
30
30
|
);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* LOCAL MODE NEEDS MORE THAN GU DOES (task #333). The Local API ships the mongodb 7 driver, whose
|
|
35
|
+
* floor is Node 20.19 — and setup loads that driver IN THIS PROCESS to seed the database, then
|
|
36
|
+
* starts the API with this same node. On Node 18 the first sign was "Could not prepare the
|
|
37
|
+
* database: crypto is not defined", halfway through a first install, after MongoDB and the API
|
|
38
|
+
* were already in place. Found on a stock Ubuntu 24.04, whose own `nodejs` package is 18.19.
|
|
39
|
+
*
|
|
40
|
+
* Kept apart from MIN_NODE_MAJOR on purpose: the CLI itself (Cloud mode) still runs on 18.
|
|
41
|
+
*/
|
|
42
|
+
export const LOCAL_API_MIN_NODE = "20.19.0";
|
|
43
|
+
|
|
44
|
+
/** True when `version` is at least `min` (both "x.y.z", a leading v allowed). */
|
|
45
|
+
export function nodeAtLeast(version, min) {
|
|
46
|
+
const parts = (v) => String(v).replace(/^v/, "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
47
|
+
const a = parts(version);
|
|
48
|
+
const b = parts(min);
|
|
49
|
+
for (let i = 0; i < 3; i++) {
|
|
50
|
+
if ((a[i] ?? 0) !== (b[i] ?? 0)) {return (a[i] ?? 0) > (b[i] ?? 0);}
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Why Local mode cannot run on this node, with what to do — or "" when it can. Pure. */
|
|
56
|
+
export function localModeNodeProblem(version = process.version, platform = process.platform) {
|
|
57
|
+
if (nodeAtLeast(version, LOCAL_API_MIN_NODE)) {return "";}
|
|
58
|
+
const how =
|
|
59
|
+
platform === "win32"
|
|
60
|
+
? [" winget install OpenJS.NodeJS.LTS (or: nvm install lts with nvm-windows)"]
|
|
61
|
+
: platform === "darwin"
|
|
62
|
+
? [" brew install node (or: nvm install --lts)"]
|
|
63
|
+
: [
|
|
64
|
+
" nvm install --lts && nvm alias default lts/*",
|
|
65
|
+
" or NodeSource:",
|
|
66
|
+
" curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -",
|
|
67
|
+
" sudo apt install -y nodejs",
|
|
68
|
+
];
|
|
69
|
+
return (
|
|
70
|
+
`Local mode needs Node ${LOCAL_API_MIN_NODE} or newer — this is ${version}.\n` +
|
|
71
|
+
" Its API uses the MongoDB 7 driver, which does not run on older Node.\n" +
|
|
72
|
+
(platform === "linux" ? " (Ubuntu 24.04's own nodejs package is 18.)\n" : "") +
|
|
73
|
+
"\n" +
|
|
74
|
+
how.join("\n") +
|
|
75
|
+
"\n\n" +
|
|
76
|
+
" Then reinstall gu with that Node: npm i -g @tiens.nguyen/gu-cli\n" +
|
|
77
|
+
" Nothing was installed or changed."
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
33
81
|
/** Print the problem and exit, or return quietly. */
|
|
34
82
|
export function requireSupportedNode() {
|
|
35
83
|
const problem = nodeVersionProblem();
|
|
36
|
-
if (!problem) return;
|
|
84
|
+
if (!problem) {return;}
|
|
37
85
|
// console.error, not the REPL's own writer: this runs before any of that exists.
|
|
38
86
|
console.error("\n" + problem + "\n");
|
|
39
87
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.793",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/platform-tools.mjs
CHANGED
|
@@ -298,23 +298,68 @@ export function windowsLauncherScript({ cmd, args = [], cwd, env = {}, logFile }
|
|
|
298
298
|
return lines.join("\r\n") + "\r\n";
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* WHAT THE TASK RUNS — the launcher script, with NO WINDOW (task #334).
|
|
303
|
+
*
|
|
304
|
+
* The task used to run the .cmd itself. A task runs in the user's desktop session, so cmd.exe got
|
|
305
|
+
* a console window there, and `start /b` keeps node attached to that console: the window stayed
|
|
306
|
+
* for as long as the API or the worker lived. One per launch — the API, the worker, a worker
|
|
307
|
+
* restart, an API restart — "3-4 command windows" on a Windows 11 desktop after a first install,
|
|
308
|
+
* each of which kills its process when closed. Measured on the box: two visible windows titled
|
|
309
|
+
* C:\WINDOWS\SYSTEM32\cmd.exe, owned by the two node processes.
|
|
310
|
+
*
|
|
311
|
+
* `conhost.exe --headless` is Windows' own way to give a console program a console with no
|
|
312
|
+
* window (Windows 10 1809 and later). Not wscript/VBScript — it is being removed from Windows —
|
|
313
|
+
* and not `powershell -WindowStyle Hidden`, which flashes a window before hiding it.
|
|
314
|
+
*
|
|
315
|
+
* THE QUOTES ARE THE OTHER FIX. The bare script path was split by schtasks at the first space,
|
|
316
|
+
* so from a home like C:\Users\Jo Smith nothing started at all (measured: no log, no process).
|
|
317
|
+
*/
|
|
318
|
+
export function hiddenTaskAction(scriptPath, systemRoot) {
|
|
319
|
+
const root = String(systemRoot || "C:\\Windows").replace(/[\\/]+$/, "");
|
|
320
|
+
return `"${root}\\System32\\conhost.exe" --headless cmd.exe /c "${scriptPath}"`;
|
|
321
|
+
}
|
|
322
|
+
|
|
301
323
|
/**
|
|
302
324
|
* Register → run → deregister. The task is a LAUNCH MECHANISM, not a persistent registration:
|
|
303
325
|
* it exists only to start the process outside our job, and is removed immediately afterwards
|
|
304
326
|
* so nothing is left scheduled on the user's machine. The started process keeps running —
|
|
305
327
|
* deleting a task does not kill what it launched (verified).
|
|
306
328
|
*/
|
|
307
|
-
export function schtasksCommands(taskName, scriptPath) {
|
|
329
|
+
export function schtasksCommands(taskName, scriptPath, { elevated = false, systemRoot = process.env.SystemRoot } = {}) {
|
|
308
330
|
return {
|
|
309
331
|
create: {
|
|
310
332
|
cmd: "schtasks.exe",
|
|
311
|
-
|
|
333
|
+
// `/rl highest` ONLY from an elevated terminal — see isElevatedGroups. From an ordinary one
|
|
334
|
+
// it is refused for an administrator ("Access is denied") and the task is never created.
|
|
335
|
+
args: [
|
|
336
|
+
"/create", "/tn", taskName, "/tr", hiddenTaskAction(scriptPath, systemRoot), "/sc", "once", "/st", "00:00",
|
|
337
|
+
...(elevated ? ["/rl", "highest"] : []),
|
|
338
|
+
"/f",
|
|
339
|
+
],
|
|
312
340
|
},
|
|
313
341
|
run: { cmd: "schtasks.exe", args: ["/run", "/tn", taskName] },
|
|
314
342
|
remove: { cmd: "schtasks.exe", args: ["/delete", "/tn", taskName, "/f"] },
|
|
315
343
|
};
|
|
316
344
|
}
|
|
317
345
|
|
|
346
|
+
/**
|
|
347
|
+
* IS THIS TERMINAL ELEVATED? — from `whoami /groups` (task #333).
|
|
348
|
+
*
|
|
349
|
+
* A scheduled task runs with the user's ORDINARY token unless it is created `/rl highest`, even
|
|
350
|
+
* when the terminal that created it is elevated — and an SSH session of an administrator always
|
|
351
|
+
* is. So the background process had FEWER rights than the `gu` that started it. On the Windows
|
|
352
|
+
* 11 test box node.exe belongs to another user's nvm and only Administrators may run it: `gu`
|
|
353
|
+
* ran, the API it launched got "Access is denied.", and setup stopped at "not up yet".
|
|
354
|
+
* A background process should have the rights of the terminal that started it — as it does on
|
|
355
|
+
* macOS and Linux, where it is simply a child.
|
|
356
|
+
*
|
|
357
|
+
* S-1-16-12288 is the High Mandatory Level label (S-1-16-16384 = System). Pure, so it is testable.
|
|
358
|
+
*/
|
|
359
|
+
export function isElevatedGroups(whoamiGroups) {
|
|
360
|
+
return /S-1-16-(12288|16384)\b/.test(String(whoamiGroups ?? ""));
|
|
361
|
+
}
|
|
362
|
+
|
|
318
363
|
/**
|
|
319
364
|
* Which pid owns a listening port, from `netstat -ano`.
|
|
320
365
|
*
|
|
@@ -382,7 +427,8 @@ export async function spawnSurviving({ cmd, args = [], cwd, env = {}, logFile, t
|
|
|
382
427
|
"utf8"
|
|
383
428
|
);
|
|
384
429
|
|
|
385
|
-
const
|
|
430
|
+
const elevated = isElevatedGroups(await run("whoami.exe", ["/groups"]));
|
|
431
|
+
const t = schtasksCommands(taskName, script, { elevated });
|
|
386
432
|
const created = await run(t.create.cmd, t.create.args);
|
|
387
433
|
if (created === null) {
|
|
388
434
|
// No scheduler (or no rights): fall back to a plain detached spawn. It dies with an SSH
|
package/terminal-layout.mjs
CHANGED
|
@@ -2156,6 +2156,24 @@ export const HIDDEN_COMMANDS = [
|
|
|
2156
2156
|
// the INPUT total — which does not exist yet as a counter
|
|
2157
2157
|
];
|
|
2158
2158
|
|
|
2159
|
+
/**
|
|
2160
|
+
* NOTHING TO ANSWER WITH (#99): an agent payload with no model server in it at all.
|
|
2161
|
+
*
|
|
2162
|
+
* A missing AGENT model alone is fine — the API puts the coding model in its place (and sets
|
|
2163
|
+
* `agentMissing`). With no coder either, `agentBaseURL` is empty: there is no server to send a
|
|
2164
|
+
* question to. The API (from 1.0.220) refuses such a question in words; this lets the terminal
|
|
2165
|
+
* say the same against an API that still queues a job nothing can read ("✗ Job not found.").
|
|
2166
|
+
*/
|
|
2167
|
+
export function nothingToAnswerWith(payload) {
|
|
2168
|
+
if (!payload || typeof payload !== "object") return false; // unknown is not "none"
|
|
2169
|
+
return !String(payload.agentBaseURL ?? "").trim();
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
export const NO_MODEL_LINES = [
|
|
2173
|
+
"No model is set for this account, so there is nothing to answer with.",
|
|
2174
|
+
"Pick a coding model with /model (and an agent model with /agent-model), then ask again.",
|
|
2175
|
+
];
|
|
2176
|
+
|
|
2159
2177
|
/**
|
|
2160
2178
|
* Does this API base point at a server on THIS machine? (task #154)
|
|
2161
2179
|
*
|
|
@@ -2177,9 +2195,20 @@ export function isLocalApiBase(apiBase) {
|
|
|
2177
2195
|
* entire distinction — go unmentioned. Reported live on a fully set-up Ubuntu box that said
|
|
2178
2196
|
* plain "client".
|
|
2179
2197
|
*/
|
|
2180
|
-
export function modeRow({ clientExpert, clientMode }) {
|
|
2198
|
+
export function modeRow({ clientExpert, clientMode, modelUrls = [], coderWhere }) {
|
|
2181
2199
|
if (clientExpert) {
|
|
2182
|
-
|
|
2200
|
+
// "models remote" WAS A CONSTANT (task #333): a box running its own Ollama on 127.0.0.1 —
|
|
2201
|
+
// the whole stack on one machine — was told its models were somewhere else. Said only when
|
|
2202
|
+
// EVERY configured model server is this machine; one cloud coder is still "remote".
|
|
2203
|
+
const urls = modelUrls.map((u) => String(u ?? "").trim()).filter(Boolean);
|
|
2204
|
+
// `coderWhere` is the active coder's "host[:port]" label (coding-choice-tags.mjs), which
|
|
2205
|
+
// names this machine as 127.0.0.1. Passed but empty = not known → not claimed to be here.
|
|
2206
|
+
const coderHere = coderWhere === undefined || /^127\.0\.0\.1(:|$)/.test(String(coderWhere));
|
|
2207
|
+
const allHere = urls.length > 0 && urls.every(isLocalApiBase) && coderHere;
|
|
2208
|
+
return {
|
|
2209
|
+
label: "client expert",
|
|
2210
|
+
detail: allHere ? "(API, database and models here)" : "(API + database here · models remote)",
|
|
2211
|
+
};
|
|
2183
2212
|
}
|
|
2184
2213
|
if (clientMode) {
|
|
2185
2214
|
return { label: "client", detail: "(agent runs here · models remote)" };
|