@tiens.nguyen/gu-cli 1.0.686
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/README.md +52 -0
- package/agent-model-command.mjs +259 -0
- package/agent-model-label.mjs +159 -0
- package/clear-state.mjs +149 -0
- package/client-expert-api.mjs +736 -0
- package/client-expert-run.mjs +892 -0
- package/client-expert-setup.mjs +616 -0
- package/coding-choice-tags.mjs +69 -0
- package/coding-key-prompt.mjs +229 -0
- package/coding-provider-setup.mjs +808 -0
- package/completed-flush.mjs +105 -0
- package/daemon-control.mjs +462 -0
- package/device-login.mjs +212 -0
- package/doctor-check.mjs +239 -0
- package/embed-model-command.mjs +157 -0
- package/first-run-steps.mjs +171 -0
- package/gonext_agent_chat.py +12299 -0
- package/gonext_mlx_embed.py +155 -0
- package/gonext_probe_agent.py +93 -0
- package/gonext_transcribe.py +130 -0
- package/gu-cli.mjs +4930 -0
- package/gu-repl.mjs +10326 -0
- package/job-pools.mjs +89 -0
- package/model-doctor.mjs +1494 -0
- package/node-version.mjs +40 -0
- package/ollama-setup.mjs +832 -0
- package/package.json +100 -0
- package/platform-tools.mjs +520 -0
- package/poll-errors.mjs +141 -0
- package/proxy-command.mjs +165 -0
- package/proxy-config.mjs +255 -0
- package/proxy-dispatcher.mjs +132 -0
- package/proxy-selftest.mjs +234 -0
- package/proxy-store.mjs +69 -0
- package/rag-job-config.mjs +59 -0
- package/rag-selftest.mjs +215 -0
- package/s3-setup.mjs +85 -0
- package/terminal-copy.mjs +248 -0
- package/terminal-hover.mjs +153 -0
- package/terminal-layout.mjs +2507 -0
- package/terminal-viewport.mjs +602 -0
- package/thinking_words.txt +1003 -0
- package/version-check.mjs +72 -0
package/package.json
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
+
"version": "1.0.686",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/tiennsloit/gu.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/tiennsloit/gu#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/tiennsloit/gu/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"gu-cli": "./gu-cli.mjs",
|
|
17
|
+
"gu-local-worker": "./gu-cli.mjs",
|
|
18
|
+
"gu": "./gu-repl.mjs"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "python3 -m unittest discover tests -v && node --test",
|
|
22
|
+
"test:js": "node --test",
|
|
23
|
+
"test:keys": "node --test tests/key-choice-regression.test.mjs tests/coding-key-line.test.mjs tests/coding-key-picker.test.mjs tests/coding-key-prompt.test.mjs tests/coding-key-notice.test.mjs tests/coding-key-fallback-line.test.mjs tests/manage-coding-providers.test.mjs tests/add-coding-provider.test.mjs tests/coding-provider-setup.test.mjs",
|
|
24
|
+
"deploy:local": "npm install -g .",
|
|
25
|
+
"run": "gu-cli",
|
|
26
|
+
"publish:org": "npm publish --access public",
|
|
27
|
+
"preflight": "node scripts/preflight.mjs",
|
|
28
|
+
"test:e2e": "GONEXT_E2E=1 node --test tests/client-expert-e2e.test.mjs",
|
|
29
|
+
"test:linux": "bash scripts/e2e-linux.sh",
|
|
30
|
+
"test:rag": "node scripts/rag-e2e.mjs",
|
|
31
|
+
"test:ollama1-e2e": "GONEXT_OLLAMA1_LIVE=1 node --test --test-timeout=1800000 tests/ollama1-e2e.test.mjs",
|
|
32
|
+
"test:ollama1": "GONEXT_OLLAMA1_LIVE=1 python3 -m unittest discover tests -p \"test_ollama1_live.py\" -v",
|
|
33
|
+
"test:proxy": "python3 -m unittest discover tests -p \"test_proxy.py\" && node --test --test-timeout=30000 tests/proxy-config.test.mjs tests/proxy-transport.test.mjs tests/proxy-local-bypass.test.mjs tests/proxy-selftest.test.mjs tests/proxy-command.test.mjs",
|
|
34
|
+
"coverage": "node scripts/coverage.mjs",
|
|
35
|
+
"capture:cache": "GONEXT_OLLAMA1_LIVE=1 node scripts/capture-cache-fixture.mjs",
|
|
36
|
+
"test:cache-e2e": "GONEXT_OLLAMA1_LIVE=1 node --test --test-timeout=1800000 tests/prefix-cache-e2e.test.mjs",
|
|
37
|
+
"test:tokens": "python3 -m unittest discover tests -p \"test_cached_tokens.py\" -v && node --test tests/prefix-cache.test.mjs tests/agent-turn-shape.test.mjs",
|
|
38
|
+
"test:selection": "node --test tests/selection-scrollback.test.mjs tests/selection-while-thinking.test.mjs tests/terminal-selection.test.mjs tests/terminal-copy.test.mjs tests/terminal-viewport.test.mjs tests/hover-repaint.test.mjs",
|
|
39
|
+
"capture:turn": "GONEXT_OLLAMA1_LIVE=1 node scripts/capture-turn-fixture.mjs",
|
|
40
|
+
"test:bearer": "node --test tests/bearer-routing.test.mjs tests/bearer-verify.test.mjs",
|
|
41
|
+
"test:bearer-e2e": "bash scripts/bearer-e2e.sh"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"gu-cli.mjs",
|
|
45
|
+
"gu-repl.mjs",
|
|
46
|
+
"terminal-hover.mjs",
|
|
47
|
+
"terminal-layout.mjs",
|
|
48
|
+
"terminal-viewport.mjs",
|
|
49
|
+
"terminal-copy.mjs",
|
|
50
|
+
"version-check.mjs",
|
|
51
|
+
"rag-selftest.mjs",
|
|
52
|
+
"device-login.mjs",
|
|
53
|
+
"daemon-control.mjs",
|
|
54
|
+
"poll-errors.mjs",
|
|
55
|
+
"node-version.mjs",
|
|
56
|
+
"clear-state.mjs",
|
|
57
|
+
"platform-tools.mjs",
|
|
58
|
+
"job-pools.mjs",
|
|
59
|
+
"rag-job-config.mjs",
|
|
60
|
+
"completed-flush.mjs",
|
|
61
|
+
"agent-model-label.mjs",
|
|
62
|
+
"coding-choice-tags.mjs",
|
|
63
|
+
"coding-provider-setup.mjs",
|
|
64
|
+
"client-expert-setup.mjs",
|
|
65
|
+
"client-expert-run.mjs",
|
|
66
|
+
"client-expert-api.mjs",
|
|
67
|
+
"first-run-steps.mjs",
|
|
68
|
+
"proxy-config.mjs",
|
|
69
|
+
"agent-model-command.mjs",
|
|
70
|
+
"ollama-setup.mjs",
|
|
71
|
+
"s3-setup.mjs",
|
|
72
|
+
"doctor-check.mjs",
|
|
73
|
+
"embed-model-command.mjs",
|
|
74
|
+
"proxy-command.mjs",
|
|
75
|
+
"proxy-selftest.mjs",
|
|
76
|
+
"proxy-store.mjs",
|
|
77
|
+
"proxy-dispatcher.mjs",
|
|
78
|
+
"model-doctor.mjs",
|
|
79
|
+
"coding-key-prompt.mjs",
|
|
80
|
+
"gonext_probe_agent.py",
|
|
81
|
+
"gonext_agent_chat.py",
|
|
82
|
+
"gonext_transcribe.py",
|
|
83
|
+
"gonext_mlx_embed.py",
|
|
84
|
+
"thinking_words.txt",
|
|
85
|
+
"README.md",
|
|
86
|
+
"launchd/"
|
|
87
|
+
],
|
|
88
|
+
"publishConfig": {
|
|
89
|
+
"access": "public"
|
|
90
|
+
},
|
|
91
|
+
"dependencies": {
|
|
92
|
+
"dotenv": "^16.4.5",
|
|
93
|
+
"express": "^4.21.0",
|
|
94
|
+
"openai": "^4.77.0",
|
|
95
|
+
"undici": "^7.29.0"
|
|
96
|
+
},
|
|
97
|
+
"engines": {
|
|
98
|
+
"node": ">=18"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three questions the CLI asks the operating system, each answered in ONE place (#157).
|
|
3
|
+
*
|
|
4
|
+
* · which processes are running, and what command started them
|
|
5
|
+
* · is this particular pid still one of ours
|
|
6
|
+
* · what is listening on a port
|
|
7
|
+
*
|
|
8
|
+
* They were previously answered inline with `ps` and `lsof`, which do not exist on Windows —
|
|
9
|
+
* so the platform difference lived at every call site, and the next POSIX assumption would
|
|
10
|
+
* have been added somewhere nobody thinks to look. One concept, one function, the platform
|
|
11
|
+
* inside it.
|
|
12
|
+
*
|
|
13
|
+
* COMMAND SELECTION AND OUTPUT PARSING ARE PURE and are tested on macOS against recorded
|
|
14
|
+
* Windows output. Actually running them is not testable from here: nothing in this file has
|
|
15
|
+
* been executed on Windows, and a parser with tests is not a working port.
|
|
16
|
+
*/
|
|
17
|
+
import { execFile } from "node:child_process";
|
|
18
|
+
|
|
19
|
+
const IS_WINDOWS = process.platform === "win32";
|
|
20
|
+
|
|
21
|
+
const run = (cmd, args, opts = {}) =>
|
|
22
|
+
new Promise((resolve) => {
|
|
23
|
+
execFile(cmd, args, { maxBuffer: 8 * 1024 * 1024, ...opts }, (err, stdout) =>
|
|
24
|
+
resolve(err ? null : String(stdout ?? ""))
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/* ------------------------------------------------------------------ process listing ----- */
|
|
29
|
+
|
|
30
|
+
/** The command that lists every process with its full command line. */
|
|
31
|
+
export function processListCommand(platform = process.platform) {
|
|
32
|
+
return platform === "win32"
|
|
33
|
+
? {
|
|
34
|
+
// CIM over WMIC: wmic is deprecated and absent from newer Windows 11 images, and
|
|
35
|
+
// `tasklist` gives no command LINE — only an image name, which cannot distinguish our
|
|
36
|
+
// daemon from any other node.exe.
|
|
37
|
+
cmd: "powershell.exe",
|
|
38
|
+
args: [
|
|
39
|
+
"-NoProfile", "-NonInteractive", "-Command",
|
|
40
|
+
"Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId) $($_.CommandLine)\" }",
|
|
41
|
+
],
|
|
42
|
+
}
|
|
43
|
+
: { cmd: "ps", args: ["-A", "-o", "pid=,command="] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse a process listing into {pid, command} rows.
|
|
48
|
+
*
|
|
49
|
+
* Both platforms are made to produce "<pid> <command line>" so there is ONE parser: a second
|
|
50
|
+
* one would drift, and the thing it decides — is our daemon running — is not something to be
|
|
51
|
+
* wrong about differently per platform.
|
|
52
|
+
*/
|
|
53
|
+
export function parseProcessList(stdout) {
|
|
54
|
+
const rows = [];
|
|
55
|
+
for (const line of String(stdout ?? "").split(/\r?\n/)) {
|
|
56
|
+
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
57
|
+
if (!m) continue;
|
|
58
|
+
const command = m[2].trim();
|
|
59
|
+
if (!command) continue; // a pid with no command line (a protected process) tells us nothing
|
|
60
|
+
rows.push({ pid: Number(m[1]), command });
|
|
61
|
+
}
|
|
62
|
+
return rows;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every running process, as {pid, command}. Empty when the platform tool is unavailable. */
|
|
66
|
+
export async function listProcesses() {
|
|
67
|
+
const { cmd, args } = processListCommand();
|
|
68
|
+
const out = await run(cmd, args);
|
|
69
|
+
return out === null ? [] : parseProcessList(out);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The command line of ONE pid, or "" if it is gone. */
|
|
73
|
+
export function processCommandCommand(pid, platform = process.platform) {
|
|
74
|
+
return platform === "win32"
|
|
75
|
+
? {
|
|
76
|
+
cmd: "powershell.exe",
|
|
77
|
+
args: [
|
|
78
|
+
"-NoProfile", "-NonInteractive", "-Command",
|
|
79
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId=${Number(pid)}").CommandLine`,
|
|
80
|
+
],
|
|
81
|
+
}
|
|
82
|
+
: { cmd: "ps", args: ["-p", String(pid), "-o", "command="] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function processCommand(pid) {
|
|
86
|
+
const { cmd, args } = processCommandCommand(pid);
|
|
87
|
+
const out = await run(cmd, args);
|
|
88
|
+
return out === null ? "" : out.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ------------------------------------------------------------------ listening ports ----- */
|
|
92
|
+
|
|
93
|
+
/** What is listening, for a set of pids. */
|
|
94
|
+
export function listeningPortsCommand(pids, platform = process.platform) {
|
|
95
|
+
const list = pids.map(Number).filter(Number.isFinite);
|
|
96
|
+
return platform === "win32"
|
|
97
|
+
? // -ano gives numeric addresses and the owning pid, and needs no elevation.
|
|
98
|
+
{ cmd: "netstat.exe", args: ["-ano", "-p", "TCP"], pids: list }
|
|
99
|
+
: {
|
|
100
|
+
cmd: "lsof",
|
|
101
|
+
args: ["-a", "-p", list.join(","), "-iTCP", "-sTCP:LISTEN", "-P", "-n"],
|
|
102
|
+
pids: list,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ports being LISTENED on by `pids`, from either tool's output.
|
|
108
|
+
*
|
|
109
|
+
* Behavioural on both platforms, deliberately: "did this command start a server" is answered
|
|
110
|
+
* by a socket existing, never by guessing from the command name. That is what lets
|
|
111
|
+
* `npm start`, `flask run` and a hand-rolled server all be recognised without a list.
|
|
112
|
+
*/
|
|
113
|
+
export function parseListeningPorts(stdout, pids, platform = process.platform) {
|
|
114
|
+
const want = new Set(pids.map(Number));
|
|
115
|
+
const ports = new Set();
|
|
116
|
+
for (const line of String(stdout ?? "").split(/\r?\n/)) {
|
|
117
|
+
if (platform === "win32") {
|
|
118
|
+
// Proto Local Address Foreign Address State PID
|
|
119
|
+
// TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 1234
|
|
120
|
+
const m = /^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/i.exec(line);
|
|
121
|
+
if (m && want.has(Number(m[2]))) ports.add(Number(m[1]));
|
|
122
|
+
} else {
|
|
123
|
+
const m = /:(\d+)\s+\(LISTEN\)/.exec(line);
|
|
124
|
+
if (m) ports.add(Number(m[1]));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return [...ports].sort((a, b) => a - b);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** TCP ports the given pids are listening on. */
|
|
131
|
+
export async function listeningPorts(pids) {
|
|
132
|
+
const { cmd, args } = listeningPortsCommand(pids);
|
|
133
|
+
const out = await run(cmd, args);
|
|
134
|
+
return out === null ? [] : parseListeningPorts(out, pids);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* ------------------------------------------------------------------ killing a tree ------ */
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Stop a process AND its children.
|
|
141
|
+
*
|
|
142
|
+
* POSIX gets one handle for the whole tree (setsid + killpg). Windows has no equivalent worth
|
|
143
|
+
* emulating: `taskkill /T` walks the tree by parent-pid, which is the honest substitute —
|
|
144
|
+
* imperfect if a child re-parents, but it does not silently leave a dev server holding a port,
|
|
145
|
+
* which is the failure that actually costs someone an afternoon.
|
|
146
|
+
*/
|
|
147
|
+
export function killTreeCommand(pid, { force = false, platform = process.platform } = {}) {
|
|
148
|
+
return platform === "win32"
|
|
149
|
+
? { cmd: "taskkill.exe", args: ["/PID", String(Number(pid)), "/T", ...(force ? ["/F"] : [])] }
|
|
150
|
+
: { cmd: "kill", args: [force ? "-9" : "-15", String(Number(pid))] };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const isWindows = () => IS_WINDOWS;
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
/* ------------------------------------------------------------------ python ------------- */
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Interpreters to try, in order, for the platform.
|
|
160
|
+
*
|
|
161
|
+
* `python3` is hardcoded across the worker and does not exist on Windows — and worse, typing
|
|
162
|
+
* it there usually hits the Microsoft Store alias stub, which runs nothing. The launcher
|
|
163
|
+
* (`py -3`) is the reliable entry point on Windows and is what the official installer
|
|
164
|
+
* registers.
|
|
165
|
+
*
|
|
166
|
+
* MEASURED ON REAL HARDWARE 2026-08-29 (Windows 11 Pro 26100, admin@192.168.1.117), because
|
|
167
|
+
* this file was written against recorded output and never executed there:
|
|
168
|
+
*
|
|
169
|
+
* python3 --version → exit 9009, "Python was not found; run without arguments to
|
|
170
|
+
* install from the Microsoft Store…" (C:\…\WindowsApps\python3.exe)
|
|
171
|
+
* py -3 -c "…" → exit 0, "3" (C:\Python314\python.exe)
|
|
172
|
+
*
|
|
173
|
+
* So on THIS build the stub exits 9009, not 0 — an earlier note here said it "looks like a
|
|
174
|
+
* success", and that half is wrong. It does not matter to the code, and that is the point of
|
|
175
|
+
* how the check is built: pythonVersionProbe asks for `sys.version_info[0]` and isPython3
|
|
176
|
+
* tests the OUTPUT, so the stub is rejected for printing prose whatever it exits with. A check
|
|
177
|
+
* that trusted the exit code would be a check that depends on a Windows build's mood.
|
|
178
|
+
*/
|
|
179
|
+
export function pythonCandidates(platform = process.platform) {
|
|
180
|
+
return platform === "win32"
|
|
181
|
+
? [
|
|
182
|
+
{ cmd: "py", args: ["-3"] }, // the launcher: present with any official install
|
|
183
|
+
{ cmd: "python", args: [] }, // a python on PATH
|
|
184
|
+
{ cmd: "python3", args: [] }, // last: often the Store stub, which runs nothing
|
|
185
|
+
]
|
|
186
|
+
: [{ cmd: "python3", args: [] }, { cmd: "python", args: [] }];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Does this candidate actually RUN python? Not "is it on PATH" — the Store stub is on PATH
|
|
191
|
+
* and answers nothing, which is the exact failure this exists to avoid.
|
|
192
|
+
*/
|
|
193
|
+
export function pythonVersionProbe(candidate) {
|
|
194
|
+
return { cmd: candidate.cmd, args: [...candidate.args, "-c", "import sys; print(sys.version_info[0])"] };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** True when the probe's output really came from a Python 3. */
|
|
198
|
+
export function isPython3(stdout) {
|
|
199
|
+
return /^3\s*$/.test(String(stdout ?? "").trim());
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let _python = null;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The interpreter to spawn python with, as a SPAWN-READY {cmd, args}.
|
|
206
|
+
*
|
|
207
|
+
* Not a bare string: the Windows answer is `py -3`, and every call site here does
|
|
208
|
+
* spawn(python, [script, ...]) — a string with a space in it would be looked up as a single
|
|
209
|
+
* executable named "py -3" and fail. Returning the pair makes that impossible to get wrong:
|
|
210
|
+
*
|
|
211
|
+
* const py = await resolvePython();
|
|
212
|
+
* spawn(py.cmd, [...py.args, script, ...rest])
|
|
213
|
+
*
|
|
214
|
+
* An explicit GONEXT_PROBE_PYTHON still wins, and is passed through untouched.
|
|
215
|
+
* Cached: otherwise this probes a subprocess on every job.
|
|
216
|
+
*/
|
|
217
|
+
export async function resolvePython() {
|
|
218
|
+
const explicit = (process.env.GONEXT_PROBE_PYTHON ?? process.env.GONEXT_MLX_LM_PYTHON ?? "").trim();
|
|
219
|
+
if (explicit) return { cmd: explicit, args: [] };
|
|
220
|
+
if (_python) return _python;
|
|
221
|
+
for (const candidate of pythonCandidates()) {
|
|
222
|
+
const probe = pythonVersionProbe(candidate);
|
|
223
|
+
const out = await run(probe.cmd, probe.args, { timeout: 8000 });
|
|
224
|
+
if (out !== null && isPython3(out)) {
|
|
225
|
+
_python = { cmd: candidate.cmd, args: [...candidate.args] };
|
|
226
|
+
return _python;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Nothing answered. Keep today's behaviour rather than inventing one, so the failure stays
|
|
230
|
+
// the familiar "python3: not found" instead of a new thing to diagnose.
|
|
231
|
+
return process.platform === "win32"
|
|
232
|
+
? { cmd: "py", args: ["-3"] }
|
|
233
|
+
: { cmd: "python3", args: [] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** How to SAY the resolved interpreter in a log line or error message. */
|
|
237
|
+
export const pythonLabel = (py) => [py.cmd, ...py.args].join(" ");
|
|
238
|
+
|
|
239
|
+
/* ------------------------------------------------- surviving the session --------------- */
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Does a background process on this platform need help to outlive the session that starts it?
|
|
243
|
+
*
|
|
244
|
+
* ONLY WINDOWS, and not for the reason it looks like. `detached: true` is enough for a POSIX
|
|
245
|
+
* daemon, and on Windows it correctly detaches from the console — but an SSH session there runs
|
|
246
|
+
* inside a JOB OBJECT, and Windows kills every process in a job when the job ends. A detached
|
|
247
|
+
* child is still IN the job, so `gu-cli api start` over SSH starts a server that is dead
|
|
248
|
+
* the moment you disconnect.
|
|
249
|
+
*
|
|
250
|
+
* Proved on a real Windows 11 box: the API died, and so did the worker DAEMON — which uses the
|
|
251
|
+
* same detached spawn that survives perfectly when started from an ordinary console window.
|
|
252
|
+
* Same code, same flags; the only difference was who its parent was.
|
|
253
|
+
*/
|
|
254
|
+
/** How long to let a scheduled task's process get going before removing the task. */
|
|
255
|
+
export const TASK_SETTLE_MS = Number(process.env.GONEXT_TASK_SETTLE_MS ?? "6000") || 6000;
|
|
256
|
+
|
|
257
|
+
export function needsJobBreakaway(platform = process.platform) {
|
|
258
|
+
return platform === "win32";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The launcher script contents for a Windows background process.
|
|
263
|
+
*
|
|
264
|
+
* A FILE, not an inline command, because `schtasks /tr` takes one quoted string and the command
|
|
265
|
+
* needs its own quotes for paths with spaces — nesting them is where this breaks. A script has
|
|
266
|
+
* no quoting problem at all, and it can be read afterwards to see exactly what was run.
|
|
267
|
+
*/
|
|
268
|
+
export function windowsLauncherScript({ cmd, args = [], cwd, env = {}, logFile }) {
|
|
269
|
+
const lines = ["@echo off"];
|
|
270
|
+
for (const [k, v] of Object.entries(env)) {
|
|
271
|
+
// SKIP names cmd cannot set. Windows really has `ProgramFiles(x86)` and
|
|
272
|
+
// `CommonProgramFiles(x86)`; `set ProgramFiles(x86)=...` is a syntax error, and one bad
|
|
273
|
+
// line does not fail loudly — the script keeps going and the process never starts. That is
|
|
274
|
+
// what happened when the daemon was handed the whole of process.env: the API, which passes
|
|
275
|
+
// four variables, worked; the daemon, which passed hundreds, silently did not.
|
|
276
|
+
//
|
|
277
|
+
// Nothing is lost by skipping them: the scheduler runs the task AS THE USER, so the real
|
|
278
|
+
// environment is inherited anyway. This block is only for the EXTRAS a caller adds.
|
|
279
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue;
|
|
280
|
+
// A newline in a value would end the `set` and turn the rest into a command.
|
|
281
|
+
if (/[\r\n]/.test(String(v))) continue;
|
|
282
|
+
// No quotes around the value: `set X="v"` puts the QUOTES in the variable. A trailing
|
|
283
|
+
// space is equally fatal — `set PORT=4100 ` sets PORT to "4100 ".
|
|
284
|
+
lines.push(`set ${k}=${String(v).replace(/\s+$/, "")}`);
|
|
285
|
+
}
|
|
286
|
+
if (cwd) lines.push(`cd /d "${cwd}"`);
|
|
287
|
+
const command = [`"${cmd}"`, ...args.map((a) => `"${a}"`)].join(" ");
|
|
288
|
+
const redirected = logFile ? `${command} >> "${logFile}" 2>&1` : command;
|
|
289
|
+
// `start "" /b` so THIS SCRIPT RETURNS IMMEDIATELY and the task instance completes.
|
|
290
|
+
//
|
|
291
|
+
// Without it the task stays in state Running for as long as the server does — and removing a
|
|
292
|
+
// RUNNING task terminates its instance, taking the process with it. That is precisely what
|
|
293
|
+
// happened: with a long settle the daemon started and its log grew; with the normal 6s settle
|
|
294
|
+
// the task was deleted while still running and the daemon vanished, leaving no error anywhere.
|
|
295
|
+
// The empty "" is the window TITLE argument — omit it and `start` treats a quoted path as the
|
|
296
|
+
// title and runs nothing.
|
|
297
|
+
lines.push(`start "" /b ${redirected}`);
|
|
298
|
+
return lines.join("\r\n") + "\r\n";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Register → run → deregister. The task is a LAUNCH MECHANISM, not a persistent registration:
|
|
303
|
+
* it exists only to start the process outside our job, and is removed immediately afterwards
|
|
304
|
+
* so nothing is left scheduled on the user's machine. The started process keeps running —
|
|
305
|
+
* deleting a task does not kill what it launched (verified).
|
|
306
|
+
*/
|
|
307
|
+
export function schtasksCommands(taskName, scriptPath) {
|
|
308
|
+
return {
|
|
309
|
+
create: {
|
|
310
|
+
cmd: "schtasks.exe",
|
|
311
|
+
args: ["/create", "/tn", taskName, "/tr", scriptPath, "/sc", "once", "/st", "00:00", "/f"],
|
|
312
|
+
},
|
|
313
|
+
run: { cmd: "schtasks.exe", args: ["/run", "/tn", taskName] },
|
|
314
|
+
remove: { cmd: "schtasks.exe", args: ["/delete", "/tn", taskName, "/f"] },
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Which pid owns a listening port, from `netstat -ano`.
|
|
320
|
+
*
|
|
321
|
+
* Needed because a task-launched process is not our child — there is no pid to record. The
|
|
322
|
+
* port is the only handle we have on it, and it is the one that matters anyway: what we care
|
|
323
|
+
* about is "who is serving this", not "who did we spawn".
|
|
324
|
+
*/
|
|
325
|
+
export function portOwnerPid(stdout, port) {
|
|
326
|
+
const want = Number(port);
|
|
327
|
+
for (const line of String(stdout ?? "").split(/\r?\n/)) {
|
|
328
|
+
const m = /^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/i.exec(line);
|
|
329
|
+
if (m && Number(m[1]) === want) return Number(m[2]);
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Start a long-lived background process that OUTLIVES this session, on any platform.
|
|
336
|
+
*
|
|
337
|
+
* POSIX: an ordinary detached spawn — nothing else is needed.
|
|
338
|
+
* Windows: written to a launcher script and started by the TASK SCHEDULER, because the
|
|
339
|
+
* scheduler's process is not in our job object and neither is what it starts. The task is
|
|
340
|
+
* removed immediately afterwards; it is a launch mechanism, not a registration, and deleting
|
|
341
|
+
* it does not kill what it started.
|
|
342
|
+
*
|
|
343
|
+
* Returns { ok, pid, how } — `pid` may be null on Windows, where the process is not our child.
|
|
344
|
+
* The caller finds it by the port it is serving, which is the better question anyway.
|
|
345
|
+
*/
|
|
346
|
+
export async function spawnSurviving({ cmd, args = [], cwd, env = {}, logFile, taskName }) {
|
|
347
|
+
if (!needsJobBreakaway()) {
|
|
348
|
+
const { spawn } = await import("node:child_process");
|
|
349
|
+
const { open } = await import("node:fs/promises");
|
|
350
|
+
const log = logFile ? await open(logFile, "a") : null;
|
|
351
|
+
const child = spawn(cmd, args, {
|
|
352
|
+
cwd,
|
|
353
|
+
env: { ...process.env, ...env },
|
|
354
|
+
stdio: ["ignore", log ? log.fd : "ignore", log ? log.fd : "ignore"],
|
|
355
|
+
detached: true,
|
|
356
|
+
});
|
|
357
|
+
child.unref();
|
|
358
|
+
await log?.close().catch(() => {});
|
|
359
|
+
return { ok: true, pid: child.pid ?? null, how: "detached" };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const { writeFile, mkdir, rm } = await import("node:fs/promises");
|
|
363
|
+
const { dirname, join } = await import("node:path");
|
|
364
|
+
const script = join(dirname(logFile || cwd || "."), `${taskName}.cmd`);
|
|
365
|
+
await mkdir(dirname(script), { recursive: true }).catch(() => {});
|
|
366
|
+
|
|
367
|
+
// THE SCHEDULER'S ENVIRONMENT IS NOT YOURS. A task launched this way starts with a minimal
|
|
368
|
+
// environment — no USERPROFILE among it — so `os.homedir()` does not resolve to the user's
|
|
369
|
+
// profile and anything that reads ~/.gonext (worker.env, the sessions, the RAG index) fails.
|
|
370
|
+
// The daemon exited 1 that way, silently, with "Last Result: 1" the only trace.
|
|
371
|
+
//
|
|
372
|
+
// Carried EXPLICITLY, and only these: passing the whole of process.env is what put names cmd
|
|
373
|
+
// cannot set (`ProgramFiles(x86)`) into the script. Identity and PATH is what a child needs;
|
|
374
|
+
// everything else is the caller's business.
|
|
375
|
+
const IDENTITY = ["USERPROFILE", "HOMEDRIVE", "HOMEPATH", "HOME", "APPDATA", "LOCALAPPDATA", "PATH", "SystemRoot", "TEMP"];
|
|
376
|
+
const carried = {};
|
|
377
|
+
for (const k of IDENTITY) if (process.env[k]) carried[k] = process.env[k];
|
|
378
|
+
|
|
379
|
+
await writeFile(
|
|
380
|
+
script,
|
|
381
|
+
windowsLauncherScript({ cmd, args, cwd, env: { ...carried, ...env }, logFile }),
|
|
382
|
+
"utf8"
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const t = schtasksCommands(taskName, script);
|
|
386
|
+
const created = await run(t.create.cmd, t.create.args);
|
|
387
|
+
if (created === null) {
|
|
388
|
+
// No scheduler (or no rights): fall back to a plain detached spawn. It dies with an SSH
|
|
389
|
+
// session, but it is correct from a console window — better than not starting at all.
|
|
390
|
+
const { spawn } = await import("node:child_process");
|
|
391
|
+
const child = spawn(cmd, args, {
|
|
392
|
+
cwd, env: { ...process.env, ...env }, stdio: "ignore", detached: true, windowsHide: true,
|
|
393
|
+
});
|
|
394
|
+
child.unref();
|
|
395
|
+
return { ok: true, pid: child.pid ?? null, how: "detached (schtasks unavailable)" };
|
|
396
|
+
}
|
|
397
|
+
await run(t.run.cmd, t.run.args);
|
|
398
|
+
|
|
399
|
+
// LET IT GET GOING BEFORE DEREGISTERING. `schtasks /run` returns as soon as the request is
|
|
400
|
+
// accepted, not when the process is up — and deleting the task while its instance is still
|
|
401
|
+
// starting kills what it started. Observed exactly that: the API logged "API listening" and
|
|
402
|
+
// "[mongo] connected", then vanished, with an immediate delete. The same sequence with a few
|
|
403
|
+
// seconds in between survived. The wait is empirical, not principled, which is why it is
|
|
404
|
+
// generous: this runs once per start, and being slow here is free.
|
|
405
|
+
await new Promise((r) => setTimeout(r, TASK_SETTLE_MS));
|
|
406
|
+
|
|
407
|
+
// Then deregister: nothing should be left scheduled on the user's machine. `/sc once` with a
|
|
408
|
+
// start time in the past would otherwise re-fire, and a task named after gu sitting in
|
|
409
|
+
// Task Scheduler forever is not something anyone asked for.
|
|
410
|
+
await run(t.remove.cmd, t.remove.args);
|
|
411
|
+
await rm(script).catch(() => {});
|
|
412
|
+
return { ok: true, pid: null, how: "scheduled task" };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The pid serving `port`, or null. The handle we have on a process we did not spawn. */
|
|
416
|
+
export async function pidOnPort(port) {
|
|
417
|
+
if (needsJobBreakaway()) {
|
|
418
|
+
const out = await run("netstat.exe", ["-ano", "-p", "TCP"]);
|
|
419
|
+
return out === null ? null : portOwnerPid(out, port);
|
|
420
|
+
}
|
|
421
|
+
const out = await run("lsof", ["-nP", `-iTCP:${Number(port)}`, "-sTCP:LISTEN", "-t"]);
|
|
422
|
+
const pid = Number(String(out ?? "").trim().split(/\s+/)[0]);
|
|
423
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* ------------------------------------------------------- "View logs" (task #166) ---------- */
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Shell-quote one argument for the shell that will actually run it.
|
|
430
|
+
*
|
|
431
|
+
* NOT COSMETIC. A home directory with a space in it is routine on macOS ("/Users/Jo Smith") and
|
|
432
|
+
* normal on Windows, and every branch below builds a COMMAND STRING for a shell rather than an
|
|
433
|
+
* argv array — so an unquoted path silently becomes two arguments and the window opens tailing
|
|
434
|
+
* the wrong file, or nothing.
|
|
435
|
+
*
|
|
436
|
+
* POSIX uses single quotes (with the standard '\'' escape); cmd.exe has no escape inside double
|
|
437
|
+
* quotes, so an embedded '"' is simply dropped — it cannot appear in a Windows path anyway.
|
|
438
|
+
*/
|
|
439
|
+
export function shellQuote(arg, platform = process.platform) {
|
|
440
|
+
const s = String(arg ?? "");
|
|
441
|
+
if (platform === "win32") return `"${s.replace(/"/g, "")}"`;
|
|
442
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The command that FOLLOWS the log, per platform.
|
|
447
|
+
*
|
|
448
|
+
* Windows has no `tail`. PowerShell's `Get-Content -Wait` is the equivalent, and -Tail seeds it
|
|
449
|
+
* with the recent lines so the window is not blank until the next write — which is what makes it
|
|
450
|
+
* feel broken. Shipping `tail -f` everywhere would work on two of the three platforms the
|
|
451
|
+
* feature was asked for.
|
|
452
|
+
*/
|
|
453
|
+
export function logTailCommand(logFile, platform = process.platform) {
|
|
454
|
+
if (platform === "win32") {
|
|
455
|
+
// SINGLE quotes for the path, inside the double-quoted -Command block. Using shellQuote()
|
|
456
|
+
// here produced nested double quotes —
|
|
457
|
+
// powershell -Command "Get-Content … -Path "C:\Users\Jo Smith\…""
|
|
458
|
+
// — which cmd.exe cannot parse, so the window opened on nothing. PowerShell reads '…' as a
|
|
459
|
+
// literal string, and doubling an embedded quote ('') is its own escape.
|
|
460
|
+
const psPath = `'${String(logFile ?? "").replace(/'/g, "''")}'`;
|
|
461
|
+
return `powershell -NoLogo -NoExit -Command "Get-Content -Wait -Tail 50 -Path ${psPath}"`;
|
|
462
|
+
}
|
|
463
|
+
return `tail -n 50 -f ${shellQuote(logFile, platform)}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* How to open a NEW terminal window running `inner`, or null when this machine has none.
|
|
468
|
+
*
|
|
469
|
+
* NULL IS A REAL ANSWER and the most important one here. A headless Linux box, a container, or a
|
|
470
|
+
* plain SSH session has no display at all: spawning gnome-terminal there fails with a confusing
|
|
471
|
+
* error, or appears to succeed and does nothing. The caller prints the command to paste instead,
|
|
472
|
+
* which is a complete answer — a button that silently does nothing is not.
|
|
473
|
+
*
|
|
474
|
+
* `available` lists the emulators actually found on PATH; the caller supplies it so this stays
|
|
475
|
+
* pure and testable. Order is deliberate: the generic Debian/Ubuntu alternative first (it points
|
|
476
|
+
* at whatever the user actually chose), then the common desktops.
|
|
477
|
+
*/
|
|
478
|
+
export function terminalOpenCommand({
|
|
479
|
+
platform = process.platform,
|
|
480
|
+
inner,
|
|
481
|
+
hasDisplay = true,
|
|
482
|
+
available = [],
|
|
483
|
+
} = {}) {
|
|
484
|
+
if (!inner) return null;
|
|
485
|
+
if (platform === "darwin") {
|
|
486
|
+
// osascript rather than `open -a Terminal <file>`: open needs a FILE, so running a command
|
|
487
|
+
// that way means writing a temp script and leaving it behind.
|
|
488
|
+
const script = `tell application "Terminal" to do script ${JSON.stringify(inner)}`;
|
|
489
|
+
return `osascript -e ${shellQuote(script, "darwin")}`;
|
|
490
|
+
}
|
|
491
|
+
if (platform === "win32") {
|
|
492
|
+
// `start ""` — the empty first argument is the WINDOW TITLE. Without it, cmd treats a quoted
|
|
493
|
+
// command as the title and opens an empty window, which is the classic version of this bug.
|
|
494
|
+
return `start "" ${inner}`;
|
|
495
|
+
}
|
|
496
|
+
// Linux and friends: a terminal is not guaranteed to exist at all.
|
|
497
|
+
if (!hasDisplay) return null;
|
|
498
|
+
for (const term of ["x-terminal-emulator", "gnome-terminal", "konsole", "xfce4-terminal", "xterm"]) {
|
|
499
|
+
if (available.includes(term)) {
|
|
500
|
+
// gnome-terminal needs `--` before the command; the others take -e.
|
|
501
|
+
return term === "gnome-terminal"
|
|
502
|
+
? `gnome-terminal -- bash -lc ${shellQuote(inner, platform)}`
|
|
503
|
+
: `${term} -e bash -lc ${shellQuote(inner, platform)}`;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* What "View logs" should DO on this machine: open a window, or tell the user what to type.
|
|
511
|
+
*
|
|
512
|
+
* Returns { action: "spawn", command } or { action: "print", command } — never throws and never
|
|
513
|
+
* silently does nothing, because the whole point of the link is that the log is otherwise hard
|
|
514
|
+
* to reach.
|
|
515
|
+
*/
|
|
516
|
+
export function viewLogsPlan({ logFile, platform = process.platform, hasDisplay = true, available = [] } = {}) {
|
|
517
|
+
const inner = logTailCommand(logFile, platform);
|
|
518
|
+
const open = terminalOpenCommand({ platform, inner, hasDisplay, available });
|
|
519
|
+
return open ? { action: "spawn", command: open, inner } : { action: "print", command: inner, inner };
|
|
520
|
+
}
|