@tiens.nguyen/gu-cli 1.0.791 → 1.0.793
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clear-logs.mjs +352 -0
- package/clear-state.mjs +4 -0
- package/cli-token.mjs +25 -2
- package/client-expert-api.mjs +249 -6
- package/client-expert-run.mjs +39 -4
- package/client-expert-setup.mjs +21 -1
- package/first-run-steps.mjs +26 -0
- package/gu-cli.mjs +38 -0
- package/gu-repl.mjs +219 -7
- package/install-path.mjs +23 -1
- package/model-doctor.mjs +33 -4
- package/node-version.mjs +50 -2
- package/package.json +2 -1
- package/platform-tools.mjs +49 -3
- package/preset-apply.mjs +18 -8
- package/presets-transfer.mjs +44 -3
- package/settings-transfer.mjs +14 -1
- package/terminal-layout.mjs +31 -2
package/platform-tools.mjs
CHANGED
|
@@ -298,23 +298,68 @@ export function windowsLauncherScript({ cmd, args = [], cwd, env = {}, logFile }
|
|
|
298
298
|
return lines.join("\r\n") + "\r\n";
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* WHAT THE TASK RUNS — the launcher script, with NO WINDOW (task #334).
|
|
303
|
+
*
|
|
304
|
+
* The task used to run the .cmd itself. A task runs in the user's desktop session, so cmd.exe got
|
|
305
|
+
* a console window there, and `start /b` keeps node attached to that console: the window stayed
|
|
306
|
+
* for as long as the API or the worker lived. One per launch — the API, the worker, a worker
|
|
307
|
+
* restart, an API restart — "3-4 command windows" on a Windows 11 desktop after a first install,
|
|
308
|
+
* each of which kills its process when closed. Measured on the box: two visible windows titled
|
|
309
|
+
* C:\WINDOWS\SYSTEM32\cmd.exe, owned by the two node processes.
|
|
310
|
+
*
|
|
311
|
+
* `conhost.exe --headless` is Windows' own way to give a console program a console with no
|
|
312
|
+
* window (Windows 10 1809 and later). Not wscript/VBScript — it is being removed from Windows —
|
|
313
|
+
* and not `powershell -WindowStyle Hidden`, which flashes a window before hiding it.
|
|
314
|
+
*
|
|
315
|
+
* THE QUOTES ARE THE OTHER FIX. The bare script path was split by schtasks at the first space,
|
|
316
|
+
* so from a home like C:\Users\Jo Smith nothing started at all (measured: no log, no process).
|
|
317
|
+
*/
|
|
318
|
+
export function hiddenTaskAction(scriptPath, systemRoot) {
|
|
319
|
+
const root = String(systemRoot || "C:\\Windows").replace(/[\\/]+$/, "");
|
|
320
|
+
return `"${root}\\System32\\conhost.exe" --headless cmd.exe /c "${scriptPath}"`;
|
|
321
|
+
}
|
|
322
|
+
|
|
301
323
|
/**
|
|
302
324
|
* Register → run → deregister. The task is a LAUNCH MECHANISM, not a persistent registration:
|
|
303
325
|
* it exists only to start the process outside our job, and is removed immediately afterwards
|
|
304
326
|
* so nothing is left scheduled on the user's machine. The started process keeps running —
|
|
305
327
|
* deleting a task does not kill what it launched (verified).
|
|
306
328
|
*/
|
|
307
|
-
export function schtasksCommands(taskName, scriptPath) {
|
|
329
|
+
export function schtasksCommands(taskName, scriptPath, { elevated = false, systemRoot = process.env.SystemRoot } = {}) {
|
|
308
330
|
return {
|
|
309
331
|
create: {
|
|
310
332
|
cmd: "schtasks.exe",
|
|
311
|
-
|
|
333
|
+
// `/rl highest` ONLY from an elevated terminal — see isElevatedGroups. From an ordinary one
|
|
334
|
+
// it is refused for an administrator ("Access is denied") and the task is never created.
|
|
335
|
+
args: [
|
|
336
|
+
"/create", "/tn", taskName, "/tr", hiddenTaskAction(scriptPath, systemRoot), "/sc", "once", "/st", "00:00",
|
|
337
|
+
...(elevated ? ["/rl", "highest"] : []),
|
|
338
|
+
"/f",
|
|
339
|
+
],
|
|
312
340
|
},
|
|
313
341
|
run: { cmd: "schtasks.exe", args: ["/run", "/tn", taskName] },
|
|
314
342
|
remove: { cmd: "schtasks.exe", args: ["/delete", "/tn", taskName, "/f"] },
|
|
315
343
|
};
|
|
316
344
|
}
|
|
317
345
|
|
|
346
|
+
/**
|
|
347
|
+
* IS THIS TERMINAL ELEVATED? — from `whoami /groups` (task #333).
|
|
348
|
+
*
|
|
349
|
+
* A scheduled task runs with the user's ORDINARY token unless it is created `/rl highest`, even
|
|
350
|
+
* when the terminal that created it is elevated — and an SSH session of an administrator always
|
|
351
|
+
* is. So the background process had FEWER rights than the `gu` that started it. On the Windows
|
|
352
|
+
* 11 test box node.exe belongs to another user's nvm and only Administrators may run it: `gu`
|
|
353
|
+
* ran, the API it launched got "Access is denied.", and setup stopped at "not up yet".
|
|
354
|
+
* A background process should have the rights of the terminal that started it — as it does on
|
|
355
|
+
* macOS and Linux, where it is simply a child.
|
|
356
|
+
*
|
|
357
|
+
* S-1-16-12288 is the High Mandatory Level label (S-1-16-16384 = System). Pure, so it is testable.
|
|
358
|
+
*/
|
|
359
|
+
export function isElevatedGroups(whoamiGroups) {
|
|
360
|
+
return /S-1-16-(12288|16384)\b/.test(String(whoamiGroups ?? ""));
|
|
361
|
+
}
|
|
362
|
+
|
|
318
363
|
/**
|
|
319
364
|
* Which pid owns a listening port, from `netstat -ano`.
|
|
320
365
|
*
|
|
@@ -382,7 +427,8 @@ export async function spawnSurviving({ cmd, args = [], cwd, env = {}, logFile, t
|
|
|
382
427
|
"utf8"
|
|
383
428
|
);
|
|
384
429
|
|
|
385
|
-
const
|
|
430
|
+
const elevated = isElevatedGroups(await run("whoami.exe", ["/groups"]));
|
|
431
|
+
const t = schtasksCommands(taskName, script, { elevated });
|
|
386
432
|
const created = await run(t.create.cmd, t.create.args);
|
|
387
433
|
if (created === null) {
|
|
388
434
|
// No scheduler (or no rights): fall back to a plain detached spawn. It dies with an SSH
|
package/preset-apply.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
|
-
import { buildFullPreset, keysToApply } from "./presets-transfer.mjs";
|
|
21
|
+
import { buildFullPreset, keysToApply, keyHiddenInName, shownKeyName } from "./presets-transfer.mjs";
|
|
22
22
|
import { planLoad, toSettingsPatch } from "./settings-transfer.mjs";
|
|
23
23
|
import { existingBackendRows, providerRowForUrl } from "./coding-provider-setup.mjs";
|
|
24
24
|
|
|
@@ -86,7 +86,7 @@ export async function applyTransfer(value, { api, io, fromLine = "", carriesSecr
|
|
|
86
86
|
: plan.lines;
|
|
87
87
|
for (const l of lines) say(l);
|
|
88
88
|
if (summary && plan.replaces) say(`replaces the ${plan.replaces} coding provider${plan.replaces === 1 ? "" : "s"} on this account.`);
|
|
89
|
-
for (const w of warnings) say("⚠ " + w, "warn");
|
|
89
|
+
for (const w of new Set(warnings)) say("⚠ " + w, "warn");
|
|
90
90
|
if (carriesSecrets) say("this file contains API keys in plain text — delete it when you are done.", "warn");
|
|
91
91
|
const go = String((await io.ask("apply this? [y/N] ")) ?? "").trim().toLowerCase();
|
|
92
92
|
if (go !== "y" && go !== "yes") {
|
|
@@ -116,22 +116,32 @@ export async function applyTransfer(value, { api, io, fromLine = "", carriesSecr
|
|
|
116
116
|
const rows = existingBackendRows(fresh);
|
|
117
117
|
const { add, kept } = keysToApply(value, { currentKeys: keys, rows, missing: plan.missingKeys });
|
|
118
118
|
for (const k of kept) {
|
|
119
|
-
say(`kept your existing key ${k
|
|
119
|
+
say(`kept your existing key ${shownKeyName(k)} for ${k.backendUrl} (the preset's copy was not used).`);
|
|
120
120
|
if (k.default && k.rowId && k.existingId && !(await api.setDefaultKey(k.rowId, k.existingId))) {
|
|
121
121
|
say(`could not make ${k.name} the default for ${k.backendUrl} — pick it in /model with ctrl+k.`, "err");
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
-
for (const
|
|
125
|
-
const where =
|
|
126
|
-
|
|
124
|
+
for (const k0 of add) {
|
|
125
|
+
const where = k0.backendUrl ? ` for ${k0.backendUrl}` : "";
|
|
126
|
+
// A NAME THAT IS THE KEY ITSELF (see keyHiddenInName): use it as the value it always was, and
|
|
127
|
+
// give the key a real name — its server — so the secret stops being printed as a label.
|
|
128
|
+
const hidden = k0.key ? "" : keyHiddenInName(k0);
|
|
129
|
+
let hostName = "";
|
|
130
|
+
try {
|
|
131
|
+
hostName = new URL(k0.backendUrl).hostname;
|
|
132
|
+
} catch { /* no server — keep a neutral name */ }
|
|
133
|
+
const k = hidden ? { ...k0, key: hidden, name: hostName || "key" } : k0;
|
|
134
|
+
const shown = shownKeyName(k0);
|
|
135
|
+
if (hidden) say(`the key named ${shown}${where} IS the key — using it, saved as "${k.name}".`);
|
|
136
|
+
const typed = k.key || String((await io.ask(`key "${shown}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)) ?? "").trim();
|
|
127
137
|
if (!typed) {
|
|
128
|
-
say(`skipped ${
|
|
138
|
+
say(`skipped ${shown}${where} — its value is not in the preset; add it later with ctrl+k.`);
|
|
129
139
|
continue;
|
|
130
140
|
}
|
|
131
141
|
const id = await api.addKey(k.name, typed, k.rowId);
|
|
132
142
|
if (!id) {
|
|
133
143
|
out.failed.push(k.name);
|
|
134
|
-
say(`could not add ${
|
|
144
|
+
say(`could not add ${shown}${where} — add it in /model with ctrl+k.`, "err");
|
|
135
145
|
continue;
|
|
136
146
|
}
|
|
137
147
|
out.added.push(k.name);
|
package/presets-transfer.mjs
CHANGED
|
@@ -337,7 +337,12 @@ export function validateFullPreset(doc) {
|
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
339
|
const noKey = v.backends.filter((b) => !v.keys.some((k) => k.key && sameServer(k.backendUrl, b.url)));
|
|
340
|
-
for (const b of noKey)
|
|
340
|
+
for (const b of noKey) {
|
|
341
|
+
const named = v.keys.some((k) => !k.key && sameServer(k.backendUrl, b.url));
|
|
342
|
+
warnings.push(named
|
|
343
|
+
? `${b.url}: the preset names its key but does not carry the value — you will be asked to paste it.`
|
|
344
|
+
: `${b.url} has no key in the preset — it keeps whatever key this machine has for it (or none).`);
|
|
345
|
+
}
|
|
341
346
|
// THE DEFAULT CODER must be a server and model the preset itself sets up.
|
|
342
347
|
const cd = doc.codingDefault && typeof doc.codingDefault === "object" ? doc.codingDefault : {};
|
|
343
348
|
if (cd.url !== undefined && cd.url !== "" && !isHttpUrl(cd.url)) {
|
|
@@ -404,6 +409,35 @@ const mask = (k) => {
|
|
|
404
409
|
return v.length <= 8 ? "…" + v.slice(-2) : `${v.startsWith("sk-") ? "sk-" : v.slice(0, 3)}…${v.slice(-4)}`;
|
|
405
410
|
};
|
|
406
411
|
|
|
412
|
+
/**
|
|
413
|
+
* IS THIS KEY'S *NAME* ACTUALLY THE KEY? (reported 2026-10-02) → the key, or "".
|
|
414
|
+
* A key pasted into the NAME question is stored as a name, and from then on it is printed in full
|
|
415
|
+
* wherever a name is shown ("skipped sk-E8Iv…3E66 — add it later") and travels in every names-only
|
|
416
|
+
* export — while its value is asked for again. The tail settles it: a name that is key-shaped AND
|
|
417
|
+
* ends in the recorded last four characters is the key.
|
|
418
|
+
*/
|
|
419
|
+
export function keyHiddenInName(k) {
|
|
420
|
+
const name = clean(k?.name);
|
|
421
|
+
const tail = clean(k?.tail);
|
|
422
|
+
if (name.length < 20 || /\s/.test(name) || keyProblem(name)) return "";
|
|
423
|
+
if (tail ? !name.endsWith(tail) : !/^(sk|pk|rk|xai|gsk|ghp|hf)[-_]/i.test(name)) return "";
|
|
424
|
+
return name;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** A key NAME as it may be shown: a name that is itself a key is masked like one. */
|
|
428
|
+
export function shownKeyName(k) {
|
|
429
|
+
const name = clean(typeof k === "string" ? k : k?.name);
|
|
430
|
+
const secret = keyHiddenInName(typeof k === "string" ? { name } : k);
|
|
431
|
+
return secret ? `${mask(secret)} (a key saved as its own name)` : name;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** How many of a preset's keys carry a VALUE, and how many are only a name. */
|
|
435
|
+
export function presetKeyCounts(v) {
|
|
436
|
+
const keys = Array.isArray(v?.keys) ? v.keys : [];
|
|
437
|
+
const withValue = keys.filter((k) => k.key).length;
|
|
438
|
+
return { withValue, nameOnly: keys.length - withValue };
|
|
439
|
+
}
|
|
440
|
+
|
|
407
441
|
/** The whole preset with every key MASKED — what is printed; the file / the copy keep the real keys. */
|
|
408
442
|
export function maskedFullPreset(doc) {
|
|
409
443
|
return { ...doc, keys: (Array.isArray(doc?.keys) ? doc.keys : []).map((k) => ({ ...k, ...(k.key ? { key: mask(k.key) } : {}) })) };
|
|
@@ -414,8 +448,15 @@ export function fullPresetSummary(v, codingDefault) {
|
|
|
414
448
|
const lines = [];
|
|
415
449
|
for (const b of v.backends) {
|
|
416
450
|
const models = [...new Set([b.defaultModel, ...(b.models ?? [])].filter(Boolean))];
|
|
417
|
-
const
|
|
418
|
-
|
|
451
|
+
const mine = v.keys.filter((k) => k.backendUrl && ensureV1Root(k.backendUrl).toLowerCase() === ensureV1Root(b.url).toLowerCase());
|
|
452
|
+
const key = mine.find((k) => k.key);
|
|
453
|
+
// "no key" was printed for a server whose key the preset NAMES but does not carry — the same
|
|
454
|
+
// words as a server with no key at all, so a names-only preset read as an empty one.
|
|
455
|
+
const named = mine.filter((k) => !k.key).map((k) => `${shownKeyName(k)}${k.tail && !keyHiddenInName(k) ? ` …${k.tail}` : ""}`);
|
|
456
|
+
const tell = key?.key
|
|
457
|
+
? ` · key ${shownKeyName(key)} ${mask(key.key)}`
|
|
458
|
+
: named.length ? ` · key NAME only (${named.join(", ")}) — the value is not in this preset` : " · no key";
|
|
459
|
+
lines.push(` ${b.url} ${models.join(", ") || "(no models)"}${tell}`);
|
|
419
460
|
}
|
|
420
461
|
if (codingDefault?.url || codingDefault?.model) lines.push(`coding model: ${codingDefault.model || "?"} at ${codingDefault.url || "?"}`);
|
|
421
462
|
return lines;
|
package/settings-transfer.mjs
CHANGED
|
@@ -344,7 +344,7 @@ export function planLoad(file, current = {}) {
|
|
|
344
344
|
}
|
|
345
345
|
if (missingKeys.length) {
|
|
346
346
|
lines.push(`${missingKeys.length} API key${missingKeys.length === 1 ? "" : "s"} this account does not have — gu will ask for ${missingKeys.length === 1 ? "it" : "them"} after:`);
|
|
347
|
-
for (const k of missingKeys) lines.push(` ${k
|
|
347
|
+
for (const k of missingKeys) lines.push(` ${listedKeyName(k)}${k.tail ? ` (…${k.tail})` : ""}${k.backendUrl ? ` for ${k.backendUrl}` : ""}`);
|
|
348
348
|
}
|
|
349
349
|
// The account default is NOT in the file and is not touched — said out loud, because someone
|
|
350
350
|
// loading "their models" onto a machine reasonably expects the coder to change too.
|
|
@@ -352,6 +352,19 @@ export function planLoad(file, current = {}) {
|
|
|
352
352
|
return { lines, missingKeys, replaces: currentRows.length, adds: file.backends.length };
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
/**
|
|
356
|
+
* A key NAME as this list may print it. A key typed into the name question is stored AS the name,
|
|
357
|
+
* and the full secret was then printed here (reported 2026-10-02). A key-shaped name ending in the
|
|
358
|
+
* key's recorded tail is masked. (presets-transfer.mjs keyHiddenInName is the same rule, with the
|
|
359
|
+
* stricter key checks it has; it imports this module, so the rule cannot be shared the other way.)
|
|
360
|
+
*/
|
|
361
|
+
function listedKeyName(k) {
|
|
362
|
+
const name = String(k?.name ?? "").trim();
|
|
363
|
+
const tail = String(k?.tail ?? "").trim();
|
|
364
|
+
if (name.length >= 20 && !/\s/.test(name) && tail && name.endsWith(tail)) return `${name.slice(0, 3)}… (a key saved as its own name)`;
|
|
365
|
+
return name;
|
|
366
|
+
}
|
|
367
|
+
|
|
355
368
|
/**
|
|
356
369
|
* The POST body for /api/worker/settings. Only fields a worker key may write.
|
|
357
370
|
*
|
package/terminal-layout.mjs
CHANGED
|
@@ -2156,6 +2156,24 @@ export const HIDDEN_COMMANDS = [
|
|
|
2156
2156
|
// the INPUT total — which does not exist yet as a counter
|
|
2157
2157
|
];
|
|
2158
2158
|
|
|
2159
|
+
/**
|
|
2160
|
+
* NOTHING TO ANSWER WITH (#99): an agent payload with no model server in it at all.
|
|
2161
|
+
*
|
|
2162
|
+
* A missing AGENT model alone is fine — the API puts the coding model in its place (and sets
|
|
2163
|
+
* `agentMissing`). With no coder either, `agentBaseURL` is empty: there is no server to send a
|
|
2164
|
+
* question to. The API (from 1.0.220) refuses such a question in words; this lets the terminal
|
|
2165
|
+
* say the same against an API that still queues a job nothing can read ("✗ Job not found.").
|
|
2166
|
+
*/
|
|
2167
|
+
export function nothingToAnswerWith(payload) {
|
|
2168
|
+
if (!payload || typeof payload !== "object") return false; // unknown is not "none"
|
|
2169
|
+
return !String(payload.agentBaseURL ?? "").trim();
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
export const NO_MODEL_LINES = [
|
|
2173
|
+
"No model is set for this account, so there is nothing to answer with.",
|
|
2174
|
+
"Pick a coding model with /model (and an agent model with /agent-model), then ask again.",
|
|
2175
|
+
];
|
|
2176
|
+
|
|
2159
2177
|
/**
|
|
2160
2178
|
* Does this API base point at a server on THIS machine? (task #154)
|
|
2161
2179
|
*
|
|
@@ -2177,9 +2195,20 @@ export function isLocalApiBase(apiBase) {
|
|
|
2177
2195
|
* entire distinction — go unmentioned. Reported live on a fully set-up Ubuntu box that said
|
|
2178
2196
|
* plain "client".
|
|
2179
2197
|
*/
|
|
2180
|
-
export function modeRow({ clientExpert, clientMode }) {
|
|
2198
|
+
export function modeRow({ clientExpert, clientMode, modelUrls = [], coderWhere }) {
|
|
2181
2199
|
if (clientExpert) {
|
|
2182
|
-
|
|
2200
|
+
// "models remote" WAS A CONSTANT (task #333): a box running its own Ollama on 127.0.0.1 —
|
|
2201
|
+
// the whole stack on one machine — was told its models were somewhere else. Said only when
|
|
2202
|
+
// EVERY configured model server is this machine; one cloud coder is still "remote".
|
|
2203
|
+
const urls = modelUrls.map((u) => String(u ?? "").trim()).filter(Boolean);
|
|
2204
|
+
// `coderWhere` is the active coder's "host[:port]" label (coding-choice-tags.mjs), which
|
|
2205
|
+
// names this machine as 127.0.0.1. Passed but empty = not known → not claimed to be here.
|
|
2206
|
+
const coderHere = coderWhere === undefined || /^127\.0\.0\.1(:|$)/.test(String(coderWhere));
|
|
2207
|
+
const allHere = urls.length > 0 && urls.every(isLocalApiBase) && coderHere;
|
|
2208
|
+
return {
|
|
2209
|
+
label: "client expert",
|
|
2210
|
+
detail: allHere ? "(API, database and models here)" : "(API + database here · models remote)",
|
|
2211
|
+
};
|
|
2183
2212
|
}
|
|
2184
2213
|
if (clientMode) {
|
|
2185
2214
|
return { label: "client", detail: "(agent runs here · models remote)" };
|