@tiens.nguyen/gu-cli 1.0.891 → 1.0.892
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/agent-deps-preflight.mjs +96 -0
- package/gu-cli.mjs +11 -0
- package/gu-repl.mjs +15 -1
- package/model-doctor.mjs +92 -20
- package/package.json +2 -1
- package/platform-tools.mjs +70 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE AGENT'S PYTHON PACKAGES, BEFORE THE TERMINAL EXISTS (GH #308).
|
|
3
|
+
*
|
|
4
|
+
* `gu` used to find out that smolagents + openai were missing only once its full-screen terminal was up (raw
|
|
5
|
+
* keyboard, pinned input bar, background worker), and ran pip from inside it. On a brand-new Windows 11 machine
|
|
6
|
+
* that never came back: the pip child was started and gu's whole event loop stopped — no output, no timeout, a
|
|
7
|
+
* fifteen-minute hang — while the very same install, run in a plain process, finished in under a minute.
|
|
8
|
+
*
|
|
9
|
+
* So the install happens FIRST, in a plain process: no readline, no raw mode, no worker, nothing else running.
|
|
10
|
+
* One question (default yes) with an ordinary line prompt, then the installer with its own heartbeat, log file
|
|
11
|
+
* and hard timeout. A failure here never blocks the terminal — it says what to run and carries on.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
|
|
17
|
+
export const DECLINED_FILE = join(homedir(), ".gonext", "agent-deps-declined");
|
|
18
|
+
|
|
19
|
+
/** Is this start one that should check? Interactive terminals only, and not when a test or script opts out. Pure. */
|
|
20
|
+
export function shouldPreflight({ env = process.env, stdinTTY = Boolean(process.stdin.isTTY), stdoutTTY = Boolean(process.stdout.isTTY), declined = existsSync(DECLINED_FILE) } = {}) {
|
|
21
|
+
if (!stdinTTY || !stdoutTTY) return false;
|
|
22
|
+
if (env.GU_NO_AGENT_DEPS_PROMPT === "1") return false;
|
|
23
|
+
if (declined) return false;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** "y"/"" → true, anything else false. Pure. */
|
|
28
|
+
export function yesAnswer(typed) {
|
|
29
|
+
const t = String(typed ?? "").trim().toLowerCase();
|
|
30
|
+
return t === "" || t === "y" || t === "yes";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A plain one-line question: its own readline, closed before the real terminal creates its own. */
|
|
34
|
+
export async function askLine(question, { input = process.stdin, output = process.stdout } = {}) {
|
|
35
|
+
const { createInterface } = await import("node:readline/promises");
|
|
36
|
+
const rl = createInterface({ input, output, terminal: false });
|
|
37
|
+
try {
|
|
38
|
+
return await rl.question(question);
|
|
39
|
+
} finally {
|
|
40
|
+
rl.close();
|
|
41
|
+
input.pause?.();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* → "present" | "installed" | "declined" | "failed" | "skipped". Every dependency is injected, so the whole decision
|
|
47
|
+
* is testable without Python, a terminal or a network.
|
|
48
|
+
*/
|
|
49
|
+
export async function agentDepsPreflight({
|
|
50
|
+
shouldRun = shouldPreflight(),
|
|
51
|
+
hasLibs,
|
|
52
|
+
install,
|
|
53
|
+
ask = askLine,
|
|
54
|
+
markDeclined = () => {},
|
|
55
|
+
log = (s) => console.log(s),
|
|
56
|
+
} = {}) {
|
|
57
|
+
if (!shouldRun) return "skipped";
|
|
58
|
+
let present = false;
|
|
59
|
+
try {
|
|
60
|
+
present = await hasLibs();
|
|
61
|
+
} catch {
|
|
62
|
+
present = false;
|
|
63
|
+
}
|
|
64
|
+
if (present) return "present";
|
|
65
|
+
log("\ngu: first start — the agent framework (smolagents + openai) isn't installed here, and every turn needs it.");
|
|
66
|
+
log(" It is a small pip install into gu's own Python environment; no models, nothing system-wide.");
|
|
67
|
+
const answer = await ask(" Install it now? [Y/n]: ");
|
|
68
|
+
if (!yesAnswer(answer)) {
|
|
69
|
+
markDeclined();
|
|
70
|
+
log(" Skipped — install later with `gu-cli deps` (gu will not ask again at start).");
|
|
71
|
+
return "declined";
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const out = await install();
|
|
75
|
+
const ok = out && (out.agentLibs === "installed" || out.agentLibs === "present");
|
|
76
|
+
if (!ok) {
|
|
77
|
+
log(" ⚠ the agent framework did not install — run `gu-cli deps` to see why. Starting gu anyway.");
|
|
78
|
+
return "failed";
|
|
79
|
+
}
|
|
80
|
+
log(" ✓ the agent framework is installed.\n");
|
|
81
|
+
return "installed";
|
|
82
|
+
} catch (e) {
|
|
83
|
+
log(` ⚠ ${e instanceof Error ? e.message : e}\n Starting gu anyway — run \`gu-cli deps\` to retry.`);
|
|
84
|
+
return "failed";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Remember "no" so the question is not asked at every start. */
|
|
89
|
+
export function markDeclinedOnDisk() {
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(dirname(DECLINED_FILE), { recursive: true });
|
|
92
|
+
writeFileSync(DECLINED_FILE, new Date().toISOString() + "\n");
|
|
93
|
+
} catch {
|
|
94
|
+
/* asking again next time is the safe failure */
|
|
95
|
+
}
|
|
96
|
+
}
|
package/gu-cli.mjs
CHANGED
|
@@ -126,6 +126,7 @@ Usage:
|
|
|
126
126
|
gu-cli stop [--models] # stop the daemon (--models: the model servers too)
|
|
127
127
|
gu-cli logs [--lines N] [-f] # tail the background daemon log
|
|
128
128
|
gu-cli logs <port|pid|chat|embed> [-f] # tail a MODEL server's log (e.g. logs 8090 -f)
|
|
129
|
+
gu-cli deps # install the agent's Python packages (smolagents + openai)
|
|
129
130
|
gu-cli doctor [--client] # set up models here (--client: agent deps only,
|
|
130
131
|
# models stay on another machine)
|
|
131
132
|
gu-cli api <start|stop|restart|status|upgrade> # the LOCAL API (Local/Client Expert only);
|
|
@@ -1818,6 +1819,16 @@ async function guServiceLines() {
|
|
|
1818
1819
|
return lines;
|
|
1819
1820
|
}
|
|
1820
1821
|
|
|
1822
|
+
if (args[0] === "deps") {
|
|
1823
|
+
// The agent's Python packages (smolagents + openai), installed in a plain process — what the installer and the
|
|
1824
|
+
// first start run, and what to run by hand when either said it could not (GH #308).
|
|
1825
|
+
const md = await import("./model-doctor.mjs");
|
|
1826
|
+
const out = await md.ensureAgentDeps({ log: (l) => console.log(l), confirm: async () => true, modelHost: false, agreed: ["agentLibs"] });
|
|
1827
|
+
const ok = out.agentLibs === "installed" || out.agentLibs === "present";
|
|
1828
|
+
console.log(ok ? "\n ✓ the agent framework is ready." : "\n ✗ the agent framework is not installed — see the message above and " + md.PIP_LOG_FILE);
|
|
1829
|
+
process.exit(ok ? 0 : 1);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1821
1832
|
if (args[0] === "doctor") {
|
|
1822
1833
|
// Task #304: a Mac whose MongoDB runs in Docker is told the native option, once, up front —
|
|
1823
1834
|
// a working Docker MongoDB is left alone (it is a choice), a silent one is named as stuck.
|
package/gu-repl.mjs
CHANGED
|
@@ -610,7 +610,7 @@ const argv = process.argv.slice(2);
|
|
|
610
610
|
// process.argv and exits inside its own subcommand branch, before it ever starts polling.
|
|
611
611
|
const CLI_SUBCOMMANDS = [
|
|
612
612
|
"status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
|
|
613
|
-
"clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp", "hooks", "support",
|
|
613
|
+
"clear", "reset", "api", "token-usage", "tokens", "token", "full-log", "mcp", "hooks", "support", "deps",
|
|
614
614
|
];
|
|
615
615
|
// `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
|
|
616
616
|
// the list, so it fell through to "no subcommand given" and a full agent session opened in that
|
|
@@ -700,6 +700,20 @@ if (REPL_LOG) {
|
|
|
700
700
|
process.on("exit", () => ll.releaseLeaseSync());
|
|
701
701
|
console.log(dim(" --log: detailed logging is on for this run (~/.gonext/worker.log); back to minimal when gu closes."));
|
|
702
702
|
}
|
|
703
|
+
|
|
704
|
+
// THE AGENT'S PYTHON PACKAGES COME FIRST (GH #308), before the first-run setup and the terminal below create their readline and
|
|
705
|
+
// raw keyboard — and before stdout is hooked for the pinned input bar (whose state does not exist yet here): the install ran from inside the live terminal and froze it on a new Windows 11 machine.
|
|
706
|
+
{
|
|
707
|
+
const pre = await import("./agent-deps-preflight.mjs");
|
|
708
|
+
if (pre.shouldPreflight()) {
|
|
709
|
+
const md0 = await import("./model-doctor.mjs");
|
|
710
|
+
await pre.agentDepsPreflight({
|
|
711
|
+
hasLibs: () => md0.hasAgentLibs(),
|
|
712
|
+
install: () => md0.ensureAgentDeps({ log: (l) => console.log(l), confirm: async () => true, modelHost: false, agreed: ["agentLibs"] }),
|
|
713
|
+
markDeclined: pre.markDeclinedOnDisk,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
703
717
|
if (REPL_VERBOSE) {
|
|
704
718
|
process.env.GONEXT_REPL_VERBOSE = "1";
|
|
705
719
|
}
|
package/model-doctor.mjs
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
// remote box). Downloads happen only when the model dir is genuinely absent AND the user
|
|
9
9
|
// opts in.
|
|
10
10
|
import { spawn, execFile } from "node:child_process";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
11
12
|
import { open, mkdir, readdir, writeFile } from "node:fs/promises";
|
|
12
13
|
import { arch, homedir, platform, totalmem } from "node:os";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { ensureSupportedPython, isBuildToolsMissing, buildFailureMessage } from "./platform-tools.mjs";
|
|
16
|
+
import { ensureSupportedPython, isBuildToolsMissing, buildFailureMessage, ownVenvPython } from "./platform-tools.mjs";
|
|
16
17
|
|
|
17
18
|
// Minimum RAM to run the default 14B-4bit chat model comfortably on Apple Silicon.
|
|
18
19
|
const MIN_CHAT_RAM_GB = 16;
|
|
@@ -40,9 +41,15 @@ const EMBED_SCRIPT = fileURLToPath(new URL("./gonext_mlx_embed.py", import.meta.
|
|
|
40
41
|
function python3Bin() {
|
|
41
42
|
const explicit = (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim();
|
|
42
43
|
if (explicit) return explicit;
|
|
44
|
+
// gu's OWN environment, when it has one (~/.gonext/venv): the agent's packages were installed INTO it, so a new
|
|
45
|
+
// process must look there, not in the system Python — or it asks to install them at every start (GH #308).
|
|
46
|
+
const own = ownVenvPython();
|
|
47
|
+
if (own) return own;
|
|
43
48
|
return process.platform === "win32" ? "py" : "python3";
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
export { ownVenvPython };
|
|
52
|
+
|
|
46
53
|
/**
|
|
47
54
|
* How to install Python HERE. The advice used to say "Xcode Command Line Tools" on every
|
|
48
55
|
* platform, which a Windows user cannot act on at all.
|
|
@@ -142,24 +149,66 @@ export function isPipMissing(output) {
|
|
|
142
149
|
return /No module named pip\b/i.test(String(output ?? ""));
|
|
143
150
|
}
|
|
144
151
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
152
|
+
/** How long one pip run may take before gu stops waiting (GU_PIP_TIMEOUT_MS overrides; a slow line needs more). */
|
|
153
|
+
export const PIP_TIMEOUT_MS = Number(process.env.GU_PIP_TIMEOUT_MS ?? "") || 600_000;
|
|
154
|
+
/** Where a pip run's whole output is kept, so a failure can be read afterwards. */
|
|
155
|
+
export const PIP_LOG_FILE = join(homedir(), ".gonext", "pip-install.log");
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run pip once. Resolves { code, err, timedOut } — never rejects, so the caller owns the policy.
|
|
159
|
+
*
|
|
160
|
+
* NEVER SILENT, NEVER FOREVER (found on a new Windows 11 box: the install sat at "installing …" for fifteen
|
|
161
|
+
* minutes with no output and no way to tell why). So: a line every 15 s says it is still working, the whole
|
|
162
|
+
* output goes to a log file, and a hard timeout kills the process tree and reports it as a failure that says
|
|
163
|
+
* what to do. stdout is piped and relayed rather than inherited, so nothing about the terminal can stall it.
|
|
164
|
+
*/
|
|
165
|
+
function runPip(python, pkgs, extraArgs = [], { log = () => {}, timeoutMs = PIP_TIMEOUT_MS } = {}) {
|
|
147
166
|
return new Promise((resolve) => {
|
|
167
|
+
const keep = async (d) => {
|
|
168
|
+
try {
|
|
169
|
+
const { mkdirSync, appendFileSync } = await import("node:fs");
|
|
170
|
+
mkdirSync(DIR, { recursive: true });
|
|
171
|
+
appendFileSync(PIP_LOG_FILE, d);
|
|
172
|
+
} catch { /* the log is a convenience */ }
|
|
173
|
+
};
|
|
148
174
|
// --prefer-binary: take an older wheel over a newer source build (a compiler is not a given, #225).
|
|
149
175
|
const child = spawn(python, ["-m", "pip", "install", "-U", "--prefer-binary", ...extraArgs, ...pkgs], {
|
|
150
176
|
// stderr is PIPED so the PEP 668 marker can be recognised, and echoed as it arrives so
|
|
151
177
|
// the user still watches a real install rather than a frozen line.
|
|
152
|
-
stdio: ["ignore", "
|
|
178
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
153
179
|
env: process.env,
|
|
180
|
+
windowsHide: true,
|
|
154
181
|
});
|
|
182
|
+
keep(`\n--- ${new Date().toISOString()} ${python} -m pip install -U --prefer-binary ${pkgs.join(" ")}\n`);
|
|
155
183
|
let err = "";
|
|
184
|
+
let done = false;
|
|
185
|
+
const started = Date.now();
|
|
186
|
+
const finish = (r) => {
|
|
187
|
+
if (done) return;
|
|
188
|
+
done = true;
|
|
189
|
+
clearInterval(beat);
|
|
190
|
+
clearTimeout(limit);
|
|
191
|
+
resolve(r);
|
|
192
|
+
};
|
|
193
|
+
child.stdout?.on("data", (d) => { keep(d); process.stdout.write(d); });
|
|
156
194
|
child.stderr?.on("data", (d) => {
|
|
157
195
|
const s = String(d);
|
|
158
196
|
err += s;
|
|
197
|
+
keep(s);
|
|
159
198
|
process.stderr.write(s);
|
|
160
199
|
});
|
|
161
|
-
|
|
162
|
-
|
|
200
|
+
const beat = setInterval(() => log(` … still installing (${Math.round((Date.now() - started) / 1000)} s)`), 15_000);
|
|
201
|
+
const limit = setTimeout(() => {
|
|
202
|
+
// The whole tree: py.exe starts python.exe, which starts the build/download helpers.
|
|
203
|
+
if (process.platform === "win32" && child.pid) {
|
|
204
|
+
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on("error", () => {});
|
|
205
|
+
} else {
|
|
206
|
+
try { child.kill("SIGKILL"); } catch { /* already gone */ }
|
|
207
|
+
}
|
|
208
|
+
finish({ code: 124, err: `pip did not finish within ${Math.round(timeoutMs / 60000)} minutes`, timedOut: true });
|
|
209
|
+
}, timeoutMs);
|
|
210
|
+
child.on("exit", (code) => finish({ code: code ?? 1, err }));
|
|
211
|
+
child.on("error", (e) => finish({ code: -1, err: String(e?.message ?? e) }));
|
|
163
212
|
});
|
|
164
213
|
}
|
|
165
214
|
|
|
@@ -204,9 +253,42 @@ async function pipInstall(pkgs, log) {
|
|
|
204
253
|
const sp = await ensureSupportedPython({ log });
|
|
205
254
|
if (!sp.ok) throw new Error(sp.message);
|
|
206
255
|
if (sp.switched) await pinPythonForDaemon({ log });
|
|
256
|
+
|
|
257
|
+
const stalled = (r) =>
|
|
258
|
+
new Error(
|
|
259
|
+
`${r.err}. The output is in ${PIP_LOG_FILE}. Install them yourself and start gu again: ` +
|
|
260
|
+
`${python3Bin()} -m pip install -U ${pkgs.join(" ")}`
|
|
261
|
+
);
|
|
262
|
+
const intoVenv = async () => {
|
|
263
|
+
const venvPy = await ensureVenv(log);
|
|
264
|
+
if (!venvPy) return null;
|
|
265
|
+
log(` installing ${pkgs.join(" ")} into gu's own Python environment … (this can take a minute)`);
|
|
266
|
+
const r = await runPip(venvPy, pkgs, [], { log });
|
|
267
|
+
if (r.timedOut) throw stalled(r);
|
|
268
|
+
if (r.code !== 0) {
|
|
269
|
+
if (isBuildToolsMissing(r.err)) throw new Error(buildFailureMessage(r.err));
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
// Pin it NOW, not at the end of doctor: the packages live in this interpreter and nothing else can
|
|
273
|
+
// import them, so a daemon started meanwhile would still see none.
|
|
274
|
+
process.env.GONEXT_PROBE_PYTHON = venvPy;
|
|
275
|
+
await pinPythonForDaemon({ log });
|
|
276
|
+
log(` installed into the virtualenv and pinned the agent's python to ${venvPy}`);
|
|
277
|
+
return venvPy;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// WINDOWS: gu's OWN environment (~/.gonext/venv) from the start. The system Python is somebody else's: writing
|
|
281
|
+
// into it needs an administrator, can clash with their packages, and cannot be undone with one folder delete.
|
|
282
|
+
// (GU_PIP_SYSTEM=1 keeps the old behaviour.)
|
|
283
|
+
if (process.platform === "win32" && process.env.GU_PIP_SYSTEM !== "1") {
|
|
284
|
+
if (await intoVenv()) return;
|
|
285
|
+
log(" ⚠ could not use a private environment — trying the system Python instead");
|
|
286
|
+
}
|
|
287
|
+
|
|
207
288
|
log(` installing ${pkgs.join(" ")} … (pip, this can take a minute)`);
|
|
208
|
-
let r = await runPip(python3Bin(), pkgs);
|
|
289
|
+
let r = await runPip(python3Bin(), pkgs, [], { log });
|
|
209
290
|
if (r.code === 0) return;
|
|
291
|
+
if (r.timedOut) throw stalled(r);
|
|
210
292
|
if (isBuildToolsMissing(r.err)) throw new Error(buildFailureMessage(r.err));
|
|
211
293
|
const noPip = isPipMissing(r.err);
|
|
212
294
|
if (!noPip && !isExternallyManaged(r.err)) {
|
|
@@ -218,18 +300,7 @@ async function pipInstall(pkgs, log) {
|
|
|
218
300
|
? " this python has no pip — using a private virtualenv, which brings its own."
|
|
219
301
|
: " this python is managed by your OS (PEP 668) — it will not accept packages directly."
|
|
220
302
|
);
|
|
221
|
-
|
|
222
|
-
if (venvPy) {
|
|
223
|
-
r = await runPip(venvPy, pkgs);
|
|
224
|
-
if (r.code === 0) {
|
|
225
|
-
// Pin it NOW, not at the end of doctor: the packages live in this interpreter and
|
|
226
|
-
// nothing else can import them, so a daemon started meanwhile would still see none.
|
|
227
|
-
process.env.GONEXT_PROBE_PYTHON = venvPy;
|
|
228
|
-
await pinPythonForDaemon({ log });
|
|
229
|
-
log(` installed into the virtualenv and pinned the agent's python to ${venvPy}`);
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
303
|
+
if (process.platform !== "win32" && (await intoVenv())) return;
|
|
233
304
|
|
|
234
305
|
// Without pip AND without venv nothing here can install anything; --break-system-packages
|
|
235
306
|
// would only fail again with the same "No module named pip". Say what to install instead.
|
|
@@ -239,7 +310,8 @@ async function pipInstall(pkgs, log) {
|
|
|
239
310
|
);
|
|
240
311
|
}
|
|
241
312
|
log(" falling back to --break-system-packages (no virtualenv available)");
|
|
242
|
-
r = await runPip(python3Bin(), pkgs, ["--break-system-packages"]);
|
|
313
|
+
r = await runPip(python3Bin(), pkgs, ["--break-system-packages"], { log });
|
|
314
|
+
if (r.timedOut) throw stalled(r);
|
|
243
315
|
if (r.code === 0) return;
|
|
244
316
|
throw new Error(`pip install ${pkgs.join(" ")} exited ${r.code}`);
|
|
245
317
|
}
|
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.892",
|
|
4
4
|
"description": "gu: the coding agent in your terminal. It reads your repo, makes the change and runs the tests, with your own models (Ollama, MLX or any OpenAI-compatible provider). macOS, Linux and Windows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
},
|
|
64
64
|
"files": [
|
|
65
65
|
"accent.mjs",
|
|
66
|
+
"agent-deps-preflight.mjs",
|
|
66
67
|
"edit-card-render.mjs",
|
|
67
68
|
"diff-syntax.mjs",
|
|
68
69
|
"theme.mjs",
|
package/platform-tools.mjs
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* been executed on Windows, and a parser with tests is not a working port.
|
|
16
16
|
*/
|
|
17
17
|
import { execFile } from "node:child_process";
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
18
21
|
|
|
19
22
|
const IS_WINDOWS = process.platform === "win32";
|
|
20
23
|
|
|
@@ -214,9 +217,22 @@ let _python = null;
|
|
|
214
217
|
* An explicit GONEXT_PROBE_PYTHON still wins, and is passed through untouched.
|
|
215
218
|
* Cached: otherwise this probes a subprocess on every job.
|
|
216
219
|
*/
|
|
220
|
+
/**
|
|
221
|
+
* The interpreter of gu's own private virtualenv (~/.gonext/venv), or "" when there is none. Pure apart from one
|
|
222
|
+
* file check. The agent's packages are installed INTO it on Windows (GH #308), so everything that runs the agent —
|
|
223
|
+
* the terminal's checks AND the background worker — must look there, whatever worker.env did or did not pin.
|
|
224
|
+
*/
|
|
225
|
+
export function ownVenvPython(home = homedir(), exists = existsSync, platform = process.platform) {
|
|
226
|
+
const win = platform === "win32";
|
|
227
|
+
const py = join(home, ".gonext", "venv", win ? "Scripts" : "bin", win ? "python.exe" : "python");
|
|
228
|
+
return exists(py) ? py : "";
|
|
229
|
+
}
|
|
230
|
+
|
|
217
231
|
export async function resolvePython() {
|
|
218
232
|
const explicit = (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim();
|
|
219
233
|
if (explicit) return { cmd: explicit, args: [] };
|
|
234
|
+
const own = ownVenvPython();
|
|
235
|
+
if (own) return { cmd: own, args: [] };
|
|
220
236
|
if (_python) return _python;
|
|
221
237
|
for (const candidate of pythonCandidates()) {
|
|
222
238
|
const probe = pythonVersionProbe(candidate);
|
|
@@ -474,6 +490,39 @@ export function isElevatedGroups(whoamiGroups) {
|
|
|
474
490
|
return /S-1-16-(12288|16384)\b/.test(String(whoamiGroups ?? ""));
|
|
475
491
|
}
|
|
476
492
|
|
|
493
|
+
/**
|
|
494
|
+
* DID THE SCHEDULED TASK NEVER START? — from `schtasks /query /v /fo list`.
|
|
495
|
+
*
|
|
496
|
+
* A task runs only while its user has a logon session. Over SSH to an administrator who is not
|
|
497
|
+
* logged on at the console (a test box, a server), `schtasks /run` answers SUCCESS and the task
|
|
498
|
+
* stays "Ready" with Last Result 267011 (0x41303, "has not yet run") — nothing starts, and setup
|
|
499
|
+
* then stops at "The API started but is not usable: not up yet" with no log at all. Pure.
|
|
500
|
+
*/
|
|
501
|
+
export function taskNeverRan(queryOutput) {
|
|
502
|
+
const text = String(queryOutput ?? "");
|
|
503
|
+
return /Last Result:\s*267011\b/i.test(text) || /Last Run Time:\s*(N\/A|11\/30\/1999)/i.test(text);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* START A PROCESS THAT OUTLIVES AN SSH SESSION, without a logon session: WMI's Win32_Process.Create.
|
|
508
|
+
*
|
|
509
|
+
* The process is created by the WMI service, so it is neither in the SSH session's job object (which
|
|
510
|
+
* kills its children at logoff) nor dependent on the user being logged on (which a scheduled task is).
|
|
511
|
+
* Needs an administrator token; measured on a Windows 11 box: a loop started this way kept running
|
|
512
|
+
* after the ssh session that started it had ended. → the pid, or null when WMI refused.
|
|
513
|
+
*/
|
|
514
|
+
export async function startViaWmi(commandLine, cwd = "") {
|
|
515
|
+
const script =
|
|
516
|
+
"$a = @{ CommandLine = $env:GU_WMI_CMD }; if ($env:GU_WMI_CWD) { $a.CurrentDirectory = $env:GU_WMI_CWD }; " +
|
|
517
|
+
"$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a; " +
|
|
518
|
+
'Write-Output "WMI $($r.ReturnValue) $($r.ProcessId)"';
|
|
519
|
+
const out = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
520
|
+
env: { ...process.env, GU_WMI_CMD: commandLine, GU_WMI_CWD: cwd },
|
|
521
|
+
});
|
|
522
|
+
const m = /WMI 0 (\d+)/.exec(String(out ?? ""));
|
|
523
|
+
return m ? Number(m[1]) : null;
|
|
524
|
+
}
|
|
525
|
+
|
|
477
526
|
/**
|
|
478
527
|
* Which pid owns a listening port, from `netstat -ano`.
|
|
479
528
|
*
|
|
@@ -564,6 +613,27 @@ export async function spawnSurviving({ cmd, args = [], cwd, env = {}, logFile, t
|
|
|
564
613
|
// generous: this runs once per start, and being slow here is free.
|
|
565
614
|
await new Promise((r) => setTimeout(r, TASK_SETTLE_MS));
|
|
566
615
|
|
|
616
|
+
// THE TASK MAY NEVER HAVE STARTED (no logon session for this user — an SSH-only administrator).
|
|
617
|
+
// Then start it as a plain child of this terminal: it lives as long as the session does, which
|
|
618
|
+
// is what an SSH user gets everywhere else, and far better than "not up yet".
|
|
619
|
+
if (taskNeverRan(await run("schtasks.exe", ["/query", "/tn", taskName, "/v", "/fo", "list"]))) {
|
|
620
|
+
await run(t.remove.cmd, t.remove.args);
|
|
621
|
+
// Best first: WMI starts it outside the SSH session's job, so it survives the terminal closing.
|
|
622
|
+
const wmiPid = await startViaWmi(hiddenTaskAction(script, process.env.SystemRoot), cwd || "");
|
|
623
|
+
if (wmiPid) return { ok: true, pid: wmiPid, how: "WMI (the scheduled task never started — no logon session)" };
|
|
624
|
+
await rm(script).catch(() => {});
|
|
625
|
+
const { spawn } = await import("node:child_process");
|
|
626
|
+
const { open } = await import("node:fs/promises");
|
|
627
|
+
const log = logFile ? await open(logFile, "a") : null;
|
|
628
|
+
const child = spawn(cmd, args, {
|
|
629
|
+
cwd, env: { ...process.env, ...env }, stdio: ["ignore", log ? log.fd : "ignore", log ? log.fd : "ignore"],
|
|
630
|
+
detached: true, windowsHide: true,
|
|
631
|
+
});
|
|
632
|
+
child.unref();
|
|
633
|
+
await log?.close().catch(() => {});
|
|
634
|
+
return { ok: true, pid: child.pid ?? null, how: "detached (the scheduled task never started — no logon session)" };
|
|
635
|
+
}
|
|
636
|
+
|
|
567
637
|
// Then deregister: nothing should be left scheduled on the user's machine. `/sc once` with a
|
|
568
638
|
// start time in the past would otherwise re-fire, and a task named after gu sitting in
|
|
569
639
|
// Task Scheduler forever is not something anyone asked for.
|