@tiens.nguyen/gu-cli 1.0.770 → 1.0.772
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/cli-token.mjs +78 -5
- package/client-expert-run.mjs +75 -3
- package/gonext_agent_chat.py +103 -4
- package/gu-cli.mjs +145 -0
- package/gu-repl.mjs +142 -17
- package/mongo-native.mjs +275 -0
- package/package.json +2 -1
- package/terminal-layout.mjs +2 -2
package/cli-token.mjs
CHANGED
|
@@ -80,10 +80,10 @@ export function verifyCliToken(token, publicKeyB64) {
|
|
|
80
80
|
!p || typeof p !== "object" ||
|
|
81
81
|
p.v !== 1 ||
|
|
82
82
|
typeof p.sub !== "string" || !p.sub ||
|
|
83
|
-
(p.plan !== "pro" && p.plan !== "free") ||
|
|
83
|
+
(p.plan !== "pro" && p.plan !== "free" && p.plan !== "trial") ||
|
|
84
84
|
!(p.paidUntil === null || isIso(p.paidUntil)) ||
|
|
85
85
|
!isIso(p.issuedAt) ||
|
|
86
|
-
(p.issuedBy !== "refresh" && p.issuedBy !== "account" && p.issuedBy !== "admin") ||
|
|
86
|
+
(p.issuedBy !== "refresh" && p.issuedBy !== "account" && p.issuedBy !== "admin" && p.issuedBy !== "trial") ||
|
|
87
87
|
!(p.device === null || (typeof p.device === "string" && /^[0-9a-f]{64}$/.test(p.device))) ||
|
|
88
88
|
!(p.activeDevices === null || (Number.isInteger(p.activeDevices) && p.activeDevices >= 0)) ||
|
|
89
89
|
!(Number.isInteger(p.deviceLimit) && p.deviceLimit > 0) ||
|
|
@@ -132,6 +132,59 @@ export async function osMachineId({ platform = process.platform, read = (p) => r
|
|
|
132
132
|
return "";
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* This computer for a FREE TRIAL (#305) — or null. Only the OS machine id counts here: the fallback
|
|
137
|
+
* thisDevice() uses when it is missing (~/.gonext/instance) changes with `gu clear`, which would make
|
|
138
|
+
* the trial once-per-install instead of once-per-computer. No stable id, no trial.
|
|
139
|
+
*/
|
|
140
|
+
export async function trialDevice(opts = {}) {
|
|
141
|
+
const raw = opts.machineId ?? (await osMachineId({ platform: opts.platform }));
|
|
142
|
+
if (!raw) return null;
|
|
143
|
+
return thisDevice({ ...opts, machineId: raw });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Ask the gu service for this computer's free trial (#305). → one of:
|
|
148
|
+
* { ok: true, token, endsAt, created } — a new trial, or the one this computer already has
|
|
149
|
+
* { ok: false, why: "used", endedAt } — this computer's trial has ended; never a second one
|
|
150
|
+
* { ok: false, why: "no-device" | "unreachable" | "http-NNN" | "bad-response" }
|
|
151
|
+
* Never throws.
|
|
152
|
+
*/
|
|
153
|
+
export async function claimTrial({ hostedBase, device, fetchImpl = fetch, timeoutMs = 8000 }) {
|
|
154
|
+
if (!device) return { ok: false, why: "no-device" };
|
|
155
|
+
const base = String(hostedBase ?? "").replace(/\/+$/, "");
|
|
156
|
+
if (!base) return { ok: false, why: "unreachable" };
|
|
157
|
+
try {
|
|
158
|
+
const res = await fetchImpl(`${base}/api/billing/cli-token/trial`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "content-type": "application/json" },
|
|
161
|
+
body: JSON.stringify({ device }),
|
|
162
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
163
|
+
});
|
|
164
|
+
const data = await res.json().catch(() => null);
|
|
165
|
+
if (res.status === 409) return { ok: false, why: "used", endedAt: data?.endedAt ?? null };
|
|
166
|
+
if (!res.ok) return { ok: false, why: `http-${res.status}` };
|
|
167
|
+
return typeof data?.token === "string"
|
|
168
|
+
? { ok: true, token: data.token, endsAt: data.endsAt ?? null, created: data.created === true }
|
|
169
|
+
: { ok: false, why: "bad-response" };
|
|
170
|
+
} catch {
|
|
171
|
+
return { ok: false, why: "unreachable" };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Whole days left until an ISO date (rounded up; 0 when past). */
|
|
176
|
+
export function daysLeft(iso, now = new Date()) {
|
|
177
|
+
const ms = Date.parse(iso ?? "") - now.getTime();
|
|
178
|
+
return Number.isFinite(ms) && ms > 0 ? Math.ceil(ms / 86_400_000) : 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The one line a first install sees instead of a token question (#305). */
|
|
182
|
+
export function trialStartedLine(endsAt, site = DEFAULT_SITE) {
|
|
183
|
+
const accountUrl = `${String(site).replace(/^https?:\/\//, "")}/account`;
|
|
184
|
+
return `Free trial on this computer: 10 days, until ${shortDate(endsAt)}. ` +
|
|
185
|
+
`Then get a free token for 1 computer at ${accountUrl} — gu token <paste> links it.`;
|
|
186
|
+
}
|
|
187
|
+
|
|
135
188
|
/** What renewals send about this computer. The raw OS id never leaves the machine — only a salted hash. */
|
|
136
189
|
export async function thisDevice({ platform = process.platform, hostName = osHostname(), machineId, instanceId } = {}) {
|
|
137
190
|
const raw = machineId ?? (await osMachineId({ platform }));
|
|
@@ -289,6 +342,15 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
|
|
|
289
342
|
if (check.ok) {
|
|
290
343
|
const p = check.payload;
|
|
291
344
|
const ended = p.paidUntil && Date.parse(p.paidUntil) <= now.getTime();
|
|
345
|
+
// THE FREE TRIAL (#305): its days, and the way on — a FREE account token covers one computer
|
|
346
|
+
// (the owner's wording: "go to <site> to get a free token for your 1 free machine"). Every start,
|
|
347
|
+
// quietly; urgent only once the trial has ended.
|
|
348
|
+
if (p.plan === "trial") {
|
|
349
|
+
const way = `free token for 1 computer at ${accountUrl}`;
|
|
350
|
+
if (ended) return { kind: "trial-ended", urgent: true, text: `free trial ended · ${way}` };
|
|
351
|
+
const left = daysLeft(p.paidUntil, now);
|
|
352
|
+
return { kind: "trial", urgent: false, text: `trial · ${left} day${left === 1 ? "" : "s"} left · ${way}` };
|
|
353
|
+
}
|
|
292
354
|
// SHORT AND CALM (#292: "make it simpler … seems so serious"): the fact, then the one thing to
|
|
293
355
|
// do. `urgent` is what the bar colours by — only these two need the user to act.
|
|
294
356
|
if (ended) return { kind: "plan-ended", urgent: true, text: `Pro ended ${shortDate(p.paidUntil)} · renew at ${pricingUrl}` };
|
|
@@ -324,7 +386,7 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
|
|
|
324
386
|
}
|
|
325
387
|
|
|
326
388
|
/** "Free plan" / "Pro plan" — the name a person uses. */
|
|
327
|
-
const planName = (plan) => (plan === "pro" ? "Pro plan" : "Free plan");
|
|
389
|
+
const planName = (plan) => (plan === "pro" ? "Pro plan" : plan === "trial" ? "free trial" : "Free plan");
|
|
328
390
|
/** "1 computer" / "4 computers" — "computer", not "device": a device is also a phone. */
|
|
329
391
|
const computers = (n) => `${n} ${n === 1 ? "computer" : "computers"}`;
|
|
330
392
|
|
|
@@ -351,6 +413,10 @@ export function accountLine({ payload = null, settingsEmail = "", now = new Date
|
|
|
351
413
|
if (payload.plan === "pro" && Number.isFinite(until) && until > now.getTime()) {
|
|
352
414
|
plan = `Pro until ${shortDate(payload.paidUntil)}`;
|
|
353
415
|
pro = true;
|
|
416
|
+
} else if (payload.plan === "trial") {
|
|
417
|
+
plan = Number.isFinite(until) && until > now.getTime()
|
|
418
|
+
? `Free trial until ${shortDate(payload.paidUntil)}`
|
|
419
|
+
: `Free trial ended ${shortDate(payload.paidUntil)}`;
|
|
354
420
|
} else if (payload.paidUntil) {
|
|
355
421
|
plan = `Free — Pro ended ${shortDate(payload.paidUntil)}`;
|
|
356
422
|
} else {
|
|
@@ -364,6 +430,10 @@ export function tokenSummary(payload, now = new Date()) {
|
|
|
364
430
|
const lines = [];
|
|
365
431
|
if (payload.plan === "pro" && payload.paidUntil && Date.parse(payload.paidUntil) > now.getTime()) {
|
|
366
432
|
lines.push(`Pro until ${shortDate(payload.paidUntil)}.`);
|
|
433
|
+
} else if (payload.plan === "trial") {
|
|
434
|
+
lines.push(Date.parse(payload.paidUntil) > now.getTime()
|
|
435
|
+
? `Free trial on this computer — ${daysLeft(payload.paidUntil, now)} day(s) left, until ${shortDate(payload.paidUntil)}.`
|
|
436
|
+
: `The free trial on this computer ended on ${shortDate(payload.paidUntil)}.`);
|
|
367
437
|
} else if (payload.paidUntil) {
|
|
368
438
|
lines.push(`Free — the Pro plan ended on ${shortDate(payload.paidUntil)}.`);
|
|
369
439
|
} else {
|
|
@@ -410,11 +480,14 @@ export function enforcementDecision({ token, publicKeyB64, now = new Date(), sit
|
|
|
410
480
|
const pricingUrl = `${host}/pricing`;
|
|
411
481
|
const accountUrl = `${host}/account`;
|
|
412
482
|
const ended = p.paidUntil && Date.parse(p.paidUntil) <= now.getTime();
|
|
413
|
-
|
|
483
|
+
// A running free trial (#305) is entitled like Pro, for its ten days.
|
|
484
|
+
if ((p.plan !== "pro" && p.plan !== "trial") || ended) {
|
|
414
485
|
return {
|
|
415
486
|
enforced: true,
|
|
416
487
|
allowed: false,
|
|
417
|
-
why: ended
|
|
488
|
+
why: ended && p.plan === "trial"
|
|
489
|
+
? `The free trial on this computer ended on ${shortDate(p.paidUntil)}. Go Pro at ${pricingUrl} to keep using the agent.`
|
|
490
|
+
: ended
|
|
418
491
|
? `Your gu Pro plan ended on ${shortDate(p.paidUntil)}. Renew at ${pricingUrl} to keep using the agent.`
|
|
419
492
|
: `gu needs a Pro plan on this account. See ${pricingUrl} — the rest of gu still works.`,
|
|
420
493
|
};
|
package/client-expert-run.mjs
CHANGED
|
@@ -193,8 +193,15 @@ export async function probeMongo() {
|
|
|
193
193
|
// both `pgrep` and `mongosh`, so setup declared it missing and refused to continue on a
|
|
194
194
|
// machine where it worked perfectly. Found exactly that way, in a container, on Linux.
|
|
195
195
|
const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
|
|
196
|
+
// A HANDSHAKE, NOT AN OPEN PORT (task #304). "The port accepts a connection" also passes when
|
|
197
|
+
// Docker Desktop is stuck: its forwarder keeps 27017 open while nothing behind it answers, so
|
|
198
|
+
// setup used to call that "running" and every API call then timed out. A MongoDB `hello` is
|
|
199
|
+
// answered with or without a password; silence means nothing usable is there.
|
|
196
200
|
if (await tcpReachable(host, port)) {
|
|
197
|
-
|
|
201
|
+
const { mongoAnswers, portHolder } = await import("./mongo-native.mjs");
|
|
202
|
+
const state = await mongoAnswers(host, port);
|
|
203
|
+
if (state === "answers") return { running: true, how: `answering at ${host}:${port}` };
|
|
204
|
+
return { running: false, how: null, silentPort: true, holder: await portHolder(port), installed: false };
|
|
198
205
|
}
|
|
199
206
|
|
|
200
207
|
if (process.platform === "win32") {
|
|
@@ -272,6 +279,58 @@ export async function probeDockerForMongo(name = MONGO_DOCKER_NAME) {
|
|
|
272
279
|
};
|
|
273
280
|
}
|
|
274
281
|
|
|
282
|
+
/**
|
|
283
|
+
* macOS: make a native MongoDB answer on 27017 (task #304). → { ok, explained }.
|
|
284
|
+
* `explained` = it printed its own guidance, so the caller adds none.
|
|
285
|
+
*/
|
|
286
|
+
export async function ensureNativeMongoMac(ask, mongo, { log = (l) => console.log(l) } = {}) {
|
|
287
|
+
const mn = await import("./mongo-native.mjs");
|
|
288
|
+
const brew = mn.findBrew();
|
|
289
|
+
const { action, holder } = mn.macMongoAction({
|
|
290
|
+
state: mongo.silentPort ? "silent" : "closed",
|
|
291
|
+
holder: mongo.holder,
|
|
292
|
+
serverInstalled: Boolean(mongo.installed),
|
|
293
|
+
brew,
|
|
294
|
+
});
|
|
295
|
+
if (action === "docker-stuck" || action === "port-silent") {
|
|
296
|
+
log("");
|
|
297
|
+
for (const l of mn.silentPortLines({ action, holder })) log(" " + (l.startsWith(" ") ? l : dim(l)));
|
|
298
|
+
return { ok: false, explained: true };
|
|
299
|
+
}
|
|
300
|
+
if (action === "no-brew") {
|
|
301
|
+
log("");
|
|
302
|
+
for (const l of mn.noBrewLines()) log(dim(" " + l));
|
|
303
|
+
return { ok: false, explained: true };
|
|
304
|
+
}
|
|
305
|
+
const installing = action === "brew-install";
|
|
306
|
+
const yes = await ask(
|
|
307
|
+
installing
|
|
308
|
+
? " Install MongoDB Community with Homebrew now? [Y/n]: "
|
|
309
|
+
: " MongoDB is installed but not running. Start it now (brew services)? [Y/n]: "
|
|
310
|
+
);
|
|
311
|
+
if (String(yes ?? "").trim().toLowerCase().startsWith("n")) {
|
|
312
|
+
log(dim("\n Not installed. To do it yourself:"));
|
|
313
|
+
for (const s of installing ? mn.BREW_INSTALL_STEPS : mn.BREW_START_STEPS) log(dim(` brew ${s.join(" ")}`));
|
|
314
|
+
return { ok: false, explained: true };
|
|
315
|
+
}
|
|
316
|
+
log("\n " + bold(installing ? "Installing MongoDB Community" : "Starting MongoDB") + dim(" Homebrew, a minute or two"));
|
|
317
|
+
const done = await mn.runBrewSteps(brew, installing ? mn.BREW_INSTALL_STEPS : mn.BREW_START_STEPS, {
|
|
318
|
+
onLine: (l) => l.length < 200 && log(dim(" " + l)),
|
|
319
|
+
});
|
|
320
|
+
if (!done) {
|
|
321
|
+
log("\n " + red("✗ ") + "Homebrew did not finish — the lines above say why.");
|
|
322
|
+
return { ok: false, explained: true };
|
|
323
|
+
}
|
|
324
|
+
const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
|
|
325
|
+
if (!(await mn.waitForMongo(host, port, { timeoutMs: 60000 }))) {
|
|
326
|
+
log("\n " + red("✗ ") + `MongoDB was started but is not answering at ${host}:${port} yet.`);
|
|
327
|
+
log(dim(" Its log: /opt/homebrew/var/log/mongodb/mongo.log — then run gu again."));
|
|
328
|
+
return { ok: false, explained: true };
|
|
329
|
+
}
|
|
330
|
+
log(" " + cyan("✓ MongoDB Community is running") + dim(` at ${host}:${port} (brew services — it starts at login)`));
|
|
331
|
+
return { ok: true, explained: true };
|
|
332
|
+
}
|
|
333
|
+
|
|
275
334
|
/**
|
|
276
335
|
* Start MongoDB in a container and wait for it to answer. → true when it is up.
|
|
277
336
|
*
|
|
@@ -684,6 +743,19 @@ export async function runClientExpertSetup({
|
|
|
684
743
|
console.log("\n " + bold("MongoDB is not running on this machine."));
|
|
685
744
|
console.log(dim(" Local mode keeps every conversation and token count in a database"));
|
|
686
745
|
console.log(dim(" on THIS machine, so it needs one before setup can go further."));
|
|
746
|
+
// A MAC GETS NATIVE MONGODB, NOT DOCKER (task #304): Homebrew's mongodb-community as a brew
|
|
747
|
+
// service. Docker on a Mac puts every call through its VM, and a stuck Docker Desktop keeps
|
|
748
|
+
// 27017 open while nothing answers — the failure that stopped this project's own Mac. The
|
|
749
|
+
// Docker branch below stays for Linux and Windows.
|
|
750
|
+
let explained = false;
|
|
751
|
+
if (machine.platform === "darwin") {
|
|
752
|
+
const r = await ensureNativeMongoMac(ask, mongo);
|
|
753
|
+
if (r.ok) {
|
|
754
|
+
mongo.running = true;
|
|
755
|
+
probe.mongoRunning = true;
|
|
756
|
+
}
|
|
757
|
+
explained = r.explained;
|
|
758
|
+
} else
|
|
687
759
|
// DOCKER FIRST, WHERE IT EXISTS. This is the one branch here that can actually finish the
|
|
688
760
|
// job rather than print homework: one container, no tap, no system service, and the same
|
|
689
761
|
// mongo:8 the project's own end-to-end tests run against. Only offered when `docker ps`
|
|
@@ -693,8 +765,8 @@ export async function runClientExpertSetup({
|
|
|
693
765
|
mongo.running = true;
|
|
694
766
|
probe.mongoRunning = true;
|
|
695
767
|
}
|
|
696
|
-
if (probe.mongoRunning) {
|
|
697
|
-
// fall through
|
|
768
|
+
if (probe.mongoRunning || explained) {
|
|
769
|
+
// fall through — running, or the Mac branch has already said what to do.
|
|
698
770
|
} else if (mongo.installed) {
|
|
699
771
|
console.log(dim("\n It looks installed but is not answering. Start it with:\n"));
|
|
700
772
|
console.log(
|
package/gonext_agent_chat.py
CHANGED
|
@@ -1004,6 +1004,13 @@ class _AgentOffline(_AgentConfigError):
|
|
|
1004
1004
|
which would need the network anyway and would answer as if the work had been done."""
|
|
1005
1005
|
|
|
1006
1006
|
|
|
1007
|
+
class _StreamStalled(RuntimeError):
|
|
1008
|
+
"""A streamed reply STOPPED mid-way: tokens had started, then nothing arrived for the stall
|
|
1009
|
+
window (task #306). What a connection that died silently looks like — the internet dropped
|
|
1010
|
+
during the reply and the dead TCP connection never errored. Handled as a lost connection
|
|
1011
|
+
(_is_offline): the same step is sent again on a fresh connection."""
|
|
1012
|
+
|
|
1013
|
+
|
|
1007
1014
|
class _AgentBackendOverloaded(RuntimeError):
|
|
1008
1015
|
"""The coding backend refused every attempt with 429/rate-limit (task #114). Carries
|
|
1009
1016
|
the user-facing explanation; like a gateway outage it must NOT degrade to a plain
|
|
@@ -3572,7 +3579,16 @@ def _is_offline(err, host: str = "") -> bool:
|
|
|
3572
3579
|
|
|
3573
3580
|
Something that answered with a status is never offline, and a timeout is its own class
|
|
3574
3581
|
(openai's APITimeoutError subclasses APIConnectionError, so it is excluded by name)."""
|
|
3575
|
-
if err is None
|
|
3582
|
+
if err is None:
|
|
3583
|
+
return False
|
|
3584
|
+
# A stream that died mid-reply (#306) — whatever the host: the reply had started, so the
|
|
3585
|
+
# server was up, and only the connection can have gone.
|
|
3586
|
+
cur, depth = err, 0
|
|
3587
|
+
while cur is not None and depth < 6:
|
|
3588
|
+
if isinstance(cur, _StreamStalled):
|
|
3589
|
+
return True
|
|
3590
|
+
cur, depth = (getattr(cur, "__cause__", None) or getattr(cur, "__context__", None)), depth + 1
|
|
3591
|
+
if _http_status_of(err) is not None or _is_request_timeout(err):
|
|
3576
3592
|
return False
|
|
3577
3593
|
local = _host_locality_is_local(host)
|
|
3578
3594
|
cur, depth = err, 0
|
|
@@ -3604,6 +3620,68 @@ def _host_locality_is_local(host: str) -> bool:
|
|
|
3604
3620
|
return "." not in h or h.endswith((".local", ".lan", ".internal", ".home"))
|
|
3605
3621
|
|
|
3606
3622
|
|
|
3623
|
+
def _stream_stall_seconds() -> float:
|
|
3624
|
+
"""How long a STARTED stream may go silent before it is treated as a dead connection
|
|
3625
|
+
(GONEXT_STREAM_STALL_S, default 60s). Only after the first token: before it, a slow box may
|
|
3626
|
+
legitimately spend many minutes in prompt-eval (see _code_request_timeout)."""
|
|
3627
|
+
try:
|
|
3628
|
+
v = float(os.environ.get("GONEXT_STREAM_STALL_S", "") or 60)
|
|
3629
|
+
except ValueError:
|
|
3630
|
+
v = 60.0
|
|
3631
|
+
return max(5.0, v)
|
|
3632
|
+
|
|
3633
|
+
|
|
3634
|
+
def _iter_with_stall(stream, started, stall_s: float, on_data=None, poll: float = 0.5):
|
|
3635
|
+
"""Yield the stream's chunks, raising _StreamStalled when it goes quiet mid-reply (#306).
|
|
3636
|
+
|
|
3637
|
+
WHY A READER THREAD. `for chunk in stream` blocks inside a socket read; when the connection
|
|
3638
|
+
died silently that read never returns, and closing the socket from another thread does not
|
|
3639
|
+
reliably wake it on every OS. So a helper thread does the blocking reads and hands chunks over
|
|
3640
|
+
a queue, and this side waits on the queue with a timeout it controls. On a stall the stream is
|
|
3641
|
+
closed best-effort and the reader is abandoned (a daemon thread on a dead socket, gone with the
|
|
3642
|
+
process) — the step is then sent again on a new connection.
|
|
3643
|
+
|
|
3644
|
+
`started()` says whether the reply has begun; silence before that is prompt-eval, not a stall."""
|
|
3645
|
+
import queue as _queue
|
|
3646
|
+
import threading as _threading
|
|
3647
|
+
q = _queue.Queue()
|
|
3648
|
+
|
|
3649
|
+
def _reader():
|
|
3650
|
+
try:
|
|
3651
|
+
for c in stream:
|
|
3652
|
+
q.put(("chunk", c))
|
|
3653
|
+
q.put(("end", None))
|
|
3654
|
+
except BaseException as e: # noqa: BLE001
|
|
3655
|
+
q.put(("error", e))
|
|
3656
|
+
|
|
3657
|
+
_threading.Thread(target=_reader, name="gu-stream-reader", daemon=True).start()
|
|
3658
|
+
last = time.monotonic()
|
|
3659
|
+
while True:
|
|
3660
|
+
try:
|
|
3661
|
+
kind, val = q.get(timeout=poll)
|
|
3662
|
+
except _queue.Empty:
|
|
3663
|
+
if started() and time.monotonic() - last > stall_s:
|
|
3664
|
+
try:
|
|
3665
|
+
stream.close()
|
|
3666
|
+
except Exception: # noqa: BLE001
|
|
3667
|
+
pass
|
|
3668
|
+
raise _StreamStalled(
|
|
3669
|
+
f"the reply stopped: no data for {stall_s:.0f}s after it had started")
|
|
3670
|
+
continue
|
|
3671
|
+
last = time.monotonic()
|
|
3672
|
+
if on_data is not None:
|
|
3673
|
+
try:
|
|
3674
|
+
on_data()
|
|
3675
|
+
except Exception: # noqa: BLE001
|
|
3676
|
+
pass
|
|
3677
|
+
if kind == "chunk":
|
|
3678
|
+
yield val
|
|
3679
|
+
elif kind == "end":
|
|
3680
|
+
return
|
|
3681
|
+
else:
|
|
3682
|
+
raise val
|
|
3683
|
+
|
|
3684
|
+
|
|
3607
3685
|
def _offline_wait_budget() -> float:
|
|
3608
3686
|
"""How long a turn waits for the network before giving up (GONEXT_OFFLINE_WAIT_S, default 10 min)."""
|
|
3609
3687
|
try:
|
|
@@ -10086,7 +10164,16 @@ def run_agent_chat(cfg):
|
|
|
10086
10164
|
# Separate this step's live thinking from the previous step summary.
|
|
10087
10165
|
_emit({"type": "stream", "text": "\n"})
|
|
10088
10166
|
|
|
10089
|
-
|
|
10167
|
+
# #306: the stream goes through the stall watchdog — a reply that stops mid-way (the
|
|
10168
|
+
# connection died silently) raises _StreamStalled instead of hanging for the hour-long
|
|
10169
|
+
# request budget. `_last_data_at` feeds the heartbeat's "no data for Ns".
|
|
10170
|
+
self._last_data_at = time.monotonic()
|
|
10171
|
+
for chunk in _iter_with_stall(
|
|
10172
|
+
stream,
|
|
10173
|
+
started=lambda: first_token_at is not None,
|
|
10174
|
+
stall_s=_stream_stall_seconds(),
|
|
10175
|
+
on_data=lambda: setattr(self, "_last_data_at", time.monotonic()),
|
|
10176
|
+
):
|
|
10090
10177
|
usage = getattr(chunk, "usage", None)
|
|
10091
10178
|
if usage is not None:
|
|
10092
10179
|
self._last_usage_seen = True
|
|
@@ -10488,8 +10575,17 @@ def run_agent_chat(cfg):
|
|
|
10488
10575
|
# and the call is in its finishing phase, not stuck waiting.
|
|
10489
10576
|
word = (_STATUS_ALMOST_DONE if getattr(self, "_first_token_seen", False)
|
|
10490
10577
|
else f"{_thinking_word()}…")
|
|
10491
|
-
|
|
10492
|
-
|
|
10578
|
+
# #306: after the first token, SAY when nothing is arriving. "Almost done"
|
|
10579
|
+
# kept counting for 20 minutes over a stream that had died.
|
|
10580
|
+
gap = 0.0
|
|
10581
|
+
if getattr(self, "_first_token_seen", False):
|
|
10582
|
+
gap = time.monotonic() - getattr(self, "_last_data_at", time.monotonic())
|
|
10583
|
+
if gap >= 20:
|
|
10584
|
+
_emit({"type": "step", "text": f"No data from the model for {gap:.0f}s — watching the connection…"})
|
|
10585
|
+
_log(f"heartbeat: model call in flight {waited}s — no data for {gap:.0f}s")
|
|
10586
|
+
else:
|
|
10587
|
+
_emit({"type": "step", "text": f"{word} ({waited}s)"})
|
|
10588
|
+
_log(f"heartbeat: model call in flight {waited}s")
|
|
10493
10589
|
|
|
10494
10590
|
hb = threading.Thread(target=_heartbeat, daemon=True)
|
|
10495
10591
|
hb.start()
|
|
@@ -10586,7 +10682,10 @@ def run_agent_chat(cfg):
|
|
|
10586
10682
|
now = time.monotonic()
|
|
10587
10683
|
if offline["since"] is None:
|
|
10588
10684
|
offline.update(since=now, n=0, said=now)
|
|
10685
|
+
_stalled = isinstance(e, _StreamStalled)
|
|
10589
10686
|
_emit({"type": "step", "text": (
|
|
10687
|
+
f"The connection to {_host} stopped mid-reply — reconnecting…"
|
|
10688
|
+
if _stalled else
|
|
10590
10689
|
f"Lost the connection to {_host} — waiting for it to come back…")})
|
|
10591
10690
|
waited = now - offline["since"]
|
|
10592
10691
|
budget = _offline_wait_budget()
|
package/gu-cli.mjs
CHANGED
|
@@ -132,6 +132,7 @@ Usage:
|
|
|
132
132
|
# terminal's update row to notice
|
|
133
133
|
gu-cli rag # test the embedder end-to-end + list knowledge bases
|
|
134
134
|
gu-cli workspace revert [runId] # undo an agent run's file edits from its backups
|
|
135
|
+
gu-cli mongo migrate-from-docker # macOS: move a Docker MongoDB to native (Homebrew)
|
|
135
136
|
gu-cli clear [--scratch|--keep-history] [--yes] # back to a fresh machine (asks first)
|
|
136
137
|
gu-cli fix-powershell [--check] # Windows: drop PowerShell's built-in \`gu\` alias
|
|
137
138
|
# (it is Get-Unique and shadows this CLI)
|
|
@@ -1480,7 +1481,151 @@ if (args[0] === "rag") {
|
|
|
1480
1481
|
process.exit(0);
|
|
1481
1482
|
}
|
|
1482
1483
|
// Task #127, Phase 3: ensure the base chat model is downloaded + running (no GoTerminal).
|
|
1484
|
+
/**
|
|
1485
|
+
* gu-cli mongo migrate-from-docker [--container <name>] — move a Docker MongoDB's data to a
|
|
1486
|
+
* native Homebrew MongoDB on this Mac (task #304). The steps done by hand on 2026-09-30, made
|
|
1487
|
+
* repeatable: nothing about the Docker container changes until the copy has been VERIFIED
|
|
1488
|
+
* collection by collection, and even then it is stopped, never deleted.
|
|
1489
|
+
*/
|
|
1490
|
+
if (args[0] === "mongo") {
|
|
1491
|
+
const mn = await import("./mongo-native.mjs");
|
|
1492
|
+
const sub = args[1] || "";
|
|
1493
|
+
if (sub !== "migrate-from-docker") {
|
|
1494
|
+
console.log("\n Usage: gu-cli mongo migrate-from-docker [--container <name>]\n");
|
|
1495
|
+
process.exit(sub ? 1 : 0);
|
|
1496
|
+
}
|
|
1497
|
+
// The container publishing 27017, detected — setup's name has changed over time, and a machine
|
|
1498
|
+
// keeps the name it was set up with.
|
|
1499
|
+
const ci = args.indexOf("--container");
|
|
1500
|
+
const container = ci >= 0 && args[ci + 1] ? args[ci + 1] : ((await mn.dockerContainerOnPort(27017)) || "");
|
|
1501
|
+
const TEMP = 27018;
|
|
1502
|
+
const DBPATH = "/opt/homebrew/var/mongodb";
|
|
1503
|
+
const plan = mn.migrationPlan({ container, tempPort: TEMP, dbPath: DBPATH });
|
|
1504
|
+
const say = (id, ok, extra = "") => {
|
|
1505
|
+
const step = plan.find((p) => p.id === id);
|
|
1506
|
+
console.log(` ${ok ? "✓" : "✗"} ${step.says}${extra ? ` — ${extra}` : ""}`);
|
|
1507
|
+
};
|
|
1508
|
+
const fail = (id, why) => {
|
|
1509
|
+
say(id, false, why);
|
|
1510
|
+
console.log("\n Nothing about the Docker container was changed.\n");
|
|
1511
|
+
process.exit(1);
|
|
1512
|
+
};
|
|
1513
|
+
if (process.platform !== "darwin") fail("install", "this command is for macOS (Homebrew)");
|
|
1514
|
+
console.log("");
|
|
1515
|
+
// 1) the source answers
|
|
1516
|
+
if ((await mn.mongoAnswers("127.0.0.1", 27017)) !== "answers") {
|
|
1517
|
+
fail("source", "nothing answers on 27017 — start Docker Desktop (or restart it if stuck) first");
|
|
1518
|
+
}
|
|
1519
|
+
const holder = await mn.portHolder(27017);
|
|
1520
|
+
if (!mn.isDockerHolder(holder)) fail("source", `27017 is served by ${holder || "something"} — not Docker, nothing to migrate`);
|
|
1521
|
+
if (!container) fail("source", "no running Docker container publishes 27017 — name it with --container");
|
|
1522
|
+
say("source", true, container);
|
|
1523
|
+
// 2) Homebrew MongoDB installed
|
|
1524
|
+
const brew = mn.findBrew();
|
|
1525
|
+
if (!brew) fail("install", "Homebrew is not installed (https://brew.sh)");
|
|
1526
|
+
const hasMongod = (await mn.captureOutput("sh", ["-c", "command -v mongod || true"])).trim();
|
|
1527
|
+
if (!hasMongod) {
|
|
1528
|
+
const ok = await mn.runBrewSteps(brew, mn.BREW_INSTALL_STEPS.slice(0, 2), { onLine: (l) => console.log(` ${l}`) });
|
|
1529
|
+
if (!ok) fail("install", "brew install failed (see above)");
|
|
1530
|
+
}
|
|
1531
|
+
say("install", true);
|
|
1532
|
+
// 3) native mongod on the temp port, its own process (MongoDB 9 refuses --fork on macOS)
|
|
1533
|
+
const { mkdirSync } = await import("node:fs");
|
|
1534
|
+
mkdirSync(DBPATH, { recursive: true });
|
|
1535
|
+
const temp = spawn("mongod", ["--dbpath", DBPATH, "--port", String(TEMP), "--bind_ip", "127.0.0.1",
|
|
1536
|
+
"--logpath", "/opt/homebrew/var/log/mongodb/mongo-migrate.log"], { stdio: "ignore", detached: true });
|
|
1537
|
+
temp.unref();
|
|
1538
|
+
const stopTemp = async () => {
|
|
1539
|
+
await mn.captureOutput("mongosh", ["--quiet", "--port", String(TEMP), "admin", "--eval", "try{db.adminCommand({shutdown:1})}catch(e){}"], 15000);
|
|
1540
|
+
};
|
|
1541
|
+
if (!(await mn.waitForMongo("127.0.0.1", TEMP, { timeoutMs: 60000 }))) fail("temp", "it did not start (log: /opt/homebrew/var/log/mongodb/mongo-migrate.log)");
|
|
1542
|
+
say("temp", true);
|
|
1543
|
+
const counts = async (port) => {
|
|
1544
|
+
try {
|
|
1545
|
+
return JSON.parse((await mn.captureOutput("mongosh", ["--quiet", "--port", String(port), "--eval", mn.COUNTS_SCRIPT], 120000)).trim() || "{}");
|
|
1546
|
+
} catch {
|
|
1547
|
+
return null;
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
// 4) the native one must be empty — never merge into data we did not put there
|
|
1551
|
+
const already = await counts(TEMP);
|
|
1552
|
+
if (already === null || Object.keys(already).length) {
|
|
1553
|
+
await stopTemp();
|
|
1554
|
+
fail("empty", already === null ? "could not read it" : `it already holds ${Object.keys(already).length} collection(s) — move them away first`);
|
|
1555
|
+
}
|
|
1556
|
+
say("empty", true);
|
|
1557
|
+
// 5) pause the API
|
|
1558
|
+
const self = process.argv[1];
|
|
1559
|
+
await mn.runStreaming(process.execPath, [self, "api", "stop"]);
|
|
1560
|
+
say("api-stop", true);
|
|
1561
|
+
// 6) copy: mongodump | mongorestore
|
|
1562
|
+
const copied = await new Promise((resolve) => {
|
|
1563
|
+
const dump = spawn("mongodump", ["--uri", "mongodb://127.0.0.1:27017", "--archive", "--quiet"], { stdio: ["ignore", "pipe", "inherit"] });
|
|
1564
|
+
const restore = spawn("mongorestore", ["--uri", `mongodb://127.0.0.1:${TEMP}`, "--archive", "--nsExclude", "admin.system.version", "--quiet"], { stdio: ["pipe", "inherit", "inherit"] });
|
|
1565
|
+
dump.stdout.pipe(restore.stdin);
|
|
1566
|
+
let codes = [];
|
|
1567
|
+
const end = () => codes.length === 2 && resolve(codes.every((c) => c === 0));
|
|
1568
|
+
dump.on("close", (c) => { codes.push(c); end(); });
|
|
1569
|
+
restore.on("close", (c) => { codes.push(c); end(); });
|
|
1570
|
+
dump.on("error", () => resolve(false));
|
|
1571
|
+
restore.on("error", () => resolve(false));
|
|
1572
|
+
});
|
|
1573
|
+
if (!copied) {
|
|
1574
|
+
await stopTemp();
|
|
1575
|
+
await mn.runStreaming(process.execPath, [self, "api", "start"]);
|
|
1576
|
+
fail("copy", "mongodump / mongorestore failed (see above)");
|
|
1577
|
+
}
|
|
1578
|
+
say("copy", true);
|
|
1579
|
+
// 7) verify
|
|
1580
|
+
const before = await counts(27017);
|
|
1581
|
+
const after = await counts(TEMP);
|
|
1582
|
+
const diff = before && after ? mn.compareCounts(before, after) : [{ ns: "(could not read counts)" }];
|
|
1583
|
+
if (diff.length) {
|
|
1584
|
+
await stopTemp();
|
|
1585
|
+
await mn.runStreaming(process.execPath, [self, "api", "start"]);
|
|
1586
|
+
for (const d of diff.slice(0, 10)) console.log(` ${d.ns}: ${d.before} → ${d.after}`);
|
|
1587
|
+
fail("verify", `${diff.length} collection(s) differ — the Docker MongoDB stays in use`);
|
|
1588
|
+
}
|
|
1589
|
+
say("verify", true, `${Object.keys(before).length} collections`);
|
|
1590
|
+
// 8) the container: stopped, not restarting, KEPT
|
|
1591
|
+
await mn.captureOutput("docker", ["update", "--restart=no", container], 30000);
|
|
1592
|
+
await mn.captureOutput("docker", ["stop", container], 60000);
|
|
1593
|
+
say("container", true);
|
|
1594
|
+
// 9) switch: temp down, the brew service on 27017
|
|
1595
|
+
await stopTemp();
|
|
1596
|
+
const started = await mn.runBrewSteps(brew, mn.BREW_START_STEPS, { onLine: (l) => console.log(` ${l}`) });
|
|
1597
|
+
if (!started || !(await mn.waitForMongo("127.0.0.1", 27017, { timeoutMs: 60000 }))) {
|
|
1598
|
+
console.log(` ✗ ${plan.find((p) => p.id === "switch").says}`);
|
|
1599
|
+
console.log(`\n The data is copied, but the native MongoDB is not answering on 27017.`);
|
|
1600
|
+
console.log(` Put Docker back while you look: docker start ${container}\n`);
|
|
1601
|
+
process.exit(1);
|
|
1602
|
+
}
|
|
1603
|
+
say("switch", true);
|
|
1604
|
+
// 10) API back
|
|
1605
|
+
await mn.runStreaming(process.execPath, [self, "api", "start"]);
|
|
1606
|
+
say("api-start", true);
|
|
1607
|
+
console.log(`\n Done. The old container ${container} is stopped and kept as a backup —`);
|
|
1608
|
+
console.log(` remove it when you are happy: docker rm ${container}\n`);
|
|
1609
|
+
process.exit(0);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1483
1612
|
if (args[0] === "doctor") {
|
|
1613
|
+
// Task #304: a Mac whose MongoDB runs in Docker is told the native option, once, up front —
|
|
1614
|
+
// a working Docker MongoDB is left alone (it is a choice), a silent one is named as stuck.
|
|
1615
|
+
if (process.platform === "darwin") {
|
|
1616
|
+
try {
|
|
1617
|
+
const mn = await import("./mongo-native.mjs");
|
|
1618
|
+
const holder = await mn.portHolder(27017);
|
|
1619
|
+
if (mn.isDockerHolder(holder)) {
|
|
1620
|
+
const state = await mn.mongoAnswers("127.0.0.1", 27017);
|
|
1621
|
+
console.log(state === "answers"
|
|
1622
|
+
? "\n MongoDB runs in Docker here. A native one is faster and needs no Docker Desktop:\n gu-cli mongo migrate-from-docker\n"
|
|
1623
|
+
: "\n ✗ Port 27017 is Docker's, but no MongoDB answers — Docker Desktop looks stuck.\n Restart it (killall Docker && open -a Docker), then: gu-cli mongo migrate-from-docker\n");
|
|
1624
|
+
}
|
|
1625
|
+
} catch {
|
|
1626
|
+
/* a note, never a reason for doctor to fail */
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1484
1629
|
/**
|
|
1485
1630
|
* Should doctor set up MODELS on this machine? → true | false | undefined (decide by hardware)
|
|
1486
1631
|
*
|
package/gu-repl.mjs
CHANGED
|
@@ -1004,7 +1004,43 @@ const regionUi = (seq) => {
|
|
|
1004
1004
|
* parked — on the frozen input line, where the very next paintBar() erases it. That is how a
|
|
1005
1005
|
* failed turn came out as `>> hi` followed by nothing at all.
|
|
1006
1006
|
*/
|
|
1007
|
+
/**
|
|
1008
|
+
* THE QUESTION IS COMMITTED ON THE TURN'S FIRST OUTPUT (task #307, the user's design).
|
|
1009
|
+
*
|
|
1010
|
+
* A submitted question used to be printed into the transcript the moment Enter was pressed — so a
|
|
1011
|
+
* change of mind a second later (Ctrl+C, edit, resend) left the abandoned ">> eys" in the
|
|
1012
|
+
* scrollback, above the corrected question, where nothing can take it back out. Now the echo is
|
|
1013
|
+
* HELD (pendingEcho) and shown in the live block above the status line, and it is written for real
|
|
1014
|
+
* only when the turn prints something — the first line through contentWrite below, so it always
|
|
1015
|
+
* lands above that output, never after it.
|
|
1016
|
+
*
|
|
1017
|
+
* · Ctrl+C BEFORE any output: nothing reached the transcript. The question goes back into the
|
|
1018
|
+
* input to be edited, and there is no trace to clean up.
|
|
1019
|
+
* · Ctrl+C AFTER output: the question is committed — it happened, and what the turn did is below
|
|
1020
|
+
* it. A plain stop: no editing, the question stands, "continue" resumes it (#247).
|
|
1021
|
+
*/
|
|
1022
|
+
let pendingEcho = null;
|
|
1023
|
+
let echoCommitted = true;
|
|
1024
|
+
/** The held question as live rows: at most 3 lines of it, each clipped to the width, then a blank. */
|
|
1025
|
+
function pendingEchoRows() {
|
|
1026
|
+
if (pendingEcho === null) return [];
|
|
1027
|
+
const width = Math.max(20, process.stdout.columns || 80);
|
|
1028
|
+
const lines = String(pendingEcho).replace(/\n+$/, "").split("\n");
|
|
1029
|
+
const shown = lines.slice(0, 3).map((l) => clipVisible(l, width - 1));
|
|
1030
|
+
if (lines.length > 3) shown.push(dim(`${GUTTER}… ${lines.length - 3} more line(s)`));
|
|
1031
|
+
return [...shown, ""];
|
|
1032
|
+
}
|
|
1033
|
+
function flushPendingEcho() {
|
|
1034
|
+
if (pendingEcho === null) return;
|
|
1035
|
+
const text = pendingEcho;
|
|
1036
|
+
pendingEcho = null;
|
|
1037
|
+
echoCommitted = true;
|
|
1038
|
+
process.stdout.write(text);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1007
1041
|
const contentWrite = (raw, chunk, enc, cb) => {
|
|
1042
|
+
// Any real output commits a held question first (see pendingEcho above).
|
|
1043
|
+
if (_uiDepth === 0 && pendingEcho !== null) flushPendingEcho();
|
|
1008
1044
|
if (_uiDepth === 0 && (typeof chunk === "string" || Buffer.isBuffer(chunk))) {
|
|
1009
1045
|
transcript.append(String(chunk));
|
|
1010
1046
|
// ALWAYS FOLLOW THE BOTTOM. New output means there is something newer to see, so a view
|
|
@@ -1538,6 +1574,21 @@ const missingNoticeText = () => {
|
|
|
1538
1574
|
return parts.length ? "⚠ " + parts.join(" · ") : "";
|
|
1539
1575
|
};
|
|
1540
1576
|
|
|
1577
|
+
// #307: the calm note after a quick change of mind — dim, not the ⚠ orange of a warning, and in the
|
|
1578
|
+
// bar rather than the transcript (which the whole point is to leave untouched).
|
|
1579
|
+
let stopNotice = "";
|
|
1580
|
+
let stopNoticeTimer = null;
|
|
1581
|
+
function showStopNotice(text) {
|
|
1582
|
+
stopNotice = text;
|
|
1583
|
+
if (stopNoticeTimer) clearTimeout(stopNoticeTimer);
|
|
1584
|
+
stopNoticeTimer = setTimeout(() => {
|
|
1585
|
+
stopNotice = "";
|
|
1586
|
+
stopNoticeTimer = null;
|
|
1587
|
+
if (barPinned()) paintBar();
|
|
1588
|
+
}, 15_000);
|
|
1589
|
+
stopNoticeTimer.unref?.();
|
|
1590
|
+
if (barPinned()) paintBar();
|
|
1591
|
+
}
|
|
1541
1592
|
function showImageNotice(text) {
|
|
1542
1593
|
imageNotice = text;
|
|
1543
1594
|
if (imageNoticeTimer) clearTimeout(imageNoticeTimer);
|
|
@@ -2134,6 +2185,32 @@ let startupRenewDone = false;
|
|
|
2134
2185
|
*/
|
|
2135
2186
|
let licenceBlock = null;
|
|
2136
2187
|
let licenceDecided = false;
|
|
2188
|
+
|
|
2189
|
+
/**
|
|
2190
|
+
* THE FREE TRIAL (#305): a first install gets ten days on this computer instead of a "paste your
|
|
2191
|
+
* token" stop. Once per computer — the gu service decides, keyed on the OS machine id's hash. → true
|
|
2192
|
+
* when a trial token is now saved. Tried at most once per start by the startup refresh, and again
|
|
2193
|
+
* right before the token question (a first start may have been offline).
|
|
2194
|
+
*/
|
|
2195
|
+
let trialTried = false;
|
|
2196
|
+
let trialEndedAt = ""; // this computer's trial has ended — the token question says so
|
|
2197
|
+
async function tryTrialToken(ct, dir, { announce = true } = {}) {
|
|
2198
|
+
const device = await ct.trialDevice().catch(() => null);
|
|
2199
|
+
if (!device) return false; // no stable machine id → no trial (see trialDevice)
|
|
2200
|
+
const { DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
2201
|
+
const hostedBase = String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE;
|
|
2202
|
+
const r = await ct.claimTrial({ hostedBase, device });
|
|
2203
|
+
if (r.ok && ct.verifyCliToken(r.token, ct.publicKey()).ok) {
|
|
2204
|
+
await ct.writeState(dir, { token: r.token, lastAttemptAt: new Date().toISOString() });
|
|
2205
|
+
if (announce) {
|
|
2206
|
+
console.log(GUTTER + accent(r.created ? ct.trialStartedLine(r.endsAt, ct.siteUrl()) : `Free trial on this computer until ${ct.shortDate(r.endsAt)}.`));
|
|
2207
|
+
}
|
|
2208
|
+
return true;
|
|
2209
|
+
}
|
|
2210
|
+
if (r.why === "used" && r.endedAt) trialEndedAt = r.endedAt;
|
|
2211
|
+
return false;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2137
2214
|
async function refreshCliToken() {
|
|
2138
2215
|
if (tokenBusy) return;
|
|
2139
2216
|
tokenBusy = true;
|
|
@@ -2148,6 +2225,11 @@ async function refreshCliToken() {
|
|
|
2148
2225
|
const mode = isClientExpert() ? "expert" : "client";
|
|
2149
2226
|
const site = ct.siteUrl();
|
|
2150
2227
|
let state = await ct.readState(dir);
|
|
2228
|
+
// #305: private mode with no usable token → this computer's free trial, before anything asks.
|
|
2229
|
+
if (mode === "expert" && !trialTried && !(state.token && ct.verifyCliToken(state.token, pk).ok)) {
|
|
2230
|
+
trialTried = true;
|
|
2231
|
+
if (await tryTrialToken(ct, dir)) state = await ct.readState(dir);
|
|
2232
|
+
}
|
|
2151
2233
|
const now = new Date();
|
|
2152
2234
|
const noticeNow = () =>
|
|
2153
2235
|
ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site });
|
|
@@ -2423,7 +2505,9 @@ const footerRows = () =>
|
|
|
2423
2505
|
// Its own row (#54), sized to the window so the fix it names is never the part cut off.
|
|
2424
2506
|
notice: imageNotice
|
|
2425
2507
|
? orange("⚠ " + fitImageNotice(imageNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
|
|
2426
|
-
:
|
|
2508
|
+
: stopNotice
|
|
2509
|
+
? dim(clipVisible(stopNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
|
|
2510
|
+
: "",
|
|
2427
2511
|
// Task #14: the skipped-setup warning — persistent (not a timed notice), red, its own row.
|
|
2428
2512
|
missing: missingNoticeText() ? red(missingNoticeText()) : "",
|
|
2429
2513
|
});
|
|
@@ -2510,7 +2594,9 @@ function frozenZone() {
|
|
|
2510
2594
|
// (reported: "the input line somehow now too high, mostly 2 lines"). Rather than trust every
|
|
2511
2595
|
// teardown path to have cleared it — there are four, and clearStatus early-returns when
|
|
2512
2596
|
// statusShown is already false — the prompt simply refuses to render any.
|
|
2513
|
-
|
|
2597
|
+
// #307: a question held until its turn prints anything sits on top of the status block — where
|
|
2598
|
+
// it will land once committed, so nothing moves when it does.
|
|
2599
|
+
const status = footerActive ? [] : [...pendingEchoRows(), ...liveStatusRows];
|
|
2514
2600
|
// DECIDED BEFORE THE LAYOUT, because the head's height depends on it: the blank under the
|
|
2515
2601
|
// separator exists to lift a CONTROL off the typed line, so with no control there is nothing
|
|
2516
2602
|
// to lift. The same three conditions the rows[sep] writes below test — kept adjacent so they
|
|
@@ -6689,7 +6775,7 @@ async function runSavePresetCommand(line) {
|
|
|
6689
6775
|
*
|
|
6690
6776
|
* What the turn printed stays on screen: it is the record of what it did.
|
|
6691
6777
|
*/
|
|
6692
|
-
async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand = "", typed = "" }) {
|
|
6778
|
+
async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand = "", typed = "", committed = true }) {
|
|
6693
6779
|
let files = [];
|
|
6694
6780
|
try {
|
|
6695
6781
|
const ws = await import("./workspace-revert.mjs");
|
|
@@ -6702,13 +6788,25 @@ async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stopped
|
|
|
6702
6788
|
} catch {
|
|
6703
6789
|
/* no backups readable — nothing to name */
|
|
6704
6790
|
}
|
|
6705
|
-
//
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6791
|
+
// #307 — THE USER'S RULE: the question can be edited again only if it was never committed (the
|
|
6792
|
+
// turn printed nothing). A turn that changed files had something to say, so it counts as
|
|
6793
|
+
// committed; its held question is written now, above the note about those files.
|
|
6794
|
+
if (!committed && files.length) {
|
|
6795
|
+
flushPendingEcho();
|
|
6796
|
+
committed = true;
|
|
6709
6797
|
}
|
|
6710
|
-
|
|
6798
|
+
if (committed) {
|
|
6799
|
+
for (const l of stopNoteLines({ files, command: stoppedCommand, restored: false })) console.log(GUTTER + dim(l));
|
|
6800
|
+
console.log("");
|
|
6801
|
+
return;
|
|
6802
|
+
}
|
|
6803
|
+
// Never committed: drop the held echo — it never reached the transcript — and give it back.
|
|
6804
|
+
pendingEcho = null;
|
|
6805
|
+
echoCommitted = true;
|
|
6806
|
+
// A bare "continue" was the typed line: giving THAT back is no help.
|
|
6807
|
+
const canRestore = Boolean(submittedInput?.raw) && !isResumeWord(typed);
|
|
6711
6808
|
if (!canRestore) return;
|
|
6809
|
+
showStopNotice("stopped — your question is back in the input; edit it and press Enter, or Ctrl+C to clear it");
|
|
6712
6810
|
const text = restoreInput(submittedInput, { pasteStore, imageStore });
|
|
6713
6811
|
rl.line = text;
|
|
6714
6812
|
rl.cursor = text.length;
|
|
@@ -9661,6 +9759,12 @@ async function ensureTokenForTurn() {
|
|
|
9661
9759
|
const state = await ct.readState(dir);
|
|
9662
9760
|
if (!ct.turnNeedsToken({ privateMode: true, token: state.token })) return true;
|
|
9663
9761
|
console.log("");
|
|
9762
|
+
// #305: the free trial first — a first start that was offline gets it here, with no question.
|
|
9763
|
+
if (await tryTrialToken(ct, dir)) {
|
|
9764
|
+
console.log("");
|
|
9765
|
+
return true;
|
|
9766
|
+
}
|
|
9767
|
+
if (trialEndedAt) console.log(GUTTER + dim(`The free trial on this computer ended on ${ct.shortDate(trialEndedAt)}.`));
|
|
9664
9768
|
for (const l of ct.tokenAskLines()) console.log(GUTTER + (l.startsWith(" ") ? dim(l) : yellow(l)));
|
|
9665
9769
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
9666
9770
|
const pasted = String((await ask(" gu token: ")) ?? "").trim();
|
|
@@ -10940,8 +11044,10 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
10940
11044
|
// killed the Python agent. Print a quiet note and return an empty, non-persisted
|
|
10941
11045
|
// turn (main() drops it from history).
|
|
10942
11046
|
clearStatus();
|
|
10943
|
-
|
|
10944
|
-
|
|
11047
|
+
// #307: `committed` — did the question reach the transcript (the turn printed something)?
|
|
11048
|
+
const committed = pendingEcho === null;
|
|
11049
|
+
if (committed) process.stdout.write("\n" + GUTTER + dim("(cancelled — the agent was stopped)") + "\n");
|
|
11050
|
+
return { text: "", shown: false, cancelled: true, started: reachedModel, stoppedCommand: runningCmd || "", committed };
|
|
10945
11051
|
}
|
|
10946
11052
|
if (job.jobStatus === "failed") {
|
|
10947
11053
|
clearStatus();
|
|
@@ -11827,7 +11933,9 @@ async function main() {
|
|
|
11827
11933
|
// agent; the poll loop then returns cleanly with the "(cancelled)" note.
|
|
11828
11934
|
cancelRequested = true;
|
|
11829
11935
|
const jobId = currentJobId;
|
|
11830
|
-
|
|
11936
|
+
// #307: while the question is still held (nothing printed yet), nothing goes to the
|
|
11937
|
+
// transcript either — a quick change of mind leaves no trace.
|
|
11938
|
+
if (pendingEcho === null) process.stdout.write("\n" + GUTTER + red("^C ") + dim("stopping the agent…\n"));
|
|
11831
11939
|
fetch(`${apiBase}/api/worker/jobs/cancel`, {
|
|
11832
11940
|
method: "POST",
|
|
11833
11941
|
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
@@ -11839,7 +11947,7 @@ async function main() {
|
|
|
11839
11947
|
// Second Ctrl+C (or no jobId yet): give up waiting locally. The worker keeps
|
|
11840
11948
|
// trying to stop, but we stop following.
|
|
11841
11949
|
followAborted = true;
|
|
11842
|
-
process.stdout.write("\n" + GUTTER + red("^C "));
|
|
11950
|
+
if (pendingEcho === null) process.stdout.write("\n" + GUTTER + red("^C ")); // #307
|
|
11843
11951
|
}
|
|
11844
11952
|
} else {
|
|
11845
11953
|
// Ctrl+C at a prompt with the slash overlay open = dismiss the overlay first (#109).
|
|
@@ -11880,6 +11988,9 @@ async function main() {
|
|
|
11880
11988
|
}
|
|
11881
11989
|
|
|
11882
11990
|
for (;;) {
|
|
11991
|
+
// #307: a held question whose turn ended without printing anything (and was not a quick
|
|
11992
|
+
// change of mind) is written now — it was asked, and the transcript should say so.
|
|
11993
|
+
if (pendingEcho !== null) flushPendingEcho();
|
|
11883
11994
|
stopPendingStatus(); // nothing from a previous turn may still be animating (task #140)
|
|
11884
11995
|
// The bar and the input line live in the frozen rows at the bottom of the window (task
|
|
11885
11996
|
// #141); `footerActive` only governs the fallback drawing used when the window is too
|
|
@@ -11962,7 +12073,20 @@ async function main() {
|
|
|
11962
12073
|
// TRANSCRIPT: the echo is written exactly once per submitted line and never repainted,
|
|
11963
12074
|
// so there is nothing here for a blank to accumulate against.
|
|
11964
12075
|
// As typed, not as sent: "[Image #1]", never the file path the agent needs (#51).
|
|
11965
|
-
|
|
12076
|
+
const echo = PROMPT_PAST + imageMarkersForDisplay(line) + "\n\n";
|
|
12077
|
+
if (stopNotice) {
|
|
12078
|
+
stopNotice = "";
|
|
12079
|
+
if (stopNoticeTimer) clearTimeout(stopNoticeTimer);
|
|
12080
|
+
}
|
|
12081
|
+
// #307: a QUESTION (not a /command or !shell line) is held until the turn prints something;
|
|
12082
|
+
// unpinned there is no live block to hold it in, so it is written at once, as before.
|
|
12083
|
+
if (barPinned() && !line.startsWith("/") && !line.startsWith("!")) {
|
|
12084
|
+
pendingEcho = echo;
|
|
12085
|
+
echoCommitted = false;
|
|
12086
|
+
paintBar();
|
|
12087
|
+
} else {
|
|
12088
|
+
process.stdout.write(echo);
|
|
12089
|
+
}
|
|
11966
12090
|
}
|
|
11967
12091
|
// ESC AT ANY QUESTION LANDS HERE (see PromptCancelled). Every command below is wrapped as
|
|
11968
12092
|
// one block rather than each flow catching for itself, because the request was to be able to
|
|
@@ -12287,7 +12411,8 @@ async function main() {
|
|
|
12287
12411
|
const block = answerBlock({
|
|
12288
12412
|
elapsedMs, answer: plain, worked: (turnOutputTokens ?? 0) > 0,
|
|
12289
12413
|
gutter: GUTTER, dim, render: renderAnswer,
|
|
12290
|
-
|
|
12414
|
+
// No "(↑ N)" here (task #310, the user's call): it was this turn's input total —
|
|
12415
|
+
// the very number the bar's "sent" shows one row below.
|
|
12291
12416
|
});
|
|
12292
12417
|
console.log(`${LINE_SPACING}${block.text}\n`);
|
|
12293
12418
|
registerCopyBlock(block.rows, plain, 1);
|
|
@@ -12365,7 +12490,7 @@ async function main() {
|
|
|
12365
12490
|
continue;
|
|
12366
12491
|
}
|
|
12367
12492
|
const { text: answer, shown, cancelled, started, detached, inputTokens, outputTokens,
|
|
12368
|
-
turnOutputTokens, elapsedMs, stoppedCommand } = turn;
|
|
12493
|
+
turnOutputTokens, elapsedMs, stoppedCommand, committed } = turn;
|
|
12369
12494
|
// Detached: the agent keeps running on the worker, so the question is neither answered
|
|
12370
12495
|
// nor stopped — leave it untagged, and say nothing contradicting "the job keeps running".
|
|
12371
12496
|
if (detached) continue;
|
|
@@ -12430,7 +12555,7 @@ async function main() {
|
|
|
12430
12555
|
// two must come from one place, and for the empty line under "thought for …".
|
|
12431
12556
|
const block = answerBlock({
|
|
12432
12557
|
elapsedMs, answer: plain, worked, gutter: GUTTER, dim, render: renderAnswer,
|
|
12433
|
-
|
|
12558
|
+
// No "(↑ N)" (task #310): the bar's "sent" already shows this turn's input.
|
|
12434
12559
|
});
|
|
12435
12560
|
console.log(`${LINE_SPACING}${block.text}\n`);
|
|
12436
12561
|
// Register the SOURCE, not the rendering (task #158): renderAnswer hard-wraps the
|
|
@@ -12462,7 +12587,7 @@ async function main() {
|
|
|
12462
12587
|
// Stopped AFTER the model was called = too slow or stuck, never a change of mind: the
|
|
12463
12588
|
// request still stands (task #247, the user's rule). Before that, nothing had begun.
|
|
12464
12589
|
await markInterrupted(cwd, history, taskLine, started ? "stopped" : "cancelled");
|
|
12465
|
-
await afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand, typed: line });
|
|
12590
|
+
await afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand, typed: line, committed: committed !== false });
|
|
12466
12591
|
} else {
|
|
12467
12592
|
await markInterrupted(cwd, history, taskLine, "failed");
|
|
12468
12593
|
// The turn ended cleanly but empty — no exception to summarize, so say exactly that.
|
package/mongo-native.mjs
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MONGODB ON A MAC: NATIVE, NOT DOCKER (task #304).
|
|
3
|
+
*
|
|
4
|
+
* Client Expert setup used to start MongoDB in Docker first wherever Docker answered. On a Mac that
|
|
5
|
+
* costs twice: every database call crosses Docker Desktop's VM and port forwarder, and when Docker
|
|
6
|
+
* Desktop hangs (seen 2026-09-29, after the disk filled) its forwarder keeps 27017 OPEN while
|
|
7
|
+
* nothing behind it answers — so the API timed out on every request instead of failing fast, gu
|
|
8
|
+
* said "Worker key lookup failed", and Docker did not recover by itself. The fix that worked was
|
|
9
|
+
* Homebrew's mongodb-community as a brew service, the data copied across. This module is that fix,
|
|
10
|
+
* made the default:
|
|
11
|
+
*
|
|
12
|
+
* · probe with a real MongoDB handshake, never "the port accepts a connection"
|
|
13
|
+
* · a Mac with no answering MongoDB gets Homebrew's mongodb-community (asked once), and setup
|
|
14
|
+
* continues in the same run
|
|
15
|
+
* · a Mac whose 27017 belongs to a stuck Docker is TOLD so, and `gu-cli mongo migrate-from-docker`
|
|
16
|
+
* moves a working Docker MongoDB's data to the native one (verified collection by collection;
|
|
17
|
+
* the container is stopped, never deleted)
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------------------------
|
|
23
|
+
// Does something MongoDB answer — not merely accept a TCP connection?
|
|
24
|
+
// ---------------------------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* An OP_MSG `{ hello: 1, $db: "admin" }` — the first thing every driver sends, answered without
|
|
28
|
+
* authentication, so it tells a live server (with or without a password) from a port that only
|
|
29
|
+
* accepts connections. Pure: the exact bytes, built by hand to need no driver.
|
|
30
|
+
*/
|
|
31
|
+
export function helloMessage(requestId = 1) {
|
|
32
|
+
const cstr = (s) => Buffer.concat([Buffer.from(s, "utf8"), Buffer.from([0])]);
|
|
33
|
+
const int32 = (n) => {
|
|
34
|
+
const b = Buffer.alloc(4);
|
|
35
|
+
b.writeInt32LE(n);
|
|
36
|
+
return b;
|
|
37
|
+
};
|
|
38
|
+
const db = "admin";
|
|
39
|
+
const elements = Buffer.concat([
|
|
40
|
+
Buffer.from([0x10]), cstr("hello"), int32(1),
|
|
41
|
+
Buffer.from([0x02]), cstr("$db"), int32(Buffer.byteLength(db) + 1), cstr(db),
|
|
42
|
+
]);
|
|
43
|
+
const doc = Buffer.concat([int32(4 + elements.length + 1), elements, Buffer.from([0])]);
|
|
44
|
+
const body = Buffer.concat([int32(0) /* flagBits */, Buffer.from([0]) /* section kind 0 */, doc]);
|
|
45
|
+
const header = Buffer.concat([int32(16 + body.length), int32(requestId), int32(0), int32(2013)]);
|
|
46
|
+
return Buffer.concat([header, body]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* → "answers" | "silent" (the port accepts, nothing replies — a stuck forwarder) | "closed".
|
|
51
|
+
* A reply is any well-formed message header coming back.
|
|
52
|
+
*/
|
|
53
|
+
export async function mongoAnswers(host = "127.0.0.1", port = 27017, timeoutMs = 3000) {
|
|
54
|
+
const net = await import("node:net");
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
let connected = false;
|
|
57
|
+
let got = Buffer.alloc(0);
|
|
58
|
+
const socket = net.connect({ host, port });
|
|
59
|
+
const done = (v) => {
|
|
60
|
+
socket.destroy();
|
|
61
|
+
resolve(v);
|
|
62
|
+
};
|
|
63
|
+
socket.setTimeout(timeoutMs);
|
|
64
|
+
socket.once("connect", () => {
|
|
65
|
+
connected = true;
|
|
66
|
+
socket.write(helloMessage());
|
|
67
|
+
});
|
|
68
|
+
socket.on("data", (d) => {
|
|
69
|
+
got = Buffer.concat([got, d]);
|
|
70
|
+
if (got.length >= 16) done(got.readInt32LE(12) === 2013 || got.readInt32LE(12) === 1 ? "answers" : "silent");
|
|
71
|
+
});
|
|
72
|
+
socket.once("timeout", () => done(connected ? "silent" : "closed"));
|
|
73
|
+
socket.once("error", () => done(connected ? "silent" : "closed"));
|
|
74
|
+
socket.once("close", () => done(connected ? (got.length ? "answers" : "silent") : "closed"));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Wait until MongoDB answers, or give up. → true when it answered. */
|
|
79
|
+
export async function waitForMongo(host, port, { timeoutMs = 60000, check = mongoAnswers, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
|
80
|
+
const until = Date.now() + timeoutMs;
|
|
81
|
+
while (Date.now() < until) {
|
|
82
|
+
if ((await check(host, port, 2000)) === "answers") return true;
|
|
83
|
+
await sleep(1000);
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The process holding a TCP port, by its program's name — null when unknown or none. Through
|
|
90
|
+
* platform-tools (pidOnPort / processCommand), where the per-OS lookups live.
|
|
91
|
+
*/
|
|
92
|
+
export async function portHolder(port, { pidOf, commandOf } = {}) {
|
|
93
|
+
const pt = await import("./platform-tools.mjs");
|
|
94
|
+
const pid = await (pidOf ?? pt.pidOnPort)(port);
|
|
95
|
+
if (!pid) return null;
|
|
96
|
+
const cmd = await (commandOf ?? pt.processCommand)(pid);
|
|
97
|
+
const exe = String(cmd || "").trim().split(/\s+/)[0] || "";
|
|
98
|
+
return exe ? exe.split(/[\\/]/).pop() : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The Docker container publishing a port — detected, not assumed: the container setup creates has
|
|
103
|
+
* been renamed over time, and a machine keeps the name it was set up with. null when none.
|
|
104
|
+
*/
|
|
105
|
+
export async function dockerContainerOnPort(port, { runCapture = captureOutput } = {}) {
|
|
106
|
+
const out = await runCapture("docker", ["ps", "--filter", `publish=${port}`, "--format", "{{.Names}}"], 15000);
|
|
107
|
+
return String(out || "").trim().split("\n")[0].trim() || null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Is that holder Docker's port forwarder? (Docker Desktop: com.docker.backend / vpnkit.) */
|
|
111
|
+
export function isDockerHolder(name) {
|
|
112
|
+
return /docker|vpnkit|com\.docker/i.test(String(name ?? ""));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------------------------
|
|
116
|
+
// What setup should do on a Mac — pure
|
|
117
|
+
// ---------------------------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param state mongoAnswers() at the database's address
|
|
121
|
+
* @param holder portHolder() — who owns the port when it is silent
|
|
122
|
+
* @param serverInstalled a `mongod` is on PATH
|
|
123
|
+
* @param brew the Homebrew binary, or ""
|
|
124
|
+
* → { action, ... }:
|
|
125
|
+
* use a MongoDB answers — use it (a working Docker one included: that is a choice)
|
|
126
|
+
* docker-stuck Docker holds the port and nothing answers — say so; never install over it
|
|
127
|
+
* port-silent something else holds the port and nothing answers
|
|
128
|
+
* brew-start mongod is installed (via brew) but not running — start the service
|
|
129
|
+
* brew-install nothing is there — install mongodb-community with Homebrew
|
|
130
|
+
* no-brew nothing is there and there is no Homebrew — say how to get one
|
|
131
|
+
*/
|
|
132
|
+
export function macMongoAction({ state, holder = null, serverInstalled = false, brew = "" }) {
|
|
133
|
+
if (state === "answers") return { action: "use" };
|
|
134
|
+
if (state === "silent") return { action: isDockerHolder(holder) ? "docker-stuck" : "port-silent", holder };
|
|
135
|
+
if (!brew) return { action: "no-brew" };
|
|
136
|
+
if (serverInstalled) return { action: "brew-start" };
|
|
137
|
+
return { action: "brew-install" };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const BREW_INSTALL_STEPS = [
|
|
141
|
+
["tap", "mongodb/brew"],
|
|
142
|
+
["install", "mongodb/brew/mongodb-community"],
|
|
143
|
+
["services", "start", "mongodb/brew/mongodb-community"],
|
|
144
|
+
];
|
|
145
|
+
export const BREW_START_STEPS = [["services", "start", "mongodb/brew/mongodb-community"]];
|
|
146
|
+
|
|
147
|
+
/** Where Homebrew is, or "". Apple Silicon first, then Intel. */
|
|
148
|
+
export function findBrew({ exists = existsSync } = {}) {
|
|
149
|
+
for (const p of ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"]) if (exists(p)) return p;
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The lines a Mac with no Homebrew is shown. */
|
|
154
|
+
export function noBrewLines() {
|
|
155
|
+
return [
|
|
156
|
+
"MongoDB is installed with Homebrew on a Mac, and Homebrew is not here.",
|
|
157
|
+
"Install Homebrew (https://brew.sh), then run gu again — setup installs MongoDB from there.",
|
|
158
|
+
"Or install MongoDB Community yourself: https://www.mongodb.com/try/download/community",
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The lines a Mac whose 27017 is a stuck Docker (or another silent program) is shown. */
|
|
163
|
+
export function silentPortLines({ action, holder, port = 27017 }) {
|
|
164
|
+
if (action === "docker-stuck") {
|
|
165
|
+
return [
|
|
166
|
+
`Port ${port} belongs to Docker (${holder}), but no MongoDB answers there — Docker Desktop looks stuck.`,
|
|
167
|
+
"Restart it: quit Docker Desktop (or run: killall Docker && open -a Docker).",
|
|
168
|
+
"Once its MongoDB answers, move the data to a native MongoDB (faster, and no Docker needed):",
|
|
169
|
+
" gu-cli mongo migrate-from-docker",
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
return [
|
|
173
|
+
`Port ${port} is held by ${holder || "another program"}, but no MongoDB answers there.`,
|
|
174
|
+
"Stop that program (or free the port), then run gu again.",
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------------------------
|
|
179
|
+
// Running commands with their output shown
|
|
180
|
+
// ---------------------------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
/** Run a command, streaming each output line to onLine. → exit code (null if it could not start). */
|
|
183
|
+
export function runStreaming(cmd, args, { onLine = () => {}, env = process.env } = {}) {
|
|
184
|
+
return new Promise((resolve) => {
|
|
185
|
+
let child;
|
|
186
|
+
try {
|
|
187
|
+
child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env });
|
|
188
|
+
} catch {
|
|
189
|
+
resolve(null);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const lines = (b) => String(b).split("\n").map((l) => l.trim()).filter(Boolean).forEach(onLine);
|
|
193
|
+
child.stdout.on("data", lines);
|
|
194
|
+
child.stderr.on("data", lines);
|
|
195
|
+
child.on("error", () => resolve(null));
|
|
196
|
+
child.on("close", (code) => resolve(code));
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Run a command and return its stdout, or "" — for lookups. */
|
|
201
|
+
export function captureOutput(cmd, args, timeoutMs = 10000) {
|
|
202
|
+
return new Promise((resolve) => {
|
|
203
|
+
let out = "";
|
|
204
|
+
let child;
|
|
205
|
+
try {
|
|
206
|
+
child = spawn(cmd, args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
207
|
+
} catch {
|
|
208
|
+
resolve("");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const t = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
212
|
+
child.stdout.on("data", (b) => (out += b));
|
|
213
|
+
child.on("error", () => {
|
|
214
|
+
clearTimeout(t);
|
|
215
|
+
resolve("");
|
|
216
|
+
});
|
|
217
|
+
child.on("close", () => {
|
|
218
|
+
clearTimeout(t);
|
|
219
|
+
resolve(out);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Homebrew steps, one after another, output shown. → true when all succeeded. HOMEBREW_NO_AUTO_UPDATE
|
|
226
|
+
* keeps `brew install` from first updating every formula on the machine — minutes nobody asked for.
|
|
227
|
+
*/
|
|
228
|
+
export async function runBrewSteps(brew, steps, { onLine = () => {}, run = runStreaming } = {}) {
|
|
229
|
+
for (const s of steps) {
|
|
230
|
+
onLine(`$ brew ${s.join(" ")}`);
|
|
231
|
+
const code = await run(brew, s, { onLine, env: { ...process.env, HOMEBREW_NO_AUTO_UPDATE: "1" } });
|
|
232
|
+
if (code !== 0) return false;
|
|
233
|
+
}
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------------------------
|
|
238
|
+
// Moving a Docker MongoDB's data to the native one
|
|
239
|
+
// ---------------------------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Per collection "docs/indexes", old vs new → the ones that differ. Pure. A migration is only
|
|
243
|
+
* finished when this is empty — the old one is not touched until then.
|
|
244
|
+
*/
|
|
245
|
+
export function compareCounts(before, after) {
|
|
246
|
+
const keys = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].sort();
|
|
247
|
+
return keys.filter((k) => (before ?? {})[k] !== (after ?? {})[k]).map((k) => ({ ns: k, before: before?.[k] ?? null, after: after?.[k] ?? null }));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The mongosh script that prints every user collection's "documents/indexes" as JSON. */
|
|
251
|
+
export const COUNTS_SCRIPT =
|
|
252
|
+
'const o={};db.adminCommand({listDatabases:1}).databases.map(d=>d.name)' +
|
|
253
|
+
'.filter(n=>!["admin","config","local"].includes(n)).forEach(n=>{const d=db.getSiblingDB(n);' +
|
|
254
|
+
'd.getCollectionNames().forEach(c=>{o[n+"."+c]=d[c].countDocuments({})+"/"+d[c].getIndexes().length})});' +
|
|
255
|
+
"print(JSON.stringify(o))";
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The migration, as named steps (`gu-cli mongo migrate-from-docker`). Pure: the orchestration lives
|
|
259
|
+
* in the CLI, which runs these in order and stops at the first that fails — before the Docker
|
|
260
|
+
* container is touched, if the copy did not verify.
|
|
261
|
+
*/
|
|
262
|
+
export function migrationPlan({ container = "the MongoDB container", tempPort = 27018, dbPath = "/opt/homebrew/var/mongodb" } = {}) {
|
|
263
|
+
return [
|
|
264
|
+
{ id: "source", says: "the Docker MongoDB answers on 27017" },
|
|
265
|
+
{ id: "install", says: "MongoDB Community installed with Homebrew" },
|
|
266
|
+
{ id: "temp", says: `the native MongoDB started on ${tempPort} (data in ${dbPath})` },
|
|
267
|
+
{ id: "empty", says: "the native MongoDB holds no user databases yet" },
|
|
268
|
+
{ id: "api-stop", says: "gu's local API paused — nothing writes during the copy" },
|
|
269
|
+
{ id: "copy", says: "every database copied (mongodump | mongorestore)" },
|
|
270
|
+
{ id: "verify", says: "every collection's documents and indexes match" },
|
|
271
|
+
{ id: "container", says: `the Docker container ${container} stopped and set not to restart (kept, not deleted)` },
|
|
272
|
+
{ id: "switch", says: "the native MongoDB serving 27017 as a brew service" },
|
|
273
|
+
{ id: "api-start", says: "gu's local API started again" },
|
|
274
|
+
];
|
|
275
|
+
}
|
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.772",
|
|
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",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"setup-prompts.mjs",
|
|
55
55
|
"turn-stop.mjs",
|
|
56
56
|
"workspace-revert.mjs",
|
|
57
|
+
"mongo-native.mjs",
|
|
57
58
|
"terminal-hover.mjs",
|
|
58
59
|
"terminal-layout.mjs",
|
|
59
60
|
"terminal-viewport.mjs",
|
package/terminal-layout.mjs
CHANGED
|
@@ -472,7 +472,7 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
|
472
472
|
* HOW TO PASTE, for this platform (issue #67). Two different things share the word: TEXT is pasted
|
|
473
473
|
* by the terminal app (gu only sees the result), an IMAGE by gu's own keys (#284).
|
|
474
474
|
*
|
|
475
|
-
* macOS text
|
|
475
|
+
* macOS text ⌘+V image ctrl+v — a terminal's ⌘+V sends nothing for an image
|
|
476
476
|
* Windows text ctrl+v image alt+v — Windows Terminal keeps ctrl+v for itself
|
|
477
477
|
* Linux text ctrl+shift+v image ctrl+v
|
|
478
478
|
*
|
|
@@ -480,7 +480,7 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
|
480
480
|
* answers "images need a Claude coder" would be a hint that leads to a warning.
|
|
481
481
|
*/
|
|
482
482
|
export function pasteHint({ platform = process.platform, images = false } = {}) {
|
|
483
|
-
const text = platform === "darwin" ? "
|
|
483
|
+
const text = platform === "darwin" ? "⌘+V" : platform === "win32" ? "ctrl+v" : "ctrl+shift+v";
|
|
484
484
|
if (!images) return `${text} to paste`;
|
|
485
485
|
const image = platform === "win32" ? "alt+v" : "ctrl+v";
|
|
486
486
|
return `${text} paste · ${image} image`;
|