@tiens.nguyen/gu-cli 1.0.706 → 1.0.708

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 CHANGED
@@ -86,11 +86,13 @@ export function verifyCliToken(token, publicKeyB64) {
86
86
  (p.issuedBy !== "refresh" && p.issuedBy !== "account" && p.issuedBy !== "admin") ||
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
- !(Number.isInteger(p.deviceLimit) && p.deviceLimit > 0)
89
+ !(Number.isInteger(p.deviceLimit) && p.deviceLimit > 0) ||
90
+ // Missing means OFF — tokens issued before the switch existed (#238) are in CLIs right now.
91
+ !(p.enforce === undefined || typeof p.enforce === "boolean")
90
92
  ) {
91
93
  return { ok: false, why: "bad-payload" };
92
94
  }
93
- return { ok: true, payload: p };
95
+ return { ok: true, payload: { ...p, enforce: p.enforce === true } };
94
96
  }
95
97
 
96
98
  // ---------------------------------------------------------------------------
@@ -251,7 +253,7 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
251
253
  if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
252
254
  return {
253
255
  kind: "over-limit",
254
- text: `gu is active on ${p.activeDevices} computers — ${p.plan === "pro" ? "Pro" : "Free"} covers ${p.deviceLimit}`,
256
+ text: `gu is active on ${p.activeDevices} devices — ${p.plan === "pro" ? "Pro" : "Free"} covers ${p.deviceLimit}`,
255
257
  };
256
258
  }
257
259
  const age = now.getTime() - Date.parse(p.issuedAt);
@@ -277,9 +279,63 @@ export function tokenSummary(payload, now = new Date()) {
277
279
  }
278
280
  if (Number.isInteger(payload.activeDevices)) {
279
281
  const n = payload.activeDevices;
280
- lines.push(`Active on ${n} ${n === 1 ? "computer" : "computers"} in the last 30 days — ${payload.plan === "pro" ? "Pro" : "Free"} covers ${payload.deviceLimit}.`);
282
+ lines.push(`Active on ${n} ${n === 1 ? "device" : "devices"} in the last 30 days — ${payload.plan === "pro" ? "Pro" : "Free"} covers ${payload.deviceLimit}.`);
281
283
  }
282
284
  lines.push(`Issued ${shortDate(payload.issuedAt)}.`);
283
285
  return lines;
284
286
  }
285
287
 
288
+ // ---------------------------------------------------------------------------
289
+ // ENFORCEMENT (#238) — decided ONCE, when gu starts
290
+ // ---------------------------------------------------------------------------
291
+
292
+ /**
293
+ * May this session send turns?
294
+ *
295
+ * The switch lives on the server and rides in the token (payload.enforce). This is read ONCE at
296
+ * startup, by the owner's requirement — a session that began is never interrupted by a later renewal,
297
+ * and nothing asks the network again per turn.
298
+ *
299
+ * WITH THE SWITCH OFF (the default and what ships today) the answer is always yes: the licence stays
300
+ * soft — barNotice() says its one line and nothing is blocked.
301
+ *
302
+ * WITH IT ON, a session may send turns when the token is valid, the plan is Pro, its paid time has not
303
+ * run out, and the account is within its computer limit. Otherwise gu says why and refuses to SEND
304
+ * TURNS — `gu login`, `gu token`, /model and every other command keep working, because the person has
305
+ * to be able to fix it from where they are.
306
+ */
307
+ export function enforcementDecision({ token, publicKeyB64, now = new Date(), site = DEFAULT_SITE }) {
308
+ if (!publicKeyB64) return { enforced: false, allowed: true, why: "" };
309
+ const check = token ? verifyCliToken(token, publicKeyB64) : { ok: false, why: "missing" };
310
+ // An unreadable or missing token cannot say whether enforcement is on, and this must not be a way
311
+ // to switch it off by deleting a file — but it is also not a reason to block someone whose server
312
+ // has enforcement OFF. Unknown means soft, exactly like a server that cannot be reached.
313
+ if (!check.ok) return { enforced: false, allowed: true, why: "" };
314
+ const p = check.payload;
315
+ if (!p.enforce) return { enforced: false, allowed: true, why: "" };
316
+
317
+ const host = site.replace(/^https?:\/\//, "");
318
+ const pricingUrl = `${host}/pricing`;
319
+ const accountUrl = `${host}/account`;
320
+ const ended = p.paidUntil && Date.parse(p.paidUntil) <= now.getTime();
321
+ if (p.plan !== "pro" || ended) {
322
+ return {
323
+ enforced: true,
324
+ allowed: false,
325
+ why: ended
326
+ ? `Your gu Pro plan ended on ${shortDate(p.paidUntil)}. Renew at ${pricingUrl} to keep using the agent.`
327
+ : `gu needs a Pro plan on this account. See ${pricingUrl} — the rest of gu still works.`,
328
+ };
329
+ }
330
+ if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
331
+ return {
332
+ enforced: true,
333
+ allowed: false,
334
+ why:
335
+ `This account is active on ${p.activeDevices} devices and Pro covers ${p.deviceLimit}. ` +
336
+ `Sign out of one, or remove it at ${accountUrl}.`,
337
+ };
338
+ }
339
+ return { enforced: true, allowed: true, why: "" };
340
+ }
341
+
@@ -0,0 +1,82 @@
1
+ /**
2
+ * WHEN THE CREDENTIALS ON THIS MACHINE NO LONGER WORK (task #240).
3
+ *
4
+ * THE DEAD END THIS EXISTS TO CLOSE, reproduced in a clean Ubuntu container: a machine whose
5
+ * ~/.gonext/worker.env holds a key the API rejects (rotated, revoked, pasted from another
6
+ * account, or written by the wizard for a different server) could not reach the first-run flow
7
+ * at all. `gu` printed one red line and exited — every time, forever. The questions a first run
8
+ * asks were unreachable, and the only route back was `gu clear`, which is a RESET: it deletes
9
+ * conversation history, registered workspaces, deploy targets, the agent's edit backups and the
10
+ * RAG indexes. Reported as "first run question doesn't appear … I then needed gu clear then gu
11
+ * again", and that is exactly what the user had been driven to.
12
+ *
13
+ * A REJECTED KEY IS THE SAME SITUATION AS NO KEY. Both mean this machine is not connected to an
14
+ * account, and the answer to both is the sign-in that the first run already offers. So gu offers
15
+ * it here too, in place, keeping everything else on the machine.
16
+ *
17
+ * WHY A MODULE AND NOT AN `if` IN THE REPL: so the rule can be called by a test. The conduct
18
+ * being pinned is "which situations offer a sign-in", and open-coded it could only be guarded by
19
+ * a regex over gu-repl.mjs — which pins the WORDING and lets the conduct change underneath.
20
+ *
21
+ * THE CODE, NOT THE WORDING, decides. payloadError() stamps `code` on the error the API call
22
+ * throws; nothing downstream matches on the message text, which is free to be reworded.
23
+ */
24
+
25
+ /** The error an agent-payload call failed with, carrying a code the recovery can switch on. */
26
+ export function payloadError(status, data) {
27
+ const message = (data && typeof data === "object" && data.error) || "";
28
+ if (status === 404) {
29
+ const e = new Error(
30
+ "the API does not have POST /api/worker/agent-payload yet — deploy the latest API (sam build && sam deploy)."
31
+ );
32
+ e.code = "API_TOO_OLD";
33
+ return e;
34
+ }
35
+ // 401/403: the key is present and WRONG. We never re-validate a stored key proactively, so
36
+ // this is where a rotated or revoked one surfaces — the first thing gu does on startup.
37
+ if (status === 401 || status === 403) {
38
+ const e = new Error(
39
+ "your worker key was rejected (it may have been rotated or revoked). " +
40
+ "Run `gu login` to sign in again, or update it with `gu set <workerKey> --api-base <url>`."
41
+ );
42
+ e.code = "KEY_REJECTED";
43
+ return e;
44
+ }
45
+ const e = new Error(message || `agent-payload failed (HTTP ${status})`);
46
+ e.code = "API_ERROR";
47
+ return e;
48
+ }
49
+
50
+ /**
51
+ * What should happen when the startup probe fails?
52
+ *
53
+ * "offer-signin" — ask, here and now, and carry on with the session when it works.
54
+ * "report" — print what is wrong and stop. The caller's existing message.
55
+ *
56
+ * ONLY a rejected key, and only with somebody there to answer. A network failure is not a
57
+ * credential problem (the key may be perfect and the wifi off), and a pipe or CI job has nobody
58
+ * at the keyboard — offering there would hang a script on a prompt no one can see, which is the
59
+ * failure mode that made `gu` unusable in the deploy app's stages.
60
+ *
61
+ * NOT ON A CLIENT EXPERT MACHINE. Its API is local and issues its own key; a hosted device-login
62
+ * there points the terminal away from the very stack it was set up to use. The same reasoning
63
+ * that made first-run setup skip the sign-in after Client Expert applies to the recovery.
64
+ */
65
+ export function recoveryPlan({ code, isTty, clientExpert } = {}) {
66
+ if (code !== "KEY_REJECTED") return { action: "report" };
67
+ if (!isTty) return { action: "report" };
68
+ if (clientExpert) return { action: "report" };
69
+ return {
70
+ action: "offer-signin",
71
+ // What it says matters as much as that it asks: someone who has just been told their key is
72
+ // bad needs to know that answering will not cost them their work.
73
+ lines: [
74
+ "gu: this machine's worker key was rejected — it may have been rotated or revoked.",
75
+ " Sign in again to reconnect this machine to an account.",
76
+ " Nothing else is touched: your folders, chat history and RAG indexes stay as they are.",
77
+ ],
78
+ // UN-INDENTED, like every other ask() caller: ask() insets by the gutter itself, and a
79
+ // prompt carrying its own spaces comes out sitting two columns right of every other question.
80
+ prompt: "worker key (or Enter to sign in): ",
81
+ };
82
+ }
package/gu-repl.mjs CHANGED
@@ -24,13 +24,13 @@
24
24
  * Commands: /exit /model /reset /help Ctrl-C stops following the current run.
25
25
  */
26
26
  import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
27
- import { existsSync, statSync, readFileSync } from "node:fs";
27
+ import { existsSync, statSync, readFileSync, writeFileSync } from "node:fs";
28
28
  import { spawn } from "node:child_process";
29
29
  // FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
30
30
  // `engines`, so an unsupported Node installs fine and then breaks somewhere unrelated.
31
31
  import { requireSupportedNode } from "./node-version.mjs";
32
32
  requireSupportedNode();
33
- import { homedir } from "node:os";
33
+ import { homedir, hostname } from "node:os";
34
34
  import { dirname, join, resolve, basename } from "node:path";
35
35
  import { fileURLToPath } from "node:url";
36
36
  import { PassThrough } from "node:stream";
@@ -137,6 +137,9 @@ import { REQUIRED_AGENT_MODEL, REQUIRED_AGENT_PREFIX, REQUIRED_EMBED_MODEL } fro
137
137
  // plain SGR code: terminals disagree about colour, so accent.mjs picks 24-bit, 256 or the
138
138
  // sixteen-colour magenta from what this one actually claims to support.
139
139
  import { accent } from "./accent.mjs";
140
+ // Task #240: what a failed agent-payload call means, and when a rejected key may be recovered
141
+ // from in place instead of ending the session.
142
+ import { payloadError, recoveryPlan } from "./credential-recovery.mjs";
140
143
  import { isNoCodeRetry, splitVerboseLine, stepHasCode } from "./verbose-lines.mjs";
141
144
  import {
142
145
  createTranscript,
@@ -792,6 +795,8 @@ const COMMANDS = [
792
795
  { name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
793
796
  { name: "/s3", usage: "/s3 [test]", desc: "set up the S3 bucket the RAG index lives in, and check the AWS keys work" },
794
797
  { name: "/context-window", usage: "/context-window [tokens|auto]", desc: "tell gu how big the coding model's context is — hosted models cannot be probed" },
798
+ { name: "/save-settings", usage: "/save-settings [path]", desc: "write this account's coding providers, models and key NAMES to a file (no secrets)" },
799
+ { name: "/load-settings", usage: "/load-settings [path]", desc: "read that file back on another machine — asks before it replaces anything" },
795
800
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
796
801
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
797
802
  { name: "/help", desc: "list these commands" },
@@ -1863,6 +1868,13 @@ async function refreshAvailableUpdates() {
1863
1868
  let tokenNotice = null;
1864
1869
  let tokenBusy = false;
1865
1870
  let startupRenewDone = false;
1871
+ /**
1872
+ * THE ENFORCEMENT DECISION (#238) — taken ONCE, on the first refresh after gu starts, and never
1873
+ * revisited. The owner's requirement: check when gu starts, not repeatedly. A session already running
1874
+ * is never cut off by a switch flipped elsewhere, and a later renewal only updates the bar's line.
1875
+ */
1876
+ let licenceBlock = null;
1877
+ let licenceDecided = false;
1866
1878
  async function refreshCliToken() {
1867
1879
  if (tokenBusy) return;
1868
1880
  tokenBusy = true;
@@ -1902,6 +1914,17 @@ async function refreshCliToken() {
1902
1914
  await ct.writeState(dir, state).catch(() => {});
1903
1915
  tokenNotice = noticeNow();
1904
1916
  }
1917
+ if (!licenceDecided) {
1918
+ licenceDecided = true;
1919
+ const d = ct.enforcementDecision({ token: state.token, publicKeyB64: pk, site });
1920
+ licenceBlock = d.enforced && !d.allowed ? d.why : null;
1921
+ if (licenceBlock) {
1922
+ console.log("");
1923
+ console.log(GUTTER + orange("gu cannot run turns on this account.") );
1924
+ console.log(GUTTER + dim(" " + licenceBlock));
1925
+ console.log(GUTTER + dim(" Everything else still works: /model, gu token, gu login.") + "\n");
1926
+ }
1927
+ }
1905
1928
  } catch {
1906
1929
  /* never a reason to disturb a session */
1907
1930
  } finally {
@@ -6072,6 +6095,138 @@ async function chooseModel() {
6072
6095
  // The settings row as the WORKER sees it (X-Worker-Key, no Firebase). This is the only route a
6073
6096
  // Client Expert machine has to its own settings — there is no web app pointed at that API — so
6074
6097
  // anything a command needs to show or change has to come through here.
6098
+ /**
6099
+ * `/save-settings [path]` and `/load-settings [path]` — move a setup between machines (#244).
6100
+ *
6101
+ * WHAT TRAVELS: the coding providers and their model lists, the agent model, and the NAMES of the
6102
+ * API keys. What does not travel is the keys themselves — the API will not hand them over (see
6103
+ * settings-transfer.mjs for why, and why that is right), so loading asks for the ones the target
6104
+ * account is missing. The file is therefore safe to copy about, which is the whole point of it.
6105
+ *
6106
+ * NOT THE CURRENT CHOICE. Which model this account codes with stays as it is on the machine being
6107
+ * loaded into: a file that moves your models should not quietly move your coder too.
6108
+ */
6109
+ const SETTINGS_FILE_DEFAULT = join(homedir(), "gu-settings.json");
6110
+
6111
+ /** The path a command was given, or the default. Quotes stripped: people paste paths with them. */
6112
+ function settingsPathFrom(line, word) {
6113
+ const typed = line.slice(word.length).trim().replace(/^['"]|['"]$/g, "");
6114
+ if (!typed) return SETTINGS_FILE_DEFAULT;
6115
+ const expanded = typed.startsWith("~/") ? join(homedir(), typed.slice(2)) : typed;
6116
+ return resolve(expanded);
6117
+ }
6118
+
6119
+ async function runSaveSettingsCommand(line) {
6120
+ const st = await import("./settings-transfer.mjs");
6121
+ const file = settingsPathFrom(line, "/save-settings");
6122
+ console.log("");
6123
+ const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
6124
+ if (!settings) {
6125
+ console.log(GUTTER + red(` couldn't reach the API at ${apiBase} — nothing was written.\n`));
6126
+ return;
6127
+ }
6128
+ const keys = (await fetchCodingKeys()) ?? [];
6129
+ const doc = st.buildTransfer({ settings, keys, host: hostname() });
6130
+ if (!doc.codingBackends.length && !doc.agentModel.url && !doc.agentModel.name) {
6131
+ console.log(GUTTER + dim(" this account has no coding providers and no agent model set — nothing to save.\n"));
6132
+ return;
6133
+ }
6134
+ // ASK BEFORE OVERWRITING. The default path is one everybody shares, so the second machine to
6135
+ // save would otherwise eat the first machine's file without a word.
6136
+ if (existsSync(file)) {
6137
+ const ok = (await ask(`${file} exists — overwrite it? [y/N] `)).trim().toLowerCase();
6138
+ if (ok !== "y" && ok !== "yes") {
6139
+ console.log(GUTTER + dim(" left it alone.\n"));
6140
+ return;
6141
+ }
6142
+ }
6143
+ try {
6144
+ // 0600: it names your providers and your key names. Not secret, but nobody else's business.
6145
+ writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
6146
+ } catch (e) {
6147
+ console.log(GUTTER + red(` could not write ${file}: ${e instanceof Error ? e.message : e}\n`));
6148
+ return;
6149
+ }
6150
+ const models = doc.codingBackends.reduce((n, b) => n + b.models.length, 0);
6151
+ console.log(GUTTER + accent(` saved to ${file}`));
6152
+ console.log(
6153
+ GUTTER +
6154
+ dim(` ${doc.codingBackends.length} provider${doc.codingBackends.length === 1 ? "" : "s"}, ${models} model${models === 1 ? "" : "s"}, ${doc.keys.length} key name${doc.keys.length === 1 ? "" : "s"} — no secrets.`),
6155
+ );
6156
+ console.log(GUTTER + dim(" load it on the other machine with /load-settings " + file + "\n"));
6157
+ }
6158
+
6159
+ async function runLoadSettingsCommand(line) {
6160
+ const st = await import("./settings-transfer.mjs");
6161
+ const file = settingsPathFrom(line, "/load-settings");
6162
+ console.log("");
6163
+ let text;
6164
+ try {
6165
+ text = readFileSync(file, "utf8");
6166
+ } catch {
6167
+ console.log(GUTTER + red(` no file at ${file}`));
6168
+ console.log(GUTTER + dim(" /load-settings <path>, or save one first with /save-settings.\n"));
6169
+ return;
6170
+ }
6171
+ // VALIDATED BEFORE ANYTHING IS TOUCHED: a settings file applied halfway is worse than one
6172
+ // refused, because the half that landed is the half nobody re-reads.
6173
+ const parsed = st.validateTransfer(text);
6174
+ if (!parsed.ok) {
6175
+ console.log(GUTTER + red(` ${parsed.problem}`));
6176
+ console.log(GUTTER + dim(` file: ${file}\n`));
6177
+ return;
6178
+ }
6179
+ const current = await withPendingStatus("reading your model settings", fetchWorkerSettings);
6180
+ if (!current) {
6181
+ console.log(GUTTER + red(` couldn't reach the API at ${apiBase} — nothing was changed.\n`));
6182
+ return;
6183
+ }
6184
+ const keys = (await fetchCodingKeys()) ?? [];
6185
+ const plan = st.planLoad(parsed.value, { ...current, keys });
6186
+ console.log(GUTTER + dim(` from ${file}${parsed.value.savedOn ? ` (saved on ${parsed.value.savedOn})` : ""}`));
6187
+ for (const l of plan.lines) console.log(GUTTER + dim(" " + l));
6188
+ if (parsed.carriesSecrets) {
6189
+ console.log(GUTTER + yellow(" this file contains API keys in plain text — delete it when you are done."));
6190
+ }
6191
+ console.log("");
6192
+ const go = (await ask(" apply this? [y/N] ")).trim().toLowerCase();
6193
+ if (go !== "y" && go !== "yes") {
6194
+ console.log(GUTTER + dim(" nothing changed.\n"));
6195
+ return;
6196
+ }
6197
+ const md = await import("./model-doctor.mjs");
6198
+ // `current` so a provider this account already has keeps its row id — see toSettingsPatch.
6199
+ const patch = st.toSettingsPatch(parsed.value, current);
6200
+ const saved = Object.keys(patch).length
6201
+ ? await md.saveWorkerSettings(apiBase, workerKey, patch)
6202
+ : true;
6203
+ if (!saved) {
6204
+ console.log(GUTTER + red(" the API refused the settings — nothing was changed.\n"));
6205
+ return;
6206
+ }
6207
+ console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}.`));
6208
+
6209
+ // THE KEYS, AFTER the settings — each needs the row it belongs to, and the rows only exist
6210
+ // once the API has minted their ids.
6211
+ const fresh = (await fetchWorkerSettings()) ?? {};
6212
+ const rows = Array.isArray(fresh.agentCodingBackends) ? fresh.agentCodingBackends : [];
6213
+ const rowIdForUrl = (url) => {
6214
+ const hit = rows.find((r) => String(r?.url ?? "").trim().toLowerCase() === String(url).trim().toLowerCase());
6215
+ return hit?.id ?? "";
6216
+ };
6217
+ for (const k of plan.missingKeys) {
6218
+ const where = k.backendUrl ? ` for ${k.backendUrl}` : "";
6219
+ const typed = k.key || (await ask(` key "${k.name}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)).trim();
6220
+ if (!typed) {
6221
+ console.log(GUTTER + dim(` skipped ${k.name} — add it later with ctrl+k.`));
6222
+ continue;
6223
+ }
6224
+ const id = await addCodingKey(k.name, typed, k.backendUrl ? rowIdForUrl(k.backendUrl) : "");
6225
+ console.log(GUTTER + (id ? accent(` added ${k.name}.`) : red(` could not add ${k.name}.`)));
6226
+ }
6227
+ console.log(GUTTER + dim(" pick a coding model with /model.\n"));
6228
+ }
6229
+
6075
6230
  async function fetchWorkerSettings() {
6076
6231
  try {
6077
6232
  const r = await fetch(`${apiBase}/api/worker/settings`, {
@@ -7624,27 +7779,65 @@ async function fetchAgentPayload(messages, codingModelOverride = "") {
7624
7779
  data = {};
7625
7780
  }
7626
7781
  if (!res.ok) {
7627
- if (res.status === 404) {
7628
- throw new Error(
7629
- "the API does not have POST /api/worker/agent-payload yet — deploy the latest API (sam build && sam deploy)."
7630
- );
7631
- }
7632
7782
  // Task #127 (BC1b): a present-but-INVALID worker key (rotated/revoked while worker.env
7633
- // kept the stale one) shows up as 401/403. Point the user at re-login rather than a
7634
- // generic error — we don't proactively re-validate the key, so the first rejection is
7635
- // where this surfaces.
7636
- if (res.status === 401 || res.status === 403) {
7637
- throw new Error(
7638
- "your worker key was rejected (it may have been rotated or revoked). " +
7639
- "Run `gu-cli login` to sign in again, or update it with " +
7640
- "`gu-cli set <workerKey> --api-base <url>`."
7641
- );
7642
- }
7643
- throw new Error(data?.error || `agent-payload failed (HTTP ${res.status})`);
7783
+ // kept the stale one) shows up as 401/403. We don't proactively re-validate the key, so
7784
+ // the first rejection is where this surfaces.
7785
+ //
7786
+ // THE MESSAGES AND THE CODES NOW LIVE IN credential-recovery.mjs (task #240), because the
7787
+ // startup probe does more than print them: a rejected key there OFFERS THE SIGN-IN rather
7788
+ // than exiting, and that decision must switch on a code, not on a regex over wording.
7789
+ throw payloadError(res.status, data);
7644
7790
  }
7645
7791
  return data;
7646
7792
  }
7647
7793
 
7794
+ /**
7795
+ * The STARTUP probe, with one way back from a rejected key (task #240).
7796
+ *
7797
+ * Reproduced in a clean Ubuntu container: with a key the API rejects, `gu` printed one red line
7798
+ * and exited — every time. The first-run questions were unreachable from that state, so the only
7799
+ * route back was `gu clear`, which also deletes chat history, workspaces, edit backups and RAG
7800
+ * indexes. A rejected key means this machine is not connected to an account, which is the same
7801
+ * situation a first run is for; so the sign-in is offered here, keeping everything else.
7802
+ *
7803
+ * EXACTLY ONE OFFER. The retry is outside the recovery, so a key that is rejected AGAIN (the
7804
+ * account has no worker, the API is confused) falls through to the normal error and exit instead
7805
+ * of asking in a loop.
7806
+ *
7807
+ * Only the startup probe recovers. The other fetchAgentPayload callers run mid-session, where a
7808
+ * sign-in prompt would interrupt work in progress; they keep their existing error handling.
7809
+ */
7810
+ async function probeWithRecovery() {
7811
+ const ping = [{ role: "user", content: "ping" }];
7812
+ try {
7813
+ return await fetchAgentPayload(ping);
7814
+ } catch (err) {
7815
+ const plan = recoveryPlan({
7816
+ code: err?.code,
7817
+ isTty: IS_TTY,
7818
+ clientExpert: isClientExpert(),
7819
+ });
7820
+ if (plan.action !== "offer-signin") throw err;
7821
+ console.log("");
7822
+ console.log(red(plan.lines[0]));
7823
+ for (const line of plan.lines.slice(1)) console.log(dim(line));
7824
+ console.log("");
7825
+ const { deviceLogin, saveWorkerKey, DEFAULT_API_BASE } = await import("./device-login.mjs");
7826
+ const typed = (await ask(plan.prompt)).trim();
7827
+ const r = typed
7828
+ ? await saveWorkerKey({
7829
+ apiBase: apiBase || DEFAULT_API_BASE,
7830
+ workerKey: typed,
7831
+ log: (s) => console.log(s),
7832
+ })
7833
+ : await deviceLogin({ apiBase: apiBase || DEFAULT_API_BASE, log: (s) => console.log(s) });
7834
+ // worker.env is written by those two; this process is still holding the rejected values.
7835
+ workerKey = r.workerKey;
7836
+ apiBase = r.apiBase;
7837
+ return await fetchAgentPayload(ping);
7838
+ }
7839
+ }
7840
+
7648
7841
  // ---------- run one agent turn via the JOB QUEUE (the daemon executes it) ----------
7649
7842
  // The question is enqueued exactly like a web question, so the RUNNING
7650
7843
  // gu-cli daemon claims + executes it — its terminal shows the full
@@ -8357,6 +8550,12 @@ const wrapThought = (s, width, maxLines) => {
8357
8550
  * own conversation.
8358
8551
  */
8359
8552
  async function runAgentTurn(history, { attachJobId = "" } = {}) {
8553
+ // #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
8554
+ // startup (see licenceBlock) — never re-checked here, so a turn cannot die halfway through.
8555
+ if (licenceBlock) {
8556
+ console.log(GUTTER + orange("Not sent.") + dim(" " + licenceBlock) + "\n");
8557
+ return;
8558
+ }
8360
8559
  const attached = Boolean(attachJobId);
8361
8560
  inputVisible = false; // the frozen zone shows an empty prompt for the duration (#141)
8362
8561
  // Something on screen from the FIRST moment (task #140): Enter has just wiped the bar, and
@@ -9783,7 +9982,7 @@ async function main() {
9783
9982
  }
9784
9983
  // Show which models will answer, straight from user settings (also validates auth).
9785
9984
  try {
9786
- const probe = await fetchAgentPayload([{ role: "user", content: "ping" }]);
9985
+ const probe = await probeWithRecovery();
9787
9986
  const p = probe?.payload ?? {};
9788
9987
  // Remember the default coder id for the token footer's global-total label, and the
9789
9988
  // default backend's coding URL so the GLOBAL output-token total is keyed to it (task
@@ -10178,6 +10377,12 @@ async function main() {
10178
10377
  dim("\n If that fails, check MongoDB is running and see ~/.gonext/local-api.log\n")
10179
10378
  );
10180
10379
  }
10380
+ } else if (err?.code === "KEY_REJECTED") {
10381
+ // WE REACHED THE API PERFECTLY WELL — it told us the key is no good (task #240). Wrapping
10382
+ // that in "couldn't reach the API" sends the reader off debugging DNS, a proxy and their
10383
+ // wifi for a problem the server already named. Seen in the container: a second bad key
10384
+ // pasted at the recovery prompt produced exactly that sentence.
10385
+ console.error(red("gu: " + err.message));
10181
10386
  } else {
10182
10387
  // The SAME bare "fetch failed" the Client Expert branch above was written to fix — that
10183
10388
  // one names the host and offers a next step, this one printed node's four words and
@@ -10465,6 +10670,14 @@ async function main() {
10465
10670
  await runS3Command(line);
10466
10671
  continue;
10467
10672
  }
10673
+ if (line === "/save-settings" || line.startsWith("/save-settings ")) {
10674
+ await runSaveSettingsCommand(line);
10675
+ continue;
10676
+ }
10677
+ if (line === "/load-settings" || line.startsWith("/load-settings ")) {
10678
+ await runLoadSettingsCommand(line);
10679
+ continue;
10680
+ }
10468
10681
  if (line === "/test-auto") {
10469
10682
  sessionTestAuto = !sessionTestAuto;
10470
10683
  await saveSession(cwd, history); // persist the choice for this folder
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.706",
3
+ "version": "1.0.708",
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",
@@ -52,6 +52,8 @@
52
52
  "version-check.mjs",
53
53
  "rag-selftest.mjs",
54
54
  "cli-token.mjs",
55
+ "credential-recovery.mjs",
56
+ "settings-transfer.mjs",
55
57
  "device-login.mjs",
56
58
  "daemon-control.mjs",
57
59
  "poll-errors.mjs",
@@ -0,0 +1,253 @@
1
+ /**
2
+ * MOVING A SETUP BETWEEN MACHINES — the file /save-settings writes and /load-settings reads (#244).
3
+ *
4
+ * WHAT IT CARRIES: the coding backends (the list /model picks from), the agent model, and the
5
+ * IDENTITY of each API key. What it deliberately does not carry is the keys themselves.
6
+ *
7
+ * WHY NO SECRETS, since this is the first question anyone asks of a "save my settings" file: the API
8
+ * will not give them to us. GET /api/worker/coding-keys returns maskCodingKeys() — id, name, last
9
+ * four characters — and says why on the route: it is the one place that lists credentials, so it is
10
+ * the one place that could turn a leaked worker key into "dump every key I own". The only route that
11
+ * returns a real secret returns exactly one, the key a given job or provider would be handed. A save
12
+ * could walk that route once per provider and harvest the lot, which is precisely the thing the
13
+ * masking exists to prevent — and it would put them in a file that then sits in Downloads.
14
+ *
15
+ * So the file names the keys and loading asks for the ones the target account is missing. The
16
+ * side-effect is the good kind: this file is safe to copy, email, or commit.
17
+ *
18
+ * ROW IDS NEVER TRAVEL. They identify a row WITHIN one account — a folder's saved pick, a job's
19
+ * record of which key it used, the account default all name one — so another account's ids would
20
+ * either collide with a row already there or silently re-point a folder at a different provider.
21
+ * The file therefore names providers by URL, which means the same thing on every machine.
22
+ *
23
+ * ON LOADING, A PROVIDER THIS ACCOUNT ALREADY HAS KEEPS ITS OWN ID (see toSettingsPatch). Minting a
24
+ * fresh one would strand every key bound to the old row: a key whose row is gone is ineligible
25
+ * everywhere and filtered out of every picker, so it can never be selected, used or deleted again.
26
+ *
27
+ * THE CURRENT CHOICE IS NOT SAVED, by instruction and by safety: agentCodingDefaultKind and
28
+ * agentCodingDefaultBackendId stay as they are on the machine being loaded into. A file that moves
29
+ * your models should not also decide which one you are coding with.
30
+ */
31
+
32
+ /** Bumped only when an older gu could not read a newer file correctly. */
33
+ export const FILE_VERSION = 1;
34
+
35
+ /** The kinds the API's own type allows (CodingBackendKind). */
36
+ const KINDS = ["", "ollama", "openai", "local"];
37
+
38
+ const isHttpUrl = (v) => typeof v === "string" && /^https?:\/\/\S+$/i.test(v.trim());
39
+ const isStr = (v) => typeof v === "string";
40
+ const clean = (v) => (typeof v === "string" ? v.trim() : "");
41
+
42
+ /**
43
+ * The object /save-settings writes.
44
+ *
45
+ * `settings` is the account's settings as the API returns them; `keys` is the masked list. Neither
46
+ * is trusted to be well-formed — a machine that has never set a coder has no backends at all.
47
+ */
48
+ export function buildTransfer({ settings = {}, keys = [], now = new Date(), host = "" } = {}) {
49
+ const rows = Array.isArray(settings.agentCodingBackends) ? settings.agentCodingBackends : [];
50
+ const backends = rows
51
+ .filter((r) => r && typeof r === "object")
52
+ .map((r) => ({
53
+ kind: KINDS.includes(r.kind) ? r.kind : "",
54
+ url: clean(r.url),
55
+ defaultModel: clean(r.defaultModel),
56
+ models: Array.isArray(r.models) ? r.models.filter(isStr).map((m) => m.trim()).filter(Boolean) : [],
57
+ enabled: r.enabled !== false,
58
+ }));
59
+ // A key's row id means nothing on another account; the provider's URL means the same everywhere.
60
+ const urlForRowId = new Map(rows.filter((r) => r?.id).map((r) => [r.id, clean(r.url)]));
61
+ const keyList = (Array.isArray(keys) ? keys : [])
62
+ .filter((k) => k && typeof k === "object" && clean(k.name))
63
+ .map((k) => ({
64
+ name: clean(k.name),
65
+ tail: clean(k.tail),
66
+ ...(urlForRowId.get(k.backendId) ? { backendUrl: urlForRowId.get(k.backendId) } : {}),
67
+ }));
68
+ return {
69
+ gu: "settings",
70
+ version: FILE_VERSION,
71
+ // Said in the file, because someone will open it and wonder.
72
+ note: "Models and providers only. This file contains NO API keys — gu asks for those when you load it.",
73
+ savedAt: new Date(now).toISOString(),
74
+ ...(host ? { savedOn: host } : {}),
75
+ codingBackends: backends,
76
+ agentModel: { url: clean(settings.agentModelUrl), name: clean(settings.agentModelName) },
77
+ keys: keyList,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Read a file's text and say either what it means or what is wrong with it.
83
+ *
84
+ * ONE PROBLEM, NAMED. Everything is checked before anything is applied, and the first real problem
85
+ * refuses the whole file: a settings file applied halfway is worse than one rejected, because the
86
+ * half that landed is the half nobody reads back.
87
+ */
88
+ export function validateTransfer(text) {
89
+ let doc;
90
+ try {
91
+ doc = JSON.parse(String(text));
92
+ } catch {
93
+ return { ok: false, problem: "that file is not JSON — is it the file gu saved?" };
94
+ }
95
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
96
+ return { ok: false, problem: "that file is not a gu settings file (expected a JSON object)." };
97
+ }
98
+ if (doc.gu !== "settings") {
99
+ return { ok: false, problem: 'that file is not a gu settings file (no "gu": "settings" marker).' };
100
+ }
101
+ if (doc.version !== FILE_VERSION) {
102
+ return {
103
+ ok: false,
104
+ problem: `that file is version ${JSON.stringify(doc.version)}; this gu writes and reads version ${FILE_VERSION}. Update gu, or save it again from the machine that wrote it.`,
105
+ };
106
+ }
107
+ const rows = doc.codingBackends;
108
+ if (rows !== undefined && !Array.isArray(rows)) {
109
+ return { ok: false, problem: "codingBackends must be a list." };
110
+ }
111
+ const backends = [];
112
+ const seen = new Set();
113
+ for (const [i, r] of (rows ?? []).entries()) {
114
+ const at = `codingBackends[${i}]`;
115
+ if (!r || typeof r !== "object" || Array.isArray(r)) return { ok: false, problem: `${at} is not an object.` };
116
+ if (!KINDS.includes(r.kind)) {
117
+ return { ok: false, problem: `${at} has kind ${JSON.stringify(r.kind)} — expected one of ${KINDS.filter(Boolean).join(", ")}.` };
118
+ }
119
+ if (r.url !== undefined && r.url !== "" && !isHttpUrl(r.url)) {
120
+ return { ok: false, problem: `${at} has a url that is not http(s): ${JSON.stringify(r.url)}.` };
121
+ }
122
+ if (r.models !== undefined && (!Array.isArray(r.models) || !r.models.every(isStr))) {
123
+ return { ok: false, problem: `${at}.models must be a list of model names.` };
124
+ }
125
+ if (r.defaultModel !== undefined && !isStr(r.defaultModel)) {
126
+ return { ok: false, problem: `${at}.defaultModel must be text.` };
127
+ }
128
+ // "false" as a STRING is truthy — the exact trap the API's own settings writer documents.
129
+ if (r.enabled !== undefined && typeof r.enabled !== "boolean") {
130
+ return { ok: false, problem: `${at}.enabled must be true or false, not ${JSON.stringify(r.enabled)}.` };
131
+ }
132
+ const dup = `${r.kind}|${clean(r.url).toLowerCase()}`;
133
+ if (seen.has(dup)) return { ok: false, problem: `${at} repeats a provider already in the file (${r.kind} ${clean(r.url) || "no url"}).` };
134
+ seen.add(dup);
135
+ backends.push({
136
+ kind: r.kind,
137
+ url: clean(r.url),
138
+ defaultModel: clean(r.defaultModel),
139
+ models: (r.models ?? []).map((m) => m.trim()).filter(Boolean),
140
+ enabled: r.enabled !== false,
141
+ });
142
+ }
143
+ const am = doc.agentModel;
144
+ if (am !== undefined && (!am || typeof am !== "object" || Array.isArray(am))) {
145
+ return { ok: false, problem: "agentModel must be an object with a url and a name." };
146
+ }
147
+ if (am?.url !== undefined && am.url !== "" && !isHttpUrl(am.url)) {
148
+ return { ok: false, problem: `agentModel.url is not http(s): ${JSON.stringify(am.url)}.` };
149
+ }
150
+ if (am?.name !== undefined && !isStr(am.name)) {
151
+ return { ok: false, problem: "agentModel.name must be text." };
152
+ }
153
+ if (doc.keys !== undefined && !Array.isArray(doc.keys)) {
154
+ return { ok: false, problem: "keys must be a list." };
155
+ }
156
+ const keys = [];
157
+ for (const [i, k] of (doc.keys ?? []).entries()) {
158
+ const at = `keys[${i}]`;
159
+ if (!k || typeof k !== "object" || Array.isArray(k)) return { ok: false, problem: `${at} is not an object.` };
160
+ if (!clean(k.name)) return { ok: false, problem: `${at} has no name — a key is identified by its name.` };
161
+ if (k.key !== undefined && !isStr(k.key)) return { ok: false, problem: `${at}.key must be text.` };
162
+ if (k.backendUrl !== undefined && k.backendUrl !== "" && !isHttpUrl(k.backendUrl)) {
163
+ return { ok: false, problem: `${at}.backendUrl is not http(s): ${JSON.stringify(k.backendUrl)}.` };
164
+ }
165
+ keys.push({
166
+ name: clean(k.name),
167
+ tail: clean(k.tail),
168
+ ...(clean(k.backendUrl) ? { backendUrl: clean(k.backendUrl) } : {}),
169
+ // Present only when a person put it there by hand; gu never writes one.
170
+ ...(clean(k.key) ? { key: clean(k.key) } : {}),
171
+ });
172
+ }
173
+ return {
174
+ ok: true,
175
+ value: { backends, agentModel: { url: clean(am?.url), name: clean(am?.name) }, keys, savedAt: clean(doc.savedAt), savedOn: clean(doc.savedOn) },
176
+ // Loading a file someone hand-edited to carry live credentials is allowed, but it is not
177
+ // allowed to be quiet: that file is now a secret on disk.
178
+ carriesSecrets: keys.some((k) => k.key),
179
+ };
180
+ }
181
+
182
+ /**
183
+ * What loading this file would do to the machine it is loaded on.
184
+ *
185
+ * REPLACES, DOES NOT MERGE. Two providers with the same URL are the same provider, and merging
186
+ * would leave a machine holding both the file's idea of a provider and its own — which is how a
187
+ * /model list ends up with two rows that look identical and behave differently. The plan says so
188
+ * plainly, because "replace" is the part a person needs to agree to.
189
+ */
190
+ export function planLoad(file, current = {}) {
191
+ const currentRows = Array.isArray(current.agentCodingBackends) ? current.agentCodingBackends : [];
192
+ const currentKeys = Array.isArray(current.keys) ? current.keys : [];
193
+ const have = new Set(currentKeys.map((k) => clean(k.name).toLowerCase()).filter(Boolean));
194
+ const missingKeys = file.keys.filter((k) => !have.has(k.name.toLowerCase()) && !k.key);
195
+ const lines = [];
196
+ lines.push(
197
+ file.backends.length
198
+ ? `${file.backends.length} coding provider${file.backends.length === 1 ? "" : "s"}${currentRows.length ? `, replacing the ${currentRows.length} on this account` : ""}:`
199
+ : "no coding providers in the file — the ones on this account stay as they are.",
200
+ );
201
+ for (const b of file.backends) {
202
+ const models = b.models.length ? `${b.models.length} model${b.models.length === 1 ? "" : "s"}` : "no models";
203
+ lines.push(` ${b.kind || "?"} ${b.url || "(no url)"} ${models}${b.defaultModel ? `, default ${b.defaultModel}` : ""}${b.enabled ? "" : " (disabled)"}`);
204
+ }
205
+ if (file.agentModel.url || file.agentModel.name) {
206
+ lines.push(`agent model: ${file.agentModel.name || "(server default)"}${file.agentModel.url ? ` at ${file.agentModel.url}` : ""}`);
207
+ }
208
+ if (missingKeys.length) {
209
+ 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:`);
210
+ for (const k of missingKeys) lines.push(` ${k.name}${k.tail ? ` (…${k.tail})` : ""}${k.backendUrl ? ` for ${k.backendUrl}` : ""}`);
211
+ }
212
+ // The account default is NOT in the file and is not touched — said out loud, because someone
213
+ // loading "their models" onto a machine reasonably expects the coder to change too.
214
+ lines.push("your current coding model choice is not changed — pick one with /model.");
215
+ return { lines, missingKeys, replaces: currentRows.length, adds: file.backends.length };
216
+ }
217
+
218
+ /**
219
+ * The POST body for /api/worker/settings. Only fields a worker key may write.
220
+ *
221
+ * A PROVIDER THIS ACCOUNT ALREADY HAS KEEPS ITS ROW ID, and that is not a detail. An API key is
222
+ * bound to a row BY ID; a key whose row has gone is, in this codebase's own words, "ineligible
223
+ * everywhere AND filtered out of every picker" — it can never be selected, used or deleted again.
224
+ * Folder picks and job records name rows the same way. So loading a file onto a machine that
225
+ * already has api.openai.com must reuse that row rather than mint a second one: matched on
226
+ * kind + url, which is what makes two rows the same provider. Genuinely new rows carry no id and
227
+ * the API mints one, which is what keeps ids unique per account instead of imported from someone
228
+ * else's.
229
+ */
230
+ export function toSettingsPatch(file, current = {}) {
231
+ const patch = {};
232
+ const existing = Array.isArray(current.agentCodingBackends) ? current.agentCodingBackends : [];
233
+ const idFor = (kind, url) =>
234
+ existing.find(
235
+ (r) => r?.id && r.kind === kind && clean(r.url).toLowerCase() === clean(url).toLowerCase(),
236
+ )?.id ?? "";
237
+ if (file.backends.length) {
238
+ patch.agentCodingBackends = file.backends.map((b) => {
239
+ const id = idFor(b.kind, b.url);
240
+ return {
241
+ ...(id ? { id } : {}),
242
+ kind: b.kind,
243
+ url: b.url,
244
+ defaultModel: b.defaultModel,
245
+ models: b.models,
246
+ enabled: b.enabled,
247
+ };
248
+ });
249
+ }
250
+ if (file.agentModel.url) patch.agentModelUrl = file.agentModel.url;
251
+ if (file.agentModel.name) patch.agentModelName = file.agentModel.name;
252
+ return patch;
253
+ }