@tiens.nguyen/gu-cli 1.0.833 → 1.0.835
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 +1 -0
- package/browser-bridge.mjs +335 -0
- package/browser-command.mjs +310 -0
- package/browser-extension/README.md +151 -0
- package/browser-extension/actions.js +319 -0
- package/browser-extension/background.js +261 -0
- package/browser-extension/icons/icon128.png +0 -0
- package/browser-extension/icons/icon16.png +0 -0
- package/browser-extension/icons/icon32.png +0 -0
- package/browser-extension/icons/icon48.png +0 -0
- package/browser-extension/manifest.json +37 -0
- package/browser-extension/popup.html +26 -0
- package/browser-extension/popup.js +45 -0
- package/client-expert-api.mjs +43 -2
- package/gonext_agent_chat.py +325 -19
- package/gu-cli.mjs +14 -12
- package/gu-repl.mjs +170 -2
- package/gu_browser.py +335 -0
- package/package.json +11 -1
- package/proc-identity.mjs +53 -0
- package/shell-details.mjs +98 -10
- package/terminal-layout.mjs +1 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// gu browser — the Chrome side of the agent's browser tool (task #363, GitHub #144).
|
|
2
|
+
//
|
|
3
|
+
// The extension connects to gu's local bridge (browser-bridge.mjs, ws://127.0.0.1:4127) and runs
|
|
4
|
+
// the commands the agent sends. Input goes through chrome.debugger (the DevTools protocol), so a
|
|
5
|
+
// click is a real mouse press and release at a point, and typing is real key input, the way a
|
|
6
|
+
// person would do it, not el.click().
|
|
7
|
+
//
|
|
8
|
+
// gu only works in ITS OWN tabs: the ones it opened (kept in a tab group named "gu"), the popups
|
|
9
|
+
// those open, and a tab the user shares from the popup ("Let gu use this tab", #150). The user's
|
|
10
|
+
// other tabs are never read, captured or controlled. What each command does is in actions.js,
|
|
11
|
+
// shared with gu's own Chrome window (browser-chrome.mjs).
|
|
12
|
+
|
|
13
|
+
import { enableDomains, forgetTab, onCdpEvent, runAction } from "./actions.js";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_PORT = 4127;
|
|
16
|
+
const VERSION = chrome.runtime.getManifest().version;
|
|
17
|
+
|
|
18
|
+
let ws = null;
|
|
19
|
+
let connecting = false; // set before the first await: startup fires connect() several times at once
|
|
20
|
+
let pingTimer = null;
|
|
21
|
+
const attached = new Set();
|
|
22
|
+
|
|
23
|
+
// ── connection ──────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
async function bridgePort() {
|
|
25
|
+
try {
|
|
26
|
+
const { port } = await chrome.storage.local.get("port");
|
|
27
|
+
return Number(port) || DEFAULT_PORT;
|
|
28
|
+
} catch {
|
|
29
|
+
return DEFAULT_PORT;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function connect() {
|
|
34
|
+
if (connecting || (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN))) return;
|
|
35
|
+
connecting = true;
|
|
36
|
+
let sock;
|
|
37
|
+
try {
|
|
38
|
+
sock = new WebSocket(`ws://127.0.0.1:${await bridgePort()}/extension`);
|
|
39
|
+
} catch {
|
|
40
|
+
return;
|
|
41
|
+
} finally {
|
|
42
|
+
connecting = false;
|
|
43
|
+
}
|
|
44
|
+
ws = sock;
|
|
45
|
+
sock.onopen = async () => {
|
|
46
|
+
sock.send(JSON.stringify({ type: "hello", version: VERSION, installType: await installType() }));
|
|
47
|
+
setBadge(true);
|
|
48
|
+
clearInterval(pingTimer);
|
|
49
|
+
// An open socket that carries a message at least every 30 s keeps the service worker awake.
|
|
50
|
+
pingTimer = setInterval(() => sock.readyState === WebSocket.OPEN && sock.send('{"type":"ping"}'), 20000);
|
|
51
|
+
};
|
|
52
|
+
sock.onmessage = (ev) => onCommand(sock, ev.data);
|
|
53
|
+
sock.onclose = (ev) => {
|
|
54
|
+
if (ws !== sock) return; // an older socket the bridge replaced
|
|
55
|
+
ws = null;
|
|
56
|
+
clearInterval(pingTimer);
|
|
57
|
+
setBadge(false);
|
|
58
|
+
// 4001: another gu extension (the store copy next to a developer one) has the bridge.
|
|
59
|
+
// Retry slowly instead of every 3 s; the alarm keeps trying too (#148).
|
|
60
|
+
setTimeout(connect, ev.code === 4001 ? 30000 : 3000);
|
|
61
|
+
};
|
|
62
|
+
sock.onerror = () => {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// "development" = loaded unpacked (Developer mode); "normal" = from the Chrome Web Store (#146).
|
|
66
|
+
// getSelf needs no "management" permission.
|
|
67
|
+
async function installType() {
|
|
68
|
+
try {
|
|
69
|
+
return (await chrome.management.getSelf()).installType;
|
|
70
|
+
} catch {
|
|
71
|
+
return "unknown";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function setBadge(on) {
|
|
76
|
+
chrome.action.setBadgeText({ text: on ? "on" : "" }).catch(() => {});
|
|
77
|
+
chrome.action.setBadgeBackgroundColor({ color: "#6d5bd0" }).catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The service worker sleeps when nothing happens; the alarm wakes it to retry the bridge.
|
|
81
|
+
chrome.alarms.create("gu-connect", { periodInMinutes: 0.5 });
|
|
82
|
+
chrome.alarms.onAlarm.addListener(() => connect());
|
|
83
|
+
chrome.runtime.onStartup.addListener(() => connect());
|
|
84
|
+
chrome.runtime.onInstalled.addListener(() => connect());
|
|
85
|
+
chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
|
|
86
|
+
if (msg?.type === "status") {
|
|
87
|
+
reply({ connected: Boolean(ws && ws.readyState === WebSocket.OPEN), version: VERSION });
|
|
88
|
+
} else if (msg?.type === "reconnect") {
|
|
89
|
+
connect().then(() => reply({ ok: true }));
|
|
90
|
+
return true;
|
|
91
|
+
} else if (msg?.type === "tabState") {
|
|
92
|
+
state().then((s) => reply({ shared: s.tabs.includes(msg.tabId) }));
|
|
93
|
+
return true;
|
|
94
|
+
} else if (msg?.type === "share") {
|
|
95
|
+
// "Let gu use this tab" (#150): the user hands gu a tab of their own.
|
|
96
|
+
addToGroup(msg.tabId)
|
|
97
|
+
.then(() => saveState({ current: msg.tabId }))
|
|
98
|
+
.then(() => reply({ ok: true }), (e) => reply({ ok: false, error: String(e?.message || e) }));
|
|
99
|
+
return true;
|
|
100
|
+
} else if (msg?.type === "unshare") {
|
|
101
|
+
unshare(msg.tabId).then(() => reply({ ok: true }));
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
});
|
|
106
|
+
connect();
|
|
107
|
+
|
|
108
|
+
async function onCommand(sock, data) {
|
|
109
|
+
let msg;
|
|
110
|
+
try {
|
|
111
|
+
msg = JSON.parse(data);
|
|
112
|
+
} catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!msg || msg.id === undefined) return;
|
|
116
|
+
let reply;
|
|
117
|
+
try {
|
|
118
|
+
reply = { id: msg.id, ok: true, result: await run(msg) };
|
|
119
|
+
} catch (e) {
|
|
120
|
+
reply = { id: msg.id, ok: false, error: String(e?.message || e) };
|
|
121
|
+
}
|
|
122
|
+
if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify(reply));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── gu's tabs ───────────────────────────────────────────────────────────────────────────────
|
|
126
|
+
async function state() {
|
|
127
|
+
const s = await chrome.storage.session.get(["tabs", "current", "group"]);
|
|
128
|
+
return { tabs: s.tabs || [], current: s.current ?? null, group: s.group ?? null };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function saveState(patch) {
|
|
132
|
+
await chrome.storage.session.set(patch);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function liveTabs() {
|
|
136
|
+
const s = await state();
|
|
137
|
+
const live = [];
|
|
138
|
+
for (const id of s.tabs) {
|
|
139
|
+
try {
|
|
140
|
+
live.push(await chrome.tabs.get(id));
|
|
141
|
+
} catch {
|
|
142
|
+
/* closed */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (live.length !== s.tabs.length) await saveState({ tabs: live.map((t) => t.id) });
|
|
146
|
+
return live;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function addToGroup(tabId) {
|
|
150
|
+
const s = await state();
|
|
151
|
+
let group = s.group;
|
|
152
|
+
try {
|
|
153
|
+
group = await chrome.tabs.group(group != null ? { tabIds: [tabId], groupId: group } : { tabIds: [tabId] });
|
|
154
|
+
} catch {
|
|
155
|
+
group = await chrome.tabs.group({ tabIds: [tabId] }); // the old group was closed
|
|
156
|
+
}
|
|
157
|
+
await chrome.tabGroups.update(group, { title: "gu", color: "purple" }).catch(() => {});
|
|
158
|
+
const tabs = s.tabs.includes(tabId) ? s.tabs : [...s.tabs, tabId];
|
|
159
|
+
await saveState({ tabs, group });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// "Stop sharing": the tab leaves the group, gu lets go of it, and never touches it again.
|
|
163
|
+
async function unshare(tabId) {
|
|
164
|
+
const s = await state();
|
|
165
|
+
await saveState({ tabs: s.tabs.filter((t) => t !== tabId), current: s.current === tabId ? null : s.current });
|
|
166
|
+
await chrome.tabs.ungroup(tabId).catch(() => {});
|
|
167
|
+
if (attached.has(tabId)) await chrome.debugger.detach({ tabId }).catch(() => {});
|
|
168
|
+
attached.delete(tabId);
|
|
169
|
+
forgetTab(tabId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A page gu opened that opens another (target=_blank, window.open) — that one is gu's too.
|
|
173
|
+
chrome.tabs.onCreated.addListener(async (tab) => {
|
|
174
|
+
const s = await state();
|
|
175
|
+
if (tab.openerTabId != null && s.tabs.includes(tab.openerTabId)) {
|
|
176
|
+
await addToGroup(tab.id).catch(() => {});
|
|
177
|
+
await saveState({ current: tab.id });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ── DevTools protocol ───────────────────────────────────────────────────────────────────────
|
|
182
|
+
function cdp(tabId, method, params = {}) {
|
|
183
|
+
return chrome.debugger.sendCommand({ tabId }, method, params);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
chrome.debugger.onDetach.addListener((src) => attached.delete(src.tabId));
|
|
187
|
+
chrome.debugger.onEvent.addListener((src, method, params) => onCdpEvent(src.tabId, method, params, cdp));
|
|
188
|
+
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
189
|
+
attached.delete(tabId);
|
|
190
|
+
forgetTab(tabId);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// The host actions.js runs on: chrome.tabs + chrome.debugger, in the user's own Chrome.
|
|
194
|
+
const host = {
|
|
195
|
+
cdp,
|
|
196
|
+
async attach(tabId) {
|
|
197
|
+
if (attached.has(tabId)) return;
|
|
198
|
+
try {
|
|
199
|
+
await chrome.debugger.attach({ tabId }, "1.3");
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (!/already attached/i.test(String(e?.message))) {
|
|
202
|
+
throw new Error(`cannot control this tab (${e?.message || e}) — Chrome pages (chrome://, the Web Store) cannot be automated`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
attached.add(tabId);
|
|
206
|
+
await enableDomains(cdp, tabId);
|
|
207
|
+
},
|
|
208
|
+
async open(url, newTab) {
|
|
209
|
+
const tabs = await liveTabs();
|
|
210
|
+
const s = await state();
|
|
211
|
+
let tab = newTab ? null : tabs.find((t) => t.id === s.current) || tabs[tabs.length - 1];
|
|
212
|
+
if (tab) tab = await chrome.tabs.update(tab.id, { url, active: true });
|
|
213
|
+
else {
|
|
214
|
+
tab = await chrome.tabs.create({ url, active: true });
|
|
215
|
+
await addToGroup(tab.id);
|
|
216
|
+
}
|
|
217
|
+
await saveState({ current: tab.id });
|
|
218
|
+
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {});
|
|
219
|
+
return tab.id;
|
|
220
|
+
},
|
|
221
|
+
async tabs() {
|
|
222
|
+
const s = await state();
|
|
223
|
+
return (await liveTabs()).map((t) => ({ id: t.id, title: t.title, url: t.url, current: t.id === s.current }));
|
|
224
|
+
},
|
|
225
|
+
async switchTo(tabId) {
|
|
226
|
+
const id = Number(tabId);
|
|
227
|
+
if (!(await liveTabs()).some((t) => t.id === id)) return false;
|
|
228
|
+
await chrome.tabs.update(id, { active: true });
|
|
229
|
+
await saveState({ current: id });
|
|
230
|
+
return true;
|
|
231
|
+
},
|
|
232
|
+
async current() {
|
|
233
|
+
const tabs = await liveTabs();
|
|
234
|
+
if (!tabs.length) throw new Error('gu has no tab open yet — use action "open" with a url first');
|
|
235
|
+
const s = await state();
|
|
236
|
+
return (tabs.find((t) => t.id === s.current) || tabs[tabs.length - 1]).id;
|
|
237
|
+
},
|
|
238
|
+
async info(tabId) {
|
|
239
|
+
const t = await chrome.tabs.get(tabId).catch(() => null);
|
|
240
|
+
return t ? { url: t.url, title: t.title, status: t.status } : null;
|
|
241
|
+
},
|
|
242
|
+
async activate(tabId) {
|
|
243
|
+
await chrome.tabs.update(tabId, { active: true });
|
|
244
|
+
},
|
|
245
|
+
async close(tabId) {
|
|
246
|
+
await chrome.tabs.remove(tabId);
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
async function run(cmd) {
|
|
251
|
+
const a = String(cmd.action || "").toLowerCase();
|
|
252
|
+
if (a === "status") return { version: VERSION, installType: await installType() };
|
|
253
|
+
if (a === "uninstall_self") {
|
|
254
|
+
// /browser cleanup (#147): a developer copy removes itself. Never the store copy — that one
|
|
255
|
+
// the user removes, or keeps.
|
|
256
|
+
if ((await installType()) !== "development") throw new Error("this is not a developer (unpacked) install — not removed");
|
|
257
|
+
setTimeout(() => chrome.management.uninstallSelf({ showConfirmDialog: false }).catch(() => {}), 200);
|
|
258
|
+
return { uninstalling: chrome.runtime.id };
|
|
259
|
+
}
|
|
260
|
+
return runAction(cmd, host);
|
|
261
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "gu browser",
|
|
4
|
+
"version": "1.0.1",
|
|
5
|
+
"description": "Lets the gu coding agent open, see and operate web pages in its own tabs (tab group \"gu\") with a real mouse and keyboard.",
|
|
6
|
+
"minimum_chrome_version": "116",
|
|
7
|
+
"icons": {
|
|
8
|
+
"16": "icons/icon16.png",
|
|
9
|
+
"32": "icons/icon32.png",
|
|
10
|
+
"48": "icons/icon48.png",
|
|
11
|
+
"128": "icons/icon128.png"
|
|
12
|
+
},
|
|
13
|
+
"permissions": [
|
|
14
|
+
"debugger",
|
|
15
|
+
"tabs",
|
|
16
|
+
"tabGroups",
|
|
17
|
+
"alarms",
|
|
18
|
+
"storage"
|
|
19
|
+
],
|
|
20
|
+
"host_permissions": [
|
|
21
|
+
"<all_urls>"
|
|
22
|
+
],
|
|
23
|
+
"background": {
|
|
24
|
+
"service_worker": "background.js",
|
|
25
|
+
"type": "module"
|
|
26
|
+
},
|
|
27
|
+
"action": {
|
|
28
|
+
"default_title": "gu browser",
|
|
29
|
+
"default_popup": "popup.html",
|
|
30
|
+
"default_icon": {
|
|
31
|
+
"16": "icons/icon16.png",
|
|
32
|
+
"32": "icons/icon32.png",
|
|
33
|
+
"48": "icons/icon48.png",
|
|
34
|
+
"128": "icons/icon128.png"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<style>
|
|
6
|
+
body { font: 13px -apple-system, system-ui, sans-serif; margin: 0; padding: 14px 16px; width: 260px; color: #1d1b26; background: #fff; }
|
|
7
|
+
h1 { font-size: 14px; margin: 0 0 8px; }
|
|
8
|
+
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #b9b6c4; margin-right: 6px; }
|
|
9
|
+
.on .dot { background: #2f9e62; }
|
|
10
|
+
p { margin: 8px 0 0; color: #5d5a6b; line-height: 1.4; }
|
|
11
|
+
button { margin-top: 10px; font: inherit; padding: 4px 10px; }
|
|
12
|
+
@media (prefers-color-scheme: dark) { body { background: #1d1b26; color: #eceaf3; } p { color: #a9a6b8; } }
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<h1>gu browser</h1>
|
|
17
|
+
<div id="status"><span class="dot"></span><span id="label">checking…</span></div>
|
|
18
|
+
<p id="help"></p>
|
|
19
|
+
<div id="tab" style="margin-top:12px;display:none">
|
|
20
|
+
<div id="tabLabel" style="color:inherit"></div>
|
|
21
|
+
<button id="share">Let gu use this tab</button>
|
|
22
|
+
</div>
|
|
23
|
+
<button id="retry">Connect now</button>
|
|
24
|
+
<script src="popup.js"></script>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Shows whether the extension is connected to gu's local bridge, and retries on request.
|
|
2
|
+
const label = document.getElementById("label");
|
|
3
|
+
const help = document.getElementById("help");
|
|
4
|
+
|
|
5
|
+
function show(s) {
|
|
6
|
+
const on = Boolean(s && s.connected);
|
|
7
|
+
document.getElementById("status").className = on ? "on" : "";
|
|
8
|
+
label.textContent = on ? `connected to gu (v${s.version})` : "not connected";
|
|
9
|
+
help.textContent = on
|
|
10
|
+
? "gu's agent can open and use pages in the \"gu\" tab group. Your other tabs are never touched."
|
|
11
|
+
: "gu connects when its agent needs the browser. In the gu terminal, /browser shows the status.";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function check() {
|
|
15
|
+
chrome.runtime.sendMessage({ type: "status" }, show);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
document.getElementById("retry").onclick = () => {
|
|
19
|
+
label.textContent = "connecting…";
|
|
20
|
+
chrome.runtime.sendMessage({ type: "reconnect" }, () => setTimeout(check, 600));
|
|
21
|
+
};
|
|
22
|
+
check();
|
|
23
|
+
|
|
24
|
+
// "Let gu use this tab" (#150): hand gu one of your own tabs — it joins the "gu" group and the
|
|
25
|
+
// agent can use it; "Stop sharing" takes it back. Chrome's own pages cannot be shared.
|
|
26
|
+
const tabBox = document.getElementById("tab");
|
|
27
|
+
const tabLabel = document.getElementById("tabLabel");
|
|
28
|
+
const shareBtn = document.getElementById("share");
|
|
29
|
+
|
|
30
|
+
async function showTab() {
|
|
31
|
+
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
32
|
+
if (!tab || !/^(https?|file):/i.test(tab.url || "")) return;
|
|
33
|
+
const { shared } = await chrome.runtime.sendMessage({ type: "tabState", tabId: tab.id });
|
|
34
|
+
tabBox.style.display = "block";
|
|
35
|
+
tabLabel.textContent = shared ? "gu can use this tab." : "This tab is yours — gu cannot see it.";
|
|
36
|
+
shareBtn.textContent = shared ? "Stop sharing this tab" : "Let gu use this tab";
|
|
37
|
+
shareBtn.onclick = async () => {
|
|
38
|
+
shareBtn.disabled = true;
|
|
39
|
+
const r = await chrome.runtime.sendMessage({ type: shared ? "unshare" : "share", tabId: tab.id });
|
|
40
|
+
shareBtn.disabled = false;
|
|
41
|
+
if (r && r.ok === false) tabLabel.textContent = r.error;
|
|
42
|
+
else showTab();
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
showTab();
|
package/client-expert-api.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { createRequire } from "node:module";
|
|
21
21
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
22
22
|
import { spawnSurviving, pidOnPort } from "./platform-tools.mjs";
|
|
23
|
+
import { procCommand, procStart, sameProcess } from "./proc-identity.mjs";
|
|
23
24
|
import { DEFAULT_API_BASE as HOSTED_API_BASE } from "./device-login.mjs";
|
|
24
25
|
import { spawn } from "node:child_process";
|
|
25
26
|
import { writeFile, mkdir, readFile, access } from "node:fs/promises";
|
|
@@ -113,6 +114,46 @@ export function localMongoUri({ env = process.env, home = homedir() } = {}) {
|
|
|
113
114
|
|
|
114
115
|
const GONEXT_DIR = join(homedir(), ".gonext");
|
|
115
116
|
export const API_PID_FILE = join(GONEXT_DIR, "local-api.pid");
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* THE PID FILE CARRIES THE PROCESS'S START TIME (#373, GitHub #154): "<pid>\n<start epoch s>\n".
|
|
120
|
+
* The file outlives the API (a crash, a reboot), and its id may belong to another program by the
|
|
121
|
+
* time `gu api stop`, restart, upgrade or reset reads it. The start time, read from ps when the
|
|
122
|
+
* file is written, is what tells them apart. Older files hold only the pid.
|
|
123
|
+
*/
|
|
124
|
+
export async function writeApiPid(pid, { path = API_PID_FILE } = {}) {
|
|
125
|
+
if (!pid) return;
|
|
126
|
+
const t = procStart(pid);
|
|
127
|
+
await writeFile(path, `${pid}\n${Number.isFinite(t) ? t : ""}\n`).catch(() => {});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** { pid, started } from the pid file; pid null when there is none. */
|
|
131
|
+
export async function readApiPid({ path = API_PID_FILE } = {}) {
|
|
132
|
+
const [a, b] = String(await readFile(path, "utf8").catch(() => "")).split(/\s+/);
|
|
133
|
+
const pid = Number(a);
|
|
134
|
+
const started = b ? Number(b) : NaN;
|
|
135
|
+
return { pid: Number.isInteger(pid) && pid > 0 ? pid : null, started: Number.isFinite(started) ? started : null };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The pid to stop — only while it is still the local API. With a start time: the same process
|
|
140
|
+
* started then. An older file without one: the process must look like the API (node … index.js).
|
|
141
|
+
* { pid } to signal, or { pid: null, why } — never a guess.
|
|
142
|
+
*/
|
|
143
|
+
export async function apiPidToStop({ path = API_PID_FILE, platform = process.platform, startOf = procStart, commandOf = procCommand } = {}) {
|
|
144
|
+
const { pid, started } = await readApiPid({ path });
|
|
145
|
+
if (!pid) return { pid: null, why: "no local API pid on record" };
|
|
146
|
+
if (platform === "win32") return { pid };
|
|
147
|
+
if (started != null) {
|
|
148
|
+
const same = sameProcess(pid, started, { before: 2, after: 2, startOf });
|
|
149
|
+
if (same === "same") return { pid };
|
|
150
|
+
if (same === "gone") return { pid: null, why: "not running" };
|
|
151
|
+
if (same === "other") return { pid: null, why: `pid ${pid} is now another program, not the local API — left alone` };
|
|
152
|
+
}
|
|
153
|
+
const cmd = commandOf(pid, { platform });
|
|
154
|
+
if (!cmd) return { pid: null, why: "not running" };
|
|
155
|
+
return /\bnode\b.*\bindex\.js\b/.test(cmd) ? { pid } : { pid: null, why: `pid ${pid} is now another program, not the local API — left alone` };
|
|
156
|
+
}
|
|
116
157
|
export const API_LOG_FILE = join(GONEXT_DIR, "local-api.log");
|
|
117
158
|
|
|
118
159
|
/** The API authenticates a worker by sha256 of the key — same function it uses server-side. */
|
|
@@ -528,14 +569,14 @@ export async function startLocalApi({
|
|
|
528
569
|
// serves — which is the better question anyway: what matters is who is answering, not who
|
|
529
570
|
// we spawned. Recorded after the server is up, by the caller's waitForApi.
|
|
530
571
|
const pid = res.pid ?? null;
|
|
531
|
-
|
|
572
|
+
await writeApiPid(pid);
|
|
532
573
|
return { pid, how: res.how, port };
|
|
533
574
|
}
|
|
534
575
|
|
|
535
576
|
/** Record whoever is serving the port, so `api stop` has something to stop. */
|
|
536
577
|
export async function recordApiPid(port = LOCAL_API_PORT) {
|
|
537
578
|
const pid = await pidOnPort(port);
|
|
538
|
-
|
|
579
|
+
await writeApiPid(pid);
|
|
539
580
|
return pid;
|
|
540
581
|
}
|
|
541
582
|
|