@tiens.nguyen/gu-cli 1.0.722 → 1.0.723
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/add-server.mjs +7 -1
- package/coding-provider-setup.mjs +12 -0
- package/gu-repl.mjs +142 -35
- package/ollama-setup.mjs +3 -0
- package/package.json +1 -1
- package/settings-transfer.mjs +156 -13
- package/terminal-layout.mjs +40 -0
package/add-server.mjs
CHANGED
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
* server kind ("dialect": an Ollama's /api/tags vs an OpenAI-compatible /v1/models) lives in
|
|
15
15
|
* serverTarget() and listRequest().
|
|
16
16
|
*/
|
|
17
|
-
import { defaultProviderUrl, ensureV1Root, hostLocality, normalizeProviderUrl, parseProviderModels, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
|
|
17
|
+
import { defaultProviderUrl, ensureV1Root, hostLocality, isCancelWord, normalizeProviderUrl, parseProviderModels, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
|
|
18
|
+
|
|
19
|
+
// Re-exported: the add-server screen's own name for the rule every URL prompt shares.
|
|
20
|
+
export { isCancelWord };
|
|
18
21
|
import { agentModelsFromProvider, isLikelyEmbedder, ollamaAgentDefault, ollamaAgentUrl, parseOllamaTags } from "./agent-model-command.mjs";
|
|
19
22
|
|
|
20
23
|
/**
|
|
@@ -76,6 +79,9 @@ export function serverRole(role) {
|
|
|
76
79
|
export function serverTarget(dialect, typed) {
|
|
77
80
|
const raw = String(typed ?? "").trim();
|
|
78
81
|
if (!raw) return { ok: false, why: "empty" };
|
|
82
|
+
// A command word is never a server. Reported: /embed-model showed "on exit" — "exit" typed at
|
|
83
|
+
// the URL prompt had been saved as http://exit, a perfectly valid-looking single-label host.
|
|
84
|
+
if (isCancelWord(raw)) return { ok: false, why: "a command, not a server" };
|
|
79
85
|
if (/^[a-z][a-z0-9+.-]*:\/\/[^/]*@/i.test(raw)) {
|
|
80
86
|
return { ok: false, why: "carrying credentials — keys go in the key prompt" };
|
|
81
87
|
}
|
|
@@ -84,9 +84,19 @@ export function hostLocality(hostname) {
|
|
|
84
84
|
* addresses" wholesale is meaningless — but 169.254.169.254 and metadata.google.internal are
|
|
85
85
|
* never a model server, and they are what an SSRF probe asks for first.
|
|
86
86
|
*/
|
|
87
|
+
/**
|
|
88
|
+
* Words that mean "get me out of here" at a prompt, not a hostname. Every URL prompt refuses them
|
|
89
|
+
* as a server. Reported: /embed-model showed "on exit" — "exit" typed at a URL prompt had been
|
|
90
|
+
* saved as http://exit, a perfectly valid-looking single-label host.
|
|
91
|
+
*/
|
|
92
|
+
export function isCancelWord(typed) {
|
|
93
|
+
return /^(no|n|skip|-|exit|quit|q|cancel|back|esc)$/i.test(String(typed ?? "").trim());
|
|
94
|
+
}
|
|
95
|
+
|
|
87
96
|
export function normalizeProviderUrl(raw) {
|
|
88
97
|
const text = String(raw ?? "").trim();
|
|
89
98
|
if (!text) return { ok: false, why: "empty" };
|
|
99
|
+
if (isCancelWord(text)) return { ok: false, why: "command" };
|
|
90
100
|
if (/\s/.test(text)) return { ok: false, why: "spaces" };
|
|
91
101
|
|
|
92
102
|
const hadScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(text);
|
|
@@ -159,6 +169,8 @@ export function describeUrlProblem(why, typed = "") {
|
|
|
159
169
|
return ["type the provider's base URL, e.g. https://api.openai.com/v1"];
|
|
160
170
|
case "spaces":
|
|
161
171
|
return ["that has a space in it — a URL cannot"];
|
|
172
|
+
case "command":
|
|
173
|
+
return ["that is a command, not a server — press Esc to cancel, or type the server's URL"];
|
|
162
174
|
case "scheme":
|
|
163
175
|
return ["only http:// and https:// addresses work here"];
|
|
164
176
|
case "credentials":
|
package/gu-repl.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
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, writeFileSync } from "node:fs";
|
|
27
|
+
import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync } 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.
|
|
@@ -73,6 +73,10 @@ import {
|
|
|
73
73
|
answerBlock,
|
|
74
74
|
updateRestartNote,
|
|
75
75
|
describeInstallFailure,
|
|
76
|
+
CLI_UPDATE_SHELL_COMMAND,
|
|
77
|
+
isFreshPublishMiss,
|
|
78
|
+
freshPublishNote,
|
|
79
|
+
lazyModuleNames,
|
|
76
80
|
inputZoneRows,
|
|
77
81
|
INPUT_HEAD_ROWS,
|
|
78
82
|
RAG_STORAGE,
|
|
@@ -795,8 +799,8 @@ const COMMANDS = [
|
|
|
795
799
|
{ name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
|
|
796
800
|
{ name: "/s3", usage: "/s3 [test]", desc: "set up the S3 bucket the RAG index lives in, and check the AWS keys work" },
|
|
797
801
|
{ 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
|
|
799
|
-
{ name: "/load-settings", usage: "/load-settings [path]", desc: "read that file back on another machine — asks before it replaces anything" },
|
|
802
|
+
{ name: "/save-settings", usage: "/save-settings [name|path]", desc: "write this account's providers, models and key NAMES to a file in your home folder (no secrets) — asks for a name, Enter keeps gu-settings.json" },
|
|
803
|
+
{ name: "/load-settings", usage: "/load-settings [name|path]", desc: "read that file back on another machine — picks from the settings files in your home folder, asks before it replaces anything" },
|
|
800
804
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
801
805
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
802
806
|
{ name: "/help", desc: "list these commands" },
|
|
@@ -1962,6 +1966,9 @@ function announceCliUpdate() {
|
|
|
1962
1966
|
* The picker names the consequence rather than the version, because the version is already on
|
|
1963
1967
|
* the button and the consequence is what the answer turns on.
|
|
1964
1968
|
*/
|
|
1969
|
+
/** How long to wait before retrying an install that hit a just-published version (ETARGET). */
|
|
1970
|
+
const UPDATE_RETRY_WAIT_MS = 15_000;
|
|
1971
|
+
|
|
1965
1972
|
async function runUpdate() {
|
|
1966
1973
|
const { cli, api } = availableUpdates;
|
|
1967
1974
|
const rows = [];
|
|
@@ -2013,8 +2020,17 @@ async function runUpdate() {
|
|
|
2013
2020
|
// stderr is captured rather than discarded so the reason can be shown. npm puts the
|
|
2014
2021
|
// useful line (EPERM, EACCES, ETARGET) there, and "it failed" without it leaves the
|
|
2015
2022
|
// user with nothing to act on — sudo and "close gu first" are different fixes.
|
|
2016
|
-
const
|
|
2017
|
-
|
|
2023
|
+
const install = () => spawnSync(CLI_UPDATE_SHELL_COMMAND,
|
|
2024
|
+
{ stdio: ["ignore", "ignore", "pipe"], shell: true, encoding: "utf8" });
|
|
2025
|
+
let r = install();
|
|
2026
|
+
// JUST PUBLISHED: the update check sees a new version the moment it lands, npm's cache and
|
|
2027
|
+
// CDN a few minutes later — ETARGET in between. Wait once and retry before calling it a
|
|
2028
|
+
// failure; if npm still has not caught up, say THAT, not a raw npm error.
|
|
2029
|
+
if (isFreshPublishMiss(r)) {
|
|
2030
|
+
await new Promise((res) => setTimeout(res, UPDATE_RETRY_WAIT_MS));
|
|
2031
|
+
r = install();
|
|
2032
|
+
if (isFreshPublishMiss(r)) throw new Error(freshPublishNote(cli));
|
|
2033
|
+
}
|
|
2018
2034
|
const failed = describeInstallFailure(r);
|
|
2019
2035
|
if (failed) throw new Error(failed);
|
|
2020
2036
|
});
|
|
@@ -5645,7 +5661,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
|
|
|
5645
5661
|
console.log(dim(dflt ? ` Enter uses ${dflt} · Esc cancels\n` : " Esc cancels.\n"));
|
|
5646
5662
|
for (let attempt = 0; attempt < 3 && !target; attempt++) {
|
|
5647
5663
|
const typed = await ask(" server URL: ");
|
|
5648
|
-
if (typed === null ||
|
|
5664
|
+
if (typed === null || as.isCancelWord(typed)) break;
|
|
5649
5665
|
const raw = typed.trim() || dflt;
|
|
5650
5666
|
if (!raw) break;
|
|
5651
5667
|
const t = as.serverTarget(dialect, raw);
|
|
@@ -6133,20 +6149,31 @@ async function chooseModel() {
|
|
|
6133
6149
|
* NOT THE CURRENT CHOICE. Which model this account codes with stays as it is on the machine being
|
|
6134
6150
|
* loaded into: a file that moves your models should not quietly move your coder too.
|
|
6135
6151
|
*/
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
6139
|
-
function settingsPathFrom(line, word) {
|
|
6140
|
-
const typed = line.slice(word.length).trim().replace(/^['"]|['"]$/g, "");
|
|
6141
|
-
if (!typed) return SETTINGS_FILE_DEFAULT;
|
|
6142
|
-
const expanded = typed.startsWith("~/") ? join(homedir(), typed.slice(2)) : typed;
|
|
6143
|
-
return resolve(expanded);
|
|
6152
|
+
/** What followed the command word, or "" — the name/path rules are settingsFilePath's. */
|
|
6153
|
+
function settingsArg(line, word) {
|
|
6154
|
+
return line.slice(word.length).trim();
|
|
6144
6155
|
}
|
|
6145
6156
|
|
|
6146
6157
|
async function runSaveSettingsCommand(line) {
|
|
6147
6158
|
const st = await import("./settings-transfer.mjs");
|
|
6148
|
-
const
|
|
6159
|
+
const home = homedir();
|
|
6149
6160
|
console.log("");
|
|
6161
|
+
// THE FILE NAME, IN THE DEFAULT FOLDER. The home directory is where it goes; all anyone should
|
|
6162
|
+
// have to type is a name — and Enter keeps gu-settings.json, as before.
|
|
6163
|
+
let typed = settingsArg(line, "/save-settings");
|
|
6164
|
+
if (!typed) {
|
|
6165
|
+
const answer = await ask(` file name in ${home} [${st.DEFAULT_SETTINGS_NAME}]: `);
|
|
6166
|
+
if (answer === null) {
|
|
6167
|
+
console.log(GUTTER + dim(" (cancelled — nothing was written)\n"));
|
|
6168
|
+
return;
|
|
6169
|
+
}
|
|
6170
|
+
typed = answer.trim();
|
|
6171
|
+
}
|
|
6172
|
+
let file = st.settingsFilePath(typed, home);
|
|
6173
|
+
if (!file) {
|
|
6174
|
+
console.log(GUTTER + dim(" (cancelled — nothing was written)\n"));
|
|
6175
|
+
return;
|
|
6176
|
+
}
|
|
6150
6177
|
const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
|
|
6151
6178
|
if (!settings) {
|
|
6152
6179
|
console.log(GUTTER + red(` couldn't reach the API at ${apiBase} — nothing was written.\n`));
|
|
@@ -6154,18 +6181,23 @@ async function runSaveSettingsCommand(line) {
|
|
|
6154
6181
|
}
|
|
6155
6182
|
const keys = (await fetchCodingKeys()) ?? [];
|
|
6156
6183
|
const doc = st.buildTransfer({ settings, keys, host: hostname() });
|
|
6157
|
-
if (!doc.codingBackends.length && !doc.agentModel.url && !doc.agentModel.name) {
|
|
6158
|
-
console.log(GUTTER + dim(" this account has no coding providers
|
|
6184
|
+
if (!doc.codingBackends.length && !doc.agentModel.url && !doc.agentModel.name && !doc.embedModel.url && !doc.embedModel.name) {
|
|
6185
|
+
console.log(GUTTER + dim(" this account has no coding providers, agent model or embedding model set — nothing to save.\n"));
|
|
6159
6186
|
return;
|
|
6160
6187
|
}
|
|
6161
|
-
// ASK BEFORE OVERWRITING. The default path is one everybody
|
|
6162
|
-
// save would otherwise eat the first machine's file
|
|
6163
|
-
|
|
6164
|
-
|
|
6165
|
-
|
|
6188
|
+
// ASK BEFORE OVERWRITING — and let a NEW NAME be the answer. The default path is one everybody
|
|
6189
|
+
// shares, so the second machine to save would otherwise eat the first machine's file; "no" alone
|
|
6190
|
+
// left nowhere to go but typing the whole command again with a path.
|
|
6191
|
+
while (existsSync(file)) {
|
|
6192
|
+
const answer = await ask(` ${file} exists — overwrite it? [y/N], or type a new name: `);
|
|
6193
|
+
const a = String(answer ?? "").trim();
|
|
6194
|
+
if (/^(y|yes)$/i.test(a)) break;
|
|
6195
|
+
const next = a && !/^(n|no)$/i.test(a) ? st.settingsFilePath(a, home) : "";
|
|
6196
|
+
if (!next) {
|
|
6166
6197
|
console.log(GUTTER + dim(" left it alone.\n"));
|
|
6167
6198
|
return;
|
|
6168
6199
|
}
|
|
6200
|
+
file = next;
|
|
6169
6201
|
}
|
|
6170
6202
|
try {
|
|
6171
6203
|
// 0600: it names your providers and your key names. Not secret, but nobody else's business.
|
|
@@ -6174,19 +6206,36 @@ async function runSaveSettingsCommand(line) {
|
|
|
6174
6206
|
console.log(GUTTER + red(` could not write ${file}: ${e instanceof Error ? e.message : e}\n`));
|
|
6175
6207
|
return;
|
|
6176
6208
|
}
|
|
6177
|
-
const models = doc.codingBackends.reduce((n, b) => n + b.models.length, 0);
|
|
6178
6209
|
console.log(GUTTER + accent(` saved to ${file}`));
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
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.`),
|
|
6182
|
-
);
|
|
6210
|
+
// Counted by the tested rule — the default model included (it once read "0 models").
|
|
6211
|
+
console.log(GUTTER + dim(` ${st.transferSummary(doc)}`));
|
|
6183
6212
|
console.log(GUTTER + dim(" load it on the other machine with /load-settings " + file + "\n"));
|
|
6184
6213
|
}
|
|
6185
6214
|
|
|
6186
6215
|
async function runLoadSettingsCommand(line) {
|
|
6187
6216
|
const st = await import("./settings-transfer.mjs");
|
|
6188
|
-
const
|
|
6217
|
+
const home = homedir();
|
|
6189
6218
|
console.log("");
|
|
6219
|
+
// A NAME IN THE DEFAULT FOLDER, like /save-settings. With none given and more than one settings
|
|
6220
|
+
// file there, pick from them rather than silently taking gu-settings.json.
|
|
6221
|
+
let typed = settingsArg(line, "/load-settings");
|
|
6222
|
+
if (!typed) {
|
|
6223
|
+
const found = st.settingsFilesIn(listJsonFiles(home));
|
|
6224
|
+
if (found.length > 1) {
|
|
6225
|
+
console.log(dim(` Settings files in ${home} (${PICK_HINT}):`));
|
|
6226
|
+
const i = await pickFromList(found, 0);
|
|
6227
|
+
if (i < 0) {
|
|
6228
|
+
console.log(GUTTER + dim(" (cancelled — nothing changed)\n"));
|
|
6229
|
+
return;
|
|
6230
|
+
}
|
|
6231
|
+
typed = found[i];
|
|
6232
|
+
}
|
|
6233
|
+
}
|
|
6234
|
+
const file = st.settingsFilePath(typed, home);
|
|
6235
|
+
if (!file) {
|
|
6236
|
+
console.log(GUTTER + dim(" (cancelled — nothing changed)\n"));
|
|
6237
|
+
return;
|
|
6238
|
+
}
|
|
6190
6239
|
let text;
|
|
6191
6240
|
try {
|
|
6192
6241
|
text = readFileSync(file, "utf8");
|
|
@@ -6231,16 +6280,16 @@ async function runLoadSettingsCommand(line) {
|
|
|
6231
6280
|
console.log(GUTTER + red(" the API refused the settings — nothing was changed.\n"));
|
|
6232
6281
|
return;
|
|
6233
6282
|
}
|
|
6234
|
-
console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}.`));
|
|
6283
|
+
console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}${patch.ragEmbedUrl || patch.ragEmbedModel ? ", the embedding model" : ""}.`));
|
|
6235
6284
|
|
|
6236
6285
|
// THE KEYS, AFTER the settings — each needs the row it belongs to, and the rows only exist
|
|
6237
6286
|
// once the API has minted their ids.
|
|
6238
6287
|
const fresh = (await fetchWorkerSettings()) ?? {};
|
|
6239
|
-
const
|
|
6240
|
-
const
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
|
|
6288
|
+
const ps = await import("./coding-provider-setup.mjs");
|
|
6289
|
+
const rows = ps.existingBackendRows(fresh);
|
|
6290
|
+
// One server however it is spelled (task #245) — a raw compare missed "…/v1" and saved the key
|
|
6291
|
+
// tied to NO server, where it can never be used.
|
|
6292
|
+
const rowIdForUrl = (url) => ps.providerRowForUrl(rows, url)?.id ?? "";
|
|
6244
6293
|
for (const k of plan.missingKeys) {
|
|
6245
6294
|
const where = k.backendUrl ? ` for ${k.backendUrl}` : "";
|
|
6246
6295
|
const typed = k.key || (await ask(` key "${k.name}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)).trim();
|
|
@@ -6248,12 +6297,42 @@ async function runLoadSettingsCommand(line) {
|
|
|
6248
6297
|
console.log(GUTTER + dim(` skipped ${k.name} — add it later with ctrl+k.`));
|
|
6249
6298
|
continue;
|
|
6250
6299
|
}
|
|
6251
|
-
const
|
|
6300
|
+
const rowId = k.backendUrl ? rowIdForUrl(k.backendUrl) : "";
|
|
6301
|
+
const id = await addCodingKey(k.name, typed, rowId);
|
|
6252
6302
|
console.log(GUTTER + (id ? accent(` added ${k.name}.`) : red(` could not add ${k.name}.`)));
|
|
6303
|
+
// The key that was its server's DEFAULT on the saving machine is the default here too.
|
|
6304
|
+
if (id && rowId && k.default) await setServerDefaultKey(rowId, id);
|
|
6253
6305
|
}
|
|
6254
6306
|
console.log(GUTTER + dim(" pick a coding model with /model.\n"));
|
|
6255
6307
|
}
|
|
6256
6308
|
|
|
6309
|
+
/** The .json files directly in a folder, with enough of each to recognise a settings file. */
|
|
6310
|
+
function listJsonFiles(dir) {
|
|
6311
|
+
try {
|
|
6312
|
+
return readdirSync(dir)
|
|
6313
|
+
.filter((n) => /\.json$/i.test(n))
|
|
6314
|
+
.slice(0, 200)
|
|
6315
|
+
.map((name) => {
|
|
6316
|
+
try {
|
|
6317
|
+
const p = join(dir, name);
|
|
6318
|
+
const st = statSync(p);
|
|
6319
|
+
// The marker is near the top; 4 KB is plenty and keeps a huge JSON from being read whole.
|
|
6320
|
+
if (!st.isFile() || st.size > 5_000_000) return null;
|
|
6321
|
+
const fd = openSync(p, "r");
|
|
6322
|
+
const buf = Buffer.alloc(Math.min(4096, st.size));
|
|
6323
|
+
readSync(fd, buf, 0, buf.length, 0);
|
|
6324
|
+
closeSync(fd);
|
|
6325
|
+
return { name, text: buf.toString("utf8"), mtimeMs: st.mtimeMs };
|
|
6326
|
+
} catch {
|
|
6327
|
+
return null;
|
|
6328
|
+
}
|
|
6329
|
+
})
|
|
6330
|
+
.filter(Boolean);
|
|
6331
|
+
} catch {
|
|
6332
|
+
return [];
|
|
6333
|
+
}
|
|
6334
|
+
}
|
|
6335
|
+
|
|
6257
6336
|
async function fetchWorkerSettings() {
|
|
6258
6337
|
try {
|
|
6259
6338
|
const r = await fetch(`${apiBase}/api/worker/settings`, {
|
|
@@ -9925,7 +10004,35 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
9925
10004
|
}
|
|
9926
10005
|
|
|
9927
10006
|
// ---------- REPL ----------
|
|
10007
|
+
/**
|
|
10008
|
+
* LOAD EVERY LAZILY-IMPORTED MODULE NOW, so this process never reads one from disk later.
|
|
10009
|
+
*
|
|
10010
|
+
* REPORTED: "✓ gu 1.0.722 installed — Then restart gu", then /embed-model in the SAME terminal
|
|
10011
|
+
* crashed it with ERR_MODULE_NOT_FOUND …/ollama-host-snippets.mjs. The running process was still
|
|
10012
|
+
* 1.0.721; its /embed-model lazily imported ollama-host-snippets.mjs, and npm had just
|
|
10013
|
+
* replaced the package directory with 1.0.722, which no longer ships that file. A module that
|
|
10014
|
+
* still EXISTS is worse, not better: the old REPL silently runs against the new helper, two
|
|
10015
|
+
* versions mixed in one process.
|
|
10016
|
+
*
|
|
10017
|
+
* The ESM loader caches a module by its resolved URL, so once each one is loaded here every later
|
|
10018
|
+
* lazy import of that name gets THIS version back without touching the disk — whatever an in-app update
|
|
10019
|
+
* wrote there. The list is read from this file's own source, so a new lazy import is covered
|
|
10020
|
+
* without anyone remembering to add it. gu-cli.mjs is excluded: importing it RUNS the CLI.
|
|
10021
|
+
* Fire-and-forget: each import is ~5 ms, verified side-effect free (no output, no files).
|
|
10022
|
+
*/
|
|
10023
|
+
function preloadLazyModules() {
|
|
10024
|
+
let text = "";
|
|
10025
|
+
try {
|
|
10026
|
+
text = readFileSync(fileURLToPath(import.meta.url), "utf8");
|
|
10027
|
+
} catch {
|
|
10028
|
+
return;
|
|
10029
|
+
}
|
|
10030
|
+
for (const name of lazyModuleNames(text)) import(`./${name}`).catch(() => {});
|
|
10031
|
+
}
|
|
10032
|
+
|
|
10033
|
+
|
|
9928
10034
|
async function main() {
|
|
10035
|
+
preloadLazyModules();
|
|
9929
10036
|
// The startup banner (logo + status facts) is printed AFTER the payload fetch below, once the
|
|
9930
10037
|
// model/coder/RAG are known — see renderBanner (task #131). Nothing prints here so the banner
|
|
9931
10038
|
// lands as one clean block.
|
package/ollama-setup.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { isLikelyEmbedder } from "./agent-model-command.mjs";
|
|
22
22
|
import { EMBED_CHOICES } from "./embed-model-command.mjs";
|
|
23
|
+
import { isCancelWord } from "./coding-provider-setup.mjs";
|
|
23
24
|
|
|
24
25
|
/** Ollama's native port, used when someone types a bare host or an IP with no port. */
|
|
25
26
|
export const OLLAMA_DEFAULT_PORT = 11434;
|
|
@@ -190,6 +191,8 @@ export function describeNotReady(host, missing) {
|
|
|
190
191
|
export function normalizeOllamaUrl(raw) {
|
|
191
192
|
const text = String(raw ?? "").trim();
|
|
192
193
|
if (!text) return null;
|
|
194
|
+
// "exit", "quit", "cancel"… are commands, never hosts (they were once saved as http://exit).
|
|
195
|
+
if (isCancelWord(text)) return null;
|
|
193
196
|
// A path, a query, or whitespace inside means this is not a host we can reason about.
|
|
194
197
|
if (/\s/.test(text)) return null;
|
|
195
198
|
|
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.723",
|
|
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",
|
package/settings-transfer.mjs
CHANGED
|
@@ -29,13 +29,100 @@
|
|
|
29
29
|
* your models should not also decide which one you are coding with.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
import { basename, extname, isAbsolute, join, resolve } from "node:path";
|
|
33
|
+
import { ensureV1Root, isCancelWord, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
|
|
34
|
+
|
|
35
|
+
/** The file name used when none is given — in the default folder (the home directory). */
|
|
36
|
+
export const DEFAULT_SETTINGS_NAME = "gu-settings.json";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What was typed → the file to use. The DEFAULT FOLDER is the home directory, so a bare NAME is
|
|
40
|
+
* all anyone has to type ("work" → ~/work.json); a real path still works as one.
|
|
41
|
+
* "" → ~/gu-settings.json
|
|
42
|
+
* "work" → ~/work.json (".json" added when there is no extension)
|
|
43
|
+
* "work.json" → ~/work.json
|
|
44
|
+
* "~/dir/x.json" → ~/dir/x.json
|
|
45
|
+
* "./x.json", "/abs" → that path exactly (relative ones from the current folder, as a shell would)
|
|
46
|
+
* Quotes around the whole thing are stripped — people paste paths with them. Returns "" for a
|
|
47
|
+
* command word ("exit", "cancel", …), which the caller treats as a cancel.
|
|
48
|
+
*/
|
|
49
|
+
export function settingsFilePath(typed, home) {
|
|
50
|
+
const t = String(typed ?? "").trim().replace(/^['"]|['"]$/g, "").trim();
|
|
51
|
+
if (!t) return join(home, DEFAULT_SETTINGS_NAME);
|
|
52
|
+
if (isCancelWord(t)) return "";
|
|
53
|
+
if (t === "~") return join(home, DEFAULT_SETTINGS_NAME);
|
|
54
|
+
if (t.startsWith("~/")) return withJson(join(home, t.slice(2)));
|
|
55
|
+
// An explicit path is taken exactly as typed — no ".json" guessed onto a name someone spelled out.
|
|
56
|
+
if (isAbsolute(t) || t.startsWith("./") || t.startsWith("../") || /[\\/]/.test(t)) return resolve(t);
|
|
57
|
+
return withJson(join(home, t));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const withJson = (p) => (extname(basename(p)) ? p : `${p}.json`);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The settings files in the default folder, newest first — what /load-settings offers when it is
|
|
64
|
+
* given no name. Only files that ARE gu settings files (the "gu": "settings" marker), so a
|
|
65
|
+
* package.json in the home directory is never offered. `entries` = [{ name, text, mtimeMs }].
|
|
66
|
+
*/
|
|
67
|
+
export function settingsFilesIn(entries) {
|
|
68
|
+
return (Array.isArray(entries) ? entries : [])
|
|
69
|
+
.filter((e) => e && /\.json$/i.test(e.name) && /"gu"\s*:\s*"settings"/.test(String(e.text ?? "")))
|
|
70
|
+
.sort((a, b) => (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0))
|
|
71
|
+
.map((e) => e.name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Bumped only when an older gu could not read a newer file correctly. Task #245 ADDED optional
|
|
76
|
+
* fields (embedModel, keys[].default) — an older gu ignores them and still loads the rest, so the
|
|
77
|
+
* version stays 1.
|
|
78
|
+
*/
|
|
33
79
|
export const FILE_VERSION = 1;
|
|
34
80
|
|
|
35
81
|
/** The kinds the API's own type allows (CodingBackendKind). */
|
|
36
82
|
const KINDS = ["", "ollama", "openai", "local"];
|
|
37
83
|
|
|
38
84
|
const isHttpUrl = (v) => typeof v === "string" && /^https?:\/\/\S+$/i.test(v.trim());
|
|
85
|
+
|
|
86
|
+
/** Two spellings of one server are one server — the rule the API and providerRowForUrl use. */
|
|
87
|
+
const sameServerUrl = (a, b) =>
|
|
88
|
+
Boolean(clean(a) && clean(b)) && ensureV1Root(clean(a)).toLowerCase() === ensureV1Root(clean(b)).toLowerCase();
|
|
89
|
+
|
|
90
|
+
/** A server's models, the default counted once: what "N models" means everywhere a person reads it. */
|
|
91
|
+
export function modelsOf(b) {
|
|
92
|
+
return [...new Set([clean(b?.defaultModel), ...(Array.isArray(b?.models) ? b.models.map(clean) : [])].filter(Boolean))];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The line /save-settings prints. REPORTED: "4 providers, 0 models" for four providers that each
|
|
97
|
+
* had a model — the count read only the EXTRA models and never the default one.
|
|
98
|
+
*/
|
|
99
|
+
export function transferSummary(doc) {
|
|
100
|
+
const n = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
101
|
+
const backends = Array.isArray(doc?.codingBackends) ? doc.codingBackends : [];
|
|
102
|
+
const models = backends.reduce((sum, b) => sum + modelsOf(b).length, 0);
|
|
103
|
+
const parts = [n(backends.length, "provider"), n(models, "model"), n(Array.isArray(doc?.keys) ? doc.keys.length : 0, "key name")];
|
|
104
|
+
if (doc?.agentModel?.name || doc?.agentModel?.url) parts.push("the agent model");
|
|
105
|
+
if (doc?.embedModel?.name || doc?.embedModel?.url) parts.push("the embedding model");
|
|
106
|
+
return `${parts.join(", ")} — no secrets.`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A server's models within MAX_MODELS_PER_SERVER (task #245) — the API refuses a row that grows
|
|
111
|
+
* past it, and a file saved before the cap may hold more. The default stays; extras are kept in
|
|
112
|
+
* order up to the cap, or up to what this account's row ALREADY holds (a row stored before the cap
|
|
113
|
+
* keeps what it has). Returns { defaultModel, models, dropped }.
|
|
114
|
+
*/
|
|
115
|
+
export function capServerModels(b, keepAtLeast = 0) {
|
|
116
|
+
const all = modelsOf(b);
|
|
117
|
+
const defaultModel = clean(b?.defaultModel) || "";
|
|
118
|
+
const limit = Math.max(MAX_MODELS_PER_SERVER, keepAtLeast);
|
|
119
|
+
const kept = all.slice(0, limit);
|
|
120
|
+
return {
|
|
121
|
+
defaultModel,
|
|
122
|
+
models: kept.filter((m) => m !== defaultModel),
|
|
123
|
+
dropped: all.slice(limit),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
39
126
|
const isStr = (v) => typeof v === "string";
|
|
40
127
|
const clean = (v) => (typeof v === "string" ? v.trim() : "");
|
|
41
128
|
|
|
@@ -64,6 +151,9 @@ export function buildTransfer({ settings = {}, keys = [], now = new Date(), host
|
|
|
64
151
|
name: clean(k.name),
|
|
65
152
|
tail: clean(k.tail),
|
|
66
153
|
...(urlForRowId.get(k.backendId) ? { backendUrl: urlForRowId.get(k.backendId) } : {}),
|
|
154
|
+
// Task #245: which key is its server's DEFAULT, so loading makes the same one the default
|
|
155
|
+
// (otherwise it would be whichever key happened to be added first).
|
|
156
|
+
...(k.default === true ? { default: true } : {}),
|
|
67
157
|
}));
|
|
68
158
|
return {
|
|
69
159
|
gu: "settings",
|
|
@@ -74,6 +164,8 @@ export function buildTransfer({ settings = {}, keys = [], now = new Date(), host
|
|
|
74
164
|
...(host ? { savedOn: host } : {}),
|
|
75
165
|
codingBackends: backends,
|
|
76
166
|
agentModel: { url: clean(settings.agentModelUrl), name: clean(settings.agentModelName) },
|
|
167
|
+
// Task #245: the third role. Its KEY travels like the others — by name, bound to its server.
|
|
168
|
+
embedModel: { url: clean(settings.ragEmbedUrl), name: clean(settings.ragEmbedModel) },
|
|
77
169
|
keys: keyList,
|
|
78
170
|
};
|
|
79
171
|
}
|
|
@@ -150,6 +242,16 @@ export function validateTransfer(text) {
|
|
|
150
242
|
if (am?.name !== undefined && !isStr(am.name)) {
|
|
151
243
|
return { ok: false, problem: "agentModel.name must be text." };
|
|
152
244
|
}
|
|
245
|
+
const em = doc.embedModel;
|
|
246
|
+
if (em !== undefined && (!em || typeof em !== "object" || Array.isArray(em))) {
|
|
247
|
+
return { ok: false, problem: "embedModel must be an object with a url and a name." };
|
|
248
|
+
}
|
|
249
|
+
if (em?.url !== undefined && em.url !== "" && !isHttpUrl(em.url)) {
|
|
250
|
+
return { ok: false, problem: `embedModel.url is not http(s): ${JSON.stringify(em.url)}.` };
|
|
251
|
+
}
|
|
252
|
+
if (em?.name !== undefined && !isStr(em.name)) {
|
|
253
|
+
return { ok: false, problem: "embedModel.name must be text." };
|
|
254
|
+
}
|
|
153
255
|
if (doc.keys !== undefined && !Array.isArray(doc.keys)) {
|
|
154
256
|
return { ok: false, problem: "keys must be a list." };
|
|
155
257
|
}
|
|
@@ -159,6 +261,9 @@ export function validateTransfer(text) {
|
|
|
159
261
|
if (!k || typeof k !== "object" || Array.isArray(k)) return { ok: false, problem: `${at} is not an object.` };
|
|
160
262
|
if (!clean(k.name)) return { ok: false, problem: `${at} has no name — a key is identified by its name.` };
|
|
161
263
|
if (k.key !== undefined && !isStr(k.key)) return { ok: false, problem: `${at}.key must be text.` };
|
|
264
|
+
if (k.default !== undefined && typeof k.default !== "boolean") {
|
|
265
|
+
return { ok: false, problem: `${at}.default must be true or false.` };
|
|
266
|
+
}
|
|
162
267
|
if (k.backendUrl !== undefined && k.backendUrl !== "" && !isHttpUrl(k.backendUrl)) {
|
|
163
268
|
return { ok: false, problem: `${at}.backendUrl is not http(s): ${JSON.stringify(k.backendUrl)}.` };
|
|
164
269
|
}
|
|
@@ -166,13 +271,21 @@ export function validateTransfer(text) {
|
|
|
166
271
|
name: clean(k.name),
|
|
167
272
|
tail: clean(k.tail),
|
|
168
273
|
...(clean(k.backendUrl) ? { backendUrl: clean(k.backendUrl) } : {}),
|
|
274
|
+
...(k.default === true ? { default: true } : {}),
|
|
169
275
|
// Present only when a person put it there by hand; gu never writes one.
|
|
170
276
|
...(clean(k.key) ? { key: clean(k.key) } : {}),
|
|
171
277
|
});
|
|
172
278
|
}
|
|
173
279
|
return {
|
|
174
280
|
ok: true,
|
|
175
|
-
value: {
|
|
281
|
+
value: {
|
|
282
|
+
backends,
|
|
283
|
+
agentModel: { url: clean(am?.url), name: clean(am?.name) },
|
|
284
|
+
embedModel: { url: clean(em?.url), name: clean(em?.name) },
|
|
285
|
+
keys,
|
|
286
|
+
savedAt: clean(doc.savedAt),
|
|
287
|
+
savedOn: clean(doc.savedOn),
|
|
288
|
+
},
|
|
176
289
|
// Loading a file someone hand-edited to carry live credentials is allowed, but it is not
|
|
177
290
|
// allowed to be quiet: that file is now a secret on disk.
|
|
178
291
|
carriesSecrets: keys.some((k) => k.key),
|
|
@@ -190,8 +303,19 @@ export function validateTransfer(text) {
|
|
|
190
303
|
export function planLoad(file, current = {}) {
|
|
191
304
|
const currentRows = Array.isArray(current.agentCodingBackends) ? current.agentCodingBackends : [];
|
|
192
305
|
const currentKeys = Array.isArray(current.keys) ? current.keys : [];
|
|
193
|
-
|
|
194
|
-
|
|
306
|
+
// A KEY BELONGS TO ITS SERVER (task #245): "work" on api.openai.com says nothing about a "work"
|
|
307
|
+
// key for ollama.com. So a same-named key counts as present when it is ON THE SAME SERVER, or
|
|
308
|
+
// tied to NO server (a pre-#245 key — the resolver still falls back to it, so asking for it
|
|
309
|
+
// again would be asking for a key the account already uses). The same name on ANOTHER server
|
|
310
|
+
// is a different key.
|
|
311
|
+
const urlOfRow = (id) => clean(currentRows.find((r) => r?.id === id)?.url);
|
|
312
|
+
const present = (k) =>
|
|
313
|
+
currentKeys.some((c) => {
|
|
314
|
+
if (clean(c.name).toLowerCase() !== k.name.toLowerCase()) return false;
|
|
315
|
+
if (!c.backendId) return true;
|
|
316
|
+
return Boolean(k.backendUrl) && sameServerUrl(urlOfRow(c.backendId), k.backendUrl);
|
|
317
|
+
});
|
|
318
|
+
const missingKeys = file.keys.filter((k) => !k.key && !present(k));
|
|
195
319
|
const lines = [];
|
|
196
320
|
lines.push(
|
|
197
321
|
file.backends.length
|
|
@@ -199,12 +323,25 @@ export function planLoad(file, current = {}) {
|
|
|
199
323
|
: "no coding providers in the file — the ones on this account stay as they are.",
|
|
200
324
|
);
|
|
201
325
|
for (const b of file.backends) {
|
|
202
|
-
const
|
|
326
|
+
const n = modelsOf(b).length;
|
|
327
|
+
const models = n ? `${n} model${n === 1 ? "" : "s"}` : "no models";
|
|
203
328
|
lines.push(` ${b.kind || "?"} ${b.url || "(no url)"} ${models}${b.defaultModel ? `, default ${b.defaultModel}` : ""}${b.enabled ? "" : " (disabled)"}`);
|
|
329
|
+
const mine = currentRows.find((r) => sameServerUrl(r?.url, b.url));
|
|
330
|
+
const capped = capServerModels(b, mine ? modelsOf(mine).length : 0);
|
|
331
|
+
if (capped.dropped.length) {
|
|
332
|
+
lines.push(` a server offers at most ${MAX_MODELS_PER_SERVER} models — not loading ${capped.dropped.join(", ")}`);
|
|
333
|
+
}
|
|
204
334
|
}
|
|
205
335
|
if (file.agentModel.url || file.agentModel.name) {
|
|
206
336
|
lines.push(`agent model: ${file.agentModel.name || "(server default)"}${file.agentModel.url ? ` at ${file.agentModel.url}` : ""}`);
|
|
207
337
|
}
|
|
338
|
+
if (file.embedModel?.url || file.embedModel?.name) {
|
|
339
|
+
const same = sameServerUrl(file.embedModel.url, current.ragEmbedUrl) && file.embedModel.name === clean(current.ragEmbedModel);
|
|
340
|
+
lines.push(`embedding model: ${file.embedModel.name || "(unchanged)"}${file.embedModel.url ? ` at ${file.embedModel.url}` : ""}${same ? " (already set)" : ""}`);
|
|
341
|
+
if (!same && file.embedModel.name && file.embedModel.name !== clean(current.ragEmbedModel)) {
|
|
342
|
+
lines.push(" a different embedder: every knowledge base rebuilds on its next search.");
|
|
343
|
+
}
|
|
344
|
+
}
|
|
208
345
|
if (missingKeys.length) {
|
|
209
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:`);
|
|
210
347
|
for (const k of missingKeys) lines.push(` ${k.name}${k.tail ? ` (…${k.tail})` : ""}${k.backendUrl ? ` for ${k.backendUrl}` : ""}`);
|
|
@@ -230,24 +367,30 @@ export function planLoad(file, current = {}) {
|
|
|
230
367
|
export function toSettingsPatch(file, current = {}) {
|
|
231
368
|
const patch = {};
|
|
232
369
|
const existing = Array.isArray(current.agentCodingBackends) ? current.agentCodingBackends : [];
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
370
|
+
// Matched as ONE SERVER however it is spelled (task #245) — "https://ollama.com" and
|
|
371
|
+
// "https://ollama.com/v1" are the same row, and a raw compare would mint a twin and strand the
|
|
372
|
+
// keys bound to the original.
|
|
373
|
+
const mineFor = (kind, url) => existing.find((r) => r?.id && r.kind === kind && sameServerUrl(r.url, url));
|
|
237
374
|
if (file.backends.length) {
|
|
238
375
|
patch.agentCodingBackends = file.backends.map((b) => {
|
|
239
|
-
const
|
|
376
|
+
const mine = mineFor(b.kind, b.url);
|
|
377
|
+
const capped = capServerModels(b, mine ? modelsOf(mine).length : 0);
|
|
240
378
|
return {
|
|
241
|
-
...(id ? { id } : {}),
|
|
379
|
+
...(mine?.id ? { id: mine.id } : {}),
|
|
242
380
|
kind: b.kind,
|
|
243
381
|
url: b.url,
|
|
244
|
-
defaultModel:
|
|
245
|
-
models:
|
|
382
|
+
defaultModel: capped.defaultModel,
|
|
383
|
+
models: capped.models,
|
|
246
384
|
enabled: b.enabled,
|
|
247
385
|
};
|
|
248
386
|
});
|
|
249
387
|
}
|
|
250
388
|
if (file.agentModel.url) patch.agentModelUrl = file.agentModel.url;
|
|
251
389
|
if (file.agentModel.name) patch.agentModelName = file.agentModel.name;
|
|
390
|
+
if (file.embedModel?.url) patch.ragEmbedUrl = file.embedModel.url;
|
|
391
|
+
if (file.embedModel?.name) patch.ragEmbedModel = file.embedModel.name;
|
|
392
|
+
// The embedder's key is resolved through its server's keys (task #245); the pre-#245
|
|
393
|
+
// account-wide pick would only shadow them.
|
|
394
|
+
if (file.embedModel?.url || file.embedModel?.name) patch.ragEmbedKeyId = "";
|
|
252
395
|
return patch;
|
|
253
396
|
}
|
package/terminal-layout.mjs
CHANGED
|
@@ -1715,6 +1715,46 @@ export function describeInstallFailure({ status = 0, stderr = "", error = null }
|
|
|
1715
1715
|
return `npm exited ${status}${why ? ` — ${_clipReason(why)}` : ""}`;
|
|
1716
1716
|
}
|
|
1717
1717
|
|
|
1718
|
+
/**
|
|
1719
|
+
* The command the in-app update RUNS — one string, run through a shell.
|
|
1720
|
+
*
|
|
1721
|
+
* ONE STRING, NOT AN ARGS ARRAY. A shell is needed (Windows finds npm as npm.cmd only through
|
|
1722
|
+
* one), and Node 24 warns DEP0190 whenever args are passed WITH `shell: true` — they are joined,
|
|
1723
|
+
* not escaped — and that warning landed in the middle of the transcript on every update.
|
|
1724
|
+
*
|
|
1725
|
+
* --prefer-online: npm REVALIDATES its cached package document instead of trusting it. The update
|
|
1726
|
+
* check reads registry.npmjs.org/<pkg>/latest, which knows a new version the moment it is
|
|
1727
|
+
* published; `npm install` reads the full document from npm's own cache and CDN, which catch up
|
|
1728
|
+
* minutes later. Without this, a version announced seconds after publishing failed with ETARGET.
|
|
1729
|
+
*/
|
|
1730
|
+
export const CLI_UPDATE_SHELL_COMMAND = `${CLI_INSTALL_COMMAND} --prefer-online`;
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Did the install fail only because npm has not caught up with a JUST-published version?
|
|
1734
|
+
* (ETARGET / notarget — "No matching version found".) Worth one retry after a short wait, and
|
|
1735
|
+
* worth saying plainly rather than as a raw npm error: nothing is wrong with the release.
|
|
1736
|
+
*/
|
|
1737
|
+
export function isFreshPublishMiss({ status = 0, stderr = "" } = {}) {
|
|
1738
|
+
if (status === 0) return false;
|
|
1739
|
+
return /\b(ETARGET|notarget)\b|No matching version found/i.test(String(stderr ?? ""));
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/** What to say when npm still has not caught up after the retry. */
|
|
1743
|
+
export function freshPublishNote(version = "") {
|
|
1744
|
+
return `gu ${version || "the new version"} was published moments ago and npm's mirrors are still catching up — ` +
|
|
1745
|
+
"try the update again in a few minutes.";
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* The local modules a source text imports LAZILY (a dynamic import of "./name.mjs"), minus the CLI entry
|
|
1750
|
+
* (importing gu-cli.mjs RUNS the CLI). gu-repl preloads these at startup so an in-app update that
|
|
1751
|
+
* replaces the package on disk can never be read into the still-running old process.
|
|
1752
|
+
*/
|
|
1753
|
+
export function lazyModuleNames(text) {
|
|
1754
|
+
return [...new Set([...String(text ?? "").matchAll(/import\("\.\/([\w.-]+\.mjs)"\)/g)].map((m) => m[1]))]
|
|
1755
|
+
.filter((m) => m !== "gu-cli.mjs");
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1718
1758
|
/** One line, bounded — a reason is for reading, and npm's can carry a whole stack frame. */
|
|
1719
1759
|
function _clipReason(s, max = 160) {
|
|
1720
1760
|
const one = String(s).replace(/\s+/g, " ").trim();
|