@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.
@@ -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
- args: ["/create", "/tn", taskName, "/tr", scriptPath, "/sc", "once", "/st", "00:00", "/f"],
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 t = schtasksCommands(taskName, script);
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.name} for ${k.backendUrl} (the preset's copy was not used).`);
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 k of add) {
125
- const where = k.backendUrl ? ` for ${k.backendUrl}` : "";
126
- const typed = k.key || String((await io.ask(`key "${k.name}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)) ?? "").trim();
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 ${k.name} — add it later with ctrl+k.`);
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 ${k.name}${where} — add it in /model with ctrl+k.`, "err");
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);
@@ -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) warnings.push(`${b.url} has no key in the preset — it keeps whatever key this machine has for it (or none).`);
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 key = v.keys.find((k) => k.backendUrl && ensureV1Root(k.backendUrl).toLowerCase() === ensureV1Root(b.url).toLowerCase());
418
- lines.push(` ${b.url} ${models.join(", ") || "(no models)"}${key?.key ? ` · key ${key.name} ${mask(key.key)}` : " · no key"}`);
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;
@@ -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.name}${k.tail ? ` (…${k.tail})` : ""}${k.backendUrl ? ` for ${k.backendUrl}` : ""}`);
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
  *
@@ -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
- return { label: "client expert", detail: "(API + database here · models remote)" };
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)" };