@tiens.nguyen/gu-cli 1.0.686
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/README.md +52 -0
- package/agent-model-command.mjs +259 -0
- package/agent-model-label.mjs +159 -0
- package/clear-state.mjs +149 -0
- package/client-expert-api.mjs +736 -0
- package/client-expert-run.mjs +892 -0
- package/client-expert-setup.mjs +616 -0
- package/coding-choice-tags.mjs +69 -0
- package/coding-key-prompt.mjs +229 -0
- package/coding-provider-setup.mjs +808 -0
- package/completed-flush.mjs +105 -0
- package/daemon-control.mjs +462 -0
- package/device-login.mjs +212 -0
- package/doctor-check.mjs +239 -0
- package/embed-model-command.mjs +157 -0
- package/first-run-steps.mjs +171 -0
- package/gonext_agent_chat.py +12299 -0
- package/gonext_mlx_embed.py +155 -0
- package/gonext_probe_agent.py +93 -0
- package/gonext_transcribe.py +130 -0
- package/gu-cli.mjs +4930 -0
- package/gu-repl.mjs +10326 -0
- package/job-pools.mjs +89 -0
- package/model-doctor.mjs +1494 -0
- package/node-version.mjs +40 -0
- package/ollama-setup.mjs +832 -0
- package/package.json +100 -0
- package/platform-tools.mjs +520 -0
- package/poll-errors.mjs +141 -0
- package/proxy-command.mjs +165 -0
- package/proxy-config.mjs +255 -0
- package/proxy-dispatcher.mjs +132 -0
- package/proxy-selftest.mjs +234 -0
- package/proxy-store.mjs +69 -0
- package/rag-job-config.mjs +59 -0
- package/rag-selftest.mjs +215 -0
- package/s3-setup.mjs +85 -0
- package/terminal-copy.mjs +248 -0
- package/terminal-hover.mjs +153 -0
- package/terminal-layout.mjs +2507 -0
- package/terminal-viewport.mjs +602 -0
- package/thinking_words.txt +1003 -0
- package/version-check.mjs +72 -0
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client Expert, the part that touches the machine (task #154).
|
|
3
|
+
*
|
|
4
|
+
* The DECISIONS live in client-expert-setup.mjs and are pure. This file is the other half:
|
|
5
|
+
* looking at the machine, asking the questions, and writing down what happened. Split that way
|
|
6
|
+
* because the decisions are the part worth testing and the probes are the part that cannot be.
|
|
7
|
+
*
|
|
8
|
+
* The walk runs end to end: mode, MongoDB, the API package, its dependencies, the database and
|
|
9
|
+
* worker key, starting the API, and pointing worker.env at it. The acting parts live in
|
|
10
|
+
* client-expert-api.mjs.
|
|
11
|
+
*
|
|
12
|
+
* ORDER MATTERS in one place: worker.env is written LAST. It is what marks the machine as
|
|
13
|
+
* paired, so writing it before the API is proven usable leaves a terminal aimed at something
|
|
14
|
+
* that does not work — and that state is much harder to get out of than an unfinished setup.
|
|
15
|
+
*/
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { readFile, writeFile, mkdir, rm, rename } from "node:fs/promises";
|
|
18
|
+
import { writeFileSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import {
|
|
24
|
+
apiDepsInstalled,
|
|
25
|
+
installApiDeps,
|
|
26
|
+
seedDatabase,
|
|
27
|
+
startLocalApi,
|
|
28
|
+
waitForApi,
|
|
29
|
+
probeLocalApi,
|
|
30
|
+
writeWorkerEnv,
|
|
31
|
+
seedSettings,
|
|
32
|
+
modelsAlreadyConfigured,
|
|
33
|
+
CLIENT_EXPERT_MODELS,
|
|
34
|
+
LOCAL_API_PORT,
|
|
35
|
+
LOCAL_API_BASE,
|
|
36
|
+
API_LOG_FILE,
|
|
37
|
+
LOCAL_MONGO_URI,
|
|
38
|
+
} from "./client-expert-api.mjs";
|
|
39
|
+
import { askOllamaSetup, localOllamaDefault } from "./ollama-setup.mjs";
|
|
40
|
+
import { parseOllamaTags, formatSize } from "./agent-model-command.mjs";
|
|
41
|
+
import {
|
|
42
|
+
planSetup,
|
|
43
|
+
recordStep,
|
|
44
|
+
resumeAction,
|
|
45
|
+
stepReason,
|
|
46
|
+
clientExpertAvailable,
|
|
47
|
+
mongoInstallPlan,
|
|
48
|
+
mongoDockerPlan,
|
|
49
|
+
MONGO_DOCKER_NAME,
|
|
50
|
+
} from "./client-expert-setup.mjs";
|
|
51
|
+
|
|
52
|
+
const run = promisify(execFile);
|
|
53
|
+
const STATE_FILE = join(homedir(), ".gonext", "client-expert.json");
|
|
54
|
+
|
|
55
|
+
/** Not an error: the machine is already configured, so the question was skipped. */
|
|
56
|
+
class SkipModelQuestion extends Error {}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* GET /api/tags, as askOllamaSetup's injected fetcher.
|
|
60
|
+
*
|
|
61
|
+
* THROWS on anything that is not a usable list, and that is the contract: the caller
|
|
62
|
+
* distinguishes "could not reach the box" (this throws) from "reached it and it has nothing"
|
|
63
|
+
* (this returns []), and those two get different messages because they need different fixes.
|
|
64
|
+
* A short timeout, because a box that has not answered in eight seconds is not going to.
|
|
65
|
+
*/
|
|
66
|
+
async function fetchOllamaTags(url) {
|
|
67
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
|
68
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
69
|
+
// parseOllamaTags is tolerant by design — HTML from a proxy, an empty body and a shape from a
|
|
70
|
+
// future Ollama all become []. That is "reachable but not usable", which the caller reports as
|
|
71
|
+
// such rather than as a network failure.
|
|
72
|
+
return parseOllamaTags(await res.json().catch(() => null));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
76
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
77
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
78
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
79
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
80
|
+
|
|
81
|
+
/** Best-effort: never let a probe throw into the setup flow. */
|
|
82
|
+
async function tryRun(cmd, args, timeout = 5000) {
|
|
83
|
+
try {
|
|
84
|
+
const { stdout } = await run(cmd, args, { timeout });
|
|
85
|
+
return String(stdout || "").trim();
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What this machine IS — enough to decide whether MongoDB can run here at all. */
|
|
92
|
+
export async function probeMachine() {
|
|
93
|
+
const platform = process.platform;
|
|
94
|
+
// platform-ok: uname is absent on Windows; tryRun returns null and process.arch answers.
|
|
95
|
+
const arch = (await tryRun("uname", ["-m"])) ?? process.arch;
|
|
96
|
+
// The CPU model is what separates a Pi 4 (ARMv8.0, mongod dies) from a Pi 5 (fine).
|
|
97
|
+
let cpuModel = "";
|
|
98
|
+
if (platform === "linux") {
|
|
99
|
+
const info = await tryRun("sh", ["-c", "grep -m1 -i 'model' /proc/cpuinfo || true"]);
|
|
100
|
+
cpuModel = (info ?? "").replace(/^.*?:\s*/, "");
|
|
101
|
+
if (!cpuModel) cpuModel = (await tryRun("sh", ["-c", "cat /proc/device-tree/model 2>/dev/null || true"])) ?? "";
|
|
102
|
+
}
|
|
103
|
+
return { platform, arch, cpuModel };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Can something be reached at this address? Raw TCP, no driver required.
|
|
108
|
+
*
|
|
109
|
+
* The driver lives inside the downloaded API, which does not exist yet at the MongoDB step —
|
|
110
|
+
* so the only connection check available here is a socket. It proves REACHABILITY, not that
|
|
111
|
+
* the thing answering speaks MongoDB; the seed step proves that, and fails legibly if not.
|
|
112
|
+
*/
|
|
113
|
+
export function mongoHostPort(uri) {
|
|
114
|
+
try {
|
|
115
|
+
// mongodb://user:pass@host:port/db — the URL parser handles the credentials and the path.
|
|
116
|
+
const u = new URL(String(uri).replace(/^mongodb(\+srv)?:\/\//, "http://"));
|
|
117
|
+
return { host: u.hostname || "127.0.0.1", port: Number(u.port) || 27017 };
|
|
118
|
+
} catch {
|
|
119
|
+
return { host: "127.0.0.1", port: 27017 };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function tcpReachable(host, port, timeoutMs = 3000) {
|
|
124
|
+
const net = await import("node:net");
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
const socket = net.connect({ host, port });
|
|
127
|
+
const done = (ok) => {
|
|
128
|
+
socket.destroy();
|
|
129
|
+
resolve(ok);
|
|
130
|
+
};
|
|
131
|
+
socket.setTimeout(timeoutMs);
|
|
132
|
+
socket.once("connect", () => done(true));
|
|
133
|
+
socket.once("timeout", () => done(false));
|
|
134
|
+
socket.once("error", () => done(false));
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Is there a database this machine can USE? Not "is a mongod installed here" — those are
|
|
140
|
+
* different questions, and answering the second one blocked a machine where the first was
|
|
141
|
+
* plainly true (see the socket check below).
|
|
142
|
+
*/
|
|
143
|
+
export async function probeMongo() {
|
|
144
|
+
// The ping first, on every platform: it is the only check that proves the database ANSWERS.
|
|
145
|
+
// A service marked "running" that refuses connections is the failure this catches.
|
|
146
|
+
const ping = await tryRun("mongosh", ["--quiet", "--eval", "db.runCommand({ping:1}).ok"], 8000);
|
|
147
|
+
if (ping && ping.trim().endsWith("1")) return { running: true, how: "mongosh ping" };
|
|
148
|
+
|
|
149
|
+
// THEN THE SOCKET, before any process or service lookup.
|
|
150
|
+
//
|
|
151
|
+
// Everything below this line asks "is a mongod running ON THIS MACHINE", which is a
|
|
152
|
+
// different question from the one that matters: "is there a database I can use". A MongoDB
|
|
153
|
+
// in Docker — a very common way to run one — is reachable on 27017 while being invisible to
|
|
154
|
+
// both `pgrep` and `mongosh`, so setup declared it missing and refused to continue on a
|
|
155
|
+
// machine where it worked perfectly. Found exactly that way, in a container, on Linux.
|
|
156
|
+
const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
|
|
157
|
+
if (await tcpReachable(host, port)) {
|
|
158
|
+
return { running: true, how: `listening at ${host}:${port}` };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (process.platform === "win32") {
|
|
162
|
+
// winget's MongoDB installs a SERVICE, so that is what to ask about — a process lookup
|
|
163
|
+
// would miss a service that is registered but stopped, which is the state we most want to
|
|
164
|
+
// tell apart from "not installed".
|
|
165
|
+
const svc = await tryRun("sc.exe", ["query", "MongoDB"]);
|
|
166
|
+
if (svc && /STATE\s*:\s*4\s+RUNNING/i.test(svc)) {
|
|
167
|
+
return { running: true, how: "MongoDB service" };
|
|
168
|
+
}
|
|
169
|
+
return { running: false, how: null, installed: Boolean(svc && /SERVICE_NAME/i.test(svc)) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// platform-ok: unreachable on Windows — the win32 branch above returns before this line.
|
|
173
|
+
const pid = await tryRun("pgrep", ["-x", "mongod"]);
|
|
174
|
+
if (pid) return { running: true, how: `mongod process ${pid.split("\n")[0]}` };
|
|
175
|
+
// THE SERVER AND THE SHELL ARE DIFFERENT QUESTIONS, and conflating them sent a user to a
|
|
176
|
+
// command that could not work. This used to be
|
|
177
|
+
// command -v mongod || command -v mongosh
|
|
178
|
+
// so `mongosh` alone — the CLIENT, which arrives on its own via a standalone download, an
|
|
179
|
+
// Atlas workflow or some other tool that bundles it — set `installed: true`. The caller then
|
|
180
|
+
// took the "installed but not answering" branch and printed
|
|
181
|
+
// brew services start mongodb-community
|
|
182
|
+
// on a machine with no server and no tap, which fails with "No available formula with the
|
|
183
|
+
// name mongodb-community". Reported on a fresh Mac and reproduced on the dev Mac, which is in
|
|
184
|
+
// exactly that state: mongosh present, mongod absent, untapped.
|
|
185
|
+
//
|
|
186
|
+
// `installed` now means THE SERVER. The shell is reported separately because it is worth
|
|
187
|
+
// saying ("you already have mongosh") and because `verify` runs through it.
|
|
188
|
+
const serverPath = (await tryRun("sh", ["-c", "command -v mongod || true"])) || "";
|
|
189
|
+
const shellPath = (await tryRun("sh", ["-c", "command -v mongosh || true"])) || "";
|
|
190
|
+
return {
|
|
191
|
+
running: false,
|
|
192
|
+
how: null,
|
|
193
|
+
installed: Boolean(serverPath.trim()),
|
|
194
|
+
shellInstalled: Boolean(shellPath.trim()),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Is Docker usable right now, and does our container already exist?
|
|
200
|
+
* → { usable, containerExists }
|
|
201
|
+
*
|
|
202
|
+
* `docker ps` rather than `which docker`: Docker Desktop is very often installed and not
|
|
203
|
+
* running, and "start Docker Desktop" is a different instruction from "install Docker".
|
|
204
|
+
*/
|
|
205
|
+
export async function probeDockerForMongo(name = MONGO_DOCKER_NAME) {
|
|
206
|
+
const ps = await tryRun("docker", ["ps", "--format", "{{.Names}}"], 8000);
|
|
207
|
+
if (ps === null) {
|
|
208
|
+
// WHICH KIND OF "no docker" IS THIS? (reported 2026-08-24, macOS)
|
|
209
|
+
//
|
|
210
|
+
// `docker ps` fails identically whether docker is absent or Docker Desktop is merely not
|
|
211
|
+
// RUNNING, and on macOS the second is overwhelmingly the common case — the CLI is installed,
|
|
212
|
+
// the daemon is not up, and the fix is to open an app rather than to install anything.
|
|
213
|
+
// `docker --version` answers from the CLI alone and does not need the daemon, so it
|
|
214
|
+
// separates the two. Callers use this to say which one it is instead of failing silently.
|
|
215
|
+
const version = await tryRun("docker", ["--version"], 5000);
|
|
216
|
+
return {
|
|
217
|
+
usable: false,
|
|
218
|
+
containerExists: false,
|
|
219
|
+
installed: version !== null,
|
|
220
|
+
daemonDown: version !== null, // installed but `docker ps` failed ⇒ the daemon is not up
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
// -a, because a STOPPED container of ours is the case where `docker start` is the answer and
|
|
224
|
+
// `docker run` would fail on the name conflict.
|
|
225
|
+
const all = await tryRun(
|
|
226
|
+
"docker", ["ps", "-a", "--filter", `name=^/${name}$`, "--format", "{{.Names}}"], 8000
|
|
227
|
+
);
|
|
228
|
+
return {
|
|
229
|
+
usable: true,
|
|
230
|
+
containerExists: Boolean((all || "").trim()),
|
|
231
|
+
installed: true,
|
|
232
|
+
daemonDown: false,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Start MongoDB in a container and wait for it to answer. → true when it is up.
|
|
238
|
+
*
|
|
239
|
+
* Waits on the SOCKET rather than on `docker run` returning: run returns as soon as the
|
|
240
|
+
* container is created, and mongod then takes a few seconds to accept connections. Reporting
|
|
241
|
+
* success at the wrong moment would fail the very next step of setup.
|
|
242
|
+
*/
|
|
243
|
+
export async function startMongoInDocker(ask, { platform = process.platform } = {}) {
|
|
244
|
+
const { usable, containerExists, daemonDown } = await probeDockerForMongo();
|
|
245
|
+
if (!usable) {
|
|
246
|
+
// SAY WHY, rather than returning false into silence (reported 2026-08-24 on macOS).
|
|
247
|
+
// The caller has just told the user MongoDB is not reachable and that it must be started
|
|
248
|
+
// first; returning quietly here left that sentence with no follow-up at all, and the
|
|
249
|
+
// commonest cause on a Mac — Docker Desktop not running — is a 10-second fix nobody was
|
|
250
|
+
// told about.
|
|
251
|
+
console.log(
|
|
252
|
+
daemonDown
|
|
253
|
+
? red(" Docker is installed but not running.") +
|
|
254
|
+
dim(
|
|
255
|
+
platform === "darwin"
|
|
256
|
+
? "\n Open Docker Desktop, wait for the whale icon to settle, then re-run this."
|
|
257
|
+
: "\n Start the Docker daemon (e.g. `sudo systemctl start docker`), then re-run this."
|
|
258
|
+
)
|
|
259
|
+
: red(" Docker is not available here, and MongoDB is what stores your conversations.") +
|
|
260
|
+
dim(
|
|
261
|
+
platform === "darwin"
|
|
262
|
+
? "\n Install Docker Desktop (https://docker.com), or a native MongoDB" +
|
|
263
|
+
"\n (`brew tap mongodb/brew && brew install mongodb-community`)."
|
|
264
|
+
: "\n Install Docker, or MongoDB itself, then re-run this."
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
const plan = mongoDockerPlan({ exists: containerExists });
|
|
270
|
+
console.log("\n " + bold("Docker is available on this machine."));
|
|
271
|
+
console.log(dim(` Simplest path: ${plan.summary}.`));
|
|
272
|
+
console.log(dim(" Your conversation history lives in that volume and survives a restart."));
|
|
273
|
+
console.log(dim("\n " + plan.commands[0] + "\n"));
|
|
274
|
+
const yes = await ask(" Start MongoDB in Docker now? [Y/n]: ");
|
|
275
|
+
if (String(yes ?? "").trim().toLowerCase().startsWith("n")) return false;
|
|
276
|
+
|
|
277
|
+
for (const cmd of plan.commands) {
|
|
278
|
+
console.log(dim(` $ ${cmd}`));
|
|
279
|
+
const out = await tryRun("sh", ["-c", cmd], 180000);
|
|
280
|
+
if (out === null) {
|
|
281
|
+
console.log(red(" that command failed — run it yourself to see why, then re-run `gu`."));
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Up to ~60s: pulling mongo:8 on a fresh machine is the slow part, and it happens inside
|
|
286
|
+
// `docker run`, so the wait here is only mongod's own start-up.
|
|
287
|
+
const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
|
|
288
|
+
process.stdout.write(dim(" waiting for MongoDB to accept connections"));
|
|
289
|
+
for (let i = 0; i < 30; i++) {
|
|
290
|
+
if (await tcpReachable(host, port, 2000)) {
|
|
291
|
+
console.log(cyan(" ✓ MongoDB is up") + dim(` at ${host}:${port} (container ${MONGO_DOCKER_NAME})`));
|
|
292
|
+
return true;
|
|
293
|
+
}
|
|
294
|
+
process.stdout.write(dim("."));
|
|
295
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
296
|
+
}
|
|
297
|
+
console.log(red("\n it did not start answering — check `docker logs " + MONGO_DOCKER_NAME + "`."));
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Where the published API packages live. Overridable so a fork, a staging bucket or an
|
|
303
|
+
* air-gapped mirror does not need a code change.
|
|
304
|
+
*/
|
|
305
|
+
export const API_PACKAGE_URL =
|
|
306
|
+
process.env.GONEXT_API_PACKAGE_URL ||
|
|
307
|
+
"https://gu-api-packages.s3.ap-southeast-1.amazonaws.com/latest.json";
|
|
308
|
+
|
|
309
|
+
/** Where a downloaded API is unpacked: one directory per version, never overwritten. */
|
|
310
|
+
export const apiInstallDir = (version) =>
|
|
311
|
+
join(homedir(), ".gonext", "api", String(version));
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* What the publisher says is current.
|
|
315
|
+
*
|
|
316
|
+
* Read EVERY time rather than cached, because "use the latest API" is the whole point: a
|
|
317
|
+
* machine set up months ago and one set up today must land on the same version.
|
|
318
|
+
*/
|
|
319
|
+
export async function fetchLatestApi(url = API_PACKAGE_URL) {
|
|
320
|
+
const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(20_000) });
|
|
321
|
+
if (!res.ok) throw new Error(`${res.status} fetching ${url}`);
|
|
322
|
+
const m = await res.json();
|
|
323
|
+
for (const field of ["version", "url", "sha256"]) {
|
|
324
|
+
if (!m?.[field]) throw new Error(`latest.json is missing "${field}"`);
|
|
325
|
+
}
|
|
326
|
+
return m;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Which API version (if any) is already unpacked here. */
|
|
330
|
+
export async function probeApiInstall(version) {
|
|
331
|
+
if (!version) return { installed: false, dir: null };
|
|
332
|
+
const dir = apiInstallDir(version);
|
|
333
|
+
try {
|
|
334
|
+
// package.json, not the folder: an interrupted download leaves a directory behind, and a
|
|
335
|
+
// directory is exactly what a naive "is it installed?" check would accept.
|
|
336
|
+
await readFile(join(dir, "package.json"), "utf8");
|
|
337
|
+
return { installed: true, dir };
|
|
338
|
+
} catch {
|
|
339
|
+
return { installed: false, dir };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Download and unpack the published API.
|
|
345
|
+
*
|
|
346
|
+
* VERIFIED BEFORE IT IS UNPACKED. A truncated download still produces a file, and a partial
|
|
347
|
+
* unpack still produces a folder — which the install check would then accept forever. The
|
|
348
|
+
* checksum is the only thing that distinguishes "downloaded" from "downloaded correctly".
|
|
349
|
+
*/
|
|
350
|
+
export async function downloadApiPackage(manifest, { onProgress = () => {} } = {}) {
|
|
351
|
+
const dir = apiInstallDir(manifest.version);
|
|
352
|
+
onProgress(`downloading ${manifest.file ?? manifest.version}…`);
|
|
353
|
+
const res = await fetch(manifest.url, {
|
|
354
|
+
redirect: "follow",
|
|
355
|
+
signal: AbortSignal.timeout(120_000),
|
|
356
|
+
});
|
|
357
|
+
if (!res.ok) throw new Error(`${res.status} downloading ${manifest.url}`);
|
|
358
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
359
|
+
|
|
360
|
+
const got = createHash("sha256").update(bytes).digest("hex");
|
|
361
|
+
if (manifest.sha256 && got !== manifest.sha256) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`checksum mismatch — expected ${manifest.sha256.slice(0, 12)}…, got ${got.slice(0, 12)}…. ` +
|
|
364
|
+
"The download was corrupted or the published package changed; nothing was installed."
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (manifest.bytes && bytes.length !== manifest.bytes) {
|
|
368
|
+
throw new Error(`size mismatch — expected ${manifest.bytes} bytes, got ${bytes.length}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Unpack into a TEMP directory and move it into place only once it is whole, so an
|
|
372
|
+
// interrupted extraction can never leave a half-populated version dir looking installed.
|
|
373
|
+
const staging = `${dir}.incoming`;
|
|
374
|
+
await rm(staging, { recursive: true, force: true });
|
|
375
|
+
await mkdir(staging, { recursive: true });
|
|
376
|
+
const tmpTar = join(staging, "package.tar.gz");
|
|
377
|
+
await writeFile(tmpTar, bytes);
|
|
378
|
+
onProgress(`unpacking ${bytes.length} bytes…`);
|
|
379
|
+
await run("tar", ["xzf", tmpTar, "-C", staging]);
|
|
380
|
+
await rm(tmpTar, { force: true });
|
|
381
|
+
await rm(dir, { recursive: true, force: true });
|
|
382
|
+
await mkdir(join(homedir(), ".gonext", "api"), { recursive: true });
|
|
383
|
+
await rename(staging, dir);
|
|
384
|
+
onProgress(`installed ${manifest.version} → ${dir}`);
|
|
385
|
+
return { dir, version: manifest.version };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function loadState() {
|
|
389
|
+
try {
|
|
390
|
+
return JSON.parse(await readFile(STATE_FILE, "utf8")) ?? {};
|
|
391
|
+
} catch {
|
|
392
|
+
return {};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function saveState(state) {
|
|
397
|
+
try {
|
|
398
|
+
await mkdir(join(homedir(), ".gonext"), { recursive: true });
|
|
399
|
+
await writeFile(STATE_FILE, JSON.stringify(state, null, 2) + "\n");
|
|
400
|
+
} catch {
|
|
401
|
+
/* the walkthrough still works without notes; it just re-verifies more */
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The one question that cannot be probed. Returns "host" | "client" | "client-expert" | null.
|
|
407
|
+
*
|
|
408
|
+
* `ask` is injected so this file never owns a readline — the caller is mid-first-run and
|
|
409
|
+
* already has one open.
|
|
410
|
+
*/
|
|
411
|
+
/** `win32` is a build target, not something to show a person. */
|
|
412
|
+
function platformName(p) {
|
|
413
|
+
return { win32: "Windows", darwin: "macOS", linux: "Linux" }[p] ?? String(p);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export async function askMode(ask, { expertAvailable, expertWhy, platform = process.platform }) {
|
|
417
|
+
// HOST IS macOS-ONLY, AND THE MENU HAS TO ENFORCE THAT, not merely mention it. Serving models
|
|
418
|
+
// to your account means running MLX, which is Apple-silicon only — but option 2 was offered
|
|
419
|
+
// and accepted everywhere, so a Windows or Linux user could read "macOS only", pick it anyway,
|
|
420
|
+
// and be put into a mode that cannot work on their machine. Local (option 3) has had a real
|
|
421
|
+
// availability gate all along; this gives Host the same one, so every line in the menu is now
|
|
422
|
+
// a promise the menu keeps. Existing machines are unaffected: `chosenMode` is already saved,
|
|
423
|
+
// and shouldAskMode() never re-asks a settled machine.
|
|
424
|
+
const hostAvailable = platform === "darwin";
|
|
425
|
+
console.log("");
|
|
426
|
+
console.log(bold(" How should this machine run?"));
|
|
427
|
+
// THE NAMES SAY WHO KEEPS THE CHAT HISTORY, by request. The previous set led with hardware
|
|
428
|
+
// ("I can share data (> 16GB of VRAM)"), which answered a question nobody asks first: the
|
|
429
|
+
// thing a person actually decides here is whether their conversations are stored by us or
|
|
430
|
+
// stay on their machine. So that is the name, and the hardware and platform facts move to
|
|
431
|
+
// the line underneath, where they still gate the choice.
|
|
432
|
+
console.log(` ${bold("1")} We store chat history for you ${dim("— models and data are hosted; this")}`);
|
|
433
|
+
console.log(` ${dim(" machine just runs the agent. Ubuntu, Windows or macOS.")}`);
|
|
434
|
+
if (hostAvailable) {
|
|
435
|
+
console.log(` ${bold("2")} MLX — We store chat history for you ${dim("— this Mac's GPU serves the")}`);
|
|
436
|
+
console.log(` ${dim(" models to your account; the history is still hosted. macOS only.")}`);
|
|
437
|
+
} else {
|
|
438
|
+
console.log(` ${bold("2")} ${dim(`MLX — We store chat history for you — not on ${platformName(platform)}: serving needs a Mac`)}`);
|
|
439
|
+
console.log(` ${dim(" with Apple silicon and 16 GB or more of unified memory.")}`);
|
|
440
|
+
}
|
|
441
|
+
if (expertAvailable) {
|
|
442
|
+
console.log(` ${bold("3")} You need private ${dim("— the whole stack (API, database, models) on")}`);
|
|
443
|
+
console.log(` ${dim(" this machine; nothing it does is shared. macOS, Ubuntu or Windows.")}`);
|
|
444
|
+
} else {
|
|
445
|
+
console.log(` ${bold("3")} ${dim(`You need private — not on ${platformName(platform)}: it needs macOS or Ubuntu,`)}`);
|
|
446
|
+
console.log(` ${dim(` ${expertWhy}`)}`);
|
|
447
|
+
}
|
|
448
|
+
const typed = (await ask(" choose [1]: ")).trim();
|
|
449
|
+
// THE RETURNED STRINGS ARE STORAGE, NOT LABELS, and they deliberately did not change with
|
|
450
|
+
// the names above. `chosenMode` is already on disk on every machine in the field; renaming
|
|
451
|
+
// the value would make shouldAskMode() stop recognising it and re-ask the mode question on
|
|
452
|
+
// upgrade — which has happened before, to machines that had been working for months, and is
|
|
453
|
+
// why client-host-modes.test.mjs exists. A value rename needs a read-side migration first.
|
|
454
|
+
if (typed === "3") return expertAvailable ? "client-expert" : null;
|
|
455
|
+
if (typed === "2") return hostAvailable ? "host" : null;
|
|
456
|
+
return "client";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Walk Client Expert setup as far as it currently goes, printing what is happening.
|
|
461
|
+
*
|
|
462
|
+
* Returns { complete, stoppedAt } so the caller knows whether to carry on into the normal
|
|
463
|
+
* first-run flow or to leave the user with instructions and exit.
|
|
464
|
+
*/
|
|
465
|
+
/**
|
|
466
|
+
* Remember where we were if the process dies mid-step.
|
|
467
|
+
*
|
|
468
|
+
* Every EARLY RETURN records its step — that is the resume mechanism and it is tested. What
|
|
469
|
+
* nothing recorded was a Ctrl+C, a closed window, or a crash DURING a step: the longest ones
|
|
470
|
+
* here are a 350-package install and a model download, which is exactly when someone gives up
|
|
471
|
+
* and walks away. The plan then still said the previous step was the last thing that happened,
|
|
472
|
+
* so the next run had nothing to say about where it stopped.
|
|
473
|
+
*
|
|
474
|
+
* Records the step as INTERRUPTED, not failed: nothing went wrong, and it must not count
|
|
475
|
+
* toward the give-up-after-three-attempts logic.
|
|
476
|
+
*/
|
|
477
|
+
export function markInterruptedOnExit(getState, stepId) {
|
|
478
|
+
let done = false;
|
|
479
|
+
const mark = () => {
|
|
480
|
+
if (done) return;
|
|
481
|
+
done = true;
|
|
482
|
+
try {
|
|
483
|
+
const state = getState();
|
|
484
|
+
// Synchronous on purpose: an exit handler gets no await, and a promise here would be
|
|
485
|
+
// abandoned before it wrote anything.
|
|
486
|
+
writeFileSync(
|
|
487
|
+
STATE_FILE,
|
|
488
|
+
JSON.stringify(
|
|
489
|
+
{ ...recordStep(state, stepId, "interrupted", { reason: "interrupted — gu stopped during this step" }) },
|
|
490
|
+
null,
|
|
491
|
+
2
|
|
492
|
+
) + "\n"
|
|
493
|
+
);
|
|
494
|
+
} catch {
|
|
495
|
+
/* a best-effort note; never let it interfere with the exit itself */
|
|
496
|
+
}
|
|
497
|
+
// AND ACTUALLY STOP. Registering a SIGINT listener REPLACES Node's default behaviour, so
|
|
498
|
+
// without this the note was written and setup carried straight on — finishing the step and
|
|
499
|
+
// recording it "done" over the interruption. Ctrl+C during a minute-long install has to
|
|
500
|
+
// mean what the user meant by it.
|
|
501
|
+
console.log(
|
|
502
|
+
"\n " + yellow("Stopped.") +
|
|
503
|
+
dim(" Run `gu` again and setup continues from this step.\n")
|
|
504
|
+
);
|
|
505
|
+
process.exit(130); // 128 + SIGINT, the conventional code for "killed by Ctrl+C"
|
|
506
|
+
};
|
|
507
|
+
process.once("SIGINT", mark);
|
|
508
|
+
process.once("SIGTERM", mark);
|
|
509
|
+
return () => {
|
|
510
|
+
process.off("SIGINT", mark);
|
|
511
|
+
process.off("SIGTERM", mark);
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export async function runClientExpertSetup({ ask }) {
|
|
516
|
+
const machine = await probeMachine();
|
|
517
|
+
const gate = clientExpertAvailable({ workerEnvExists: false, ...machine });
|
|
518
|
+
if (!gate.available) {
|
|
519
|
+
console.log("\n " + red("✗ ") + gate.message + "\n");
|
|
520
|
+
return { complete: false, stoppedAt: "gate" };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
let state = await loadState();
|
|
524
|
+
const mongo = await probeMongo();
|
|
525
|
+
const probe = {
|
|
526
|
+
mode: "client-expert",
|
|
527
|
+
mongoRunning: mongo.running,
|
|
528
|
+
// Everything below MongoDB is not implemented yet — reported honestly rather than guessed.
|
|
529
|
+
dbReady: false,
|
|
530
|
+
apiInstalled: false,
|
|
531
|
+
apiHealthy: false,
|
|
532
|
+
contractOk: false,
|
|
533
|
+
workerEnvPointsLocal: false,
|
|
534
|
+
modelsConfigured: false,
|
|
535
|
+
};
|
|
536
|
+
state = recordStep(state, "mode", "done");
|
|
537
|
+
await saveState(state);
|
|
538
|
+
|
|
539
|
+
const plan = planSetup({ recorded: state, probe });
|
|
540
|
+
console.log("\n " + bold("Local mode setup") + dim(` ${machine.platform}/${machine.arch}`));
|
|
541
|
+
for (const step of plan.steps) {
|
|
542
|
+
const mark = step.state === "done" ? cyan("✓") : step.state === "reopened" ? yellow("↻") : dim("·");
|
|
543
|
+
console.log(` ${mark} ${step.state === "done" ? dim(step.title) : step.title}`);
|
|
544
|
+
}
|
|
545
|
+
if (plan.resuming) {
|
|
546
|
+
console.log("\n " + dim(`resuming — you stopped during "${plan.next}" last time`));
|
|
547
|
+
const why = stepReason(state, plan.next);
|
|
548
|
+
if (why) console.log(" " + dim(`(${why})`));
|
|
549
|
+
const resume = resumeAction(state, plan.next);
|
|
550
|
+
for (const c of resume.cleanup) console.log(" " + dim(`first: ${c}`));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (plan.next === "mongo") {
|
|
554
|
+
// WE ONLY CHECK. Installing a database is a system-wide change needing sudo, a package
|
|
555
|
+
// manager and a working repo for this architecture — none of which a terminal should take
|
|
556
|
+
// on unasked, and all of which the user can do better themselves. An earlier version asked
|
|
557
|
+
// "Install MongoDB now? [y/N]", which was a question this code could not honour: it prints
|
|
558
|
+
// commands either way, so "yes" and "no" did the same thing. A choice that changes nothing
|
|
559
|
+
// is worse than no choice.
|
|
560
|
+
console.log("\n " + bold("MongoDB is not running on this machine."));
|
|
561
|
+
console.log(dim(" Local mode keeps every conversation and token count in a database"));
|
|
562
|
+
console.log(dim(" on THIS machine, so it needs one before setup can go further."));
|
|
563
|
+
// DOCKER FIRST, WHERE IT EXISTS. This is the one branch here that can actually finish the
|
|
564
|
+
// job rather than print homework: one container, no tap, no system service, and the same
|
|
565
|
+
// mongo:8 the project's own end-to-end tests run against. Only offered when `docker ps`
|
|
566
|
+
// answers, so a machine with Docker installed-but-not-running is not sent down a dead end.
|
|
567
|
+
// On success setup CONTINUES in this same run instead of asking the user to start over.
|
|
568
|
+
if (await startMongoInDocker(ask)) {
|
|
569
|
+
mongo.running = true;
|
|
570
|
+
probe.mongoRunning = true;
|
|
571
|
+
}
|
|
572
|
+
if (probe.mongoRunning) {
|
|
573
|
+
// fall through to the rest of setup below — nothing left to instruct.
|
|
574
|
+
} else if (mongo.installed) {
|
|
575
|
+
console.log(dim("\n It looks installed but is not answering. Start it with:\n"));
|
|
576
|
+
console.log(
|
|
577
|
+
" " + (machine.platform === "darwin"
|
|
578
|
+
? "brew services start mongodb-community"
|
|
579
|
+
: machine.platform === "win32"
|
|
580
|
+
? "net start MongoDB"
|
|
581
|
+
: "sudo systemctl enable --now mongod")
|
|
582
|
+
);
|
|
583
|
+
} else {
|
|
584
|
+
const install = mongoInstallPlan(machine.platform);
|
|
585
|
+
console.log(dim("\n Install it however you prefer — for reference, the usual way here is:\n"));
|
|
586
|
+
for (const c of install.commands) console.log(dim(" " + c));
|
|
587
|
+
console.log("\n " + dim(`then check it answers: ${install.verify}`));
|
|
588
|
+
}
|
|
589
|
+
// STOP ONLY IF IT IS STILL NOT RUNNING. When Docker started one above there is nothing to
|
|
590
|
+
// wait for, so setup carries straight on in this run — sending someone away to re-run
|
|
591
|
+
// `gu` after we have just solved their problem for them is the kind of small
|
|
592
|
+
// indignity that makes a tool feel unfinished.
|
|
593
|
+
if (!probe.mongoRunning) {
|
|
594
|
+
// No "meanwhile this works in CLIENT mode" any more: that fallback signed the user in,
|
|
595
|
+
// which wrote the worker.env that marks a machine as paired — and then refused Local
|
|
596
|
+
// mode because the machine was paired. Nothing is configured here yet, so the honest
|
|
597
|
+
// end of this path is the instruction and an exit.
|
|
598
|
+
console.log("\n " + dim("Run `gu` again once it is running — setup continues from here."));
|
|
599
|
+
// Recorded as interrupted, not failed: nothing went wrong, the user simply has something
|
|
600
|
+
// to do first. The next run greets them as a resume rather than as a stranger.
|
|
601
|
+
state = recordStep(state, "mongo", "interrupted", {
|
|
602
|
+
reason: mongo.installed ? "installed but not answering" : "not installed yet",
|
|
603
|
+
});
|
|
604
|
+
await saveState(state);
|
|
605
|
+
console.log("");
|
|
606
|
+
return { complete: false, stoppedAt: "mongo" };
|
|
607
|
+
}
|
|
608
|
+
// Re-probe rather than trusting the flag: the socket answered, but `how` is what the
|
|
609
|
+
// success line below reports, and it should say what actually found it.
|
|
610
|
+
const after = await probeMongo();
|
|
611
|
+
if (after.running) {
|
|
612
|
+
mongo.running = true;
|
|
613
|
+
mongo.how = after.how;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
console.log("\n " + cyan("✓ MongoDB is running") + dim(` (${mongo.how})`));
|
|
618
|
+
state = recordStep(state, "mongo", "done");
|
|
619
|
+
await saveState(state);
|
|
620
|
+
|
|
621
|
+
// --- the API package -----------------------------------------------------------------
|
|
622
|
+
// ALWAYS asks the publisher what is current, rather than trusting anything cached: "a new
|
|
623
|
+
// machine gets the latest API" is the whole promise, and a machine set up months from now
|
|
624
|
+
// must land on the same version as one set up today.
|
|
625
|
+
let manifest;
|
|
626
|
+
try {
|
|
627
|
+
manifest = await fetchLatestApi();
|
|
628
|
+
} catch (e) {
|
|
629
|
+
console.log("\n " + red("✗ ") + `Could not reach the API package index: ${e.message}`);
|
|
630
|
+
console.log(dim(" Setup needs it to know which API to install. Check the network and run"));
|
|
631
|
+
console.log(dim(" `gu` again — nothing has been changed on this machine.\n"));
|
|
632
|
+
state = recordStep(state, "api-files", "failed", { reason: `index unreachable: ${e.message}` });
|
|
633
|
+
await saveState(state);
|
|
634
|
+
return { complete: false, stoppedAt: "api-files" };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const have = await probeApiInstall(manifest.version);
|
|
638
|
+
if (have.installed) {
|
|
639
|
+
console.log(" " + cyan("✓ API ") + dim(`${manifest.version} already downloaded`));
|
|
640
|
+
} else {
|
|
641
|
+
console.log("\n " + bold(`Downloading the API (${manifest.version})`) +
|
|
642
|
+
dim(` ${Math.round((manifest.bytes ?? 0) / 1024)} KB`));
|
|
643
|
+
const unmark = markInterruptedOnExit(() => state, "api-files");
|
|
644
|
+
try {
|
|
645
|
+
await downloadApiPackage(manifest, { onProgress: (m) => console.log(dim(" " + m)) });
|
|
646
|
+
} catch (e) {
|
|
647
|
+
// A checksum or size mismatch lands here, and NOTHING was installed — the unpack goes
|
|
648
|
+
// to a staging dir and is only moved into place once it is whole.
|
|
649
|
+
console.log("\n " + red("✗ ") + e.message + "\n");
|
|
650
|
+
state = recordStep(state, "api-files", "failed", { reason: e.message });
|
|
651
|
+
await saveState(state);
|
|
652
|
+
return { complete: false, stoppedAt: "api-files" };
|
|
653
|
+
} finally {
|
|
654
|
+
unmark();
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
state = recordStep(state, "api-files", "done");
|
|
658
|
+
await saveState(state);
|
|
659
|
+
|
|
660
|
+
// --- dependencies --------------------------------------------------------------------
|
|
661
|
+
// The package ships source only (~145 KB); its ~360 dependencies are installed here. This
|
|
662
|
+
// is the slowest step by far, so it says so rather than looking hung.
|
|
663
|
+
const apiDir = apiInstallDir(manifest.version);
|
|
664
|
+
if (!(await apiDepsInstalled(apiDir))) {
|
|
665
|
+
console.log("\n " + bold("Installing the API's dependencies") + dim(" this takes a minute"));
|
|
666
|
+
const unmark = markInterruptedOnExit(() => state, "database");
|
|
667
|
+
try {
|
|
668
|
+
await installApiDeps(apiDir, {
|
|
669
|
+
onLine: (l) => l && l.length < 200 && console.log(dim(" " + l)),
|
|
670
|
+
});
|
|
671
|
+
} catch (e) {
|
|
672
|
+
console.log("\n " + red("✗ ") + e.message + "\n");
|
|
673
|
+
state = recordStep(state, "database", "failed", { reason: `npm install: ${e.message}` });
|
|
674
|
+
await saveState(state);
|
|
675
|
+
return { complete: false, stoppedAt: "database" };
|
|
676
|
+
} finally {
|
|
677
|
+
unmark();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// --- database + worker key -----------------------------------------------------------
|
|
682
|
+
// Reuses the key already minted for this machine when there is one, so re-running setup
|
|
683
|
+
// does not strand the rows written under the previous identity.
|
|
684
|
+
let creds;
|
|
685
|
+
const hadKey = Boolean(state.localWorkerKey);
|
|
686
|
+
try {
|
|
687
|
+
creds = await seedDatabase({
|
|
688
|
+
apiDir,
|
|
689
|
+
userId: state.localUserId,
|
|
690
|
+
workerKey: state.localWorkerKey,
|
|
691
|
+
});
|
|
692
|
+
} catch (e) {
|
|
693
|
+
console.log("\n " + red("✗ ") + `Could not prepare the database: ${e.message}`);
|
|
694
|
+
console.log(dim(" MongoDB answered a moment ago, so this is usually a permissions or"));
|
|
695
|
+
console.log(dim(" connection-string problem. Nothing has been changed.\n"));
|
|
696
|
+
state = recordStep(state, "database", "failed", { reason: e.message });
|
|
697
|
+
await saveState(state);
|
|
698
|
+
return { complete: false, stoppedAt: "database" };
|
|
699
|
+
}
|
|
700
|
+
// Says what actually happened: on a re-run nothing is created, and claiming otherwise would
|
|
701
|
+
// make an idempotent step look destructive.
|
|
702
|
+
console.log(
|
|
703
|
+
" " + cyan("✓ Database ready") +
|
|
704
|
+
dim(` (${creds.dbName}) — worker key ${hadKey ? "reused" : "created"}`)
|
|
705
|
+
);
|
|
706
|
+
// Persisted so a later run reuses this identity instead of minting a second one.
|
|
707
|
+
state = { ...state, localUserId: creds.userId, localWorkerKey: creds.workerKey };
|
|
708
|
+
state = recordStep(state, "database", "done");
|
|
709
|
+
await saveState(state);
|
|
710
|
+
|
|
711
|
+
// --- the API itself -------------------------------------------------------------------
|
|
712
|
+
let health = await probeLocalApi(LOCAL_API_PORT);
|
|
713
|
+
if (!health.ok) {
|
|
714
|
+
console.log("\n " + bold(`Starting the local API on port ${LOCAL_API_PORT}`));
|
|
715
|
+
await startLocalApi({ apiDir });
|
|
716
|
+
health = await waitForApi({ port: LOCAL_API_PORT });
|
|
717
|
+
}
|
|
718
|
+
if (!health.ok) {
|
|
719
|
+
// apiIsUsable, not a 200: a wrong DATA_BACKEND answers 200 and stores nothing.
|
|
720
|
+
console.log("\n " + red("✗ ") + `The API started but is not usable: ${health.why}`);
|
|
721
|
+
console.log(dim(` Its log is ${API_LOG_FILE}`));
|
|
722
|
+
console.log(dim(" Run `gu` again to retry — nothing else has been changed.\n"));
|
|
723
|
+
state = recordStep(state, "api-running", "failed", { reason: health.why });
|
|
724
|
+
await saveState(state);
|
|
725
|
+
return { complete: false, stoppedAt: "api-running" };
|
|
726
|
+
}
|
|
727
|
+
console.log(" " + cyan("✓ API answering") + dim(` on ${LOCAL_API_BASE} (mongo, persistence on)`));
|
|
728
|
+
state = recordStep(state, "api-running", "done");
|
|
729
|
+
await saveState(state);
|
|
730
|
+
|
|
731
|
+
// --- point this terminal at it --------------------------------------------------------
|
|
732
|
+
// LAST, and only now: worker.env is what makes the machine "paired", so writing it before
|
|
733
|
+
// the API was proven usable would leave a terminal pointed at something that does not work.
|
|
734
|
+
const envPath = await writeWorkerEnv({
|
|
735
|
+
apiBase: LOCAL_API_BASE,
|
|
736
|
+
workerKey: creds.workerKey,
|
|
737
|
+
});
|
|
738
|
+
console.log(" " + cyan("✓ Terminal pointed at the local API") + dim(` (${envPath})`));
|
|
739
|
+
state = recordStep(state, "worker-env", "done");
|
|
740
|
+
await saveState(state);
|
|
741
|
+
|
|
742
|
+
// RESTART THE DAEMON, because worker.env just changed under it.
|
|
743
|
+
//
|
|
744
|
+
// The daemon reads GONEXT_API_BASE and GONEXT_WORKER_KEY ONCE, at startup. A machine that ran
|
|
745
|
+
// `gu` before choosing Client Expert already has one polling the HOSTED api with the old
|
|
746
|
+
// key — and daemonStatus() finds a running daemon by scanning processes, so nothing starts a
|
|
747
|
+
// new one either. The result is a machine where every check passes and no turn ever runs:
|
|
748
|
+
// questions are enqueued into the local database and the only worker is watching a different
|
|
749
|
+
// one. "The local API works but actually not working" — reported exactly that way.
|
|
750
|
+
try {
|
|
751
|
+
const ctl = await import("./daemon-control.mjs");
|
|
752
|
+
const status = await ctl.daemonStatus();
|
|
753
|
+
if (status.running) {
|
|
754
|
+
await ctl.stopDaemon();
|
|
755
|
+
const r = await ctl.startDaemon();
|
|
756
|
+
console.log(
|
|
757
|
+
" " + cyan("✓ Background worker restarted") +
|
|
758
|
+
dim(r.started ? ` (pid ${r.pid}) — it now polls the local API` : " — start it with `gu start`")
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
} catch (e) {
|
|
762
|
+
// Non-fatal, but say so: a stale daemon is the difference between "set up" and "works".
|
|
763
|
+
console.log("\n " + yellow("⚠ could not restart the background worker: ") + e.message);
|
|
764
|
+
console.log(dim(" Run `gu-cli restart` — until you do, turns will be enqueued but"));
|
|
765
|
+
console.log(dim(" never picked up, because the running worker is still polling the old API.\n"));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// --- models -------------------------------------------------------------------------
|
|
769
|
+
// The LAST step, and the one that was missing entirely: this machine's database is brand
|
|
770
|
+
// new, so it has no settings row, and the terminal refused to start with "no agent model is
|
|
771
|
+
// configured for your account yet" — advice that points at the hosted API this machine
|
|
772
|
+
// deliberately does not use. A normal account gets these defaults seeded when it pairs;
|
|
773
|
+
// Client Expert never pairs, so it seeds its own.
|
|
774
|
+
// ASK WHICH BOX (task #220). It used to seed the shared Ollama with no question, because the
|
|
775
|
+
// alternative at the time was "a URL that answers nothing" — the failure that shipped a
|
|
776
|
+
// "successfully configured" machine whose first question failed. The question is back, and it
|
|
777
|
+
// is safe now for one reason: askOllamaSetup PROVES the box answers and lists a usable model
|
|
778
|
+
// before it returns anything. Enter accepts the shared box, so nobody who does not care has to
|
|
779
|
+
// think about it, and a machine on a LAN can finally say so.
|
|
780
|
+
//
|
|
781
|
+
// A null answer — declined, or a box we could not reach after retries — falls through to the
|
|
782
|
+
// seeded defaults, which is exactly today's behaviour plus today's Kimi path via `/model`.
|
|
783
|
+
let chosenModels;
|
|
784
|
+
try {
|
|
785
|
+
// DO NOT ASK A QUESTION WE WOULD THEN IGNORE. settingsPatch leaves an already-configured
|
|
786
|
+
// machine alone (that rule protects anyone who set their models up by hand), so on a re-run
|
|
787
|
+
// of this step the answer would be collected and silently discarded. Point at `/model`
|
|
788
|
+
// instead — it is the one path that CAN change a machine that is already set up.
|
|
789
|
+
const already = await modelsAlreadyConfigured({ apiDir, userId: creds.userId });
|
|
790
|
+
if (already) {
|
|
791
|
+
console.log("");
|
|
792
|
+
console.log(dim(" Models are already configured on this machine — leaving them alone."));
|
|
793
|
+
console.log(dim(" To point it at a different Ollama server: `/model` → change the Ollama"));
|
|
794
|
+
console.log(dim(" server this machine uses."));
|
|
795
|
+
throw new SkipModelQuestion();
|
|
796
|
+
}
|
|
797
|
+
console.log("");
|
|
798
|
+
console.log(" " + bold("Which Ollama server should this machine use?"));
|
|
799
|
+
// THIS MACHINE FIRST, when it has one. Client Expert is the mode whose whole promise is
|
|
800
|
+
// "the whole stack on this machine; nothing it does is shared" — and it was offering a
|
|
801
|
+
// shared server on someone else's hardware as the default, which is the opposite of what
|
|
802
|
+
// was chosen one question earlier. Probed rather than assumed, so a machine without Ollama
|
|
803
|
+
// is still offered the shared box exactly as before.
|
|
804
|
+
const defaultUrl = await localOllamaDefault(
|
|
805
|
+
CLIENT_EXPERT_MODELS.url,
|
|
806
|
+
fetchOllamaTags,
|
|
807
|
+
{ log: (l) => console.log(dim(l)) },
|
|
808
|
+
);
|
|
809
|
+
const isLocal = defaultUrl !== CLIENT_EXPERT_MODELS.url;
|
|
810
|
+
console.log(
|
|
811
|
+
dim(
|
|
812
|
+
isLocal
|
|
813
|
+
? " Enter uses the one on this machine. Or give another — 192.168.1.40,"
|
|
814
|
+
: " Enter keeps the shared one. Or give your own — 192.168.1.40,",
|
|
815
|
+
),
|
|
816
|
+
);
|
|
817
|
+
console.log(dim(" ollama.local, http://box:11434 — and pick from what it actually has."));
|
|
818
|
+
chosenModels = await askOllamaSetup(ask, {
|
|
819
|
+
defaultUrl,
|
|
820
|
+
fetchTags: fetchOllamaTags,
|
|
821
|
+
formatSize,
|
|
822
|
+
// THE SHIPPED DEFAULT WINS WHEN THE BOX HAS IT. Without this a fresh machine falls back to
|
|
823
|
+
// a size heuristic, and on a box with several similar builds that picks by size alone —
|
|
824
|
+
// observed on ollama2, where gemma4-quadro:latest and gemma4-quadro-49k:latest are
|
|
825
|
+
// byte-identical and only the second has the configured 49k context. Every other machine
|
|
826
|
+
// gets the shipped coder; a new one should not differ because of a tie-break.
|
|
827
|
+
preferred: { code: CLIENT_EXPERT_MODELS.code },
|
|
828
|
+
});
|
|
829
|
+
} catch (e) {
|
|
830
|
+
// A question that throws must never cost the machine its setup — fall back to the seed.
|
|
831
|
+
// The skip is not a failure and says nothing extra; it has already printed its own line.
|
|
832
|
+
if (!(e instanceof SkipModelQuestion)) {
|
|
833
|
+
console.log(dim(` (could not ask: ${e.message} — using the shared Ollama)`));
|
|
834
|
+
}
|
|
835
|
+
chosenModels = null;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
try {
|
|
839
|
+
const written = await seedSettings({
|
|
840
|
+
apiDir,
|
|
841
|
+
userId: creds.userId,
|
|
842
|
+
...(chosenModels ? { models: chosenModels } : {}),
|
|
843
|
+
});
|
|
844
|
+
if (Object.keys(written).length === 0) {
|
|
845
|
+
console.log(" " + cyan("✓ Models and RAG") + dim(" — already configured, left alone"));
|
|
846
|
+
} else {
|
|
847
|
+
console.log(
|
|
848
|
+
" " + cyan("✓ Models and RAG configured") +
|
|
849
|
+
dim(chosenModels ? " — the server you chose" : " — the shared Ollama")
|
|
850
|
+
);
|
|
851
|
+
// NAME ALL THREE. Without a key there is no cloud coder, so the machine falls back to
|
|
852
|
+
// the shared Ollama for everything — and someone who did not choose that should not have
|
|
853
|
+
// to read the database to find out what their turns are actually using.
|
|
854
|
+
const host = String(written.agentModelUrl || "").replace(/^https?:\/\//, "").replace(/\/v1\/?$/, "");
|
|
855
|
+
if (written.agentModelUrl) {
|
|
856
|
+
console.log(dim(` chat ${written.agentModel || "(default)"} ${dim(`@ ${host}`)}`));
|
|
857
|
+
}
|
|
858
|
+
if (written.agentCodingModelName) {
|
|
859
|
+
console.log(
|
|
860
|
+
dim(` code ${written.agentCodingModelName} @ ${host}`) +
|
|
861
|
+
dim(" — change it later with `/model`")
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
if (written.ragEmbedModel) {
|
|
865
|
+
const eh = String(written.ragEmbedUrl).replace(/^https?:\/\//, "").replace(/\/v1\/?$/, "");
|
|
866
|
+
console.log(dim(` RAG ${written.ragEmbedModel} @ ${eh}`));
|
|
867
|
+
}
|
|
868
|
+
console.log(
|
|
869
|
+
dim("\n No API key needed. `/model` switches the code model") +
|
|
870
|
+
dim(chosenModels ? " (and re-runs this setup)." : " (Kimi K3 is listed).")
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
state = recordStep(state, "models", "done");
|
|
874
|
+
await saveState(state);
|
|
875
|
+
} catch (e) {
|
|
876
|
+
// NOT fatal: everything else works, and the model can be set from the terminal. But say so
|
|
877
|
+
// clearly, because the symptom otherwise is the terminal refusing to start.
|
|
878
|
+
console.log("\n " + yellow("⚠ could not seed the model settings: ") + e.message);
|
|
879
|
+
console.log(dim(" The terminal may say no agent model is configured. Set one with `/model`,"));
|
|
880
|
+
console.log(dim(" or run `gu doctor` to set up a local one.\n"));
|
|
881
|
+
state = recordStep(state, "models", "failed", { reason: e.message });
|
|
882
|
+
await saveState(state);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
console.log(
|
|
886
|
+
"\n " + cyan(bold("Local mode is set up.")) +
|
|
887
|
+
dim("\n Everything — conversations, token counts, workspaces — stays in MongoDB on this") +
|
|
888
|
+
dim("\n machine. The API restarts with `gu-cli api start`; its log is") +
|
|
889
|
+
dim(`\n ${API_LOG_FILE}\n`)
|
|
890
|
+
);
|
|
891
|
+
return { complete: true, stoppedAt: null };
|
|
892
|
+
}
|